feat(app): unified demo mode + public-demo build for Vercel

- Central demo-mode helper (lib/demo-mode.ts): ?demo=1 query flag plus
  VITE_PUBLIC_DEMO build flag that makes every route demo for the
  public Vercel showcase.
- Single demo fixture source (lib/demo-data.ts): projects, services,
  builds, databases, cron, HA, security, host monitoring, templates.
  Fixes cross-page ID drift that broke project navigation.
- Demo support on Projects, HA, Security, Usage, Templates, Landing,
  Service detail; DemoRestricted gate on People/Settings/Audit/Admin.
- Demo-aware PlatformShell: nav filtering, env:demo, demo account menu,
  ?demo=1 preserved through internal links.
- TemplatesPage: selection moved into ?template= URL param for deep
  links; template fixtures shared via demo-data.
- requestJson returns {} for unparseable 200 bodies (SPA fallback) so
  envelope unwraps degrade instead of dereferencing null.
- Recharts ResponsiveContainer initialDimension kills -1 warnings.
- vercel.json: vite framework + SPA rewrites for direct deep links.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-23 17:38:02 +02:00
1 parent 84b102a2c6
commit 5b0a8e0759
20 files changed
+873 -525

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
.vercel
.env*
+1 -1
View File
@@ -32,7 +32,7 @@ describe('App smoke routes', () => {
expect(await screen.findByRole('heading', { name: /^projects\s*_?$/i })).toBeInTheDocument();
expect(screen.getByText(/recent deployments/i)).toBeInTheDocument();
expect(screen.getAllByText(/core-services/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/core platform/i).length).toBeGreaterThan(0);
});
it('renders builds page in demo mode', async () => {
+1 -5
View File
@@ -23,14 +23,10 @@ import {
} from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client';
import { ErrorBoundary, LoadingState } from '@/shared/components';
function useDemoMode() {
const location = useLocation();
return new URLSearchParams(location.search).get('demo') === '1';
}
function AuthRequired() {
const location = useLocation();
const isDemoMode = useDemoMode();
+63 -38
View File
@@ -37,6 +37,7 @@ import {
pullUpgradeImage,
} from '@/lib/api-client';
import { signOutAuthSession } from '@/lib/auth-client';
import { isDemoSearch } from '@/lib/demo-mode';
import { useAuthSession } from '@/lib/use-auth-session';
import { useToast } from '@/shared/components';
@@ -103,8 +104,18 @@ export function PlatformShell() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const isDemoMode = new URLSearchParams(location.search).get('demo') === '1';
const isDemoMode = isDemoSearch(location.search);
const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target);
// Auth/admin-scoped pages have no demo fixtures — keep the demo nav honest.
const visibleNavSections = isDemoMode
? navSections
.map((section) =>
section.title === 'Workspace'
? { ...section, items: section.items.filter((item) => item.href === '/docs') }
: section,
)
.filter((section) => section.items.length > 0)
: navSections;
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
const [notificationsOpen, setNotificationsOpen] = useState(false);
@@ -118,7 +129,7 @@ export function PlatformShell() {
staleTime: 60_000,
retry: false,
});
const isAdmin = isDemoMode || Boolean(profileQuery.data?.isAdmin);
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const upgradeQuery = useQuery({
queryKey: ['upgrade-status'],
queryFn: getUpgradeStatus,
@@ -174,8 +185,8 @@ export function PlatformShell() {
const isActiveRoute = (itemHref: string) =>
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
const userName = profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
const userEmail = profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
const initials = userName
.split(' ')
.map((part) => part[0])
@@ -220,7 +231,7 @@ export function PlatformShell() {
</div>
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
{navSections.map((section) => (
{visibleNavSections.map((section) => (
<div key={section.title}>
{sidebarExpanded ? (
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
@@ -297,27 +308,31 @@ export function PlatformShell() {
<p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p>
</div>
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
<DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
<User size={14} /> Profile & account
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
<Settings size={14} /> Settings
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/settings/audit-logs'))} className={menuItemClass}>
<ScrollText size={14} /> Audit logs
</button>
</DropdownMenu.Item>
{isAdmin ? (
<DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/admin'))} className={menuItemClass}>
<ShieldCheck size={14} /> Admin console
</button>
</DropdownMenu.Item>
{!isDemoMode ? (
<>
<DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
<User size={14} /> Profile & account
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
<Settings size={14} /> Settings
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/settings/audit-logs'))} className={menuItemClass}>
<ScrollText size={14} /> Audit logs
</button>
</DropdownMenu.Item>
{isAdmin ? (
<DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/admin'))} className={menuItemClass}>
<ShieldCheck size={14} /> Admin console
</button>
</DropdownMenu.Item>
) : null}
</>
) : null}
<DropdownMenu.Item asChild>
<button
@@ -331,13 +346,23 @@ export function PlatformShell() {
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => void signOut()}
className={`${menuItemClass} text-[var(--error)] hover:!bg-[var(--error-soft)] hover:!text-[var(--error)]`}
>
<LogOut size={14} /> Sign out
</button>
{isDemoMode ? (
<button
type="button"
onClick={() => navigate('/')}
className={`${menuItemClass} text-[var(--error)] hover:!bg-[var(--error-soft)] hover:!text-[var(--error)]`}
>
<LogOut size={14} /> Exit demo
</button>
) : (
<button
type="button"
onClick={() => void signOut()}
className={`${menuItemClass} text-[var(--error)] hover:!bg-[var(--error-soft)] hover:!text-[var(--error)]`}
>
<LogOut size={14} /> Sign out
</button>
)}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
@@ -382,7 +407,7 @@ export function PlatformShell() {
</div>
<div className="ml-auto flex items-center gap-4">
<span className="v-env">env:production</span>
<span className="v-env">{isDemoMode ? 'env:demo' : 'env:production'}</span>
<button
type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
@@ -393,11 +418,11 @@ export function PlatformShell() {
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
</button>
{isAdmin ? (
{isAdmin && !isDemoMode ? (
<button
type="button"
onClick={() => pullMutation.mutate()}
disabled={isDemoMode || pullMutation.isPending || !upgradeQuery.data?.imageRef}
disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef}
className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-[var(--accent-on)] disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
title={upgradeQuery.data?.message || 'Pull latest configured image'}
@@ -407,7 +432,7 @@ export function PlatformShell() {
</button>
) : null}
{signedIn ? (
{signedIn && !isDemoMode ? (
<div className="relative">
<button
type="button"
@@ -504,7 +529,7 @@ export function PlatformShell() {
</button>
</div>
<nav className="flex gap-1 overflow-x-auto px-3 pb-3">
{navSections.flatMap((section) => section.items).map((item) => {
{visibleNavSections.flatMap((section) => section.items).map((item) => {
const Icon = item.icon;
const isActive = isActiveRoute(item.href);
return (
@@ -23,7 +23,8 @@ import {
type ProjectEntity,
} from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { LoadingState, useToast } from '@/shared/components';
import { useDemoMode } from '@/lib/demo-mode';
import { DemoRestricted, LoadingState, useToast } from '@/shared/components';
const statLabels: Record<string, string> = {
users: 'Users',
@@ -42,12 +43,13 @@ const statLabels: Record<string, string> = {
export function AdminPage() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const sessionQuery = useAuthSession();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const myId = sessionQuery.data?.user.id;
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview });
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers });
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }) });
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
const [editForm, setEditForm] = useState({ name: '', description: '' });
@@ -99,6 +101,10 @@ export function AdminPage() {
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
});
if (isDemoMode) {
return <DemoRestricted feature="The admin console" />;
}
if (overviewQuery.isPending) {
return <LoadingState message="Loading admin console…" className="py-24" />;
}
@@ -1,7 +1,9 @@
import { useDeferredValue, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listAuditLogs } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
import { formatDate, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
const PAGE_SIZE = 50;
@@ -17,6 +19,7 @@ const inputClass =
'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
export function AuditLogsPage() {
const isDemoMode = useDemoMode();
const [resource, setResource] = useState('');
const [action, setAction] = useState('');
const [actor, setActor] = useState('');
@@ -42,8 +45,13 @@ export function AuditLogsPage() {
const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined;
return listAuditLogs({ ...filters, since });
},
enabled: !isDemoMode,
});
if (isDemoMode) {
return <DemoRestricted feature="Audit logs" />;
}
const logs = logsQuery.data ?? [];
const hasNext = logs.length === PAGE_SIZE;
const hasFilters = Boolean(resource || action || actor || range);
@@ -1,6 +1,5 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import {
cancelBuild,
getBuildLogs,
@@ -9,6 +8,8 @@ import {
type BuildStatus,
} from '@/lib/api-client';
import { useBuildUpdates } from '@/lib/use-build-updates';
import { useDemoMode } from '@/lib/demo-mode';
import { demoBuilds } from '@/lib/demo-data';
import { formatRelative } from '@/lib/time';
import {
Check,
@@ -33,36 +34,6 @@ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
{ value: 'cancelled', label: 'Cancelled' },
];
const demoBuilds: BuildEntity[] = [
{
id: 'build-demo-01',
projectId: 'project-demo',
serviceId: 'service-api',
status: 'success',
progress: 100,
startedAt: new Date(Date.now() - 20 * 60_000).toISOString(),
completedAt: new Date(Date.now() - 18 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/api',
imageTag: 'sha-1f2e3d4',
size: 156_000_000,
log: '[demo] Build finished successfully.',
metadata: { branch: 'main' },
},
{
id: 'build-demo-02',
projectId: 'project-demo',
serviceId: 'service-worker',
status: 'running',
progress: 54,
startedAt: new Date(Date.now() - 2 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/worker',
imageTag: 'sha-9a8b7c6',
size: 0,
log: '[demo] Building image layers...',
metadata: { branch: 'feature/queue' },
},
];
function StatusBadge({ status }: { status: BuildStatus }) {
const config = {
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
@@ -99,8 +70,7 @@ function bytesToHumanReadable(bytes: number): string {
export function BuildsPage() {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1';
const isDemoMode = useDemoMode();
const [projectFilter, setProjectFilter] = useState('');
const [serviceFilter, setServiceFilter] = useState('');
@@ -14,6 +14,8 @@ import {
import { listProjects, listTemplates } from '@/lib/api-client';
import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects, demoTemplates } from '@/lib/demo-data';
const features = [
{ icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' },
@@ -25,14 +27,16 @@ const features = [
];
export function LandingPage() {
const sessionQuery = useAuthSession();
const signedIn = Boolean(sessionQuery.data);
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
const demo = isDemoMode ? '?demo=1' : '';
const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000 });
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000 });
const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode });
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode });
const projects = projectsQuery.data ?? [];
const templates = (templatesQuery.data ?? []).slice(0, 12);
const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []);
const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12);
return (
<div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]">
@@ -51,7 +55,7 @@ export function LandingPage() {
<a href="#templates" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Templates</a>
<a href="#docs" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Docs</a>
<Link
to="/projects"
to={`/projects${demo}`}
className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
Browse
@@ -65,10 +69,10 @@ export function LandingPage() {
</Link>
) : (
<Link
to="/auth/sign-in"
to={isDemoMode ? `/projects${demo}` : '/auth/sign-in'}
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
>
Sign in <ArrowRight size={13} />
{isDemoMode ? 'Live demo' : 'Sign in'} <ArrowRight size={13} />
</Link>
)}
</nav>
@@ -91,10 +95,10 @@ export function LandingPage() {
</p>
<div className="mt-8 flex items-center justify-center gap-3">
<Link
to="/projects"
to={`/projects${demo}`}
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-5 py-2.5 text-sm font-semibold text-[var(--accent-on)] transition-transform hover:scale-[1.02]"
>
Browse projects <ArrowRight size={15} />
{isDemoMode ? 'Explore the live demo' : 'Browse projects'} <ArrowRight size={15} />
</Link>
<a
href="#docs"
@@ -152,7 +156,7 @@ export function LandingPage() {
</p>
</div>
<Link
to="/templates"
to={`/templates${demo}`}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline"
>
Browse all <ArrowRight size={13} />
@@ -162,7 +166,7 @@ export function LandingPage() {
{templates.map((template) => (
<Link
key={template.id}
to="/templates"
to={`/templates?template=${template.id}${isDemoMode ? '&demo=1' : ''}`}
className="group rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-4 transition-colors hover:border-[var(--border-strong)]"
>
<div className="flex items-center gap-2.5">
@@ -212,9 +216,9 @@ export function LandingPage() {
Containr — self-hosted container platform
</span>
<div className="flex items-center gap-4">
<Link to="/projects" className="hover:text-[var(--text-secondary)]">Projects</Link>
<Link to="/templates" className="hover:text-[var(--text-secondary)]">Templates</Link>
<Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link>
<Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link>
<Link to={`/templates${demo}`} className="hover:text-[var(--text-secondary)]">Templates</Link>
{!isDemoMode ? <Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link> : null}
</div>
</div>
</footer>
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
createProject,
@@ -15,33 +15,14 @@ import {
X,
} from 'lucide-react';
import { useAuthSession } from '@/lib/use-auth-session';
const demoProjects: ProjectEntity[] = [
{
id: 'project-demo',
name: 'core-services',
description: 'Sample project with mock services for UI preview.',
isApproved: true,
createdAt: new Date(Date.now() - 14 * 86_400_000).toISOString(),
updatedAt: new Date().toISOString(),
stats: { service_count: 5, deployment_count: 12, running_services: 5, last_deployment: null },
},
{
id: 'project-staging',
name: 'ml-pipeline',
description: 'Pre-production environment for testing new releases.',
isApproved: true,
createdAt: new Date(Date.now() - 7 * 86_400_000).toISOString(),
updatedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
stats: { service_count: 2, deployment_count: 5, running_services: 1, last_deployment: null },
},
];
import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects } from '@/lib/demo-data';
const demoDeploys = [
{ id: 'd1', name: 'api-gateway', project: 'core-services', status: 'DEPLOYED', when: '4m' },
{ id: 'd2', name: 'web-frontend', project: 'core-services', status: 'BUILDING', when: '6m' },
{ id: 'd3', name: 'worker', project: 'ml-pipeline', status: 'FAILED', when: '1h' },
{ id: 'd4', name: 'site', project: 'growth-site', status: 'DEPLOYED', when: '2h' },
{ id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' },
{ id: 'd2', name: 'Web Frontend', project: 'Core Platform', status: 'BUILDING', when: '6m' },
{ id: 'd3', name: 'Batch Evaluator', project: 'Inference Lab', status: 'FAILED', when: '1h' },
{ id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' },
];
function getHealthStatus(stats: ProjectStats): 'healthy' | 'degraded' | 'critical' {
@@ -137,9 +118,8 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
export function ProjectsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1';
const isDemoMode = useDemoMode();
const [search, setSearch] = useState('');
const [isCreateOpen, setCreateOpen] = useState(false);
+103 -59
View File
@@ -50,13 +50,27 @@ import {
type DatabaseEntity,
type FailoverPolicy,
} from '@/lib/api-client';
import { demoDatabases } from '@/lib/demo-data';
import {
demoBuilds,
demoDatabases,
demoHAAlerts,
demoHAHealthResults,
demoHAPolicies,
demoHAStatus,
demoHostMonitoring,
demoProjects,
demoSecurityMetrics,
demoSecurityScans,
demoVulnerabilities,
getDemoServicesByProject,
} from '@/lib/demo-data';
import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time';
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
import { useBuildUpdates } from '@/lib/use-build-updates';
import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart } from '@/shared/components';
import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart, DemoRestricted } from '@/shared/components';
import {
Clock,
Activity,
@@ -203,25 +217,27 @@ function formatUptime(seconds: number): string {
export function UsagePage() {
const queryClient = useQueryClient();
const sessionQuery = useAuthSession();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: Boolean(sessionQuery.data),
enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false,
});
const signedIn = Boolean(sessionQuery.data);
const isAdmin = Boolean(profileQuery.data?.isAdmin);
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const buildsQuery = useQuery({
queryKey: ['usage-builds'],
queryFn: () => listBuilds({ page: 1, limit: 100 }),
enabled: signedIn,
enabled: signedIn && !isDemoMode,
retry: false,
});
const hostQuery = useQuery({
queryKey: ['usage-host-monitoring'],
queryFn: getHostMonitoring,
refetchInterval: 15_000,
enabled: !isDemoMode,
});
const agentsQuery = useQuery({
queryKey: ['usage-agents'],
@@ -251,9 +267,12 @@ export function UsagePage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
});
const builds = buildsQuery.data?.builds ?? [];
const builds = useMemo(
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
[isDemoMode, buildsQuery.data],
);
const liveStatus = useBuildUpdates(
builds.map((build) => build.id),
isDemoMode ? [] : builds.map((build) => build.id),
() => {
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
},
@@ -280,20 +299,19 @@ export function UsagePage() {
const avgBuildMinutes = averageBuildDurationMs / 60_000;
const activeBuildPressure = runningBuilds + pendingBuilds;
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
const host = hostQuery.data;
const host = isDemoMode ? demoHostMonitoring : hostQuery.data;
const agents = agentsQuery.data ?? [];
// Accumulate host samples for the load sparkline (polled every 15s).
const [loadHistory, setLoadHistory] = useState<number[]>([]);
useEffect(() => {
const sample = hostQuery.data;
if (!sample || sample.cpu.cores <= 0) {
if (!host || host.cpu.cores <= 0) {
return;
}
const loadPercent = Math.min(100, (sample.load.load1m / sample.cpu.cores) * 100);
const loadPercent = Math.min(100, (host.load.load1m / host.cpu.cores) * 100);
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
setLoadHistory((prev) => [...prev, loadPercent].slice(-24));
}, [hostQuery.data]);
}, [host]);
const hostLoadPercent = host && host.cpu.cores > 0
? Math.min(100, (host.load.load1m / host.cpu.cores) * 100)
@@ -307,14 +325,14 @@ export function UsagePage() {
const buildsPerDay = useMemo(() => {
const days = 14;
const counts = new Array<number>(days).fill(0);
const timestamps = (buildsQuery.data?.builds ?? [])
const timestamps = builds
.map((build) => new Date((build.startedAt ?? build.completedAt) as string).getTime())
.filter((t) => Number.isFinite(t));
if (timestamps.length === 0) {
return counts;
}
const anchor = Math.max(...timestamps);
(buildsQuery.data?.builds ?? []).forEach((build) => {
builds.forEach((build) => {
const timestamp = build.startedAt ?? build.completedAt;
if (!timestamp) {
return;
@@ -327,7 +345,7 @@ export function UsagePage() {
});
const peak = Math.max(...counts, 1);
return counts.map((count) => (count / peak) * 100);
}, [buildsQuery.data]);
}, [builds]);
const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length;
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
@@ -382,12 +400,12 @@ containr-agent`;
</span>
</div>
{buildsQuery.isLoading ? (
{!isDemoMode && buildsQuery.isLoading ? (
<div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p>
</div>
) : buildsQuery.isError ? (
) : !isDemoMode && buildsQuery.isError ? (
<div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<AlertCircle size={24} className="text-[var(--error)]" />
@@ -455,7 +473,7 @@ containr-agent`;
</button>
</div>
{hostQuery.isError ? (
{!isDemoMode && hostQuery.isError ? (
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
Host monitoring unavailable.
</div>
@@ -672,9 +690,11 @@ containr-agent`;
export function PeoplePage() {
const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode,
});
const [newUserName, setNewUserName] = useState('');
const [newUserEmail, setNewUserEmail] = useState('');
@@ -699,12 +719,18 @@ export function PeoplePage() {
const projectsQuery = useQuery({
queryKey: ['people-projects'],
queryFn: () => listProjects(),
enabled: !isDemoMode,
});
const auditLogsQuery = useQuery({
queryKey: ['people-audit-logs'],
queryFn: () => listAuditLogs({ page: 1, limit: 20 }),
enabled: !isDemoMode,
});
if (isDemoMode) {
return <DemoRestricted feature="People and access management" />;
}
let membersBody = 'Owner-only mode currently. Multi-user collaboration is planned.';
if (profileQuery.isLoading) {
@@ -1242,9 +1268,11 @@ function PlatformSettingsSection() {
export function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const profileQuery = useQuery({
queryKey: ['settings-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode,
});
const [nameDraft, setNameDraft] = useState<string | null>(null);
@@ -1308,6 +1336,10 @@ export function SettingsPage() {
refreshStorage();
};
if (isDemoMode) {
return <DemoRestricted feature="Account and platform settings" />;
}
return (
<div className="min-h-screen">
<SecondaryPageHeader
@@ -1561,8 +1593,7 @@ const DATABASE_PLANS = ['hobby', 'starter', 'standard', 'business'] as const;
export function DatabasesPage() {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1';
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -2074,14 +2105,15 @@ const failoverStrategies = [
export function HighAvailabilityPage() {
const queryClient = useQueryClient();
const sessionQuery = useAuthSession();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: Boolean(sessionQuery.data),
enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false,
});
const isAdmin = Boolean(profileQuery.data?.isAdmin);
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const [failoverReason, setFailoverReason] = useState('');
const [policyForm, setPolicyForm] = useState<{
serviceId: string;
@@ -2093,11 +2125,11 @@ export function HighAvailabilityPage() {
} | null>(null);
const [policyProjectId, setPolicyProjectId] = useState('');
const statusQuery = useQuery({ queryKey: ['ha-status'], queryFn: getHAStatus, refetchInterval: 15_000 });
const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies });
const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000 });
const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000 });
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() });
const statusQuery = useQuery({ queryKey: ['ha-status'], queryFn: getHAStatus, refetchInterval: 15_000, enabled: !isDemoMode });
const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies, enabled: !isDemoMode });
const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000, enabled: !isDemoMode });
const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
const servicesQuery = useQuery({
queryKey: ['services', policyProjectId],
queryFn: () => listServicesByProject(policyProjectId),
@@ -2142,7 +2174,10 @@ export function HighAvailabilityPage() {
onSuccess: invalidate,
});
const status = statusQuery.data;
const status = isDemoMode ? demoHAStatus : statusQuery.data;
const alerts = isDemoMode ? demoHAAlerts : alertsQuery.data ?? [];
const policies = isDemoMode ? demoHAPolicies : policiesQuery.data ?? [];
const healthResults = isDemoMode ? demoHAHealthResults : healthQuery.data ?? [];
const pageError =
statusQuery.error ?? policiesQuery.error ?? alertsQuery.error ?? healthQuery.error ??
toggleMutation.error ?? failoverMutation.error ?? savePolicyMutation.error ??
@@ -2158,7 +2193,7 @@ export function HighAvailabilityPage() {
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<ShieldCheck size={13} className="text-[var(--accent-primary)]" />
<span>HA state feeds the platform security posture.</span>
<Link to="/security" className="font-medium text-[var(--accent-primary)] hover:underline">
<Link to={isDemoMode ? '/security?demo=1' : '/security'} className="font-medium text-[var(--accent-primary)] hover:underline">
Open Security →
</Link>
</div>
@@ -2246,13 +2281,13 @@ export function HighAvailabilityPage() {
<AlertCircle size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2>
</div>
{alertsQuery.isLoading ? (
{!isDemoMode && alertsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (alertsQuery.data ?? []).length === 0 ? (
) : alerts.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No active alerts.</p>
) : (
<div className="space-y-2">
{(alertsQuery.data ?? []).map((alert) => (
{alerts.map((alert) => (
<div
key={alert.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
@@ -2398,13 +2433,13 @@ export function HighAvailabilityPage() {
</div>
)}
{policiesQuery.isLoading ? (
{!isDemoMode && policiesQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (policiesQuery.data ?? []).length === 0 ? (
) : policies.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No failover policies configured.</p>
) : (
<div className="space-y-2">
{(policiesQuery.data ?? []).map((policy: FailoverPolicy) => (
{policies.map((policy: FailoverPolicy) => (
<div
key={policy.service_id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
@@ -2461,13 +2496,13 @@ export function HighAvailabilityPage() {
<HeartPulse size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2>
</div>
{healthQuery.isLoading ? (
{!isDemoMode && healthQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (healthQuery.data ?? []).length === 0 ? (
) : healthResults.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No health checks configured.</p>
) : (
<div className="space-y-2">
{(healthQuery.data ?? []).slice(0, 20).map((result, i) => (
{healthResults.slice(0, 20).map((result, i) => (
<div
key={`${result.check_id}-${i}`}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
@@ -2509,34 +2544,37 @@ const severityColor: Record<string, string> = {
export function SecurityPage() {
const queryClient = useQueryClient();
const sessionQuery = useAuthSession();
const signedIn = Boolean(sessionQuery.data);
const [projectId, setProjectId] = useState('');
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
const [projectId, setProjectId] = useState(isDemoMode ? 'demo-project-core' : '');
const [scanType, setScanType] = useState<string>('comprehensive');
const [scanServiceId, setScanServiceId] = useState('');
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() });
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
const servicesQuery = useQuery({
queryKey: ['services', projectId],
queryFn: () => listServicesByProject(projectId),
enabled: Boolean(projectId),
enabled: !isDemoMode && Boolean(projectId),
});
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? [];
const metricsQuery = useQuery({
queryKey: ['security-metrics', projectId],
queryFn: () => getSecurityMetrics(projectId),
enabled: Boolean(projectId),
enabled: !isDemoMode && Boolean(projectId),
refetchInterval: 15_000,
});
const historyQuery = useQuery({
queryKey: ['security-history', projectId],
queryFn: () => getSecurityHistory(projectId),
enabled: Boolean(projectId),
enabled: !isDemoMode && Boolean(projectId),
refetchInterval: 15_000,
});
const vulnsQuery = useQuery({
queryKey: ['security-vulns', projectId],
queryFn: () => listVulnerabilities(projectId),
enabled: Boolean(projectId),
enabled: !isDemoMode && Boolean(projectId),
});
const invalidate = () => {
@@ -2559,8 +2597,14 @@ export function SecurityPage() {
onSuccess: invalidate,
});
const metrics = metricsQuery.data;
const vulns = (vulnsQuery.data ?? []).filter((v) => v.status !== 'resolved');
const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
const scanHistory = isDemoMode
? demoSecurityScans.filter((s) => s.project_id === projectId)
: historyQuery.data ?? [];
const vulns = (isDemoMode
? demoVulnerabilities.filter((v) => v.project_id === projectId)
: vulnsQuery.data ?? []
).filter((v) => v.status !== 'resolved');
const pageError =
projectsQuery.error ?? metricsQuery.error ?? historyQuery.error ?? vulnsQuery.error ??
scanMutation.error ?? vulnMutation.error;
@@ -2575,7 +2619,7 @@ export function SecurityPage() {
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<HeartPulse size={13} className="text-[var(--accent-primary)]" />
<span>Platform availability and failover live under High availability.</span>
<Link to="/ha" className="font-medium text-[var(--accent-primary)] hover:underline">
<Link to={isDemoMode ? '/ha?demo=1' : '/ha'} className="font-medium text-[var(--accent-primary)] hover:underline">
Open HA →
</Link>
</div>
@@ -2594,7 +2638,7 @@ export function SecurityPage() {
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56"
>
<option value="">Select project…</option>
{(projectsQuery.data ?? []).map((p) => (
{projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
@@ -2621,7 +2665,7 @@ export function SecurityPage() {
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
<option value="">All services</option>
{(servicesQuery.data ?? []).map((s) => (
{services.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
@@ -2636,7 +2680,7 @@ export function SecurityPage() {
{scanMutation.isPending ? 'Starting…' : 'Run scan'}
</button>
) : (
<span className="self-end pb-2 text-xs text-[var(--text-tertiary)]">Sign in to run scans.</span>
<span className="self-end pb-2 text-xs text-[var(--text-tertiary)]">{isDemoMode ? 'Read-only in demo.' : 'Sign in to run scans.'}</span>
)}
</>
)}
@@ -2679,7 +2723,7 @@ export function SecurityPage() {
<Shield size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2>
</div>
{vulnsQuery.isLoading ? (
{!isDemoMode && vulnsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : vulns.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No open findings.</p>
@@ -2735,13 +2779,13 @@ export function SecurityPage() {
<Clock size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2>
</div>
{historyQuery.isLoading ? (
{!isDemoMode && historyQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (historyQuery.data ?? []).length === 0 ? (
) : scanHistory.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No scans yet.</p>
) : (
<div className="space-y-2">
{(historyQuery.data ?? []).map((scan) => (
{scanHistory.map((scan) => (
<div
key={scan.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import {
createDeployment,
deleteService,
@@ -36,6 +36,7 @@ import {
type CronJobEntity,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
import { useAuthSession } from '@/lib/use-auth-session';
import { getCurrentUserProfile } from '@/lib/api-client';
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
@@ -120,10 +121,9 @@ function StatusBadge({ status }: { status: string }) {
export function ServiceDetailPage() {
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1';
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const profileQuery = useQuery({
@@ -10,11 +10,12 @@ import {
listProjects,
listTemplates,
updateTemplate,
type TemplateDetailEntity,
type TemplateEntity,
type TemplateWriteInput,
} from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data';
import {
Search,
Filter,
@@ -37,327 +38,6 @@ import {
X,
} from 'lucide-react';
const demoTemplates: TemplateEntity[] = [
{
id: 'tpl-react',
name: 'React Application',
description: 'Single-page frontend with Vite and static serving runtime.',
category: 'frontend',
logo: 'https://cdn.simpleicons.org/react',
configRaw: '{"runtime":"node"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-go',
name: 'Go API Service',
description: 'API-ready Go runtime with direct binary startup.',
category: 'web',
logo: 'https://cdn.simpleicons.org/go',
configRaw: '{"runtime":"go"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-postgres',
name: 'PostgreSQL Database',
description: 'Managed PostgreSQL service with credential setup variables.',
category: 'database',
logo: 'https://cdn.simpleicons.org/postgresql',
configRaw: '{"runtime":"postgres"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-mysql',
name: 'MySQL Database',
description: 'Managed MySQL service for transactional workloads.',
category: 'database',
logo: 'https://cdn.simpleicons.org/mysql',
configRaw: '{"runtime":"mysql"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-mariadb',
name: 'MariaDB Database',
description: 'Managed MariaDB service with MySQL compatibility.',
category: 'database',
logo: 'https://cdn.simpleicons.org/mariadb',
configRaw: '{"runtime":"mariadb"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-clickhouse',
name: 'ClickHouse Database',
description: 'Columnar analytics database template for high-speed queries.',
category: 'database',
logo: 'https://cdn.simpleicons.org/clickhouse',
configRaw: '{"runtime":"clickhouse"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-dragonfly',
name: 'Dragonfly Database',
description: 'Redis-compatible in-memory store powered by Dragonfly.',
category: 'database',
logo: 'https://cdn.simpleicons.org/redis',
configRaw: '{"runtime":"dragonfly"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
];
const demoTemplateDetails: Record<string, TemplateDetailEntity> = {
'tpl-react': {
template: demoTemplates[0],
config: {
type: 'web',
runtime: 'node',
buildCommand: 'npm install && npm run build',
startCommand: 'npx serve -s dist',
port: 3000,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'VITE_API_URL',
label: 'API URL',
defaultValue: 'https://api.example.com',
required: true,
secret: false,
description: 'Public API endpoint for frontend calls',
},
],
},
'tpl-go': {
template: demoTemplates[1],
config: {
type: 'web',
runtime: 'go',
buildCommand: 'go build -o app .',
startCommand: './app',
port: 8080,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'GO_ENV',
label: 'Go Environment',
defaultValue: 'production',
required: false,
secret: false,
description: 'Runtime environment value',
},
],
},
'tpl-postgres': {
template: demoTemplates[2],
config: {
type: 'database',
runtime: 'postgres',
buildCommand: '',
startCommand: '',
port: 5432,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'POSTGRES_USER',
label: 'Username',
defaultValue: 'postgres',
required: true,
secret: false,
description: 'Database user',
},
{
key: 'POSTGRES_PASSWORD',
label: 'Password',
defaultValue: '',
required: true,
secret: true,
description: 'Database password',
},
],
},
'tpl-mysql': {
template: demoTemplates[3],
config: {
type: 'database',
runtime: 'mysql',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MYSQL_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MYSQL_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MYSQL_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MYSQL_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-mariadb': {
template: demoTemplates[4],
config: {
type: 'database',
runtime: 'mariadb',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MARIADB_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MARIADB_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MARIADB_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MARIADB_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-clickhouse': {
template: demoTemplates[5],
config: {
type: 'database',
runtime: 'clickhouse',
buildCommand: '',
startCommand: '',
port: 8123,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'CLICKHOUSE_DB',
label: 'Database Name',
defaultValue: 'app',
required: false,
secret: false,
description: 'Default database name',
},
{
key: 'CLICKHOUSE_USER',
label: 'Username',
defaultValue: 'default',
required: false,
secret: false,
description: 'ClickHouse user',
},
{
key: 'CLICKHOUSE_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'ClickHouse password',
},
],
},
'tpl-dragonfly': {
template: demoTemplates[6],
config: {
type: 'database',
runtime: 'dragonfly',
buildCommand: '',
startCommand: '',
port: 6379,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'DRAGONFLY_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'Optional Redis-compatible password',
},
],
},
};
const demoProjects = [
{ id: 'project-demo', name: 'Demo Project' },
{ id: 'project-internal', name: 'Internal Tooling' },
];
function toServiceName(value: string): string {
const normalized = value
.toLowerCase()
@@ -428,8 +108,8 @@ const EMPTY_TEMPLATE_JSON = JSON.stringify(
export function TemplatesPage() {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1';
const [searchParams, setSearchParams] = useSearchParams();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
@@ -449,7 +129,18 @@ export function TemplatesPage() {
const [categoryFilter, setCategoryFilter] = useState('');
const [searchFilter, setSearchFilter] = useState('');
const [selectedTemplateIdState, setSelectedTemplateId] = useState<string | null>(null);
// Selection lives in the URL (?template=<id>) so catalog deep links and
// card clicks share one source of truth.
const templateParam = searchParams.get('template');
const selectTemplate = (id: string | null) => {
const next = new URLSearchParams(searchParams);
if (id) {
next.set('template', id);
} else {
next.delete('template');
}
setSearchParams(next, { replace: true });
};
const [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? '');
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
@@ -507,11 +198,11 @@ export function TemplatesPage() {
if (filteredTemplates.length === 0) {
return null;
}
if (selectedTemplateIdState && filteredTemplates.some((template) => template.id === selectedTemplateIdState)) {
return selectedTemplateIdState;
if (templateParam && filteredTemplates.some((template) => template.id === templateParam)) {
return templateParam;
}
return filteredTemplates[0].id;
}, [filteredTemplates, selectedTemplateIdState]);
}, [filteredTemplates, templateParam]);
const templateDetailQuery = useQuery({
queryKey: ['template-detail-page', selectedTemplateId],
@@ -637,7 +328,7 @@ export function TemplatesPage() {
setEditorOpen(false);
setEditingTemplate(null);
setEditorError(null);
setSelectedTemplateId(template.id);
selectTemplate(template.id);
},
onError: (error) => {
setEditorError(error instanceof Error ? error.message : 'Failed to save template');
@@ -648,7 +339,7 @@ export function TemplatesPage() {
mutationFn: (id: string) => deleteTemplate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates-page'] });
setSelectedTemplateId(null);
selectTemplate(null);
},
});
@@ -846,7 +537,7 @@ export function TemplatesPage() {
return (
<button
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
onClick={() => selectTemplate(template.id)}
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
@@ -19,6 +19,7 @@ import {
type CreateServiceInput,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
import { useAuthSession } from '@/lib/use-auth-session';
import { formatDate, formatRelative } from '@/lib/time';
import type { ServiceVariable } from '../auto-connections';
@@ -466,7 +467,7 @@ export function ProjectWorkspacePage() {
const toast = useToast();
const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas';
const isDemoMode = searchParams.get('demo') === '1';
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const [createOpen, setCreateOpen] = useState(false);
+4 -1
View File
@@ -424,7 +424,10 @@ async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
throw new ApiError(message, response.status);
}
return payload as T;
// SPA fallback servers may answer /api/* with index.html — an ok response
// whose body is not JSON. Surface an empty object so envelope unwraps
// (`payload.x ?? []`) degrade instead of dereferencing null.
return (payload ?? {}) as T;
}
async function requestText(path: string, init?: RequestInit): Promise<string> {
+567 -1
View File
@@ -1,4 +1,20 @@
import type { CronJobEntity, DatabaseEntity, ProjectEntity, ServiceEntity } from '@/lib/api-client';
import type {
BuildEntity,
CronJobEntity,
DatabaseEntity,
FailoverPolicy,
HAAlert,
HAHealthResult,
HAStatus,
HostMonitoring,
ProjectEntity,
SecurityMetrics,
SecurityScan,
ServiceEntity,
TemplateDetailEntity,
TemplateEntity,
Vulnerability,
} from '@/lib/api-client';
import type { ServiceVariable } from '@/features/workspace/auto-connections';
export const demoProjects: ProjectEntity[] = [
@@ -332,3 +348,553 @@ export const demoCronJobsByService: Record<string, CronJobEntity[]> = {
export function getDemoCronJobsByService(serviceId: string): CronJobEntity[] {
return demoCronJobsByService[serviceId] ?? [];
}
export const demoBuilds: BuildEntity[] = [
{
id: 'demo-build-api',
projectId: 'demo-project-core',
serviceId: 'demo-svc-api',
status: 'success',
progress: 100,
startedAt: new Date(Date.now() - 20 * 60_000).toISOString(),
completedAt: new Date(Date.now() - 18 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/api-gateway',
imageTag: 'sha-1f2e3d4',
size: 156_000_000,
log: '[demo] Build finished successfully.',
metadata: { branch: 'main' },
},
{
id: 'demo-build-worker',
projectId: 'demo-project-core',
serviceId: 'demo-svc-worker',
status: 'running',
progress: 54,
startedAt: new Date(Date.now() - 2 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/queue-worker',
imageTag: 'sha-9a8b7c6',
size: 0,
log: '[demo] Building image layers...',
metadata: { branch: 'feature/queue' },
},
{
id: 'demo-build-site',
projectId: 'demo-project-growth',
serviceId: 'demo-growth-site',
status: 'success',
progress: 100,
startedAt: new Date(Date.now() - 3 * 3_600_000).toISOString(),
completedAt: new Date(Date.now() - 3 * 3_600_000 + 95_000).toISOString(),
imageName: 'ghcr.io/containr/marketing-site',
imageTag: 'sha-77aa1c0',
size: 89_000_000,
log: '[demo] Build finished successfully.',
metadata: { branch: 'main' },
},
{
id: 'demo-build-ml',
projectId: 'demo-project-ml',
serviceId: 'demo-ml-worker',
status: 'failed',
progress: 71,
startedAt: new Date(Date.now() - 26 * 3_600_000).toISOString(),
completedAt: new Date(Date.now() - 26 * 3_600_000 + 240_000).toISOString(),
imageName: 'ghcr.io/containr/batch-evaluator',
imageTag: 'sha-3d4e5f6',
size: 0,
error: 'pip install failed: could not resolve torch==2.4.0',
log: '[demo] ERROR: dependency resolution failed.',
metadata: { branch: 'experiment/eval-v2' },
},
];
export const demoHostMonitoring: HostMonitoring = {
hostname: 'containr-node-1',
os: 'linux',
architecture: 'amd64',
cpu: { cores: 16 },
memory: {
total: 13.5 * 1024 * 1024 * 1024,
used: 9.8 * 1024 * 1024 * 1024,
available: 3.7 * 1024 * 1024 * 1024,
usagePercent: 72,
},
storage: {
path: '/var/lib/containr',
total: 465 * 1024 * 1024 * 1024,
used: 338 * 1024 * 1024 * 1024,
available: 127 * 1024 * 1024 * 1024,
usagePercent: 73,
},
load: { load1m: 2.2, load5m: 2.6, load15m: 2.4 },
uptimeSeconds: 1_828_800,
dockerAvailable: true,
docker: { containers: 11, images: 34, driver: 'overlay2', server: '27.3.1' },
collectedAt: new Date().toISOString(),
};
export const demoHAStatus: HAStatus = {
enabled: true,
nodes: { total: 3, healthy: 3, unhealthy: 0 },
health_checks: { total: 6, healthy: 5, unhealthy: 1 },
alerts: { active: 1 },
};
export const demoHAAlerts: HAAlert[] = [
{
id: 'demo-alert-worker-heartbeat',
rule_id: 'health-check-failing',
status: 'firing',
severity: 'warning',
message: 'Health check failing for demo-svc-worker — 2 consecutive misses',
starts_at: new Date(Date.now() - 14 * 60_000).toISOString(),
},
];
export const demoHAPolicies: FailoverPolicy[] = [
{
service_id: 'demo-svc-api',
enabled: true,
failover_strategy: 'active_passive',
min_healthy_nodes: 1,
max_failures: 3,
},
{
service_id: 'demo-svc-postgres',
enabled: true,
failover_strategy: 'graceful',
min_healthy_nodes: 1,
max_failures: 2,
},
];
export const demoHAHealthResults: HAHealthResult[] = [
{
check_id: 'demo-svc-api:http',
status: 'healthy',
message: 'GET /healthz returned 200',
latency: 42_000_000,
timestamp: new Date(Date.now() - 30_000).toISOString(),
},
{
check_id: 'demo-svc-postgres:tcp',
status: 'healthy',
message: 'TCP connect to :5432 succeeded',
latency: 8_000_000,
timestamp: new Date(Date.now() - 45_000).toISOString(),
},
{
check_id: 'demo-svc-worker:exec',
status: 'unhealthy',
message: 'Heartbeat file stale — last write 4m ago',
latency: 0,
timestamp: new Date(Date.now() - 60_000).toISOString(),
},
];
export const demoSecurityMetrics: SecurityMetrics = {
security_score: 86,
vulnerabilities: { total: 5, critical: 0, high: 1, medium: 2, low: 2, open: 3, resolved: 2 },
latest_scan: {
id: 'demo-scan-latest',
score: 86,
status: 'completed',
scanned_at: new Date(Date.now() - 5 * 3_600_000).toISOString(),
},
compliance: { overall_status: 'assessed', score: 92, last_assessed: new Date(Date.now() - 2 * 86_400_000).toISOString() },
};
export const demoVulnerabilities: Vulnerability[] = [
{
id: 'demo-vuln-1',
type: 'dependency',
severity: 'high',
title: 'openssl 3.0.13 — CVE-2024-5535',
description: 'API Gateway base image ships an openssl release with a known TLS session-ticket flaw.',
service_id: 'demo-svc-api',
project_id: 'demo-project-core',
status: 'open',
found_at: new Date(Date.now() - 5 * 3_600_000).toISOString(),
},
{
id: 'demo-vuln-2',
type: 'configuration',
severity: 'medium',
title: 'Postgres listens on 0.0.0.0',
description: 'Database port is bound on all interfaces; restrict to the internal network.',
service_id: 'demo-svc-postgres',
project_id: 'demo-project-core',
status: 'open',
found_at: new Date(Date.now() - 5 * 3_600_000).toISOString(),
},
{
id: 'demo-vuln-3',
type: 'dependency',
severity: 'low',
title: 'express 4.19.x — outdated minor',
description: 'A newer patch release is available for the web frontend dependency.',
service_id: 'demo-svc-web',
project_id: 'demo-project-core',
status: 'open',
found_at: new Date(Date.now() - 2 * 86_400_000).toISOString(),
},
{
id: 'demo-vuln-4',
type: 'dependency',
severity: 'medium',
title: 'redis 7.2.3 — CVE-2024-31227',
description: 'Resolved by upgrading the cache image to 7.2.5.',
service_id: 'demo-growth-cache',
project_id: 'demo-project-growth',
status: 'resolved',
found_at: new Date(Date.now() - 9 * 86_400_000).toISOString(),
resolved_at: new Date(Date.now() - 6 * 86_400_000).toISOString(),
},
];
export const demoSecurityScans: SecurityScan[] = [
{
id: 'demo-scan-1',
project_id: 'demo-project-core',
scan_type: 'comprehensive',
status: 'completed',
findings_count: 3,
started_at: new Date(Date.now() - 5 * 3_600_000).toISOString(),
completed_at: new Date(Date.now() - 5 * 3_600_000 + 48_000).toISOString(),
},
{
id: 'demo-scan-2',
project_id: 'demo-project-core',
service_id: 'demo-svc-api',
scan_type: 'dependency',
status: 'completed',
findings_count: 1,
started_at: new Date(Date.now() - 2 * 86_400_000).toISOString(),
completed_at: new Date(Date.now() - 2 * 86_400_000 + 31_000).toISOString(),
},
{
id: 'demo-scan-3',
project_id: 'demo-project-core',
scan_type: 'configuration',
status: 'failed',
findings_count: 0,
started_at: new Date(Date.now() - 4 * 86_400_000).toISOString(),
completed_at: new Date(Date.now() - 4 * 86_400_000 + 12_000).toISOString(),
},
];
export const demoTemplates: TemplateEntity[] = [
{
id: 'tpl-react',
name: 'React Application',
description: 'Single-page frontend with Vite and static serving runtime.',
category: 'frontend',
logo: 'https://cdn.simpleicons.org/react',
configRaw: '{"runtime":"node"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-go',
name: 'Go API Service',
description: 'API-ready Go runtime with direct binary startup.',
category: 'web',
logo: 'https://cdn.simpleicons.org/go',
configRaw: '{"runtime":"go"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-postgres',
name: 'PostgreSQL Database',
description: 'Managed PostgreSQL service with credential setup variables.',
category: 'database',
logo: 'https://cdn.simpleicons.org/postgresql',
configRaw: '{"runtime":"postgres"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-mysql',
name: 'MySQL Database',
description: 'Managed MySQL service for transactional workloads.',
category: 'database',
logo: 'https://cdn.simpleicons.org/mysql',
configRaw: '{"runtime":"mysql"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-mariadb',
name: 'MariaDB Database',
description: 'Managed MariaDB service with MySQL compatibility.',
category: 'database',
logo: 'https://cdn.simpleicons.org/mariadb',
configRaw: '{"runtime":"mariadb"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-clickhouse',
name: 'ClickHouse Database',
description: 'Columnar analytics database template for high-speed queries.',
category: 'database',
logo: 'https://cdn.simpleicons.org/clickhouse',
configRaw: '{"runtime":"clickhouse"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-dragonfly',
name: 'Dragonfly Database',
description: 'Redis-compatible in-memory store powered by Dragonfly.',
category: 'database',
logo: 'https://cdn.simpleicons.org/redis',
configRaw: '{"runtime":"dragonfly"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
];
export const demoTemplateDetails: Record<string, TemplateDetailEntity> = {
'tpl-react': {
template: demoTemplates[0],
config: {
type: 'web',
runtime: 'node',
buildCommand: 'npm install && npm run build',
startCommand: 'npx serve -s dist',
port: 3000,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'VITE_API_URL',
label: 'API URL',
defaultValue: 'https://api.example.com',
required: true,
secret: false,
description: 'Public API endpoint for frontend calls',
},
],
},
'tpl-go': {
template: demoTemplates[1],
config: {
type: 'web',
runtime: 'go',
buildCommand: 'go build -o app .',
startCommand: './app',
port: 8080,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'GO_ENV',
label: 'Go Environment',
defaultValue: 'production',
required: false,
secret: false,
description: 'Runtime environment value',
},
],
},
'tpl-postgres': {
template: demoTemplates[2],
config: {
type: 'database',
runtime: 'postgres',
buildCommand: '',
startCommand: '',
port: 5432,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'POSTGRES_USER',
label: 'Username',
defaultValue: 'postgres',
required: true,
secret: false,
description: 'Database user',
},
{
key: 'POSTGRES_PASSWORD',
label: 'Password',
defaultValue: '',
required: true,
secret: true,
description: 'Database password',
},
],
},
'tpl-mysql': {
template: demoTemplates[3],
config: {
type: 'database',
runtime: 'mysql',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MYSQL_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MYSQL_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MYSQL_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MYSQL_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-mariadb': {
template: demoTemplates[4],
config: {
type: 'database',
runtime: 'mariadb',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MARIADB_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MARIADB_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MARIADB_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MARIADB_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-clickhouse': {
template: demoTemplates[5],
config: {
type: 'database',
runtime: 'clickhouse',
buildCommand: '',
startCommand: '',
port: 8123,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'CLICKHOUSE_DB',
label: 'Database Name',
defaultValue: 'app',
required: false,
secret: false,
description: 'Default database name',
},
{
key: 'CLICKHOUSE_USER',
label: 'Username',
defaultValue: 'default',
required: false,
secret: false,
description: 'ClickHouse user',
},
{
key: 'CLICKHOUSE_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'ClickHouse password',
},
],
},
'tpl-dragonfly': {
template: demoTemplates[6],
config: {
type: 'database',
runtime: 'dragonfly',
buildCommand: '',
startCommand: '',
port: 6379,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'DRAGONFLY_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'Optional Redis-compatible password',
},
],
},
};
+17
View File
@@ -0,0 +1,17 @@
import { useSearchParams } from 'react-router-dom';
/**
* Build-time flag for public demo deployments that ship without a backend
* (e.g. the Vercel showcase). When enabled every route behaves as if
* `?demo=1` was passed — fixtures render instead of API calls.
*/
export const PUBLIC_DEMO = (import.meta.env.VITE_PUBLIC_DEMO ?? '') === '1';
export function isDemoSearch(search: string): boolean {
return PUBLIC_DEMO || new URLSearchParams(search).get('demo') === '1';
}
export function useDemoMode(): boolean {
const [searchParams] = useSearchParams();
return PUBLIC_DEMO || searchParams.get('demo') === '1';
}
@@ -78,7 +78,7 @@ export function LineChart({
return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 320, height }}>
<RechartsLineChart data={chartData}>
<Line
type={smooth ? 'monotone' : 'linear'}
@@ -105,7 +105,7 @@ export function LineAreaChart({
return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 320, height }}>
<AreaChart data={chartData}>
<defs>
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
@@ -142,7 +142,7 @@ export function MultiLineChart({
return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 320, height }}>
<AreaChart data={chartData}>
<defs>
{datasets.map((dataset, index) => {
@@ -0,0 +1,25 @@
import { Link } from 'react-router-dom';
import { Sparkles } from 'lucide-react';
/**
* Shown by auth/admin-scoped pages when `?demo=1` is active — the demo only
* carries sample project/platform data, not real accounts or admin state.
*/
export function DemoRestricted({ feature }: { feature: string }) {
return (
<div className="flex min-h-[60vh] items-center justify-center p-8">
<div className="panel max-w-md p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]">
<Sparkles size={22} className="text-[var(--accent-primary)]" />
</div>
<p className="text-lg font-semibold text-[var(--text-primary)]">Not part of the demo</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
{feature} needs a live Containr session. The demo covers sample projects, services, and platform views only.
</p>
<Link to="/projects?demo=1" className="v-btn mt-5 inline-flex">
Back to demo projects
</Link>
</div>
</div>
);
}
@@ -1,3 +1,4 @@
export * from './DemoGate';
export * from './ErrorBoundary';
export * from './LoadingSpinner';
export * from './Toast';
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "vite",
"buildCommand": "npm run build",
"outputDirectory": "dist",
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}