mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
84b102a2c6
commit
5b0a8e0759
20 files changed
+845
-497
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
.vercel
|
||||||
|
.env*
|
||||||
@@ -32,7 +32,7 @@ describe('App smoke routes', () => {
|
|||||||
|
|
||||||
expect(await screen.findByRole('heading', { name: /^projects\s*_?$/i })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: /^projects\s*_?$/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/recent deployments/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 () => {
|
it('renders builds page in demo mode', async () => {
|
||||||
|
|||||||
@@ -23,14 +23,10 @@ import {
|
|||||||
} from '@/features/secondary/pages';
|
} from '@/features/secondary/pages';
|
||||||
import { SignInPage, SignUpPage } from '@/features/auth/pages';
|
import { SignInPage, SignUpPage } from '@/features/auth/pages';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||||
import { ErrorBoundary, LoadingState } from '@/shared/components';
|
import { ErrorBoundary, LoadingState } from '@/shared/components';
|
||||||
|
|
||||||
function useDemoMode() {
|
|
||||||
const location = useLocation();
|
|
||||||
return new URLSearchParams(location.search).get('demo') === '1';
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthRequired() {
|
function AuthRequired() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isDemoMode = useDemoMode();
|
const isDemoMode = useDemoMode();
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
pullUpgradeImage,
|
pullUpgradeImage,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { signOutAuthSession } from '@/lib/auth-client';
|
import { signOutAuthSession } from '@/lib/auth-client';
|
||||||
|
import { isDemoSearch } from '@/lib/demo-mode';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { useToast } from '@/shared/components';
|
import { useToast } from '@/shared/components';
|
||||||
|
|
||||||
@@ -103,8 +104,18 @@ export function PlatformShell() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showToast } = useToast();
|
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);
|
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 [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
|
||||||
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
|
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
|
||||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
@@ -118,7 +129,7 @@ export function PlatformShell() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const isAdmin = isDemoMode || Boolean(profileQuery.data?.isAdmin);
|
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
|
||||||
const upgradeQuery = useQuery({
|
const upgradeQuery = useQuery({
|
||||||
queryKey: ['upgrade-status'],
|
queryKey: ['upgrade-status'],
|
||||||
queryFn: getUpgradeStatus,
|
queryFn: getUpgradeStatus,
|
||||||
@@ -174,8 +185,8 @@ export function PlatformShell() {
|
|||||||
const isActiveRoute = (itemHref: string) =>
|
const isActiveRoute = (itemHref: string) =>
|
||||||
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
|
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
|
||||||
|
|
||||||
const userName = profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
|
const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
|
||||||
const userEmail = profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
|
const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
|
||||||
const initials = userName
|
const initials = userName
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((part) => part[0])
|
.map((part) => part[0])
|
||||||
@@ -220,7 +231,7 @@ export function PlatformShell() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
<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}>
|
<div key={section.title}>
|
||||||
{sidebarExpanded ? (
|
{sidebarExpanded ? (
|
||||||
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
|
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
|
||||||
@@ -297,6 +308,8 @@ export function PlatformShell() {
|
|||||||
<p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p>
|
<p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
|
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
|
||||||
|
{!isDemoMode ? (
|
||||||
|
<>
|
||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
|
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
|
||||||
<User size={14} /> Profile & account
|
<User size={14} /> Profile & account
|
||||||
@@ -319,6 +332,8 @@ export function PlatformShell() {
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -331,6 +346,15 @@ export function PlatformShell() {
|
|||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
|
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
|
||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void signOut()}
|
onClick={() => void signOut()}
|
||||||
@@ -338,6 +362,7 @@ export function PlatformShell() {
|
|||||||
>
|
>
|
||||||
<LogOut size={14} /> Sign out
|
<LogOut size={14} /> Sign out
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
@@ -382,7 +407,7 @@ export function PlatformShell() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-4">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
||||||
@@ -393,11 +418,11 @@ export function PlatformShell() {
|
|||||||
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
|
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isAdmin ? (
|
{isAdmin && !isDemoMode ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => pullMutation.mutate()}
|
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"
|
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)' }}
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
title={upgradeQuery.data?.message || 'Pull latest configured image'}
|
title={upgradeQuery.data?.message || 'Pull latest configured image'}
|
||||||
@@ -407,7 +432,7 @@ export function PlatformShell() {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{signedIn ? (
|
{signedIn && !isDemoMode ? (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -504,7 +529,7 @@ export function PlatformShell() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex gap-1 overflow-x-auto px-3 pb-3">
|
<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 Icon = item.icon;
|
||||||
const isActive = isActiveRoute(item.href);
|
const isActive = isActiveRoute(item.href);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
type ProjectEntity,
|
type ProjectEntity,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
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> = {
|
const statLabels: Record<string, string> = {
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
@@ -42,12 +43,13 @@ const statLabels: Record<string, string> = {
|
|||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const sessionQuery = useAuthSession();
|
const isDemoMode = useDemoMode();
|
||||||
|
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||||
const myId = sessionQuery.data?.user.id;
|
const myId = sessionQuery.data?.user.id;
|
||||||
|
|
||||||
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview });
|
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
|
||||||
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers });
|
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
|
||||||
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }) });
|
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
|
||||||
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
|
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
|
||||||
const [editForm, setEditForm] = useState({ name: '', description: '' });
|
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),
|
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
return <DemoRestricted feature="The admin console" />;
|
||||||
|
}
|
||||||
|
|
||||||
if (overviewQuery.isPending) {
|
if (overviewQuery.isPending) {
|
||||||
return <LoadingState message="Loading admin console…" className="py-24" />;
|
return <LoadingState message="Loading admin console…" className="py-24" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useDeferredValue, useState } from 'react';
|
import { useDeferredValue, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { listAuditLogs } from '@/lib/api-client';
|
import { listAuditLogs } from '@/lib/api-client';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
import { formatDate, formatRelative } from '@/lib/time';
|
import { formatDate, formatRelative } from '@/lib/time';
|
||||||
|
import { DemoRestricted } from '@/shared/components';
|
||||||
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
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';
|
'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() {
|
export function AuditLogsPage() {
|
||||||
|
const isDemoMode = useDemoMode();
|
||||||
const [resource, setResource] = useState('');
|
const [resource, setResource] = useState('');
|
||||||
const [action, setAction] = useState('');
|
const [action, setAction] = useState('');
|
||||||
const [actor, setActor] = 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;
|
const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined;
|
||||||
return listAuditLogs({ ...filters, since });
|
return listAuditLogs({ ...filters, since });
|
||||||
},
|
},
|
||||||
|
enabled: !isDemoMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
return <DemoRestricted feature="Audit logs" />;
|
||||||
|
}
|
||||||
|
|
||||||
const logs = logsQuery.data ?? [];
|
const logs = logsQuery.data ?? [];
|
||||||
const hasNext = logs.length === PAGE_SIZE;
|
const hasNext = logs.length === PAGE_SIZE;
|
||||||
const hasFilters = Boolean(resource || action || actor || range);
|
const hasFilters = Boolean(resource || action || actor || range);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
cancelBuild,
|
cancelBuild,
|
||||||
getBuildLogs,
|
getBuildLogs,
|
||||||
@@ -9,6 +8,8 @@ import {
|
|||||||
type BuildStatus,
|
type BuildStatus,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { useBuildUpdates } from '@/lib/use-build-updates';
|
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 { formatRelative } from '@/lib/time';
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
@@ -33,36 +34,6 @@ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
|
|||||||
{ value: 'cancelled', label: 'Cancelled' },
|
{ 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 }) {
|
function StatusBadge({ status }: { status: BuildStatus }) {
|
||||||
const config = {
|
const config = {
|
||||||
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
|
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
|
||||||
@@ -99,8 +70,7 @@ function bytesToHumanReadable(bytes: number): string {
|
|||||||
|
|
||||||
export function BuildsPage() {
|
export function BuildsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchParams] = useSearchParams();
|
const isDemoMode = useDemoMode();
|
||||||
const isDemoMode = searchParams.get('demo') === '1';
|
|
||||||
|
|
||||||
const [projectFilter, setProjectFilter] = useState('');
|
const [projectFilter, setProjectFilter] = useState('');
|
||||||
const [serviceFilter, setServiceFilter] = useState('');
|
const [serviceFilter, setServiceFilter] = useState('');
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
import { listProjects, listTemplates } from '@/lib/api-client';
|
import { listProjects, listTemplates } from '@/lib/api-client';
|
||||||
import { DocsBrowser } from '@/features/docs/DocsBrowser';
|
import { DocsBrowser } from '@/features/docs/DocsBrowser';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
|
import { demoProjects, demoTemplates } from '@/lib/demo-data';
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{ icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' },
|
{ 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() {
|
export function LandingPage() {
|
||||||
const sessionQuery = useAuthSession();
|
const isDemoMode = useDemoMode();
|
||||||
const signedIn = Boolean(sessionQuery.data);
|
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 projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode });
|
||||||
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000 });
|
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode });
|
||||||
|
|
||||||
const projects = projectsQuery.data ?? [];
|
const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []);
|
||||||
const templates = (templatesQuery.data ?? []).slice(0, 12);
|
const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]">
|
<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="#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>
|
<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
|
<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)]"
|
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
|
Browse
|
||||||
@@ -65,10 +69,10 @@ export function LandingPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<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)]"
|
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>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -91,10 +95,10 @@ export function LandingPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-8 flex items-center justify-center gap-3">
|
<div className="mt-8 flex items-center justify-center gap-3">
|
||||||
<Link
|
<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]"
|
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>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="#docs"
|
href="#docs"
|
||||||
@@ -152,7 +156,7 @@ export function LandingPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/templates"
|
to={`/templates${demo}`}
|
||||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline"
|
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline"
|
||||||
>
|
>
|
||||||
Browse all <ArrowRight size={13} />
|
Browse all <ArrowRight size={13} />
|
||||||
@@ -162,7 +166,7 @@ export function LandingPage() {
|
|||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
<Link
|
<Link
|
||||||
key={template.id}
|
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)]"
|
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">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -212,9 +216,9 @@ export function LandingPage() {
|
|||||||
Containr — self-hosted container platform
|
Containr — self-hosted container platform
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link to="/projects" className="hover:text-[var(--text-secondary)]">Projects</Link>
|
<Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link>
|
||||||
<Link to="/templates" className="hover:text-[var(--text-secondary)]">Templates</Link>
|
<Link to={`/templates${demo}`} className="hover:text-[var(--text-secondary)]">Templates</Link>
|
||||||
<Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link>
|
{!isDemoMode ? <Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
createProject,
|
createProject,
|
||||||
@@ -15,33 +15,14 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
const demoProjects: ProjectEntity[] = [
|
import { demoProjects } from '@/lib/demo-data';
|
||||||
{
|
|
||||||
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 },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const demoDeploys = [
|
const demoDeploys = [
|
||||||
{ id: 'd1', name: 'api-gateway', project: 'core-services', status: 'DEPLOYED', when: '4m' },
|
{ id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' },
|
||||||
{ id: 'd2', name: 'web-frontend', project: 'core-services', status: 'BUILDING', when: '6m' },
|
{ id: 'd2', name: 'Web Frontend', project: 'Core Platform', status: 'BUILDING', when: '6m' },
|
||||||
{ id: 'd3', name: 'worker', project: 'ml-pipeline', status: 'FAILED', when: '1h' },
|
{ id: 'd3', name: 'Batch Evaluator', project: 'Inference Lab', status: 'FAILED', when: '1h' },
|
||||||
{ id: 'd4', name: 'site', project: 'growth-site', status: 'DEPLOYED', when: '2h' },
|
{ id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getHealthStatus(stats: ProjectStats): 'healthy' | 'degraded' | 'critical' {
|
function getHealthStatus(stats: ProjectStats): 'healthy' | 'degraded' | 'critical' {
|
||||||
@@ -137,9 +118,8 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
|
|||||||
|
|
||||||
export function ProjectsPage() {
|
export function ProjectsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isDemoMode = searchParams.get('demo') === '1';
|
const isDemoMode = useDemoMode();
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [isCreateOpen, setCreateOpen] = useState(false);
|
const [isCreateOpen, setCreateOpen] = useState(false);
|
||||||
|
|||||||
@@ -50,13 +50,27 @@ import {
|
|||||||
type DatabaseEntity,
|
type DatabaseEntity,
|
||||||
type FailoverPolicy,
|
type FailoverPolicy,
|
||||||
} from '@/lib/api-client';
|
} 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 { DocsBrowser } from '@/features/docs/DocsBrowser';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
import { formatRelative } from '@/lib/time';
|
import { formatRelative } from '@/lib/time';
|
||||||
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
|
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
|
||||||
import { useBuildUpdates } from '@/lib/use-build-updates';
|
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 {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
Activity,
|
Activity,
|
||||||
@@ -203,25 +217,27 @@ function formatUptime(seconds: number): string {
|
|||||||
|
|
||||||
export function UsagePage() {
|
export function UsagePage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const sessionQuery = useAuthSession();
|
const isDemoMode = useDemoMode();
|
||||||
|
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||||
const profileQuery = useQuery({
|
const profileQuery = useQuery({
|
||||||
queryKey: ['user-profile'],
|
queryKey: ['user-profile'],
|
||||||
queryFn: getCurrentUserProfile,
|
queryFn: getCurrentUserProfile,
|
||||||
enabled: Boolean(sessionQuery.data),
|
enabled: !isDemoMode && Boolean(sessionQuery.data),
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const signedIn = Boolean(sessionQuery.data);
|
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||||
const isAdmin = Boolean(profileQuery.data?.isAdmin);
|
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
|
||||||
const buildsQuery = useQuery({
|
const buildsQuery = useQuery({
|
||||||
queryKey: ['usage-builds'],
|
queryKey: ['usage-builds'],
|
||||||
queryFn: () => listBuilds({ page: 1, limit: 100 }),
|
queryFn: () => listBuilds({ page: 1, limit: 100 }),
|
||||||
enabled: signedIn,
|
enabled: signedIn && !isDemoMode,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const hostQuery = useQuery({
|
const hostQuery = useQuery({
|
||||||
queryKey: ['usage-host-monitoring'],
|
queryKey: ['usage-host-monitoring'],
|
||||||
queryFn: getHostMonitoring,
|
queryFn: getHostMonitoring,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
|
enabled: !isDemoMode,
|
||||||
});
|
});
|
||||||
const agentsQuery = useQuery({
|
const agentsQuery = useQuery({
|
||||||
queryKey: ['usage-agents'],
|
queryKey: ['usage-agents'],
|
||||||
@@ -251,9 +267,12 @@ export function UsagePage() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
|
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(
|
const liveStatus = useBuildUpdates(
|
||||||
builds.map((build) => build.id),
|
isDemoMode ? [] : builds.map((build) => build.id),
|
||||||
() => {
|
() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
|
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
|
||||||
},
|
},
|
||||||
@@ -280,20 +299,19 @@ export function UsagePage() {
|
|||||||
const avgBuildMinutes = averageBuildDurationMs / 60_000;
|
const avgBuildMinutes = averageBuildDurationMs / 60_000;
|
||||||
const activeBuildPressure = runningBuilds + pendingBuilds;
|
const activeBuildPressure = runningBuilds + pendingBuilds;
|
||||||
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
|
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 ?? [];
|
const agents = agentsQuery.data ?? [];
|
||||||
|
|
||||||
// Accumulate host samples for the load sparkline (polled every 15s).
|
// Accumulate host samples for the load sparkline (polled every 15s).
|
||||||
const [loadHistory, setLoadHistory] = useState<number[]>([]);
|
const [loadHistory, setLoadHistory] = useState<number[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sample = hostQuery.data;
|
if (!host || host.cpu.cores <= 0) {
|
||||||
if (!sample || sample.cpu.cores <= 0) {
|
|
||||||
return;
|
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
|
// 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));
|
setLoadHistory((prev) => [...prev, loadPercent].slice(-24));
|
||||||
}, [hostQuery.data]);
|
}, [host]);
|
||||||
|
|
||||||
const hostLoadPercent = host && host.cpu.cores > 0
|
const hostLoadPercent = host && host.cpu.cores > 0
|
||||||
? Math.min(100, (host.load.load1m / host.cpu.cores) * 100)
|
? Math.min(100, (host.load.load1m / host.cpu.cores) * 100)
|
||||||
@@ -307,14 +325,14 @@ export function UsagePage() {
|
|||||||
const buildsPerDay = useMemo(() => {
|
const buildsPerDay = useMemo(() => {
|
||||||
const days = 14;
|
const days = 14;
|
||||||
const counts = new Array<number>(days).fill(0);
|
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())
|
.map((build) => new Date((build.startedAt ?? build.completedAt) as string).getTime())
|
||||||
.filter((t) => Number.isFinite(t));
|
.filter((t) => Number.isFinite(t));
|
||||||
if (timestamps.length === 0) {
|
if (timestamps.length === 0) {
|
||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
const anchor = Math.max(...timestamps);
|
const anchor = Math.max(...timestamps);
|
||||||
(buildsQuery.data?.builds ?? []).forEach((build) => {
|
builds.forEach((build) => {
|
||||||
const timestamp = build.startedAt ?? build.completedAt;
|
const timestamp = build.startedAt ?? build.completedAt;
|
||||||
if (!timestamp) {
|
if (!timestamp) {
|
||||||
return;
|
return;
|
||||||
@@ -327,7 +345,7 @@ export function UsagePage() {
|
|||||||
});
|
});
|
||||||
const peak = Math.max(...counts, 1);
|
const peak = Math.max(...counts, 1);
|
||||||
return counts.map((count) => (count / peak) * 100);
|
return counts.map((count) => (count / peak) * 100);
|
||||||
}, [buildsQuery.data]);
|
}, [builds]);
|
||||||
const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length;
|
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 totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
|
||||||
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
||||||
@@ -382,12 +400,12 @@ containr-agent`;
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{buildsQuery.isLoading ? (
|
{!isDemoMode && buildsQuery.isLoading ? (
|
||||||
<div className="py-16 text-center">
|
<div className="py-16 text-center">
|
||||||
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
<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>
|
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p>
|
||||||
</div>
|
</div>
|
||||||
) : buildsQuery.isError ? (
|
) : !isDemoMode && buildsQuery.isError ? (
|
||||||
<div className="panel p-8 text-center">
|
<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">
|
<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)]" />
|
<AlertCircle size={24} className="text-[var(--error)]" />
|
||||||
@@ -455,7 +473,7 @@ containr-agent`;
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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)]">
|
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
|
||||||
Host monitoring unavailable.
|
Host monitoring unavailable.
|
||||||
</div>
|
</div>
|
||||||
@@ -672,9 +690,11 @@ containr-agent`;
|
|||||||
|
|
||||||
export function PeoplePage() {
|
export function PeoplePage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isDemoMode = useDemoMode();
|
||||||
const profileQuery = useQuery({
|
const profileQuery = useQuery({
|
||||||
queryKey: ['user-profile'],
|
queryKey: ['user-profile'],
|
||||||
queryFn: getCurrentUserProfile,
|
queryFn: getCurrentUserProfile,
|
||||||
|
enabled: !isDemoMode,
|
||||||
});
|
});
|
||||||
const [newUserName, setNewUserName] = useState('');
|
const [newUserName, setNewUserName] = useState('');
|
||||||
const [newUserEmail, setNewUserEmail] = useState('');
|
const [newUserEmail, setNewUserEmail] = useState('');
|
||||||
@@ -699,12 +719,18 @@ export function PeoplePage() {
|
|||||||
const projectsQuery = useQuery({
|
const projectsQuery = useQuery({
|
||||||
queryKey: ['people-projects'],
|
queryKey: ['people-projects'],
|
||||||
queryFn: () => listProjects(),
|
queryFn: () => listProjects(),
|
||||||
|
enabled: !isDemoMode,
|
||||||
});
|
});
|
||||||
const auditLogsQuery = useQuery({
|
const auditLogsQuery = useQuery({
|
||||||
queryKey: ['people-audit-logs'],
|
queryKey: ['people-audit-logs'],
|
||||||
queryFn: () => listAuditLogs({ page: 1, limit: 20 }),
|
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.';
|
let membersBody = 'Owner-only mode currently. Multi-user collaboration is planned.';
|
||||||
|
|
||||||
if (profileQuery.isLoading) {
|
if (profileQuery.isLoading) {
|
||||||
@@ -1242,9 +1268,11 @@ function PlatformSettingsSection() {
|
|||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isDemoMode = useDemoMode();
|
||||||
const profileQuery = useQuery({
|
const profileQuery = useQuery({
|
||||||
queryKey: ['settings-profile'],
|
queryKey: ['settings-profile'],
|
||||||
queryFn: getCurrentUserProfile,
|
queryFn: getCurrentUserProfile,
|
||||||
|
enabled: !isDemoMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [nameDraft, setNameDraft] = useState<string | null>(null);
|
const [nameDraft, setNameDraft] = useState<string | null>(null);
|
||||||
@@ -1308,6 +1336,10 @@ export function SettingsPage() {
|
|||||||
refreshStorage();
|
refreshStorage();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
return <DemoRestricted feature="Account and platform settings" />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<SecondaryPageHeader
|
<SecondaryPageHeader
|
||||||
@@ -1561,8 +1593,7 @@ const DATABASE_PLANS = ['hobby', 'starter', 'standard', 'business'] as const;
|
|||||||
|
|
||||||
export function DatabasesPage() {
|
export function DatabasesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchParams] = useSearchParams();
|
const isDemoMode = useDemoMode();
|
||||||
const isDemoMode = searchParams.get('demo') === '1';
|
|
||||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
@@ -2074,14 +2105,15 @@ const failoverStrategies = [
|
|||||||
|
|
||||||
export function HighAvailabilityPage() {
|
export function HighAvailabilityPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const sessionQuery = useAuthSession();
|
const isDemoMode = useDemoMode();
|
||||||
|
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||||
const profileQuery = useQuery({
|
const profileQuery = useQuery({
|
||||||
queryKey: ['user-profile'],
|
queryKey: ['user-profile'],
|
||||||
queryFn: getCurrentUserProfile,
|
queryFn: getCurrentUserProfile,
|
||||||
enabled: Boolean(sessionQuery.data),
|
enabled: !isDemoMode && Boolean(sessionQuery.data),
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const isAdmin = Boolean(profileQuery.data?.isAdmin);
|
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
|
||||||
const [failoverReason, setFailoverReason] = useState('');
|
const [failoverReason, setFailoverReason] = useState('');
|
||||||
const [policyForm, setPolicyForm] = useState<{
|
const [policyForm, setPolicyForm] = useState<{
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
@@ -2093,11 +2125,11 @@ export function HighAvailabilityPage() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [policyProjectId, setPolicyProjectId] = useState('');
|
const [policyProjectId, setPolicyProjectId] = useState('');
|
||||||
|
|
||||||
const statusQuery = useQuery({ queryKey: ['ha-status'], queryFn: getHAStatus, refetchInterval: 15_000 });
|
const statusQuery = useQuery({ queryKey: ['ha-status'], queryFn: getHAStatus, refetchInterval: 15_000, enabled: !isDemoMode });
|
||||||
const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies });
|
const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies, enabled: !isDemoMode });
|
||||||
const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000 });
|
const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000, enabled: !isDemoMode });
|
||||||
const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000 });
|
const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000, enabled: !isDemoMode });
|
||||||
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() });
|
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
|
||||||
const servicesQuery = useQuery({
|
const servicesQuery = useQuery({
|
||||||
queryKey: ['services', policyProjectId],
|
queryKey: ['services', policyProjectId],
|
||||||
queryFn: () => listServicesByProject(policyProjectId),
|
queryFn: () => listServicesByProject(policyProjectId),
|
||||||
@@ -2142,7 +2174,10 @@ export function HighAvailabilityPage() {
|
|||||||
onSuccess: invalidate,
|
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 =
|
const pageError =
|
||||||
statusQuery.error ?? policiesQuery.error ?? alertsQuery.error ?? healthQuery.error ??
|
statusQuery.error ?? policiesQuery.error ?? alertsQuery.error ?? healthQuery.error ??
|
||||||
toggleMutation.error ?? failoverMutation.error ?? savePolicyMutation.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)]">
|
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
||||||
<ShieldCheck size={13} className="text-[var(--accent-primary)]" />
|
<ShieldCheck size={13} className="text-[var(--accent-primary)]" />
|
||||||
<span>HA state feeds the platform security posture.</span>
|
<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 →
|
Open Security →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -2246,13 +2281,13 @@ export function HighAvailabilityPage() {
|
|||||||
<AlertCircle size={18} className="text-[var(--accent-primary)]" />
|
<AlertCircle size={18} className="text-[var(--accent-primary)]" />
|
||||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2>
|
<h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2>
|
||||||
</div>
|
</div>
|
||||||
{alertsQuery.isLoading ? (
|
{!isDemoMode && alertsQuery.isLoading ? (
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
<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>
|
<p className="text-sm text-[var(--text-tertiary)]">No active alerts.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(alertsQuery.data ?? []).map((alert) => (
|
{alerts.map((alert) => (
|
||||||
<div
|
<div
|
||||||
key={alert.id}
|
key={alert.id}
|
||||||
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{policiesQuery.isLoading ? (
|
{!isDemoMode && policiesQuery.isLoading ? (
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
<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>
|
<p className="text-sm text-[var(--text-tertiary)]">No failover policies configured.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(policiesQuery.data ?? []).map((policy: FailoverPolicy) => (
|
{policies.map((policy: FailoverPolicy) => (
|
||||||
<div
|
<div
|
||||||
key={policy.service_id}
|
key={policy.service_id}
|
||||||
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
|
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)]" />
|
<HeartPulse size={18} className="text-[var(--accent-primary)]" />
|
||||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2>
|
<h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2>
|
||||||
</div>
|
</div>
|
||||||
{healthQuery.isLoading ? (
|
{!isDemoMode && healthQuery.isLoading ? (
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
<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>
|
<p className="text-sm text-[var(--text-tertiary)]">No health checks configured.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(healthQuery.data ?? []).slice(0, 20).map((result, i) => (
|
{healthResults.slice(0, 20).map((result, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${result.check_id}-${i}`}
|
key={`${result.check_id}-${i}`}
|
||||||
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
|
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() {
|
export function SecurityPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const sessionQuery = useAuthSession();
|
const isDemoMode = useDemoMode();
|
||||||
const signedIn = Boolean(sessionQuery.data);
|
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||||
const [projectId, setProjectId] = useState('');
|
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
|
||||||
|
const [projectId, setProjectId] = useState(isDemoMode ? 'demo-project-core' : '');
|
||||||
const [scanType, setScanType] = useState<string>('comprehensive');
|
const [scanType, setScanType] = useState<string>('comprehensive');
|
||||||
const [scanServiceId, setScanServiceId] = useState('');
|
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({
|
const servicesQuery = useQuery({
|
||||||
queryKey: ['services', projectId],
|
queryKey: ['services', projectId],
|
||||||
queryFn: () => listServicesByProject(projectId),
|
queryFn: () => listServicesByProject(projectId),
|
||||||
enabled: Boolean(projectId),
|
enabled: !isDemoMode && Boolean(projectId),
|
||||||
});
|
});
|
||||||
|
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? [];
|
||||||
const metricsQuery = useQuery({
|
const metricsQuery = useQuery({
|
||||||
queryKey: ['security-metrics', projectId],
|
queryKey: ['security-metrics', projectId],
|
||||||
queryFn: () => getSecurityMetrics(projectId),
|
queryFn: () => getSecurityMetrics(projectId),
|
||||||
enabled: Boolean(projectId),
|
enabled: !isDemoMode && Boolean(projectId),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
const historyQuery = useQuery({
|
const historyQuery = useQuery({
|
||||||
queryKey: ['security-history', projectId],
|
queryKey: ['security-history', projectId],
|
||||||
queryFn: () => getSecurityHistory(projectId),
|
queryFn: () => getSecurityHistory(projectId),
|
||||||
enabled: Boolean(projectId),
|
enabled: !isDemoMode && Boolean(projectId),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
const vulnsQuery = useQuery({
|
const vulnsQuery = useQuery({
|
||||||
queryKey: ['security-vulns', projectId],
|
queryKey: ['security-vulns', projectId],
|
||||||
queryFn: () => listVulnerabilities(projectId),
|
queryFn: () => listVulnerabilities(projectId),
|
||||||
enabled: Boolean(projectId),
|
enabled: !isDemoMode && Boolean(projectId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
@@ -2559,8 +2597,14 @@ export function SecurityPage() {
|
|||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const metrics = metricsQuery.data;
|
const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
|
||||||
const vulns = (vulnsQuery.data ?? []).filter((v) => v.status !== 'resolved');
|
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 =
|
const pageError =
|
||||||
projectsQuery.error ?? metricsQuery.error ?? historyQuery.error ?? vulnsQuery.error ??
|
projectsQuery.error ?? metricsQuery.error ?? historyQuery.error ?? vulnsQuery.error ??
|
||||||
scanMutation.error ?? vulnMutation.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)]">
|
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
||||||
<HeartPulse size={13} className="text-[var(--accent-primary)]" />
|
<HeartPulse size={13} className="text-[var(--accent-primary)]" />
|
||||||
<span>Platform availability and failover live under High availability.</span>
|
<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 →
|
Open HA →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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"
|
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>
|
<option value="">Select project…</option>
|
||||||
{(projectsQuery.data ?? []).map((p) => (
|
{projects.map((p) => (
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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)]"
|
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>
|
<option value="">All services</option>
|
||||||
{(servicesQuery.data ?? []).map((s) => (
|
{services.map((s) => (
|
||||||
<option key={s.id} value={s.id}>{s.name}</option>
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -2636,7 +2680,7 @@ export function SecurityPage() {
|
|||||||
{scanMutation.isPending ? 'Starting…' : 'Run scan'}
|
{scanMutation.isPending ? 'Starting…' : 'Run scan'}
|
||||||
</button>
|
</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)]" />
|
<Shield size={18} className="text-[var(--accent-primary)]" />
|
||||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2>
|
<h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2>
|
||||||
</div>
|
</div>
|
||||||
{vulnsQuery.isLoading ? (
|
{!isDemoMode && vulnsQuery.isLoading ? (
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||||
) : vulns.length === 0 ? (
|
) : vulns.length === 0 ? (
|
||||||
<p className="text-sm text-[var(--text-tertiary)]">No open findings.</p>
|
<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)]" />
|
<Clock size={18} className="text-[var(--accent-primary)]" />
|
||||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2>
|
<h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2>
|
||||||
</div>
|
</div>
|
||||||
{historyQuery.isLoading ? (
|
{!isDemoMode && historyQuery.isLoading ? (
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
<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>
|
<p className="text-sm text-[var(--text-tertiary)]">No scans yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(historyQuery.data ?? []).map((scan) => (
|
{scanHistory.map((scan) => (
|
||||||
<div
|
<div
|
||||||
key={scan.id}
|
key={scan.id}
|
||||||
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
|
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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
createDeployment,
|
createDeployment,
|
||||||
deleteService,
|
deleteService,
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
type CronJobEntity,
|
type CronJobEntity,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||||
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
|
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
|
||||||
@@ -120,10 +121,9 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
|
|
||||||
export function ServiceDetailPage() {
|
export function ServiceDetailPage() {
|
||||||
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
|
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isDemoMode = searchParams.get('demo') === '1';
|
const isDemoMode = useDemoMode();
|
||||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||||
const profileQuery = useQuery({
|
const profileQuery = useQuery({
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import {
|
|||||||
listProjects,
|
listProjects,
|
||||||
listTemplates,
|
listTemplates,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
type TemplateDetailEntity,
|
|
||||||
type TemplateEntity,
|
type TemplateEntity,
|
||||||
type TemplateWriteInput,
|
type TemplateWriteInput,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
|
import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
@@ -37,327 +38,6 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} 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 {
|
function toServiceName(value: string): string {
|
||||||
const normalized = value
|
const normalized = value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -428,8 +108,8 @@ const EMPTY_TEMPLATE_JSON = JSON.stringify(
|
|||||||
|
|
||||||
export function TemplatesPage() {
|
export function TemplatesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const isDemoMode = searchParams.get('demo') === '1';
|
const isDemoMode = useDemoMode();
|
||||||
|
|
||||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||||
@@ -449,7 +129,18 @@ export function TemplatesPage() {
|
|||||||
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState('');
|
const [categoryFilter, setCategoryFilter] = useState('');
|
||||||
const [searchFilter, setSearchFilter] = 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 [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? '');
|
||||||
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
|
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
|
||||||
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
||||||
@@ -507,11 +198,11 @@ export function TemplatesPage() {
|
|||||||
if (filteredTemplates.length === 0) {
|
if (filteredTemplates.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (selectedTemplateIdState && filteredTemplates.some((template) => template.id === selectedTemplateIdState)) {
|
if (templateParam && filteredTemplates.some((template) => template.id === templateParam)) {
|
||||||
return selectedTemplateIdState;
|
return templateParam;
|
||||||
}
|
}
|
||||||
return filteredTemplates[0].id;
|
return filteredTemplates[0].id;
|
||||||
}, [filteredTemplates, selectedTemplateIdState]);
|
}, [filteredTemplates, templateParam]);
|
||||||
|
|
||||||
const templateDetailQuery = useQuery({
|
const templateDetailQuery = useQuery({
|
||||||
queryKey: ['template-detail-page', selectedTemplateId],
|
queryKey: ['template-detail-page', selectedTemplateId],
|
||||||
@@ -637,7 +328,7 @@ export function TemplatesPage() {
|
|||||||
setEditorOpen(false);
|
setEditorOpen(false);
|
||||||
setEditingTemplate(null);
|
setEditingTemplate(null);
|
||||||
setEditorError(null);
|
setEditorError(null);
|
||||||
setSelectedTemplateId(template.id);
|
selectTemplate(template.id);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setEditorError(error instanceof Error ? error.message : 'Failed to save template');
|
setEditorError(error instanceof Error ? error.message : 'Failed to save template');
|
||||||
@@ -648,7 +339,7 @@ export function TemplatesPage() {
|
|||||||
mutationFn: (id: string) => deleteTemplate(id),
|
mutationFn: (id: string) => deleteTemplate(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['templates-page'] });
|
queryClient.invalidateQueries({ queryKey: ['templates-page'] });
|
||||||
setSelectedTemplateId(null);
|
selectTemplate(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -846,7 +537,7 @@ export function TemplatesPage() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={template.id}
|
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 ${
|
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
|
||||||
selected
|
selected
|
||||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type CreateServiceInput,
|
type CreateServiceInput,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
|
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { formatDate, formatRelative } from '@/lib/time';
|
import { formatDate, formatRelative } from '@/lib/time';
|
||||||
import type { ServiceVariable } from '../auto-connections';
|
import type { ServiceVariable } from '../auto-connections';
|
||||||
@@ -466,7 +467,7 @@ export function ProjectWorkspacePage() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas';
|
const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas';
|
||||||
const isDemoMode = searchParams.get('demo') === '1';
|
const isDemoMode = useDemoMode();
|
||||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|||||||
@@ -424,7 +424,10 @@ async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
throw new ApiError(message, response.status);
|
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> {
|
async function requestText(path: string, init?: RequestInit): Promise<string> {
|
||||||
|
|||||||
@@ -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';
|
import type { ServiceVariable } from '@/features/workspace/auto-connections';
|
||||||
|
|
||||||
export const demoProjects: ProjectEntity[] = [
|
export const demoProjects: ProjectEntity[] = [
|
||||||
@@ -332,3 +348,553 @@ export const demoCronJobsByService: Record<string, CronJobEntity[]> = {
|
|||||||
export function getDemoCronJobsByService(serviceId: string): CronJobEntity[] {
|
export function getDemoCronJobsByService(serviceId: string): CronJobEntity[] {
|
||||||
return demoCronJobsByService[serviceId] ?? [];
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
|
<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}>
|
<RechartsLineChart data={chartData}>
|
||||||
<Line
|
<Line
|
||||||
type={smooth ? 'monotone' : 'linear'}
|
type={smooth ? 'monotone' : 'linear'}
|
||||||
@@ -105,7 +105,7 @@ export function LineAreaChart({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
|
<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}>
|
<AreaChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||||
@@ -142,7 +142,7 @@ export function MultiLineChart({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
|
<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}>
|
<AreaChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
{datasets.map((dataset, index) => {
|
{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 './ErrorBoundary';
|
||||||
export * from './LoadingSpinner';
|
export * from './LoadingSpinner';
|
||||||
export * from './Toast';
|
export * from './Toast';
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
|
"framework": "vite",
|
||||||
|
"buildCommand": "npm run build",
|
||||||
|
"outputDirectory": "dist",
|
||||||
|
"rewrites": [
|
||||||
|
{ "source": "/(.*)", "destination": "/index.html" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user