diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx index 24aeff2..4a47aab 100644 --- a/app/frontend/src/app/layout/PlatformShell.tsx +++ b/app/frontend/src/app/layout/PlatformShell.tsx @@ -43,9 +43,11 @@ import { markAllNotificationsRead, markNotificationRead, pullUpgradeImage, + listProjects, } from '@/lib/api-client'; import { signOutAuthSession } from '@/lib/auth-client'; import { isDemoSearch } from '@/lib/demo-mode'; +import { demoProjects } from '@/lib/demo-data'; import { useAuthSession } from '@/lib/use-auth-session'; import { BannerBar, BrandWordmark, CommandPalette, useToast } from '@/shared/components'; @@ -273,16 +275,20 @@ export function PlatformShell() { .toUpperCase() || 'C'; const paletteNavigate = (target: string) => { - const routes: Record = { - dashboard: '/', - canvas: '/projects', - logs: '/activity', - observability: '/operations', - settings: '/settings', - }; - navigate(href(routes[target] ?? '/')); + navigate(href(target)); }; + // Project deep-links for the palette — only fetched once it opens. + const paletteProjectsQuery = useQuery({ + queryKey: ['palette-projects'], + queryFn: () => listProjects(), + enabled: paletteOpen && !isDemoMode, + staleTime: 60_000, + }); + const paletteProjects = isDemoMode + ? demoProjects + : paletteProjectsQuery.data ?? []; + if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) { return ; } @@ -474,7 +480,7 @@ export function PlatformShell() { ⌘ K - {isDemoMode ? 'env:demo' : 'env:production'} + {isDemoMode ? 'env:demo' : `env:${import.meta.env.MODE}`}
@@ -623,6 +629,11 @@ export function PlatformShell() { open={paletteOpen} onClose={() => setPaletteOpen(false)} onNavigate={paletteNavigate} + openTargets={paletteProjects.map((p) => ({ + label: p.name, + target: `/projects/${p.id}`, + description: 'Project workspace', + }))} />
); diff --git a/app/frontend/src/features/auth/pages.tsx b/app/frontend/src/features/auth/pages.tsx index 654885f..1eb3da5 100644 --- a/app/frontend/src/features/auth/pages.tsx +++ b/app/frontend/src/features/auth/pages.tsx @@ -17,12 +17,12 @@ import { GhostBtn, QuietBtn } from '@/shared/components/sentry'; function sanitizeRedirect(raw: string | null): string { if (!raw) { - return '/projects'; + return '/'; } const value = raw.trim(); if (!value.startsWith('/') || value.startsWith('//')) { - return '/projects'; + return '/'; } return value; @@ -271,7 +271,7 @@ export function AcceptInvitePage() { const [isSubmitting, setIsSubmitting] = useState(false); if (sessionQuery.data) { - return ; + return ; } const boundEmail = inviteQuery.data?.email ?? undefined; @@ -288,7 +288,7 @@ export function AcceptInvitePage() { // account, sign-in creates the browser session. await signInWithEmail(effectiveEmail.trim(), password); await queryClient.invalidateQueries({ queryKey: ['auth-session'] }); - navigate('/projects', { replace: true }); + navigate('/', { replace: true }); } catch (exception) { setError(exception instanceof Error ? exception.message : 'Failed to accept invite'); } finally { diff --git a/app/frontend/src/features/dashboard/pages/DashboardPage.tsx b/app/frontend/src/features/dashboard/pages/DashboardPage.tsx index 0d6cfa7..432a14c 100644 --- a/app/frontend/src/features/dashboard/pages/DashboardPage.tsx +++ b/app/frontend/src/features/dashboard/pages/DashboardPage.tsx @@ -148,6 +148,7 @@ type RangeKey = '1h' | '24h' | '7d'; function formatRelative(date?: string): string { if (!date) return '—'; const diff = Date.now() - new Date(date).getTime(); + if (diff < 0 || diff > 365 * 86_400_000) return '—'; if (diff < 60_000) return 'just now'; if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; diff --git a/app/frontend/src/features/nodes/NodeDetailPage.tsx b/app/frontend/src/features/nodes/NodeDetailPage.tsx index 2c8ee23..9faee48 100644 --- a/app/frontend/src/features/nodes/NodeDetailPage.tsx +++ b/app/frontend/src/features/nodes/NodeDetailPage.tsx @@ -376,7 +376,12 @@ export function NodeDetailPage() {

{c.name ?? c.id}

{c.image}

- {c.status ?? '—'} + {/* Persisted query cache can replay the raw status object — flatten defensively. */} + + {typeof c.status === 'string' + ? c.status + : (c.status as { state?: string } | undefined)?.state ?? '—'} + ))} diff --git a/app/frontend/src/features/setup/SetupWizardPage.tsx b/app/frontend/src/features/setup/SetupWizardPage.tsx index 4f8033f..e8696c2 100644 --- a/app/frontend/src/features/setup/SetupWizardPage.tsx +++ b/app/frontend/src/features/setup/SetupWizardPage.tsx @@ -40,7 +40,7 @@ export function SetupWizardPage() { mutationFn: completeSetup, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['setup-status'] }); - navigate('/projects', { replace: true }); + navigate('/', { replace: true }); }, }); @@ -54,7 +54,7 @@ export function SetupWizardPage() { // Wizard is optional once the instance is already configured. if (status && !status.needs_setup && !override) { - return ; + return ; } const steps: { key: Step; label: string }[] = [ diff --git a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx index e738158..1482b1d 100644 --- a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx +++ b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx @@ -1124,6 +1124,9 @@ export function ProjectWorkspacePage() { return next; }); }} + navTargets={viewItems + .filter((item) => signedIn || !item.authOnly) + .map((item) => ({ label: `Go to ${item.label}`, target: item.key }))} /> {/* Error Banner */} diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 2344e7f..837dc59 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1239,9 +1239,17 @@ export type AgentContainer = { created_at?: string; }; +type RawAgentContainer = Omit & { + status?: string | { state?: string; health?: string }; +}; + export async function listAgentContainers(id: string): Promise { - const payload = await requestJson<{ containers?: AgentContainer[] }>(`/agents/${id}/containers`); - return payload.containers ?? []; + const payload = await requestJson<{ containers?: RawAgentContainer[] }>(`/agents/${id}/containers`); + // The wire sends status as a Docker state object — flatten to its state string. + return (payload.containers ?? []).map((c) => ({ + ...c, + status: typeof c.status === 'string' ? c.status : c.status?.state || '—', + })); } export type AgentCommand = { diff --git a/app/frontend/src/lib/time.ts b/app/frontend/src/lib/time.ts index 1aa01cc..475a032 100644 --- a/app/frontend/src/lib/time.ts +++ b/app/frontend/src/lib/time.ts @@ -13,6 +13,11 @@ export function formatRelative(iso?: string): string { const hour = 60 * minute; const day = 24 * hour; + // Future-dated or year-plus-old timestamps are effectively meaningless — + // surface them as absent rather than "739898d ago". + if (diffMs < 0 || diffMs > 365 * day) { + return 'n/a'; + } if (diffMs < minute) { return 'just now'; } diff --git a/app/frontend/src/shared/components/CommandPalette.tsx b/app/frontend/src/shared/components/CommandPalette.tsx index 374881a..179a676 100644 --- a/app/frontend/src/shared/components/CommandPalette.tsx +++ b/app/frontend/src/shared/components/CommandPalette.tsx @@ -1,24 +1,25 @@ import { useEffect, useState } from 'react'; import { Command } from 'cmdk'; import { + ArrowRight, Box, + CornerDownRight, Database, - Github, - Image, Clock, Search, - Settings, - FileText, - Activity, - LayoutGrid, } from 'lucide-react'; +export interface PaletteTarget { + label: string; + target: string; + description?: string; +} + interface CommandItem { id: string; label: string; description?: string; icon: typeof Box; - shortcut?: string; action: () => void; category?: string; } @@ -27,14 +28,39 @@ interface CommandPaletteProps { open: boolean; onClose: () => void; onAddService?: (type: string) => void; - onNavigate?: (path: string) => void; + onNavigate?: (target: string) => void; + /** Navigate-group items; falls back to the full app route map. */ + navTargets?: PaletteTarget[]; + /** Extra searchable "Open …" rows, e.g. projects. */ + openTargets?: PaletteTarget[]; } +const APP_NAV: PaletteTarget[] = [ + { label: 'Dashboard', target: '/', description: 'Index — stats, nodes, deploys' }, + { label: 'Projects', target: '/projects', description: 'All projects' }, + { label: 'Templates', target: '/templates', description: 'Template catalog' }, + { label: 'Builds', target: '/builds', description: 'Build pipeline' }, + { label: 'Databases', target: '/databases', description: 'Managed databases' }, + { label: 'Operations', target: '/operations', description: 'Backups, cron, maintenance' }, + { label: 'Activity', target: '/activity', description: 'Platform event feed' }, + { label: 'High Availability', target: '/ha', description: 'Failover & alerts' }, + { label: 'Security', target: '/security', description: 'Access, sessions, secrets' }, + { label: 'Usage', target: '/usage', description: 'Resource consumption' }, + { label: 'People', target: '/people', description: 'Team & access control' }, + { label: 'Audit Logs', target: '/settings/audit-logs', description: 'Recorded actions' }, + { label: 'Webhooks', target: '/settings/webhooks', description: 'Signed event delivery' }, + { label: 'Settings', target: '/settings', description: 'Platform preferences' }, + { label: 'Docs', target: '/docs', description: 'Guides & reference' }, + { label: 'Landing', target: '/landing', description: 'Public page' }, +]; + export function CommandPalette({ open, onClose, onAddService, onNavigate, + navTargets, + openTargets, }: CommandPaletteProps) { if (!open) return null; @@ -43,6 +69,8 @@ export function CommandPalette({ onClose={onClose} onAddService={onAddService} onNavigate={onNavigate} + navTargets={navTargets} + openTargets={openTargets} /> ); } @@ -51,6 +79,8 @@ function CommandPaletteContent({ onClose, onAddService, onNavigate, + navTargets, + openTargets, }: Omit) { const [search, setSearch] = useState(''); @@ -66,130 +96,69 @@ function CommandPaletteContent({ return () => document.removeEventListener('keydown', down); }, [onClose]); - const commands: CommandItem[] = [ - { - id: 'add-web', - label: 'Add Web Service', - description: 'Deploy a web application', - icon: Box, - shortcut: 'W', - category: 'Create', - action: () => { - onAddService?.('web'); - onClose(); + const commands: CommandItem[] = []; + + // Create items only exist where onAddService is wired — elsewhere the + // entries would silently no-op. + if (onAddService) { + commands.push( + { + id: 'add-web', + label: 'Add Web Service', + description: 'Deploy a web application', + icon: Box, + category: 'Create', + action: () => { onAddService('web'); onClose(); }, }, - }, - { - id: 'add-worker', - label: 'Add Worker Service', - description: 'Deploy a background worker', - icon: Clock, - shortcut: 'K', - category: 'Create', - action: () => { - onAddService?.('worker'); - onClose(); + { + id: 'add-worker', + label: 'Add Worker Service', + description: 'Deploy a background worker', + icon: Clock, + category: 'Create', + action: () => { onAddService('worker'); onClose(); }, }, - }, - { - id: 'add-database', - label: 'Add Database', - description: 'Provision a PostgreSQL database', - icon: Database, - shortcut: 'D', - category: 'Create', - action: () => { - onAddService?.('database'); - onClose(); + { + id: 'add-database', + label: 'Add Database', + description: 'Provision a PostgreSQL database', + icon: Database, + category: 'Create', + action: () => { onAddService('database'); onClose(); }, }, - }, - { - id: 'add-cron', - label: 'Add Cron Job', - description: 'Schedule a recurring task', - icon: Clock, - category: 'Create', - action: () => { - onAddService?.('cron'); - onClose(); + { + id: 'add-cron', + label: 'Add Cron Job', + description: 'Schedule a recurring task', + icon: Clock, + category: 'Create', + action: () => { onAddService('cron'); onClose(); }, }, - }, - { - id: 'connect-github', - label: 'Connect GitHub Repo', - description: 'Link a repository for auto-deploy', - icon: Github, - category: 'Connect', - action: () => { - onClose(); - }, - }, - { - id: 'deploy-image', - label: 'Deploy Docker Image', - description: 'Deploy from a container registry', - icon: Image, - category: 'Connect', - action: () => { - onClose(); - }, - }, - { - id: 'goto-dashboard', - label: 'Go to Dashboard', - icon: LayoutGrid, - shortcut: 'H', - category: 'Navigate', - action: () => { - onNavigate?.('dashboard'); - onClose(); - }, - }, - { - id: 'goto-canvas', - label: 'Go to Canvas', - icon: LayoutGrid, - shortcut: 'C', - category: 'Navigate', - action: () => { - onNavigate?.('canvas'); - onClose(); - }, - }, - { - id: 'goto-logs', - label: 'Go to Logs', - icon: FileText, - shortcut: 'L', - category: 'Navigate', - action: () => { - onNavigate?.('logs'); - onClose(); - }, - }, - { - id: 'goto-metrics', - label: 'Go to Metrics', - icon: Activity, - shortcut: 'M', - category: 'Navigate', - action: () => { - onNavigate?.('observability'); - onClose(); - }, - }, - { - id: 'goto-settings', - label: 'Go to Settings', - icon: Settings, - shortcut: 'S', - category: 'Navigate', - action: () => { - onNavigate?.('settings'); - onClose(); - }, - }, - ]; + ); + } + + if (onNavigate) { + (navTargets ?? APP_NAV).forEach((t, i) => + commands.push({ + id: `nav-${i}`, + label: t.label, + description: t.description, + icon: CornerDownRight, + category: 'Navigate', + action: () => { onNavigate(t.target); onClose(); }, + }), + ); + (openTargets ?? []).forEach((t, i) => + commands.push({ + id: `open-${i}`, + label: t.label, + description: t.description, + icon: ArrowRight, + category: 'Open', + action: () => { onNavigate(t.target); onClose(); }, + }), + ); + } const filteredCommands = commands.filter( (cmd) => @@ -208,6 +177,10 @@ function CommandPaletteContent({ {} as Record ); + const placeholder = onNavigate + ? 'Search pages, projects, actions…' + : 'What would you like to create?'; + return (
@@ -251,18 +224,13 @@ function CommandPaletteContent({ onSelect={item.action} className="flex items-center gap-3 px-3 py-2 rounded-[var(--radius-md)] cursor-pointer text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] aria-selected:bg-[var(--accent-primary-soft)] aria-selected:text-[var(--accent-primary)] transition-colors" > - +
{item.label}
{item.description && (
{item.description}
)}
- {item.shortcut && ( - - {item.shortcut} - - )} ); })}