Compare commits

...
2 Commits
Author SHA1 Message Date
Tomas DvorakandDevin 5b1cf0402c perf(ui): route-level code splitting via React.lazy
CI / Changed paths (push) Canceled after 0s
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Mobile (Expo) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
Eager page imports put the entire app plus recharts/xyflow into the
1.3MB index chunk. Lazying every route drops the entry to 332kB and
defers charts, canvas, and service-detail code to first visit.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 19:36:31 +02:00
Tomáš DvořákandDevin 9271d0e3ac feat(ui): full frontend redesign to the Sentry-style design system (#86)
* feat(ui): full frontend redesign to the Sentry-style design system

Replace the legacy UI with the dark, mono-accent design language from
design/23-sentry.html across every non-canvas surface.

- PlatformShell: fixed 252px sidebar with grouped nav (Platform /
  Operate / Workspace), brand mark, user card, and a topbar with
  search (Cmd+K palette), env badge, upgrade, notifications, and
  theme toggle; mobile collapses to a top bar with horizontal nav
- New shared component kit in shared/components/sentry.tsx:
  SPageHead, SStat, SCard, STable, SSection, SPill, Ticks, Seg,
  GhostBtn/QuietBtn/IconBtn, status tone helpers
- New DashboardPage at / — stats row, per-node CPU chart with
  1h/24h/7d ranges, resource usage tick strips, anomaly box,
  node summary table, recent deployments and project cards;
  real API data with demo-mode fixtures
- Landing page moved to /landing and rebuilt: live host telemetry
  window, install strip, feature grid, template catalog, docs
- Every page (Projects, Templates, Builds, Databases, HA,
  Operations, Activity, Security, Usage, Docs, People, Settings,
  Audit Logs, Webhooks, Admin, NodeDetail, ServiceDetail, auth,
  setup, not-found) migrated to the new kit and tokens
- Nav active state resolves by longest matching href
- Canvas internals intentionally untouched — separate pass

Verified: tsc --noEmit, eslint, vite build, Playwright walkthrough
of every route at desktop and mobile widths (demo + live session).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): deep audit pass — palette navigation, crash fix, honest badges

- CommandPalette: full route navigation, project deep-links fetched on
  open, Create group only where onAddService is wired, dead Connect
  items removed, decorative kbd hints dropped; workspace passes its own
  view targets
- NodeDetailPage crash: container status arrives as a Docker state
  object, not a string — flattened in listAgentContainers and guarded
  at render for persisted-cache payloads
- env badge reflects import.meta.env.MODE instead of hardcoded
  'production'
- Relative times capped: >1y or future timestamps render 'n/a'/'—'
  (zero-time heartbeats no longer show 739898d ago)
- Post-auth and setup redirects land on / (dashboard is the index)

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): use SPageHead actions prop, satisfy tsc -b literal types

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 17:20:44 +02:00
44 changed files with 4117 additions and 3047 deletions

No files matched your search

+6
View File
@@ -5,6 +5,12 @@
<link rel="icon" type="image/svg+xml" href="/containr.svg" /> <link rel="icon" type="image/svg+xml" href="/containr.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>containr</title> <title>containr</title>
<script>
try {
document.documentElement.dataset.theme =
localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark';
} catch { document.documentElement.dataset.theme = 'dark'; }
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -3
View File
@@ -1,12 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs> <defs>
<linearGradient id="bg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"> <linearGradient id="bg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#cdf06f"/> <stop offset="0" stop-color="#56c9f8"/>
<stop offset="1" stop-color="#7fb524"/> <stop offset="1" stop-color="#0e86c9"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="64" height="64" rx="15" fill="url(#bg)"/> <rect width="64" height="64" rx="15" fill="url(#bg)"/>
<g fill="none" stroke="#131507" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round"> <g fill="none" stroke="#061018" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="15" y="18" width="34" height="26" rx="4"/> <rect x="15" y="18" width="34" height="26" rx="4"/>
<path d="M23 18v26M31 18v26M39 18v26"/> <path d="M23 18v26M31 18v26M39 18v26"/>
<path d="M20 51h24"/> <path d="M20 51h24"/>

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 582 B

+66 -54
View File
@@ -1,39 +1,50 @@
import { lazy, Suspense, type ReactNode } from 'react';
import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'; import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Compass, ShieldCheck } from 'lucide-react'; import { Compass, ShieldCheck } from 'lucide-react';
import { PlatformShell } from './layout/PlatformShell'; import { PlatformShell } from './layout/PlatformShell';
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
import { ServiceDetailPage } from '@/features/service/pages/ServiceDetailPage';
import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetricsDashboard';
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
import { OperationsPage } from '@/features/operations/pages/OperationsPage';
import { ActivityPage } from '@/features/activity/pages/ActivityPage';
import { AdminPage } from '@/features/admin/pages/AdminPage';
import { LandingPage } from '@/features/landing/LandingPage';
import { useBranding } from '@/lib/use-branding'; import { useBranding } from '@/lib/use-branding';
import {
DocsPage,
PeoplePage,
SettingsPage,
UsagePage,
DatabasesPage,
HighAvailabilityPage,
SecurityPage,
ComponentShowcase,
GitHubAppCallbackPage,
} from '@/features/secondary/pages';
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
import { SetupWizardPage } from '@/features/setup/SetupWizardPage';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; 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';
const LandingPage = lazy(() => import('@/features/landing/LandingPage').then((m) => ({ default: m.LandingPage })));
const SignInPage = lazy(() => import('@/features/auth/pages').then((m) => ({ default: m.SignInPage })));
const SignUpPage = lazy(() => import('@/features/auth/pages').then((m) => ({ default: m.SignUpPage })));
const AcceptInvitePage = lazy(() => import('@/features/auth/pages').then((m) => ({ default: m.AcceptInvitePage })));
const SetupWizardPage = lazy(() => import('@/features/setup/SetupWizardPage').then((m) => ({ default: m.SetupWizardPage })));
const ProjectWorkspacePage = lazy(() => import('@/features/workspace/pages/ProjectWorkspacePage').then((m) => ({ default: m.ProjectWorkspacePage })));
const ServiceDetailPage = lazy(() => import('@/features/service/pages/ServiceDetailPage').then((m) => ({ default: m.ServiceDetailPage })));
const ServiceMetricsDashboard = lazy(() => import('@/features/service/pages/ServiceMetricsDashboard').then((m) => ({ default: m.ServiceMetricsDashboard })));
const DashboardPage = lazy(() => import('@/features/dashboard/pages/DashboardPage').then((m) => ({ default: m.DashboardPage })));
const ProjectsPage = lazy(() => import('@/features/projects/pages/ProjectsPage').then((m) => ({ default: m.ProjectsPage })));
const TemplatesPage = lazy(() => import('@/features/templates/pages/TemplatesPage').then((m) => ({ default: m.TemplatesPage })));
const DatabasesPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.DatabasesPage })));
const HighAvailabilityPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.HighAvailabilityPage })));
const OperationsPage = lazy(() => import('@/features/operations/pages/OperationsPage').then((m) => ({ default: m.OperationsPage })));
const ActivityPage = lazy(() => import('@/features/activity/pages/ActivityPage').then((m) => ({ default: m.ActivityPage })));
const SecurityPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.SecurityPage })));
const UsagePage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.UsagePage })));
const DocsPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.DocsPage })));
const BuildsPage = lazy(() => import('@/features/builds/pages/BuildsPage').then((m) => ({ default: m.BuildsPage })));
const PeoplePage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.PeoplePage })));
const SettingsPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.SettingsPage })));
const GitHubAppCallbackPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.GitHubAppCallbackPage })));
const AuditLogsPage = lazy(() => import('@/features/audit/pages/AuditLogsPage').then((m) => ({ default: m.AuditLogsPage })));
const WebhooksPage = lazy(() => import('@/features/webhooks/pages/WebhooksPage').then((m) => ({ default: m.WebhooksPage })));
const ComponentShowcase = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.ComponentShowcase })));
const AdminPage = lazy(() => import('@/features/admin/pages/AdminPage').then((m) => ({ default: m.AdminPage })));
const NodeDetailPage = lazy(() => import('@/features/nodes/NodeDetailPage').then((m) => ({ default: m.NodeDetailPage })));
function Page({ children }: { children: ReactNode }) {
return (
<Suspense fallback={<LoadingState message="Loading..." className="min-h-[60vh]" />}>
{children}
</Suspense>
);
}
function AuthRequired() { function AuthRequired() {
const location = useLocation(); const location = useLocation();
const isDemoMode = useDemoMode(); const isDemoMode = useDemoMode();
@@ -92,7 +103,7 @@ function AdminRequired() {
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--bg-void)] px-6 text-center"> <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--bg-void)] px-6 text-center">
<ShieldCheck size={32} className="text-[var(--error)]" /> <ShieldCheck size={32} className="text-[var(--error)]" />
<div> <div>
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1> <h1 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]"> <p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
This area is restricted to the platform administrator. This area is restricted to the platform administrator.
</p> </p>
@@ -115,7 +126,7 @@ function NotFoundPage() {
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-6 text-center"> <div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-6 text-center">
<Compass size={32} className="text-[var(--text-tertiary)]" /> <Compass size={32} className="text-[var(--text-tertiary)]" />
<div> <div>
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Page not found</h1> <h1 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Page not found</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]"> <p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
The page you are looking for does not exist or was moved. The page you are looking for does not exist or was moved.
</p> </p>
@@ -135,43 +146,44 @@ export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<Routes> <Routes>
<Route path="/" element={<LandingPage />} /> <Route path="/landing" element={<Page><LandingPage /></Page>} />
<Route path="/auth/sign-in" element={<SignInPage />} /> <Route path="/auth/sign-in" element={<Page><SignInPage /></Page>} />
<Route path="/auth/sign-up" element={<SignUpPage />} /> <Route path="/auth/sign-up" element={<Page><SignUpPage /></Page>} />
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} /> <Route path="/auth/accept-invite" element={<Page><AcceptInvitePage /></Page>} />
<Route path="/setup" element={<SetupWizardPage />} /> <Route path="/setup" element={<Page><SetupWizardPage /></Page>} />
{/* Public browsing — approved projects, templates, docs. */} {/* Public browsing — approved projects, templates, docs. */}
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */} {/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} /> <Route path="/projects/:projectId" element={<Page><ProjectWorkspacePage /></Page>} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} /> <Route path="/projects/:projectId/services/:serviceId" element={<Page><ServiceDetailPage /></Page>} />
<Route element={<PlatformShell />}> <Route element={<PlatformShell />}>
<Route path="/projects" element={<ProjectsPage />} /> <Route path="/" element={<Page><DashboardPage /></Page>} />
<Route path="/templates" element={<TemplatesPage />} /> <Route path="/projects" element={<Page><ProjectsPage /></Page>} />
<Route path="/databases" element={<DatabasesPage />} /> <Route path="/templates" element={<Page><TemplatesPage /></Page>} />
<Route path="/ha" element={<HighAvailabilityPage />} /> <Route path="/databases" element={<Page><DatabasesPage /></Page>} />
<Route path="/operations" element={<OperationsPage />} /> <Route path="/ha" element={<Page><HighAvailabilityPage /></Page>} />
<Route path="/activity" element={<ActivityPage />} /> <Route path="/operations" element={<Page><OperationsPage /></Page>} />
<Route path="/security" element={<SecurityPage />} /> <Route path="/activity" element={<Page><ActivityPage /></Page>} />
<Route path="/usage" element={<UsagePage />} /> <Route path="/security" element={<Page><SecurityPage /></Page>} />
<Route path="/docs" element={<DocsPage />} /> <Route path="/usage" element={<Page><UsagePage /></Page>} />
<Route path="/docs" element={<Page><DocsPage /></Page>} />
{/* Authenticated areas */} {/* Authenticated areas */}
<Route element={<AuthRequired />}> <Route element={<AuthRequired />}>
<Route path="/builds" element={<BuildsPage />} /> <Route path="/builds" element={<Page><BuildsPage /></Page>} />
<Route path="/people" element={<PeoplePage />} /> <Route path="/people" element={<Page><PeoplePage /></Page>} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<Page><SettingsPage /></Page>} />
<Route path="/git/github-app/callback" element={<GitHubAppCallbackPage />} /> <Route path="/git/github-app/callback" element={<Page><GitHubAppCallbackPage /></Page>} />
<Route path="/settings/audit-logs" element={<AuditLogsPage />} /> <Route path="/settings/audit-logs" element={<Page><AuditLogsPage /></Page>} />
<Route path="/settings/webhooks" element={<WebhooksPage />} /> <Route path="/settings/webhooks" element={<Page><WebhooksPage /></Page>} />
<Route path="/showcase" element={<ComponentShowcase />} /> <Route path="/showcase" element={<Page><ComponentShowcase /></Page>} />
</Route> </Route>
{/* Platform owner only */} {/* Platform owner only */}
<Route element={<AdminRequired />}> <Route element={<AdminRequired />}>
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<Page><AdminPage /></Page>} />
<Route path="/nodes/:id" element={<NodeDetailPage />} /> <Route path="/nodes/:id" element={<Page><NodeDetailPage /></Page>} />
</Route> </Route>
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
@@ -179,7 +191,7 @@ export default function App() {
{/* Full-bleed design demo — renders its own chrome, no app shell */} {/* Full-bleed design demo — renders its own chrome, no app shell */}
<Route element={<AuthRequired />}> <Route element={<AuthRequired />}>
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} /> <Route path="/metrics-demo" element={<Page><ServiceMetricsDashboard /></Page>} />
</Route> </Route>
</Routes> </Routes>
</ErrorBoundary> </ErrorBoundary>
+127 -105
View File
@@ -9,11 +9,13 @@ import {
ChartBar, ChartBar,
CheckCircle2, CheckCircle2,
ChevronsLeft, ChevronsLeft,
ChevronsUpDown,
Clock, Clock,
Container, Container,
Database, Database,
FileText, FileText,
FolderKanban, FolderKanban,
LayoutDashboard,
LayoutTemplate, LayoutTemplate,
LogIn, LogIn,
LogOut, LogOut,
@@ -41,22 +43,26 @@ import {
markAllNotificationsRead, markAllNotificationsRead,
markNotificationRead, markNotificationRead,
pullUpgradeImage, pullUpgradeImage,
listProjects,
} 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 { isDemoSearch } from '@/lib/demo-mode';
import { demoProjects } from '@/lib/demo-data';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { BannerBar, BrandWordmark, useToast } from '@/shared/components'; import { BannerBar, BrandWordmark, CommandPalette, useToast } from '@/shared/components';
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string }; type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
type NavSection = { title: string; items: NavItem[] }; type NavSection = { title: string; items: NavItem[] };
const navSections: NavSection[] = [ const navSections: NavSection[] = [
{ {
title: 'Build', title: 'Platform',
items: [ items: [
{ label: 'Dashboard', href: '/', icon: LayoutDashboard, hint: 'Fleet overview' },
{ label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' }, { label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' },
{ label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' }, { label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' },
{ label: 'Builds', href: '/builds', icon: FileText, hint: 'Build history' }, { label: 'Builds', href: '/builds', icon: FileText, hint: 'Build history' },
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
], ],
}, },
{ {
@@ -64,7 +70,6 @@ const navSections: NavSection[] = [
items: [ items: [
{ label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' }, { label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' },
{ label: 'Activity', href: '/activity', icon: Clock, hint: 'Platform event feed' }, { label: 'Activity', href: '/activity', icon: Clock, hint: 'Platform event feed' },
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' }, { label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' }, { label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
{ label: 'Usage', href: '/usage', icon: ChartBar, hint: 'Resource consumption' }, { label: 'Usage', href: '/usage', icon: ChartBar, hint: 'Resource consumption' },
@@ -164,6 +169,18 @@ export function PlatformShell() {
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);
const [paletteOpen, setPaletteOpen] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setPaletteOpen((open) => !open);
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, []);
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
@@ -234,8 +251,19 @@ export function PlatformShell() {
} }
}; };
const isActiveRoute = (itemHref: string) => // Longest matching href wins so /settings/audit-logs lights only
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`); // "Audit Logs", not "Settings" as well.
const activeHref = location.pathname === '/'
? '/'
: visibleNavSections
.flatMap((section) => section.items.map((item) => item.href))
.filter(
(itemHref) =>
itemHref !== '/' &&
(location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`)),
)
.sort((a, b) => b.length - a.length)[0] ?? null;
const isActiveRoute = (itemHref: string) => itemHref === activeHref;
const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account'; const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session'; const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
@@ -246,14 +274,20 @@ export function PlatformShell() {
.slice(0, 2) .slice(0, 2)
.toUpperCase() || 'C'; .toUpperCase() || 'C';
const avatar = ( const paletteNavigate = (target: string) => {
<div navigate(href(target));
className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full text-[11px] font-bold" };
style={{ background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))', color: 'var(--accent-on)' }}
> // Project deep-links for the palette — only fetched once it opens.
{initials} const paletteProjectsQuery = useQuery({
</div> queryKey: ['palette-projects'],
); queryFn: () => listProjects(),
enabled: paletteOpen && !isDemoMode,
staleTime: 60_000,
});
const paletteProjects = isDemoMode
? demoProjects
: paletteProjectsQuery.data ?? [];
if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) { if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) {
return <Navigate to="/setup" replace />; return <Navigate to="/setup" replace />;
@@ -261,40 +295,46 @@ export function PlatformShell() {
return ( return (
<div className="app-shell min-h-screen"> <div className="app-shell min-h-screen">
<div className="ambient-glow" />
<div className="relative flex min-h-screen"> <div className="relative flex min-h-screen">
{/* ── Sidebar — fixed rail per design/23-sentry ── */}
<aside <aside
className="hidden h-screen shrink-0 flex-col border-r py-4 transition-[width] duration-200 md:flex" className="shell-side hidden h-screen shrink-0 flex-col md:flex"
style={{ style={{ width: sidebarExpanded ? '252px' : '64px' }}
width: sidebarExpanded ? '218px' : '62px',
background: 'var(--bg-base)',
borderRightColor: 'var(--border-subtle)',
}}
> >
<div className={`flex items-center ${sidebarExpanded ? 'justify-between px-3' : 'flex-col items-center gap-2'}`}> <div
<NavLink to={href('/projects')} title="Containr"> className={`flex items-center ${
<img src="/containr.svg" alt="Containr" className="h-[38px] w-[38px] rounded-xl" /> sidebarExpanded ? 'gap-2.5 px-[18px] pb-5 pt-4' : 'flex-col gap-3 px-2 pb-4 pt-4'
}`}
>
<NavLink to={href('/')} title="Containr" className="flex items-center gap-2.5">
<span className="shell-mark grid h-[26px] w-[26px] shrink-0 place-items-center">
<Container size={14} />
</span>
{sidebarExpanded ? (
<span className="font-headline text-[15px] font-semibold tracking-[-0.01em] text-[var(--text-primary)]">
<BrandWordmark />
</span>
) : null}
</NavLink> </NavLink>
<button <button
type="button" type="button"
onClick={() => setSidebarExpanded((v) => !v)} onClick={() => setSidebarExpanded((v) => !v)}
title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'} title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]" className={`shell-collapse flex items-center justify-center ${
sidebarExpanded ? 'ml-auto h-[22px] w-[22px]' : 'h-7 w-7'
}`}
> >
{sidebarExpanded ? <ChevronsLeft size={15} /> : <PanelLeft size={15} />} {sidebarExpanded ? <ChevronsLeft size={14} /> : <PanelLeft size={15} />}
</button> </button>
</div> </div>
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2"> <nav className={`flex flex-1 flex-col overflow-y-auto ${sidebarExpanded ? 'px-3' : 'gap-4 px-2'}`}>
{visibleNavSections.map((section) => ( {visibleNavSections.map((section, si) => (
<div key={section.title}> <div key={section.title} className={si > 0 && sidebarExpanded ? 'mt-[26px]' : ''}>
{sidebarExpanded ? ( {sidebarExpanded ? (
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]"> <p className="shell-nav-group">{section.title}</p>
{section.title}
</p>
) : null} ) : null}
<div className={`flex flex-col gap-0.5 ${sidebarExpanded ? '' : 'items-center'}`}> <div className={`flex flex-col ${sidebarExpanded ? '' : 'items-center gap-1'}`}>
{section.items.map((item) => { {section.items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = isActiveRoute(item.href); const isActive = isActiveRoute(item.href);
@@ -303,27 +343,12 @@ export function PlatformShell() {
key={item.href} key={item.href}
to={href(item.href)} to={href(item.href)}
title={sidebarExpanded ? item.hint : `${item.label} — ${item.hint}`} title={sidebarExpanded ? item.hint : `${item.label} — ${item.hint}`}
className={`relative flex items-center rounded-[11px] transition-colors ${ className={`shell-ni ${isActive ? 'on' : ''} ${
sidebarExpanded ? 'gap-2.5 px-2 py-2' : 'h-10 w-10 justify-center' sidebarExpanded ? '' : 'h-10 w-10 justify-center !px-0'
} ${
isActive
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-secondary)]'
}`} }`}
> >
{isActive ? ( <Icon size={16} className="shrink-0" />
<span {sidebarExpanded ? <span className="truncate">{item.label}</span> : null}
className="absolute -left-2 h-4 w-[2.5px] rounded-full"
style={{ background: 'var(--accent-primary)' }}
/>
) : null}
<Icon size={17} className="shrink-0" />
{sidebarExpanded ? (
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium leading-tight">{item.label}</span>
<span className="block truncate text-[10px] text-[var(--text-tertiary)]">{item.hint}</span>
</span>
) : null}
</NavLink> </NavLink>
); );
})} })}
@@ -332,23 +357,26 @@ export function PlatformShell() {
))} ))}
</nav> </nav>
<div className={`flex flex-col gap-2 px-2 pt-2 ${sidebarExpanded ? '' : 'items-center'}`}> <div className={`mt-auto ${sidebarExpanded ? 'px-3 pb-3.5' : 'flex justify-center px-2 pb-3'}`}>
{signedIn ? ( {signedIn ? (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<button <button
type="button" type="button"
title={sidebarExpanded ? undefined : userEmail} title={sidebarExpanded ? undefined : userEmail}
className={`flex items-center gap-2.5 rounded-[11px] text-left transition-colors hover:bg-[var(--surface-muted)] ${ className={`shell-user ${
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center' sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center border-transparent bg-transparent'
}`} }`}
> >
{avatar} <span className="shell-avatar">{initials}</span>
{sidebarExpanded ? ( {sidebarExpanded ? (
<span className="min-w-0 flex-1"> <>
<span className="block truncate text-[12.5px] font-medium text-[var(--text-primary)]">{userName}</span> <span className="min-w-0 flex-1 text-left">
<span className="block truncate text-[13px] font-medium text-[var(--text-primary)]">{userName}</span>
<span className="block truncate text-[10.5px] text-[var(--text-tertiary)]">{userEmail}</span> <span className="block truncate text-[10.5px] text-[var(--text-tertiary)]">{userEmail}</span>
</span> </span>
<ChevronsUpDown size={13} className="shrink-0 text-[var(--text-tertiary)]" />
</>
) : null} ) : null}
</button> </button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -427,12 +455,12 @@ export function PlatformShell() {
<NavLink <NavLink
to="/auth/sign-in" to="/auth/sign-in"
title="Sign in" title="Sign in"
className={`flex items-center gap-2.5 rounded-[11px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] ${ className={`shell-ni ${
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center' sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center !px-0'
}`} }`}
> >
<LogIn size={16} className="shrink-0" /> <LogIn size={16} className="shrink-0" />
{sidebarExpanded ? <span className="text-[12.5px] font-medium">Sign in</span> : null} {sidebarExpanded ? <span className="text-[13px] font-medium">Sign in</span> : null}
</NavLink> </NavLink>
)} )}
</div> </div>
@@ -440,51 +468,31 @@ export function PlatformShell() {
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden"> <div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
<BannerBar /> <BannerBar />
<header {/* ── Topbar — search left, utility icons right ── */}
className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex" <header className="shell-top hidden h-[56px] shrink-0 items-center gap-3.5 md:flex">
style={{
background: 'var(--bg-base)',
borderBottomColor: 'var(--border-subtle)',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '11px',
}}
>
<NavLink to={href('/projects')} className="mr-5 text-sm font-extrabold tracking-tight text-[var(--text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}>
<BrandWordmark />
</NavLink>
<div className="flex items-center gap-5">
<span className="v-tick"><i className="sig" /><b>{isDemoMode ? 'demo' : 'nominal'}</b></span>
<span className="v-tick"><i className="sig" style={{ background: 'var(--text-tertiary)' }} />node <b>local</b></span>
</div>
<div className="search-box ml-6 max-w-[300px]" style={{ height: 30 }}>
<Search size={13} />
<input type="text" placeholder="search…" className="v-mono" style={{ fontSize: 11.5 }} />
</div>
<div className="ml-auto flex items-center gap-4">
<span className="v-env">{isDemoMode ? 'env:demo' : 'env:production'}</span>
<button <button
type="button" type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))} onClick={() => setPaletteOpen(true)}
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]" className="shell-search"
style={{ borderColor: 'var(--border-subtle)' }}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
> >
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />} <Search size={13} className="shrink-0" />
Search anything
<kbd>⌘ K</kbd>
</button> </button>
<span className="v-env">{isDemoMode ? 'env:demo' : `env:${import.meta.env.MODE}`}</span>
<div className="flex-1" />
{isAdmin && !isDemoMode ? ( {isAdmin && !isDemoMode ? (
<button <button
type="button" type="button"
onClick={() => pullMutation.mutate()} onClick={() => pullMutation.mutate()}
disabled={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="s-btn-accent"
style={{ background: 'var(--accent-primary)' }}
title={upgradeQuery.data?.message || 'Pull latest configured image'} title={upgradeQuery.data?.message || 'Pull latest configured image'}
> >
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />} {pullMutation.isPending ? <RefreshCw size={13} className="animate-spin" /> : <UploadCloud size={13} />}
Upgrade Upgrade
</button> </button>
) : null} ) : null}
@@ -494,16 +502,11 @@ export function PlatformShell() {
<button <button
type="button" type="button"
onClick={() => setNotificationsOpen((open) => !open)} onClick={() => setNotificationsOpen((open) => !open)}
className="relative flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]" className="shell-tic relative"
style={{ borderColor: 'var(--border-subtle)' }}
aria-label="Notifications" aria-label="Notifications"
> >
<Bell size={15} /> <Bell size={16} />
{unreadCount > 0 ? ( {unreadCount > 0 ? <span className="shell-tic-dot" /> : null}
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-primary)] px-1 text-[10px] font-semibold text-[var(--accent-on)]">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button> </button>
{notificationsOpen ? ( {notificationsOpen ? (
@@ -566,13 +569,21 @@ export function PlatformShell() {
) : null} ) : null}
</div> </div>
) : null} ) : null}
</div>
<button
type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
className="shell-tic"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
</header> </header>
<header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/85 backdrop-blur-2xl"> <header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/85 backdrop-blur-2xl">
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4">
<NavLink to={href('/projects')} className="flex items-center gap-3"> <NavLink to={href('/')} className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)]"> <div className="shell-mark flex h-9 w-9 items-center justify-center">
<Container size={16} /> <Container size={16} />
</div> </div>
<span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span> <span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span>
@@ -613,6 +624,17 @@ export function PlatformShell() {
</main> </main>
</div> </div>
</div> </div>
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onNavigate={paletteNavigate}
openTargets={paletteProjects.map((p) => ({
label: p.name,
target: `/projects/${p.id}`,
description: 'Project workspace',
}))}
/>
</div> </div>
); );
} }
@@ -6,6 +6,7 @@ import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter'; import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { DemoRestricted, FilterBar } from '@/shared/components'; import { DemoRestricted, FilterBar } from '@/shared/components';
import { QuietBtn, SCard, SPageHead, SPill } from '@/shared/components/sentry';
import { import {
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key, Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench, Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench,
@@ -24,6 +25,10 @@ const SEVERITY_STYLE: Record<string, { icon: typeof Info; cls: string; dot: stri
info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' }, info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' },
}; };
const SEV_TONE: Record<string, 'err' | 'warn' | 'ok' | 'info'> = {
error: 'err', warning: 'warn', success: 'ok', info: 'info',
};
const SCHEMA: FilterConfig<ActivityEntry>[] = [ const SCHEMA: FilterConfig<ActivityEntry>[] = [
{ key: 'q', label: 'events', type: 'search', searchFields: ['label', 'action', 'user_email'] }, { key: 'q', label: 'events', type: 'search', searchFields: ['label', 'action', 'user_email'] },
{ {
@@ -58,46 +63,33 @@ export function ActivityPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5"> <SPageHead
<h1 className="v-title">Activity<span className="v-cursor">_</span></h1> title="Activity"
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]"> titleAccent="_"
platform events across your visible projects sub="Platform events across your visible projects"
</p> actions={query.isFetching ? <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" /> : undefined}
</div> />
</div> </div>
<div className="px-4 py-6 sm:px-8 space-y-4"> <div className="px-4 sm:px-8 space-y-4">
<div className="flex items-start gap-3"> <SCard icon={<Activity />} title="Event Feed" accent={`${entries.length} events`} pad={false}>
<div className="flex-1"> <div className="px-4 pt-1 pb-3">
<FilterBar {...filter} /> <FilterBar {...filter} />
</div> </div>
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />} <div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
</div>
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
{query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>} {query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>}
{!query.isLoading && entries.length === 0 && ( {!query.isLoading && entries.length === 0 && (
<p className="px-5 py-8 text-xs text-[var(--text-muted)]">No events yet — activity appears here as you deploy, configure, and manage services.</p> <p className="px-5 py-8 text-xs text-[var(--text-muted)]">No events yet — activity appears here as you deploy, configure, and manage services.</p>
)} )}
{entries.map((e) => <Entry key={e.id} entry={e} />)} {entries.map((e) => <Entry key={e.id} entry={e} />)}
</div> </div>
</SCard>
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5">
<button <QuietBtn disabled={page <= 1} onClick={() => setPage(page - 1)}>← newer</QuietBtn>
disabled={page <= 1} <QuietBtn disabled={!query.data?.has_more} onClick={() => setPage(page + 1)}>older →</QuietBtn>
onClick={() => setPage(page - 1)} <span className="v-mono text-[10.5px] text-[var(--text-muted)]">page {page}</span>
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
>
newer
</button>
<button
disabled={!query.data?.has_more}
onClick={() => setPage(page + 1)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
>
older
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -108,14 +100,14 @@ function Entry({ entry }: { entry: ActivityEntry }) {
const Cat = CATEGORY_ICONS[entry.category] ?? Activity; const Cat = CATEGORY_ICONS[entry.category] ?? Activity;
const sev = SEVERITY_STYLE[entry.severity] ?? SEVERITY_STYLE.info; const sev = SEVERITY_STYLE[entry.severity] ?? SEVERITY_STYLE.info;
return ( return (
<div className="flex items-center gap-3 px-5 py-3"> <div className="flex items-center gap-3 px-4 py-3 hover:bg-[var(--tint-03)] transition-colors">
<div className={`w-8 h-8 rounded-md bg-[var(--bg-elevated)] flex items-center justify-center ${sev.cls}`}> <div className={`s-ibox ${sev.cls}`} style={{ width: 30, height: 30 }}>
<Cat size={15} /> <Cat size={14} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[13px] text-[var(--text-primary)] truncate">{entry.label || entry.action}</span> <span className="text-[13px] font-medium text-[var(--text-primary)] truncate">{entry.label || entry.action}</span>
<span className={`v-mono text-[9px] uppercase px-1.5 py-0.5 rounded ${sev.cls} bg-[var(--bg-elevated)]`}>{entry.severity}</span> <SPill tone={SEV_TONE[entry.severity] ?? 'info'}>{entry.severity}</SPill>
</div> </div>
<div className="v-mono text-[10px] text-[var(--text-muted)] mt-0.5"> <div className="v-mono text-[10px] text-[var(--text-muted)] mt-0.5">
{entry.category} · {entry.action}{entry.user_email ? ` · ${entry.user_email}` : ''} {entry.category} · {entry.action}{entry.user_email ? ` · ${entry.user_email}` : ''}
@@ -38,6 +38,7 @@ import {
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { DemoRestricted, LoadingState, useToast } from '@/shared/components'; import { DemoRestricted, LoadingState, useToast } from '@/shared/components';
import { GhostBtn, QuietBtn, SPageHead, SPill } from '@/shared/components/sentry';
const statLabels: Record<string, string> = { const statLabels: Record<string, string> = {
users: 'Users', users: 'Users',
@@ -199,7 +200,7 @@ export function AdminPage() {
return ( return (
<div className="mx-auto max-w-xl px-5 py-24 text-center"> <div className="mx-auto max-w-xl px-5 py-24 text-center">
<ShieldCheck size={28} className="mx-auto text-[var(--error)]" /> <ShieldCheck size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1> <h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <p className="mt-2 text-sm text-[var(--text-secondary)]">
This area is restricted to platform administrators. This area is restricted to platform administrators.
</p> </p>
@@ -216,74 +217,74 @@ export function AdminPage() {
const invites = invitesQuery.data ?? []; const invites = invitesQuery.data ?? [];
return ( return (
<div className="mx-auto max-w-6xl px-5 py-8"> <div className="w-full px-4 py-6 sm:px-8">
<div className="mb-8"> <SPageHead
<h1 className="text-[24px] font-bold tracking-tight text-[var(--text-primary)]">Admin console</h1> title="Admin Console"
<p className="mt-1 text-sm text-[var(--text-secondary)]"> titleAccent="_"
Platform overview, project approvals, and user management. sub="Platform overview, project approvals, and user management."
/>
<div className="grid grid-cols-4 gap-3.5 max-lg:grid-cols-3 max-sm:grid-cols-2">
{Object.entries(statLabels).map(([key, label]) => (
<div key={key} className="s-stat">
<div className="px-4 pb-1 pt-4">
<p className="text-[10.5px] font-medium uppercase tracking-[0.08em] text-[var(--text-tertiary)]">{label}</p>
<p className="font-headline mt-3 text-[28px] font-bold leading-none tabular-nums text-[var(--text-primary)]">
{stats[key] ?? 0}
</p> </p>
</div> </div>
<div className="s-stat-foot mt-3">
<div className="grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2"> <span>total</span>
{Object.entries(statLabels).map(([key, label]) => ( </div>
<div
key={key}
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5"
>
<p className="text-[20px] font-bold tabular-nums text-[var(--text-primary)]">{stats[key] ?? 0}</p>
<p className="mt-0.5 text-[10.5px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
</div> </div>
))} ))}
</div> </div>
<section className="mt-8"> <section className="s-card !p-0 mt-6 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Clock size={15} className="text-[var(--warning)]" /> <span className="s-ibox"><Clock /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Pending approvals</h2> <h2 className="s-t">Pending Approvals</h2>
{pending.length > 0 ? ( <span className="s-trail">
<span className="rounded-full bg-[var(--warning-soft)] px-2 py-0.5 text-[10.5px] font-semibold text-[var(--warning)]"> {pending.length > 0 ? <SPill tone="warn">{pending.length}</SPill> : null}
{pending.length}
</span> </span>
) : null}
</div> </div>
{pending.length > 0 ? ( {pending.length > 0 ? (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
{pending.map((project) => ( {pending.map((project) => (
<div <div
key={project.id} key={project.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0" className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
> >
<FolderKanban size={15} className="shrink-0 text-[var(--text-tertiary)]" /> <span className="s-ibox !h-7 !w-7"><FolderKanban /></span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline"> <Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
{project.name} {project.name}
</Link> </Link>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{project.description || 'No description'}</p> <p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{project.description || 'No description'}</p>
</div> </div>
<button <GhostBtn
type="button"
onClick={() => approvalMutation.mutate({ id: project.id, approved: true })} onClick={() => approvalMutation.mutate({ id: project.id, approved: true })}
disabled={approvalMutation.isPending} disabled={approvalMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-1.5 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
<CheckCircle2 size={13} /> Approve <CheckCircle2 size={13} /> Approve
</button> </GhostBtn>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]"> <p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">
Nothing waiting for approval. Nothing waiting for approval.
</div> </p>
)} )}
</section> </section>
<section className="mt-8"> <section className="s-card !p-0 mt-6 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<FolderKanban size={15} className="text-[var(--accent-primary)]" /> <span className="s-ibox"><FolderKanban /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">All projects</h2> <h2 className="s-t">All Projects</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{allProjects.length}</span></span>
</div> </div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
{allProjects.map((project) => ( {allProjects.map((project) => (
<div <div
key={project.id} key={project.id}
@@ -297,22 +298,16 @@ export function AdminPage() {
{project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys {project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys
</p> </p>
</div> </div>
<span <SPill tone={project.isApproved ? 'ok' : 'warn'}>
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
project.isApproved
? 'bg-[var(--success-soft)] text-[var(--success)]'
: 'bg-[var(--warning-soft)] text-[var(--warning)]'
}`}
>
{project.isApproved ? 'public' : 'pending'} {project.isApproved ? 'public' : 'pending'}
</span> </SPill>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setEditingProject(project); setEditingProject(project);
setEditForm({ name: project.name, description: project.description ?? '' }); setEditForm({ name: project.name, description: project.description ?? '' });
}} }}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]" className="s-icon-btn"
title="Edit project" title="Edit project"
> >
<Pencil size={12} /> <Pencil size={12} />
@@ -325,19 +320,17 @@ export function AdminPage() {
} }
}} }}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50" className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)] disabled:opacity-40"
title="Delete project" title="Delete project"
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
<button <QuietBtn
type="button"
onClick={() => approvalMutation.mutate({ id: project.id, approved: !project.isApproved })} onClick={() => approvalMutation.mutate({ id: project.id, approved: !project.isApproved })}
disabled={approvalMutation.isPending} disabled={approvalMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
{project.isApproved ? 'Unapprove' : 'Approve'} {project.isApproved ? 'Unapprove' : 'Approve'}
</button> </QuietBtn>
</div> </div>
))} ))}
{allProjects.length === 0 ? ( {allProjects.length === 0 ? (
@@ -346,12 +339,13 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="mt-8"> <section className="s-card !p-0 mt-6 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Megaphone size={15} className="text-[var(--accent-primary)]" /> <span className="s-ibox"><Megaphone /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Announcements</h2> <h2 className="s-t">Announcements</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{banners.length}</span></span>
</div> </div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3"> <div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input <input
value={bannerForm.title} value={bannerForm.title}
@@ -375,14 +369,12 @@ export function AdminPage() {
<option value="warning">warning</option> <option value="warning">warning</option>
<option value="error">error</option> <option value="error">error</option>
</select> </select>
<button <GhostBtn
type="button"
onClick={() => bannerCreateMutation.mutate()} onClick={() => bannerCreateMutation.mutate()}
disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0} disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
{bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'} {bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'}
</button> </GhostBtn>
</div> </div>
{banners.map((banner) => ( {banners.map((banner) => (
<div <div
@@ -396,14 +388,12 @@ export function AdminPage() {
{banner.body ? ` · ${banner.body}` : ''} {banner.body ? ` · ${banner.body}` : ''}
</p> </p>
</div> </div>
<button <QuietBtn
type="button"
onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })} onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })}
disabled={bannerToggleMutation.isPending} disabled={bannerToggleMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
{banner.active ? 'Deactivate' : 'Activate'} {banner.active ? 'Deactivate' : 'Activate'}
</button> </QuietBtn>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -412,7 +402,7 @@ export function AdminPage() {
} }
}} }}
disabled={bannerDeleteMutation.isPending} disabled={bannerDeleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50" className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)] disabled:opacity-40"
title="Delete banner" title="Delete banner"
> >
<Trash2 size={12} /> <Trash2 size={12} />
@@ -425,12 +415,13 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="mt-8"> <section className="s-card !p-0 mt-6 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<UserPlus size={15} className="text-[var(--accent-primary)]" /> <span className="s-ibox"><UserPlus /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Team invites</h2> <h2 className="s-t">Team Invites</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{invites.length}</span></span>
</div> </div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3"> <div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input <input
value={inviteEmail} value={inviteEmail}
@@ -439,35 +430,25 @@ export function AdminPage() {
type="email" type="email"
className="h-9 min-w-64 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]" className="h-9 min-w-64 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/> />
<button <GhostBtn
type="button"
onClick={() => inviteCreateMutation.mutate()} onClick={() => inviteCreateMutation.mutate()}
disabled={inviteCreateMutation.isPending} disabled={inviteCreateMutation.isPending}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
{inviteCreateMutation.isPending ? 'Creating…' : 'Create invite link'} {inviteCreateMutation.isPending ? 'Creating…' : 'Create invite link'}
</button> </GhostBtn>
</div> </div>
{inviteLink ? ( {inviteLink ? (
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--accent-primary-soft)] px-4 py-2.5"> <div className="flex items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--accent-primary-soft)] px-4 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code> <code className="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
<button <QuietBtn
type="button"
onClick={() => { onClick={() => {
navigator.clipboard?.writeText(inviteLink); navigator.clipboard?.writeText(inviteLink);
showToast('success', 'Link copied'); showToast('success', 'Link copied');
}} }}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
> >
<Copy size={11} /> Copy <Copy size={11} /> Copy
</button> </QuietBtn>
<button <QuietBtn onClick={() => setInviteLink(null)}>Dismiss</QuietBtn>
type="button"
onClick={() => setInviteLink(null)}
className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
>
Dismiss
</button>
</div> </div>
) : null} ) : null}
{invites.map((invite) => { {invites.map((invite) => {
@@ -486,26 +467,17 @@ export function AdminPage() {
expires {new Date(invite.expires_at).toLocaleString()} expires {new Date(invite.expires_at).toLocaleString()}
</p> </p>
</div> </div>
<span <SPill tone={used ? 'off' : expired ? 'err' : 'ok'}>
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
used
? 'bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
: expired
? 'bg-[var(--error-soft)] text-[var(--error)]'
: 'bg-[var(--success-soft)] text-[var(--success)]'
}`}
>
{used ? 'used' : expired ? 'expired' : 'open'} {used ? 'used' : expired ? 'expired' : 'open'}
</span> </SPill>
{!used ? ( {!used ? (
<button <QuietBtn
type="button"
onClick={() => inviteRevokeMutation.mutate(invite.id)} onClick={() => inviteRevokeMutation.mutate(invite.id)}
disabled={inviteRevokeMutation.isPending} disabled={inviteRevokeMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50" className="hover:!text-[var(--error)] hover:!border-[var(--error)]"
> >
Revoke Revoke
</button> </QuietBtn>
) : null} ) : null}
</div> </div>
); );
@@ -516,12 +488,13 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="mt-8 pb-10"> <section className="s-card !p-0 mt-6 mb-10 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Users size={15} className="text-[var(--accent-tertiary)]" /> <span className="s-ibox"><Users /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Users</h2> <h2 className="s-t">Users</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{users.length}</span></span>
</div> </div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
{users.map((user) => ( {users.map((user) => (
<UserRow <UserRow
key={user.id} key={user.id}
@@ -540,30 +513,29 @@ export function AdminPage() {
{impersonation ? ( {impersonation ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl"> <div className="w-full max-w-lg s-card !p-0 overflow-hidden">
<div className="border-b border-[var(--border-subtle)] px-5 py-4"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]"> <span className="s-ibox"><ShieldCheck /></span>
Impersonating {impersonation.user.email} <h2 className="s-t">Impersonating {impersonation.user.email}</h2>
</h2> <span className="s-trail">
<p className="text-xs text-[var(--text-tertiary)]"> <span className="v-mono text-[10.5px] text-[var(--text-muted)]">
Token valid until {new Date(impersonation.expires_at).toLocaleString()} — audit-logged. expires {new Date(impersonation.expires_at).toLocaleString()}
</p> </span>
</span>
</div> </div>
<div className="space-y-3 px-5 py-4"> <div className="space-y-3 px-5 py-4">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5"> <div className="s-inset flex items-center gap-2 px-3 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]"> <code className="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]">
{impersonation.token} {impersonation.token}
</code> </code>
<button <QuietBtn
type="button"
onClick={() => { onClick={() => {
navigator.clipboard?.writeText(impersonation.token); navigator.clipboard?.writeText(impersonation.token);
showToast('success', 'Token copied'); showToast('success', 'Token copied');
}} }}
className="inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
> >
<Copy size={11} /> Copy <Copy size={11} /> Copy
</button> </QuietBtn>
</div> </div>
<p className="text-[12px] leading-relaxed text-[var(--text-tertiary)]"> <p className="text-[12px] leading-relaxed text-[var(--text-tertiary)]">
Use it as a bearer token — e.g. <code className="text-[var(--text-secondary)]">CONTAINR_TOKEN=&lt;token&gt; Use it as a bearer token — e.g. <code className="text-[var(--text-secondary)]">CONTAINR_TOKEN=&lt;token&gt;
@@ -572,13 +544,7 @@ export function AdminPage() {
</p> </p>
</div> </div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5"> <div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button <GhostBtn onClick={() => setImpersonation(null)}>Done</GhostBtn>
type="button"
onClick={() => setImpersonation(null)}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)]"
>
Done
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -586,10 +552,13 @@ export function AdminPage() {
{editingProject ? ( {editingProject ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl"> <div className="w-full max-w-md s-card !p-0 overflow-hidden">
<div className="border-b border-[var(--border-subtle)] px-5 py-4"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Edit project</h2> <span className="s-ibox"><Pencil /></span>
<p className="text-xs text-[var(--text-tertiary)]">Admin edit — changes apply immediately.</p> <h2 className="s-t">Edit Project</h2>
<span className="s-trail">
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">admin edit</span>
</span>
</div> </div>
<div className="space-y-4 px-5 py-4"> <div className="space-y-4 px-5 py-4">
<div> <div>
@@ -611,21 +580,13 @@ export function AdminPage() {
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5"> <div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button <QuietBtn onClick={() => setEditingProject(null)}>Cancel</QuietBtn>
type="button" <GhostBtn
onClick={() => setEditingProject(null)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
</button>
<button
type="button"
onClick={() => editMutation.mutate()} onClick={() => editMutation.mutate()}
disabled={editMutation.isPending || editForm.name.trim().length < 2} disabled={editMutation.isPending || editForm.name.trim().length < 2}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
{editMutation.isPending ? 'Saving…' : 'Save'} {editMutation.isPending ? 'Saving…' : 'Save'}
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
@@ -660,32 +621,22 @@ function UserRow({
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p> <p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p>
</div> </div>
{user.is_admin ? ( {user.is_admin ? (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--accent-primary-soft)] px-2.5 py-0.5 text-[10.5px] font-semibold text-[var(--accent-primary)]"> <SPill tone="info"><ShieldCheck size={10} /> admin</SPill>
<ShieldCheck size={11} /> admin
</span>
) : null} ) : null}
{onImpersonate ? ( {onImpersonate ? (
<button <QuietBtn onClick={onImpersonate}>Impersonate</QuietBtn>
type="button"
onClick={onImpersonate}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Impersonate
</button>
) : null} ) : null}
<button <QuietBtn
type="button"
onClick={() => onToggle(!user.is_admin)} onClick={() => onToggle(!user.is_admin)}
disabled={pending || isSelf} disabled={pending || isSelf}
title={isSelf ? 'You cannot change your own admin flag' : undefined} title={isSelf ? 'You cannot change your own admin flag' : undefined}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
> >
{user.is_admin ? ( {user.is_admin ? (
<span className="inline-flex items-center gap-1"><XCircle size={12} /> Revoke</span> <span className="inline-flex items-center gap-1"><XCircle size={12} /> Revoke</span>
) : ( ) : (
'Make admin' 'Make admin'
)} )}
</button> </QuietBtn>
</div> </div>
); );
} }
@@ -6,7 +6,9 @@ import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter } from '@/lib/use-dynamic-filter'; import { useDynamicFilter } from '@/lib/use-dynamic-filter';
import { formatDate, formatRelative } from '@/lib/time'; import { formatDate, formatRelative } from '@/lib/time';
import { DemoRestricted, FilterBar } from '@/shared/components'; import { DemoRestricted, FilterBar } from '@/shared/components';
import { QuietBtn, SCard, SPageHead, SPill, STable, type SCol } from '@/shared/components/sentry';
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import type { AuditLogEntity } from '@/lib/api-client';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -72,109 +74,101 @@ export function AuditLogsPage() {
const hasNext = logs.length === PAGE_SIZE; const hasNext = logs.length === PAGE_SIZE;
const hasFilters = filter.activeCount > 0; const hasFilters = filter.activeCount > 0;
const cols: SCol<AuditLogEntity>[] = [
{
key: 'time', label: 'Time', width: '100px', sortable: false,
render: (log) => (
<span className="v-mono text-[11px] text-[var(--text-tertiary)]" title={log.createdAt ? formatDate(log.createdAt) : undefined}>
{log.createdAt ? formatRelative(log.createdAt) : '—'}
</span>
),
},
{
key: 'actor', label: 'Actor', width: 'minmax(140px,1.1fr)',
sortValue: (log) => log.userEmail ?? log.userId ?? 'system',
render: (log) => (
<span className="text-[12px] text-[var(--text-primary)] truncate">
{log.userEmail || (log.userId ? log.userId.slice(0, 8) : 'system')}
</span>
),
},
{
key: 'resource', label: 'Resource', width: 'minmax(120px,1fr)',
sortValue: (log) => log.resource ?? '',
render: (log) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{log.resource}</span>,
},
{
key: 'action', label: 'Action', width: 'minmax(130px,1fr)',
sortValue: (log) => log.action ?? '',
render: (log) => <SPill tone="info">{log.action}</SPill>,
},
{
key: 'rid', label: 'Resource ID', width: 'minmax(110px,0.8fr)', sortable: false,
render: (log) => (
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)] truncate block" title={log.resourceId}>
{log.resourceId || '—'}
</span>
),
},
{
key: 'ip', label: 'IP', width: '110px', sortable: false,
render: (log) => <span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{log.ipAddress?.replace(/\/\d+$/, '') || '—'}</span>,
},
{
key: 'details', label: 'Details', width: 'minmax(160px,1.2fr)', sortable: false,
render: (log) => (
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)] truncate block" title={log.details}>
{log.details && log.details !== '{}' ? log.details : '—'}
</span>
),
},
];
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5"> <SPageHead
<h1 className="v-title"> title="Audit Logs"
Audit Logs<span className="v-cursor">_</span> titleAccent="_"
</h1> sub="Every authenticated action recorded by the platform"
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]"> />
Every authenticated action recorded by the platform
</p>
</div>
</div> </div>
<div className="w-full px-4 py-6 sm:px-8"> <div className="w-full px-4 sm:px-8">
<section className="panel p-6"> <SCard icon={<ScrollText />} title="Event Log" accent={`page ${page}`} pad={false}>
<div className="px-4 pt-1 pb-3">
<FilterBar {...filter} /> <FilterBar {...filter} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border-subtle)] text-left">
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Time</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Actor</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Resource</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Action</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Resource ID</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">IP</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-subtle)]">
{logsQuery.isLoading ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-[var(--text-muted)]">
<Loader2 size={18} className="animate-spin inline-block" />
</td>
</tr>
) : logsQuery.isError ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-[var(--error)]">
Failed to load audit logs.
</td>
</tr>
) : logs.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center">
<ScrollText size={20} className="inline-block mb-2 text-[var(--text-muted)]" />
<p className="text-[var(--text-secondary)]">No audit events{hasFilters ? ' match these filters' : ' recorded yet'}.</p>
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="hover:bg-[var(--surface-muted)]/50 transition-colors">
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--text-tertiary)]" title={log.createdAt ? formatDate(log.createdAt) : undefined}>
{log.createdAt ? formatRelative(log.createdAt) : '—'}
</td>
<td className="px-3 py-2.5 text-[var(--text-primary)]">
{log.userEmail || (log.userId ? log.userId.slice(0, 8) : 'system')}
</td>
<td className="px-3 py-2.5 mono text-[var(--text-secondary)]">{log.resource}</td>
<td className="px-3 py-2.5">
<span className="px-2 py-0.5 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] text-xs font-medium text-[var(--accent-primary)]">
{log.action}
</span>
</td>
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[140px] truncate" title={log.resourceId}>
{log.resourceId || '—'}
</td>
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)]">{log.ipAddress?.replace(/\/\d+$/, '') || '—'}</td>
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[220px] truncate" title={log.details}>
{log.details && log.details !== '{}' ? log.details : '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div> </div>
<div className="border-t border-[var(--border-subtle)] px-4 overflow-x-auto">
<div className="mt-4 flex items-center justify-between"> {logsQuery.isLoading ? (
<p className="text-xs text-[var(--text-muted)]"> <div className="py-10 text-center text-[var(--text-muted)]">
<Loader2 size={18} className="animate-spin inline-block" />
</div>
) : logsQuery.isError ? (
<div className="py-10 text-center text-[var(--error)]">Failed to load audit logs.</div>
) : logs.length === 0 ? (
<div className="py-10 text-center">
<div className="s-ibox mx-auto mb-2"><ScrollText /></div>
<p className="text-[var(--text-secondary)] text-sm">No audit events{hasFilters ? ' match these filters' : ' recorded yet'}.</p>
</div>
) : (
<STable cols={cols} rows={logs} rowKey={(l) => l.id} />
)}
</div>
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-subtle)]">
<p className="v-mono text-[10.5px] text-[var(--text-muted)]">
Page {page}{logs.length > 0 ? ` — ${logs.length} entries` : ''} Page {page}{logs.length > 0 ? ` — ${logs.length} entries` : ''}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <QuietBtn onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
onClick={() => setPage((p) => Math.max(1, p - 1))} <ChevronLeft size={12} /> Prev
disabled={page <= 1} </QuietBtn>
className="flex items-center gap-1 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" <QuietBtn onClick={() => setPage((p) => p + 1)} disabled={!hasNext}>
> Next <ChevronRight size={12} />
<ChevronLeft size={12} /> </QuietBtn>
Prev
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={!hasNext}
className="flex items-center gap-1 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
Next
<ChevronRight size={12} />
</button>
</div> </div>
</div> </div>
</section> </SCard>
</div> </div>
</div> </div>
); );
+27 -46
View File
@@ -13,15 +13,16 @@ import {
} from '@/lib/auth-client'; } from '@/lib/auth-client';
import { acceptInvite, getInvite } from '@/lib/api-client'; import { acceptInvite, getInvite } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
function sanitizeRedirect(raw: string | null): string { function sanitizeRedirect(raw: string | null): string {
if (!raw) { if (!raw) {
return '/projects'; return '/';
} }
const value = raw.trim(); const value = raw.trim();
if (!value.startsWith('/') || value.startsWith('//')) { if (!value.startsWith('/') || value.startsWith('//')) {
return '/projects'; return '/';
} }
return value; return value;
@@ -54,9 +55,11 @@ function AuthCard({
}) { }) {
return ( return (
<div className="w-full max-w-[400px]"> <div className="w-full max-w-[400px]">
<div className="rounded-[var(--radius-xl)] border border-[var(--border-subtle)] bg-[var(--surface-card)]/95 p-8 shadow-2xl shadow-black/50 backdrop-blur-xl"> <div className="s-card shadow-2xl shadow-black/50">
<h2 className="font-headline text-xl font-semibold text-[var(--text-primary)]">{title}</h2> <div className="s-cardhead mb-1">
<p className="mb-6 mt-1.5 text-sm text-[var(--text-secondary)]">{subtitle}</p> <h2 className="font-headline text-[17px] font-semibold text-[var(--text-primary)]">{title}</h2>
</div>
<p className="mb-6 v-mono text-[11.5px] text-[var(--text-tertiary)]">{subtitle}</p>
{children} {children}
</div> </div>
</div> </div>
@@ -67,19 +70,14 @@ function AuthLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="relative min-h-screen overflow-hidden bg-[var(--bg-void)]"> <div className="relative min-h-screen overflow-hidden bg-[var(--bg-void)]">
<div className="subtle-grid absolute inset-0" /> <div className="subtle-grid absolute inset-0" />
<div className="ambient-glow" />
<div
className="absolute left-1/2 top-0 h-[420px] w-[720px] -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ background: 'radial-gradient(ellipse at center, rgba(180,227,74,0.10) 0%, transparent 65%)' }}
/>
<div className="relative flex min-h-screen flex-col items-center justify-center px-5 py-10"> <div className="relative flex min-h-screen flex-col items-center justify-center px-5 py-10">
<div className="mb-8 flex flex-col items-center"> <div className="mb-8 flex flex-col items-center">
<img src="/containr.svg" alt="Containr" className="h-14 w-14" /> <img src="/containr.svg" alt="Containr" className="h-14 w-14" />
<span className="mt-4 font-headline text-2xl font-bold tracking-tight text-[var(--text-primary)]">Containr</span> <span className="mt-4 font-headline text-2xl font-bold tracking-tight text-[var(--text-primary)]">Containr</span>
<span className="mt-1 text-xs text-[var(--text-muted)]">Self-hosted container platform</span> <span className="mt-1 v-mono text-[11px] uppercase tracking-[0.14em] text-[var(--text-muted)]">Self-hosted container platform</span>
</div> </div>
{children} {children}
<p className="mt-8 text-center text-[11px] text-[var(--text-muted)]"> <p className="mt-8 text-center v-mono text-[10.5px] text-[var(--text-muted)]">
Sessions are cookie-based · first account becomes platform owner Sessions are cookie-based · first account becomes platform owner
</p> </p>
</div> </div>
@@ -89,8 +87,8 @@ function AuthLayout({ children }: { children: React.ReactNode }) {
function AuthErrorNotice({ message }: { message: string }) { function AuthErrorNotice({ message }: { message: string }) {
return ( return (
<div className="mb-4 flex items-start gap-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] bg-[var(--error-soft)] px-3 py-2.5 text-sm text-[var(--error)]"> <div className="mb-4 s-inset flex items-start gap-2 !border-[var(--error)]/30 px-3 py-2.5 text-[12.5px] text-[var(--error)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" /> <AlertCircle size={15} className="mt-0.5 shrink-0" />
<span>{message}</span> <span>{message}</span>
</div> </div>
); );
@@ -98,7 +96,7 @@ function AuthErrorNotice({ message }: { message: string }) {
function AuthInfoNotice({ message }: { message: string }) { function AuthInfoNotice({ message }: { message: string }) {
return ( return (
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--success-soft)] bg-[var(--success-soft)] px-3 py-2.5 text-sm text-[var(--success)]"> <div className="mb-4 s-inset !border-[var(--success)]/30 px-3 py-2.5 text-[12.5px] text-[var(--success)]">
{message} {message}
</div> </div>
); );
@@ -206,15 +204,10 @@ export function SignInPage() {
/> />
</label> </label>
<button <GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center">
type="submit"
disabled={isSubmitting || sessionQuery.isPending}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <Mail size={15} />} {isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <Mail size={15} />}
Continue Continue
</button> </GhostBtn>
</form> </form>
{providers.length > 0 ? ( {providers.length > 0 ? (
@@ -227,24 +220,22 @@ export function SignInPage() {
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{providers.includes('github') ? ( {providers.includes('github') ? (
<button <QuietBtn
type="button"
onClick={() => void signInWithGitHubProvider()} onClick={() => void signInWithGitHubProvider()}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]" className="!h-10 justify-center text-sm"
> >
<Github size={15} /> <Github size={15} />
GitHub GitHub
</button> </QuietBtn>
) : null} ) : null}
{providers.includes('google') ? ( {providers.includes('google') ? (
<button <QuietBtn
type="button"
onClick={() => void signInWithGoogleProvider()} onClick={() => void signInWithGoogleProvider()}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]" className="!h-10 justify-center text-sm"
> >
<GoogleMark /> <GoogleMark />
Google Google
</button> </QuietBtn>
) : null} ) : null}
</div> </div>
</> </>
@@ -280,7 +271,7 @@ export function AcceptInvitePage() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
if (sessionQuery.data) { if (sessionQuery.data) {
return <Navigate to="/projects" replace />; return <Navigate to="/" replace />;
} }
const boundEmail = inviteQuery.data?.email ?? undefined; const boundEmail = inviteQuery.data?.email ?? undefined;
@@ -297,7 +288,7 @@ export function AcceptInvitePage() {
// account, sign-in creates the browser session. // account, sign-in creates the browser session.
await signInWithEmail(effectiveEmail.trim(), password); await signInWithEmail(effectiveEmail.trim(), password);
await queryClient.invalidateQueries({ queryKey: ['auth-session'] }); await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate('/projects', { replace: true }); navigate('/', { replace: true });
} catch (exception) { } catch (exception) {
setError(exception instanceof Error ? exception.message : 'Failed to accept invite'); setError(exception instanceof Error ? exception.message : 'Failed to accept invite');
} finally { } finally {
@@ -369,15 +360,10 @@ export function AcceptInvitePage() {
/> />
</label> </label>
<button <GhostBtn type="submit" disabled={isSubmitting} className="!h-10 w-full justify-center">
type="submit"
disabled={isSubmitting}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />} {isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Accept invite Accept invite
</button> </GhostBtn>
</form> </form>
) : null} ) : null}
@@ -487,15 +473,10 @@ export function SignUpPage() {
/> />
</label> </label>
<button <GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center">
type="submit"
disabled={isSubmitting || sessionQuery.isPending}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />} {isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Create Account Create Account
</button> </GhostBtn>
</form> </form>
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]"> <div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
@@ -14,6 +14,18 @@ import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
import { FilterBar } from '@/shared/components'; import { FilterBar } from '@/shared/components';
import { demoBuilds } from '@/lib/demo-data'; import { demoBuilds } from '@/lib/demo-data';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import {
SCard,
SPageHead,
SPill,
SStat,
STable,
IconBtn,
QuietBtn,
Ticks,
type SCol,
} from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { import {
Check, Check,
X, X,
@@ -24,8 +36,6 @@ import {
Box, Box,
Sparkles, Sparkles,
Filter, Filter,
Circle,
HardDrive,
} from 'lucide-react'; } from 'lucide-react';
const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
@@ -50,26 +60,11 @@ const BUILD_SCHEMA: FilterConfig<BuildEntity>[] = [
}, },
]; ];
function StatusBadge({ status }: { status: BuildStatus }) { function BuildIcon({ status }: { status: BuildStatus }) {
const config = { if (status === 'success') return <Check size={14} className="text-[var(--success)]" />;
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false }, if (status === 'failed') return <X size={14} className="text-[var(--error)]" />;
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false }, if (status === 'running') return <Loader2 size={14} className="text-[var(--warning)] animate-spin" />;
running: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true }, return <Clock size={14} className="text-[var(--text-tertiary)]" />;
pending: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Clock, animate: false },
cancelled: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: X, animate: false },
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Circle, animate: false };
const { Icon } = config;
return (
<div
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
style={{ background: config.bg, color: config.color }}
>
<Icon size={12} className={config.animate ? 'animate-spin' : ''} />
{status}
</div>
);
} }
function bytesToHumanReadable(bytes: number): string { function bytesToHumanReadable(bytes: number): string {
@@ -149,109 +144,113 @@ export function BuildsPage() {
return { running, success, failed, total: builds.length }; return { running, success, failed, total: builds.length };
}, [builds]); }, [builds]);
const buildCols: SCol<BuildEntity>[] = [
{
key: 'build', label: 'Build', width: 'minmax(220px,1.4fr)',
sortValue: (b) => b.id,
render: (b) => (
<span className="flex items-center gap-2.5 min-w-0">
<span className="s-ibox"><BuildIcon status={b.status} /></span>
<span className="min-w-0">
<span className="block v-mono text-[12px] font-medium text-[var(--text-primary)] truncate">{b.id}</span>
<span className="block v-mono text-[10.5px] text-[var(--text-tertiary)] truncate">
{b.imageName || '—'}{b.imageTag ? `:${b.imageTag}` : ''}
</span>
</span>
</span>
),
},
{
key: 'status', label: 'Status', width: '110px',
sortValue: (b) => b.status,
render: (b) => <SPill tone={statusTone(b.status)}>{b.status}</SPill>,
},
{
key: 'progress', label: 'Progress', width: '1fr',
sortValue: (b) => b.progress,
render: (b) =>
b.status === 'running' || b.status === 'pending' ? (
<span className="flex items-center gap-2.5">
<Ticks pct={b.progress} count={40} />
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{b.progress}%</span>
</span>
) : (
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">—</span>
),
},
{
key: 'size', label: 'Size', width: '90px',
sortValue: (b) => b.size,
render: (b) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{bytesToHumanReadable(b.size)}</span>,
},
{
key: 'service', label: 'Service', width: '1fr',
sortValue: (b) => b.serviceId ?? '',
render: (b) => <span className="v-mono text-[11px] text-[var(--text-tertiary)] truncate">{b.serviceId || '—'}</span>,
},
{
key: 'when', label: 'Started', width: '90px',
sortValue: (b) => b.startedAt ?? '',
render: (b) => <span className="v-mono text-[11px] text-[var(--text-tertiary)]">{b.startedAt ? formatRelative(b.startedAt) : '—'}</span>,
},
];
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
{/* Header */} <div className="w-full px-4 pt-6 sm:px-8">
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm"> <SPageHead
<div className="w-full px-8 py-4"> title="Build Pipeline"
<div className="flex items-center justify-between gap-4"> titleAccent="_"
<div> sub="Monitor build progress and manage jobs"
<h1 className="v-title">Build Pipeline<span className="v-cursor">_</span></h1> actions={
<p className="text-sm text-[var(--text-secondary)]">Monitor build progress and manage jobs</p> <>
</div> <div className="flex items-center gap-2 text-[11px] v-mono">
<div className={`w-1.5 h-1.5 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm">
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}> <span className={liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
{liveStatus === 'live' ? 'Live' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling'} {liveStatus === 'live' ? 'live' : liveStatus === 'offline' ? 'reconnecting' : 'polling'}
</span> </span>
</div> </div>
<button <IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh builds">
onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} /> <RefreshCw size={14} />
Refresh </IconBtn>
</button> </>
</div> }
</div> />
</div>
</div> </div>
{/* Demo Mode Banner */} {/* Demo Mode Banner */}
{isDemoMode && ( {isDemoMode && (
<div className="w-full px-8 py-4"> <div className="w-full px-8 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50"> <div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]"> <Sparkles size={13} />
<Sparkles size={16} /> Demo mode active — using sample data
<span>Demo mode active — using sample data</span>
</div>
</div> </div>
</div> </div>
)} )}
{/* Stats Overview */} <div className="w-full px-4 py-6 sm:px-8 space-y-4">
<div className="w-full px-4 py-6 sm:px-8"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <SStat icon={<Box />} label="Total Builds" value={stats.total} foot="all jobs" />
<div className="panel p-4"> <SStat icon={<Loader2 />} label="In Progress" value={stats.running} foot="building now" />
<div className="flex items-center justify-between mb-3"> <SStat icon={<Check />} label="Successful" value={stats.success} foot="completed ok" />
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <SStat icon={<X />} label="Failed" value={stats.failed} foot="need attention" />
<Box size={16} className="text-[var(--accent-primary)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--text-primary)]">{stats.total}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Total Builds</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--warning-soft)] flex items-center justify-center">
<Loader2 size={16} className="text-[var(--warning)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--warning)]">{stats.running}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">In Progress</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--success-soft)] flex items-center justify-center">
<Check size={16} className="text-[var(--success)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--success)]">{stats.success}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Successful</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--error-soft)] flex items-center justify-center">
<X size={16} className="text-[var(--error)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--error)]">{stats.failed}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Failed</p>
</div>
</div>
</div> </div>
{/* Filters */} <SCard icon={<Filter />} title="Filters"
<div className="w-full px-8"> trail={<span className="v-mono text-[10.5px] text-[var(--text-muted)]">{builds.length} jobs shown</span>}
<div className="panel p-4"> >
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-end gap-3">
<Filter size={16} className="text-[var(--text-tertiary)]" /> <div className="flex-1 min-w-[280px]">
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div>
<div className="flex flex-wrap items-end gap-3 mt-3">
<div className="flex-1">
<FilterBar {...filter} /> <FilterBar {...filter} />
</div> </div>
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <label className="block text-[10.5px] font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Page Size Page Size
</label> </label>
<select <select
value={String(limit)} value={String(limit)}
onChange={(e) => setLimit(Number(e.target.value))} onChange={(e) => setLimit(Number(e.target.value))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors" className="s-chip !cursor-pointer appearance-none pr-6"
> >
<option value="20">20</option> <option value="20">20</option>
<option value="50">50</option> <option value="50">50</option>
@@ -259,127 +258,41 @@ export function BuildsPage() {
</select> </select>
</div> </div>
</div> </div>
</div> </SCard>
</div>
{/* Build Table */} <SCard icon={<Box />} title="Build Queue" accent={`${builds.length} jobs`} pad={false}
<div className="w-full px-4 py-6 sm:px-8"> trail={
<div className="panel overflow-hidden"> <IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh">
<RefreshCw size={13} />
</IconBtn>
}
>
{!isDemoMode && buildsQuery.isLoading ? ( {!isDemoMode && buildsQuery.isLoading ? (
<div className="p-12 text-center"> <div className="p-12 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 builds...</p> <p className="mt-3 text-sm text-[var(--text-muted)]">Loading builds...</p>
</div> </div>
) : null} ) : !isDemoMode && buildsQuery.isError ? (
<div className="p-10 text-center">
{!isDemoMode && buildsQuery.isError ? (
<div className="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">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-sm text-[var(--error)]">Failed to load builds</p> <p className="text-sm text-[var(--error)]">Failed to load builds</p>
<p className="text-xs text-[var(--text-muted)] mt-1">{buildsQuery.error instanceof Error ? buildsQuery.error.message : 'Unknown error'}</p> <p className="text-xs text-[var(--text-muted)] mt-1">{buildsQuery.error instanceof Error ? buildsQuery.error.message : 'Unknown error'}</p>
</div> </div>
) : null} ) : builds.length === 0 ? (
{builds.length === 0 && !buildsQuery.isLoading ? (
<div className="p-12 text-center"> <div className="p-12 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center"> <div className="s-ibox mx-auto mb-3 !w-10 !h-10"><Box /></div>
<Box size={24} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-muted)]">No builds match current filters</p> <p className="text-sm text-[var(--text-muted)]">No builds match current filters</p>
</div> </div>
) : null} ) : (
<div className="px-4">
{builds.length > 0 ? ( <STable
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4"> cols={buildCols}
{builds.map((build) => ( rows={builds}
<div rowKey={(b) => b.id}
key={build.id} onRowClick={(b) => setSelectedBuild(b)}
className="panel p-4 group hover:border-[var(--accent-primary)]/30 transition-all duration-300 card-lift cursor-pointer"
onClick={() => setSelectedBuild(build)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
build.status === 'success'
? 'bg-[var(--success-soft)]'
: build.status === 'failed'
? 'bg-[var(--error-soft)]'
: build.status === 'running'
? 'bg-[var(--warning-soft)]'
: 'bg-[var(--surface-muted)]'
}`}>
{build.status === 'success' && <Check size={18} className="text-[var(--success)]" />}
{build.status === 'failed' && <X size={18} className="text-[var(--error)]" />}
{build.status === 'running' && <Loader2 size={18} className="text-[var(--warning)] animate-spin" />}
{build.status === 'pending' && <Clock size={18} className="text-[var(--text-tertiary)]" />}
{build.status === 'cancelled' && <X size={18} className="text-[var(--text-tertiary)]" />}
</div>
<div>
<p className="mono text-sm font-medium text-[var(--text-primary)]">{build.id}</p>
<div className="flex items-center gap-2 mt-1">
<StatusBadge status={build.status} />
<span className="text-xs text-[var(--text-tertiary)]">
{build.startedAt ? formatRelative(build.startedAt) : '—'}
</span>
</div>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 rounded-lg hover:bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-all">
<FileText size={14} />
</button>
</div>
</div>
<div className="space-y-3">
{/* Image info */}
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-tertiary)]">Image</span>
<span className="mono text-[var(--text-secondary)]">
{build.imageName || '—'}:{build.imageTag || 'latest'}
</span>
</div>
{/* Progress bar */}
{(build.status === 'running' || build.status === 'pending') && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-tertiary)]">Progress</span>
<span className="text-[var(--text-secondary)] font-medium">{build.progress}%</span>
</div>
<div className="h-2 rounded-full bg-[var(--surface-muted)] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{ width: `${Math.max(0, Math.min(100, build.progress))}%`, background: 'var(--accent-primary)' }}
/> />
</div> </div>
</div>
)} )}
</SCard>
{/* Size and service */}
<div className="flex items-center justify-between text-xs pt-2 border-t border-[var(--border-subtle)]">
<div className="flex items-center gap-1.5 text-[var(--text-tertiary)]">
<HardDrive size={10} />
<span>{bytesToHumanReadable(build.size)}</span>
</div>
<span className="mono text-[var(--text-tertiary)]">{build.serviceId || '—'}</span>
</div>
</div>
{/* Error message if present */}
{build.error && (
<div className="mt-3 p-2 rounded-lg bg-[var(--error-soft)]/50 border border-[var(--error)]/20">
<p className="text-xs text-[var(--error)] line-clamp-2">{build.error}</p>
</div>
)}
</div>
))}
</div>
) : null}
</div>
</div> </div>
{/* Error Toast */} {/* Error Toast */}
@@ -393,27 +306,21 @@ export function BuildsPage() {
{selectedBuild && ( {selectedBuild && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setSelectedBuild(null)} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setSelectedBuild(null)} />
<div className="relative w-full max-w-3xl panel p-6"> <div className="relative w-full max-w-3xl s-card">
<div className="flex items-center justify-between mb-4"> <div className="s-cardhead">
<div className="flex items-center gap-3"> <span className="s-ibox"><FileText /></span>
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Build Logs</h2> <div className="s-t">Build Logs</div>
<p className="mono text-xs text-[var(--text-tertiary)]">{selectedBuild.id}</p> <div className="v-mono mt-0.5 text-[11px] text-[var(--text-tertiary)]">{selectedBuild.id}</div>
</div>
<div className="s-trail">
<QuietBtn onClick={() => setSelectedBuild(null)}>Close</QuietBtn>
</div> </div>
</div> </div>
<button <div className="px-4 pb-4">
onClick={() => setSelectedBuild(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
Close
</button>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 max-h-[400px] overflow-auto"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 max-h-[400px] overflow-auto">
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all"> <pre className="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all">
{isDemoMode {isDemoMode
? selectedBuild.log || '[demo] No logs available.' ? selectedBuild.log || '[demo] No logs available.'
: logsQuery.isLoading : logsQuery.isLoading
@@ -425,6 +332,7 @@ export function BuildsPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
)} )}
</div> </div>
); );
@@ -0,0 +1,662 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueries, useQuery } from '@tanstack/react-query';
import {
CartesianGrid,
Line,
LineChart as RechartsLineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import {
AlertTriangle,
Cpu,
FolderKanban,
HardDrive,
MemoryStick,
RefreshCw,
Rocket,
Server,
Settings,
} from 'lucide-react';
import {
getAgentMetrics,
getHostMonitoring,
listActiveAlerts,
listAgents,
listProjects,
listRecentDeployments,
type AgentMetricPoint,
type NodeAgentEntity,
} from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoHostMonitoring, demoProjects } from '@/lib/demo-data';
import {
GhostBtn,
IconBtn,
MiniBars,
SAnomaly,
SCard,
SLegend,
SPageHead,
SPill,
STable,
SStat,
Ticks,
type SCol,
} from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { QuietBtn } from '@/shared/components/sentry';
/* ------------------------------------------------------------------ */
/* Demo fixtures — deterministic, clearly sample data. */
/* ------------------------------------------------------------------ */
const demoAgents: NodeAgentEntity[] = [
{
id: 'demo-node-1',
name: 'core-01',
hostname: 'core-01.containr.local',
ipAddress: '10.0.1.11',
port: 9090,
status: 'online',
version: '0.9.2',
capabilities: {
containerRuntimes: ['docker'],
supportedArchitectures: ['amd64'],
maxContainers: 64,
storageDriver: 'overlay2',
networkPlugins: ['bridge'],
features: [],
},
resources: {
cpu: { cores: 8, allocation: 8, usage: 2.6 },
memory: { total: 16_662_000_000, allocated: 0, used: 9_832_000_000, available: 6_830_000_000 },
storage: { total: 240_000_000_000, allocated: 0, used: 141_000_000_000, available: 99_000_000_000 },
},
autoPrune: true,
schedulable: true,
tags: ['prod'],
metadata: {},
lastHeartbeat: new Date(Date.now() - 42_000).toISOString(),
},
{
id: 'demo-node-2',
name: 'edge-01',
hostname: 'edge-01.containr.local',
ipAddress: '10.0.2.4',
port: 9090,
status: 'degraded',
version: '0.9.2',
capabilities: {
containerRuntimes: ['docker'],
supportedArchitectures: ['arm64'],
maxContainers: 32,
storageDriver: 'overlay2',
networkPlugins: ['bridge'],
features: [],
},
resources: {
cpu: { cores: 4, allocation: 4, usage: 3.4 },
memory: { total: 8_290_000_000, allocated: 0, used: 7_110_000_000, available: 1_180_000_000 },
storage: { total: 120_000_000_000, allocated: 0, used: 98_000_000_000, available: 22_000_000_000 },
},
autoPrune: false,
schedulable: true,
tags: ['edge'],
metadata: {},
lastHeartbeat: new Date(Date.now() - 610_000).toISOString(),
},
];
/* Deterministic pseudo metric series for demo mode (sine + wobble). */
function demoMetricSeries(seed: number, points = 48): AgentMetricPoint[] {
const now = Date.now();
return Array.from({ length: points }, (_, i) => {
const t = now - (points - 1 - i) * 30 * 60_000;
const wave = Math.sin(i / 5.5 + seed) * 14 + Math.sin(i / 2.3 + seed * 2) * 6;
const cpu = Math.max(4, Math.min(96, 42 + wave + seed * 9));
const mem = Math.max(20, Math.min(95, 55 + wave / 2 + seed * 6));
return {
timestamp: new Date(t).toISOString(),
cpu: { usage: cpu, usage_percent: cpu, cores: 8 },
memory: { usage: mem, usage_percent: mem, limit: 100, available: 100 - mem },
system_load: { load_1m: cpu / 12, load_5m: cpu / 14, load_15m: cpu / 16 },
container_count: 12 + seed * 5,
};
});
}
const demoDeployRows = [
{ id: 'd1', serviceId: 's1', serviceName: 'api-gateway', projectName: 'Core Platform', status: 'deployed', imageName: undefined, startedAt: new Date(Date.now() - 240_000).toISOString(), completedAt: undefined, createdAt: '' },
{ id: 'd2', serviceId: 's2', serviceName: 'web-frontend', projectName: 'Core Platform', status: 'building', imageName: undefined, startedAt: new Date(Date.now() - 390_000).toISOString(), completedAt: undefined, createdAt: '' },
{ id: 'd3', serviceId: 's3', serviceName: 'batch-evaluator', projectName: 'Inference Lab', status: 'failed', imageName: undefined, startedAt: new Date(Date.now() - 3_900_000).toISOString(), completedAt: undefined, createdAt: '' },
{ id: 'd4', serviceId: 's4', serviceName: 'marketing-site', projectName: 'Growth Surface', status: 'deployed', imageName: undefined, startedAt: new Date(Date.now() - 7_400_000).toISOString(), completedAt: undefined, createdAt: '' },
];
/* ------------------------------------------------------------------ */
const SERIES_COLORS = ['#38bdf8', '#1cd8ec', '#a271d9', '#45e896'];
const MAX_CHART_AGENTS = 4;
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`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
const pct = (used: number, total: number) => (total > 0 ? Math.round((used / total) * 100) : 0);
/* Merge per-agent metric arrays into recharts rows keyed by index. */
function mergeSeries(seriesList: { key: string; points: AgentMetricPoint[] }[]) {
const len = Math.max(...seriesList.map((s) => s.points.length), 0);
return Array.from({ length: len }, (_, i) => {
const row: Record<string, number | string> = {};
seriesList.forEach((s) => {
const p = s.points[Math.min(i, s.points.length - 1)];
if (p) {
row.t = row.t ?? new Date(p.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
row[s.key] = Math.round(p.cpu.usage_percent * 10) / 10;
}
});
return row;
});
}
function ChartTooltip({ active, payload, label }: {
active?: boolean;
payload?: Array<{ name: string; value: number; color: string }>;
label?: string;
}) {
if (!active || !payload?.length) return null;
return (
<div className="s-tip">
<div className="s-tip-t">{label}</div>
{payload.map((p) => (
<div key={p.name} className="s-tip-r">
<span><i style={{ background: p.color }} />{p.name}</span>
<b>{p.value}<small>%</small></b>
</div>
))}
</div>
);
}
export function DashboardPage() {
const navigate = useNavigate();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const userName = sessionQuery.data?.user.name;
const firstName = isDemoMode ? 'Operator' : userName?.split(' ')[0];
const [range, setRange] = useState<RangeKey>('24h');
const projectsQuery = useQuery({
queryKey: ['projects'],
enabled: !isDemoMode,
queryFn: () => listProjects(),
});
const hostQuery = useQuery({
queryKey: ['host-monitoring'],
enabled: !isDemoMode,
queryFn: getHostMonitoring,
refetchInterval: 30_000,
});
// Agents are admin-scoped; failures collapse to an empty list, never an error state.
const agentsQuery = useQuery({
queryKey: ['agents'],
enabled: !isDemoMode && signedIn,
queryFn: listAgents,
retry: false,
staleTime: 30_000,
});
const deploysQuery = useQuery({
queryKey: ['recent-deploys', 50],
enabled: !isDemoMode,
queryFn: () => listRecentDeployments(50),
refetchInterval: 30_000,
});
const alertsQuery = useQuery({
queryKey: ['ha-alerts'],
enabled: !isDemoMode && signedIn,
queryFn: listActiveAlerts,
retry: false,
});
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
const host = isDemoMode ? demoHostMonitoring : hostQuery.data;
const agents = isDemoMode ? demoAgents : agentsQuery.data ?? [];
const deployments = isDemoMode ? demoDeployRows : deploysQuery.data ?? [];
const alerts = isDemoMode ? [{ id: 'a1', severity: 'warning' as const, message: 'Disk I/O spikes at 03:22' }] : alertsQuery.data ?? [];
const chartAgents = agents.slice(0, MAX_CHART_AGENTS);
const metricQueries = useQueries({
queries: chartAgents.map((a) => ({
queryKey: ['agent-metrics', a.id, range],
queryFn: () => getAgentMetrics(a.id, range),
enabled: !isDemoMode && agents.length > 0,
staleTime: 60_000,
retry: false,
})),
});
/* Series: real per-agent CPU%; demo synth; empty otherwise.
Plain computation — the compiler memoizes it. */
const seriesList = chartAgents.map((a, i) => ({
key: a.name || a.hostname || `node-${i + 1}`,
points: isDemoMode ? demoMetricSeries(i + 1) : metricQueries[i]?.data ?? [],
}));
const chartData = {
rows: mergeSeries(seriesList),
series: seriesList.map((s, i) => ({ key: s.key, color: SERIES_COLORS[i % SERIES_COLORS.length] })),
hasData: seriesList.some((s) => s.points.length > 1),
};
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'Host';
const cpuPct = host && host.cpu.cores > 0
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
: null;
const memPct = host ? Math.round(host.memory.usagePercent) : null;
const diskPct = host ? Math.round(host.storage.usagePercent) : null;
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
const onlineAgents = agents.filter((a) => a.status === 'online').length;
const succeeded = deployments.filter((d) => !['failed', 'error'].includes((d.status ?? '').toLowerCase())).length;
const anomalies = [
...(diskPct !== null && diskPct > 85 ? [`Disk usage at ${diskPct}% — prune images or expand volume`] : []),
...(memPct !== null && memPct > 85 ? [`Memory pressure at ${memPct}% — rebalance services`] : []),
...alerts.slice(0, 3).map((a) => a.message).filter((m): m is string => Boolean(m)),
...(deployments.some((d) => (d.status ?? '').toLowerCase() === 'failed')
? [`${deployments.filter((d) => (d.status ?? '').toLowerCase() === 'failed').length} failed deployment${deployments.filter((d) => (d.status ?? '').toLowerCase() === 'failed').length > 1 ? 's' : ''} in the feed`]
: []),
];
const statDefs = [
{
icon: <Server size={13.5} />,
label: 'Active Nodes',
value: agents.length > 0 ? onlineAgents : host?.dockerAvailable ? 1 : '—',
unit: agents.length > 0 ? `of ${agents.length}` : 'local',
ticks: null,
foot: agents.length > 0 ? `${agents.length - onlineAgents} offline or degraded` : 'single-host install',
delta: undefined,
},
{
icon: <Cpu size={13.5} />,
label: `${hostLabel} CPU`,
value: cpuPct ?? '—',
unit: cpuPct !== null ? '%' : undefined,
ticks: cpuPct,
foot: host ? `${host.cpu.cores} cores · load avg` : 'no telemetry',
delta: undefined,
},
{
icon: <MemoryStick size={13.5} />,
label: 'Memory Usage',
value: memPct ?? '—',
unit: memPct !== null ? '%' : undefined,
ticks: memPct,
foot: host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)} used` : 'no telemetry',
delta: undefined,
},
{
icon: <Rocket size={13.5} />,
label: 'Deployments',
value: deployments.length,
unit: 'recent',
ticks: null,
foot: `${succeeded} succeeded`,
delta: deployments.length > 0 ? { dir: 'up' as const, text: `+${deployments.length}` } : undefined,
},
];
type NodeRow = {
id: string;
name: string;
address: string;
status: string;
cpu: number | null;
mem: number | null;
disk: number | null;
containers: string;
heartbeat: string;
};
const nodeRows: NodeRow[] = agents.length > 0
? agents.map((a) => ({
id: a.id,
name: a.name || a.hostname,
address: `${a.ipAddress}${a.port ? `:${a.port}` : ''}`,
status: a.status,
cpu: a.resources.cpu.cores > 0 ? Math.round((a.resources.cpu.usage / a.resources.cpu.cores) * 100) : null,
mem: pct(a.resources.memory.used, a.resources.memory.total),
disk: pct(a.resources.storage.used, a.resources.storage.total),
containers: String(a.capabilities.maxContainers ? `≤${a.capabilities.maxContainers}` : '—'),
heartbeat: formatRelative(a.lastHeartbeat),
}))
: host
? [{
id: 'local',
name: host.hostname || 'local host',
address: `${host.os}/${host.architecture}`,
status: host.dockerAvailable ? 'online' : 'degraded',
cpu: cpuPct,
mem: memPct,
disk: diskPct,
containers: host.docker?.containers !== undefined ? String(host.docker.containers) : '—',
heartbeat: 'live',
}]
: [];
const metricCell = (v: number | null) =>
v === null ? <span className="text-[var(--text-muted)]">—</span> : (
<span className="inline-flex items-center gap-2">
<MiniBars tone={v > 85 ? 'var(--error)' : v > 70 ? 'var(--warning)' : 'var(--accent-primary)'} />
<span className="text-[12.5px] font-medium text-[var(--text-primary)]">{v}<small className="text-[var(--text-tertiary)] font-normal">%</small></span>
</span>
);
const nodeCols: SCol<NodeRow>[] = [
{ key: 'name', label: 'Node', width: '1.7fr', sortValue: (r) => r.name,
render: (r) => <span className="text-[var(--text-primary)] font-medium">{r.name}</span> },
{ key: 'address', label: 'Address', width: '1.15fr', sortValue: (r) => r.address,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{r.address}</span> },
{ key: 'status', label: 'Status', width: '0.85fr', sortValue: (r) => r.status,
render: (r) => <SPill tone={statusTone(r.status)}>{r.status}</SPill> },
{ key: 'cpu', label: 'CPU', width: '0.9fr', sortValue: (r) => r.cpu ?? -1, render: (r) => metricCell(r.cpu) },
{ key: 'mem', label: 'Memory', width: '0.9fr', sortValue: (r) => r.mem ?? -1, render: (r) => metricCell(r.mem) },
{ key: 'disk', label: 'Disk', width: '0.9fr', sortValue: (r) => r.disk ?? -1, render: (r) => metricCell(r.disk) },
{ key: 'containers', label: 'Capacity', width: '0.8fr', sortable: false,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{r.containers}</span> },
{ key: 'heartbeat', label: 'Heartbeat', width: '0.8fr', sortable: false,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-tertiary)]">{r.heartbeat}</span> },
];
type DeployRow = { id: string; service: string; project: string; status: string; when: string };
const deployRows: DeployRow[] = deployments.slice(0, 7).map((d) => ({
id: d.id,
service: d.serviceName || d.imageName || d.serviceId || d.id,
project: d.projectName || '—',
status: d.status || 'queued',
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
}));
const deployCols: SCol<DeployRow>[] = [
{ key: 'service', label: 'Service', width: '1.6fr', sortValue: (r) => r.service,
render: (r) => (
<span className="text-[var(--text-primary)] font-medium">
{r.service}
<span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{r.project}</span>
</span>
) },
{ key: 'status', label: 'Status', width: '0.9fr', sortValue: (r) => r.status,
render: (r) => <SPill tone={statusTone(r.status)}>{r.status}</SPill> },
{ key: 'when', label: 'When', width: '0.55fr', sortable: false,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-tertiary)]">{r.when}</span> },
];
const refetchAll = () => {
projectsQuery.refetch();
hostQuery.refetch();
deploysQuery.refetch();
agentsQuery.refetch();
alertsQuery.refetch();
metricQueries.forEach((q) => q.refetch());
};
return (
<div className="min-h-screen">
<div className="w-full px-4 py-6 sm:px-7">
<SPageHead
title={firstName ? 'Welcome back,' : 'Fleet'}
titleAccent={firstName ?? 'overview'}
sub={
<>
{String(runningServices).padStart(2, '0')} of {String(totalServices).padStart(2, '0')} services online ·{' '}
</>
}
subAccent={agents.length > 0 ? `${onlineAgents}/${agents.length} nodes live` : 'single host'}
actions={
<>
{signedIn && (
<GhostBtn onClick={() => navigate('/projects')}>+ New Project</GhostBtn>
)}
<QuietBtn onClick={() => navigate('/operations')}>
<AlertTriangle size={13} />
View alerts
</QuietBtn>
<IconBtn title="Refresh" onClick={refetchAll}>
<RefreshCw size={14} />
</IconBtn>
<IconBtn title="Settings" onClick={() => navigate('/settings')}>
<Settings size={14} />
</IconBtn>
</>
}
/>
{/* Stat row */}
<div className="mb-4 grid grid-cols-2 gap-3.5 xl:grid-cols-4">
{statDefs.map((s) => (
<SStat
key={s.label}
icon={s.icon}
label={s.label}
value={s.value}
unit={s.unit}
foot={s.foot}
delta={s.delta}
extra={s.ticks !== null && s.ticks !== undefined ? <Ticks pct={s.ticks} count={44} warn={s.ticks > 85} /> : undefined}
/>
))}
</div>
{/* Chart + resource usage */}
<div className="mb-4 grid grid-cols-1 gap-3.5 xl:grid-cols-[2.32fr_1fr]">
<SCard
icon={<Cpu size={13.5} />}
title="Node CPU Load"
accent={`(${range})`}
trail={
<IconBtn title="Refresh" onClick={refetchAll}>
<RefreshCw size={13} />
</IconBtn>
}
pad={false}
>
<SLegend items={chartData.series.map((s) => ({ color: s.color, label: s.key }))} />
<div className="px-3 pb-3">
{chartData.hasData ? (
<ResponsiveContainer width="100%" height={292} initialDimension={{ width: 640, height: 292 }}>
<RechartsLineChart data={chartData.rows} margin={{ top: 14, right: 14, bottom: 4, left: -14 }}>
<CartesianGrid stroke="var(--border-subtle)" vertical={false} />
<XAxis
dataKey="t"
tick={{ fill: 'var(--text-tertiary)', fontSize: 10.5, fontFamily: 'IBM Plex Mono' }}
tickLine={false}
axisLine={{ stroke: 'var(--border-subtle)' }}
minTickGap={48}
/>
<YAxis
domain={[0, 100]}
tick={{ fill: 'var(--text-tertiary)', fontSize: 10, fontFamily: 'IBM Plex Mono' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v}%`}
/>
<Tooltip content={<ChartTooltip />} cursor={{ stroke: 'var(--border-default)' }} />
{chartData.series.map((s, i) => (
<Line
key={s.key}
dataKey={s.key}
stroke={s.color}
strokeWidth={i === 0 ? 1.6 : 1.3}
dot={false}
isAnimationActive={false}
/>
))}
</RechartsLineChart>
</ResponsiveContainer>
) : (
<div className="flex h-[292px] flex-col items-center justify-center gap-2 text-center">
<Server size={22} className="text-[var(--text-tertiary)]" />
<p className="text-[12.5px] text-[var(--text-secondary)]">No node telemetry yet</p>
<p className="max-w-xs text-[11px] text-[var(--text-tertiary)]">
Enroll a Containr agent on a host to chart per-node CPU over time.
</p>
</div>
)}
</div>
{/* range picker rendered as a real control row under the chart */}
<div className="flex items-center gap-1.5 border-t border-[var(--border-subtle)] px-4 py-2">
{(['1h', '24h', '7d'] as RangeKey[]).map((r) => (
<button
key={r}
onClick={() => setRange(r)}
className={`rounded-[5px] px-2 py-1 text-[10.5px] font-medium transition-colors ${
range === r
? 'bg-[var(--nav-active)] text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
}`}
>
{r}
</button>
))}
<span className="v-mono ml-auto text-[10.5px] text-[var(--text-muted)]">
{chartAgents.length > 0 ? `${chartAgents.length} node${chartAgents.length > 1 ? 's' : ''} sampled` : 'awaiting agents'}
</span>
</div>
</SCard>
<SCard
icon={<HardDrive size={13.5} />}
title="Resource Usage"
accent="(host)"
pad={false}
>
<div className="px-4 pt-1">
{[
{ label: 'CPU Load', v: cpuPct },
{ label: 'Memory', v: memPct },
{ label: 'Disk I/O', v: diskPct },
{ label: 'Services online', v: totalServices > 0 ? Math.round((runningServices / totalServices) * 100) : null },
].map((r) => (
<div key={r.label} className="py-2">
<div className="mb-2 flex items-baseline justify-between text-[12px]">
<span className="font-medium text-[var(--text-secondary)]">{r.label}</span>
<b className="font-semibold text-[var(--accent-primary)]">{r.v !== null ? `${r.v}%` : '—'}</b>
</div>
<Ticks pct={r.v ?? 0} count={56} warn={(r.v ?? 0) > 85} />
</div>
))}
</div>
<div className="p-4 pt-2.5">
<SAnomaly
icon={<AlertTriangle size={12.5} className="text-[var(--accent-primary)]" />}
title={
<>
<b className="font-semibold text-[var(--text-primary)]">{anomalies.length}</b>
{anomalies.length === 1 ? ' anomaly' : ' anomalies'} detected
</>
}
items={anomalies.length > 0 ? anomalies : ['All systems nominal']}
/>
</div>
</SCard>
</div>
{/* Node summary */}
<SCard
icon={<Server size={13.5} />}
title="Node Summary"
trail={
<IconBtn title="Refresh" onClick={() => agentsQuery.refetch()}>
<RefreshCw size={13} />
</IconBtn>
}
pad={false}
>
<STable
cols={nodeCols}
rows={nodeRows}
rowKey={(r) => r.id}
selectable
onRowClick={(r) => { if (r.id !== 'local') navigate(`/nodes/${r.id}`); }}
/>
{nodeRows.length === 0 && (
<p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">
No nodes enrolled. The local host appears here once telemetry arrives.
</p>
)}
</SCard>
{/* Deployments + projects */}
<div className="mt-4 grid grid-cols-1 gap-3.5 xl:grid-cols-[1.6fr_1fr]">
<SCard
icon={<Rocket size={13.5} />}
title="Recent Deployments"
accent={deployRows.length > 0 ? `(${deployments.length})` : undefined}
trail={
<IconBtn title="Refresh" onClick={() => deploysQuery.refetch()}>
<RefreshCw size={13} />
</IconBtn>
}
pad={false}
>
<STable cols={deployCols} rows={deployRows} rowKey={(r) => r.id} />
{deployRows.length === 0 && (
<p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">No deployments yet.</p>
)}
</SCard>
<SCard
icon={<FolderKanban size={13.5} />}
title="Projects"
accent={`(${projects.length})`}
pad={false}
>
<div className="px-2 pb-2">
{projects.slice(0, 6).map((p) => {
const running = p.stats.running_services;
const total = p.stats.service_count;
const tone = total === 0 ? 'off' : running === total ? 'ok' : running > 0 ? 'warn' : 'err';
return (
<button
key={p.id}
onClick={() => navigate(isDemoMode ? `/projects/${p.id}?demo=1` : `/projects/${p.id}`)}
className="flex w-full items-center gap-3 rounded-[8px] px-3 py-2.5 text-left transition-colors hover:bg-[var(--surface-muted)]"
>
<span className="s-ibox"><FolderKanban size={13.5} /></span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12.5px] font-medium text-[var(--text-primary)]">{p.name}</span>
<span className="v-mono block text-[10.5px] text-[var(--text-tertiary)]">
{running}/{total} services · {p.stats.deployment_count} deploys
</span>
</span>
<SPill tone={tone}>{total === 0 ? 'idle' : running === total ? 'healthy' : running > 0 ? 'partial' : 'down'}</SPill>
</button>
);
})}
{projects.length === 0 && (
<p className="px-5 py-4 text-[12px] text-[var(--text-tertiary)]">No projects yet.</p>
)}
</div>
</SCard>
</div>
</div>
</div>
);
}
+154 -86
View File
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
@@ -8,16 +9,18 @@ import {
GitBranch, GitBranch,
LayoutTemplate, LayoutTemplate,
Rocket, Rocket,
Server,
ShieldCheck, ShieldCheck,
Terminal,
Zap, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { listProjects, listTemplates } from '@/lib/api-client'; import { getHostMonitoring, listProjects, listRecentDeployments, 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 { useDemoMode } from '@/lib/demo-mode';
import { useBranding } from '@/lib/use-branding'; import { useBranding } from '@/lib/use-branding';
import { BrandWordmark } from '@/shared/components'; import { BrandWordmark, MiniBars, SPill, Ticks } from '@/shared/components';
import { demoProjects, demoTemplates } from '@/lib/demo-data'; import { demoHostMonitoring, 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.' },
@@ -28,7 +31,14 @@ const features = [
{ icon: Zap, title: 'Autoscaling', text: 'Metric-driven scaling policies adapt replicas to real load.' }, { icon: Zap, title: 'Autoscaling', text: 'Metric-driven scaling policies adapt replicas to real load.' },
]; ];
const demoDeploys = [
{ id: 'd1', serviceName: 'api-gateway', status: 'deployed', startedAt: new Date(Date.now() - 240_000).toISOString() },
{ id: 'd2', serviceName: 'web-frontend', status: 'building', startedAt: new Date(Date.now() - 390_000).toISOString() },
{ id: 'd3', serviceName: 'batch-evaluator', status: 'failed', startedAt: new Date(Date.now() - 3_900_000).toISOString() },
];
export function LandingPage() { export function LandingPage() {
const [now] = useState(() => Date.now());
const isDemoMode = useDemoMode(); const isDemoMode = useDemoMode();
const { logoUrl, productName } = useBranding(); const { logoUrl, productName } = useBranding();
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
@@ -37,19 +47,35 @@ export function LandingPage() {
const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode }); const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode });
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode }); const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode });
const hostQuery = useQuery({ queryKey: ['landing-host'], queryFn: getHostMonitoring, staleTime: 30_000, enabled: !isDemoMode });
const deploysQuery = useQuery({ queryKey: ['landing-deploys'], queryFn: () => listRecentDeployments(4), staleTime: 30_000, enabled: !isDemoMode });
const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []); const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []);
const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12); const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12);
const host = isDemoMode ? demoHostMonitoring : hostQuery.data;
const deploys = isDemoMode ? demoDeploys : (deploysQuery.data ?? []).slice(0, 3);
const cpuPct = host ? Math.min(100, Math.round((host.load.load1m / Math.max(1, host.cpu.cores)) * 100)) : null;
const memPct = host ? Math.round(host.memory.usagePercent) : null;
const diskPct = host ? Math.round(host.storage.usagePercent) : null;
const rel = (d?: string) => {
if (!d) return '—';
const s = Math.floor((now - new Date(d).getTime()) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
};
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)]">
<div className="ambient-glow" /> {/* ── Top bar ── */}
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl"> <header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5"> <div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
<Link to="/" className="flex items-center gap-2.5"> <Link to="/" className="flex items-center gap-2.5">
<img src={logoUrl || '/containr.svg'} alt={productName} className="h-7 w-7 rounded-lg object-contain" /> <img src={logoUrl || '/containr.svg'} alt={productName} className="h-7 w-7 rounded-lg object-contain" />
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]"> <span className="font-headline text-[15px] font-semibold tracking-tight text-[var(--text-primary)]">
<BrandWordmark /> <BrandWordmark />
</span> </span>
</Link> </Link>
@@ -57,112 +83,160 @@ export function LandingPage() {
<a href="#features" 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">Features</a> <a href="#features" 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">Features</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="#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${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)]">
to={`/projects${demo}`}
className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
Browse Browse
</Link> </Link>
{signedIn ? ( {signedIn || isDemoMode ? (
<Link <Link to={`/${demo}`} className="ml-1 s-btn-accent">
to="/projects"
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)]"
>
Console <ArrowRight size={13} /> Console <ArrowRight size={13} />
</Link> </Link>
) : ( ) : (
<Link <Link to="/auth/sign-in" className="ml-1 s-btn-accent">
to={isDemoMode ? `/projects${demo}` : '/auth/sign-in'} Sign in <ArrowRight size={13} />
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)]"
>
{isDemoMode ? 'Live demo' : 'Sign in'} <ArrowRight size={13} />
</Link> </Link>
)} )}
</nav> </nav>
</div> </div>
</header> </header>
<main className="relative"> <main>
<section className="mx-auto max-w-6xl px-5 pb-16 pt-20 text-center"> {/* ── Hero + live instance window ── */}
<p className="mx-auto mb-5 inline-flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-card)] px-3.5 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-[var(--text-tertiary)]"> <section className="border-b border-[var(--border-subtle)]">
<Container size={12} className="text-[var(--accent-primary)]" /> <div className="mx-auto grid max-w-6xl gap-10 px-5 pb-16 pt-16 lg:grid-cols-[1.1fr_1fr] lg:items-center">
<div>
<p className="s-chip mb-6 inline-flex">
<Container size={11} />
self-hosted PaaS self-hosted PaaS
</p> </p>
<h1 className="mx-auto max-w-3xl text-[42px] font-extrabold leading-[1.08] tracking-[-0.03em] text-[var(--text-primary)] max-sm:text-[32px]"> <h1 className="font-headline text-[44px] font-bold leading-[1.06] tracking-[-0.03em] text-[var(--text-primary)] max-sm:text-[32px]">
Deploy containers like it's{' '} Deploy containers like it's{' '}
<span style={{ color: 'var(--accent-primary)' }}>your own cloud</span> <span className="text-[var(--accent-primary)]">your own cloud</span>
</h1> </h1>
<p className="mx-auto mt-5 max-w-xl text-[15px] leading-relaxed text-[var(--text-secondary)]"> <p className="mt-5 max-w-lg text-[14px] leading-relaxed text-[var(--text-secondary)]">
Containr turns any Docker host into a full deployment platform — git-push builds, Containr turns any Docker host into a full deployment platform — git-push builds,
managed databases, preview environments, HA failover, and a searchable knowledge base. managed databases, preview environments, HA failover, and a searchable knowledge base.
</p> </p>
<div className="mt-8 flex items-center justify-center gap-3"> <div className="mt-8 flex items-center gap-3">
<Link <Link to={`/${demo}`} className="s-btn-accent !px-5 !h-10 text-sm">
to={`/projects${demo}`} {isDemoMode ? 'Explore the live demo' : 'Open the console'} <ArrowRight size={15} />
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]"
>
{isDemoMode ? 'Explore the live demo' : 'Browse projects'} <ArrowRight size={15} />
</Link> </Link>
<a <a href="#docs" className="s-btn-quiet !px-5 !h-10 text-sm">
href="#docs"
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-default)] px-5 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"
>
<BookOpen size={15} /> Read the docs <BookOpen size={15} /> Read the docs
</a> </a>
</div> </div>
<div className="v-mono mt-8 flex items-center gap-5 text-[11px] text-[var(--text-tertiary)]">
<span className="flex items-center gap-2">
<i className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--success)]" />
{projects.length} public projects
</span>
<span>{projects.reduce((s, p) => s + (p.stats?.running_services ?? 0), 0)} services online</span>
<span className="max-sm:hidden">{projects.reduce((s, p) => s + (p.stats?.deployment_count ?? 0), 0)} deployments</span>
</div>
</div>
{projects.length > 0 ? ( {/* Live window — real telemetry from this instance */}
<div className="mx-auto mt-12 grid max-w-3xl grid-cols-3 gap-3 text-left max-sm:grid-cols-1"> <div className="s-card !p-0 overflow-hidden">
<Stat label="Public projects" value={projects.length} /> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Stat <span className="s-ibox"><Server size={13.5} /></span>
label="Running services" <span className="s-t">{host?.hostname ?? 'this instance'} <span className="s-ta">live</span></span>
value={projects.reduce((sum, p) => sum + (p.stats?.running_services ?? 0), 0)} <span className="s-trail">
/> <SPill tone={host?.dockerAvailable ? 'ok' : 'off'}>{host?.dockerAvailable ? 'online' : 'offline'}</SPill>
<Stat </span>
label="Deployments" </div>
value={projects.reduce((sum, p) => sum + (p.stats?.deployment_count ?? 0), 0)} <div className="px-4 pb-2 pt-3">
/> {[
{ label: 'CPU', v: cpuPct },
{ label: 'Memory', v: memPct },
{ label: 'Disk', v: diskPct },
].map((r) => (
<div key={r.label} className="py-1.5">
<div className="mb-1.5 flex items-baseline justify-between text-[11.5px]">
<span className="text-[var(--text-secondary)]">{r.label}</span>
<b className="font-semibold text-[var(--accent-primary)]">{r.v !== null ? `${r.v}%` : '—'}</b>
</div>
<Ticks pct={r.v ?? 0} count={48} warn={(r.v ?? 0) > 85} />
</div>
))}
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3">
<p className="v-mono mb-2 text-[10px] uppercase tracking-[0.12em] text-[var(--text-tertiary)]">latest deploys</p>
{deploys.map((d) => (
<div key={d.id} className="flex items-center gap-2.5 py-1.5 text-[12px]">
<MiniBars tone={(d.status ?? '').toLowerCase() === 'failed' ? 'var(--error)' : 'var(--accent-primary)'} />
<span className="min-w-0 flex-1 truncate font-medium text-[var(--text-primary)]">{d.serviceName || d.id}</span>
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{rel(d.startedAt)}</span>
</div>
))}
{deploys.length === 0 && (
<p className="py-1.5 text-[11.5px] text-[var(--text-tertiary)]">No deployments yet.</p>
)}
</div>
</div>
</div> </div>
) : null}
</section> </section>
<section id="features" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60"> {/* ── Install strip ── */}
<section className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-4 px-5 py-5">
<span className="s-ibox"><Terminal size={13.5} /></span>
<code className="v-mono flex-1 text-[12.5px] text-[var(--text-secondary)]">
<span className="text-[var(--accent-primary)]">$</span> docker compose up -d
</code>
<span className="v-mono text-[11px] text-[var(--text-tertiary)]">one host · one command · full platform</span>
</div>
</section>
{/* ── Features ── */}
<section id="features" className="border-b border-[var(--border-subtle)]">
<div className="mx-auto max-w-6xl px-5 py-16"> <div className="mx-auto max-w-6xl px-5 py-16">
<h2 className="text-center text-[26px] font-bold tracking-tight text-[var(--text-primary)]"> <div className="flex items-end justify-between gap-6 max-sm:flex-col max-sm:items-start">
<div>
<p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
platform
</p>
<h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]">
Everything a deployment platform needs Everything a deployment platform needs
</h2> </h2>
<p className="mx-auto mt-2 max-w-lg text-center text-sm text-[var(--text-secondary)]"> <p className="mt-1.5 max-w-lg text-[13px] text-[var(--text-secondary)]">
One install, one dashboard, full control over your own infrastructure. One install, one dashboard, full control over your own infrastructure.
</p> </p>
<div className="mt-10 grid grid-cols-3 gap-4 max-md:grid-cols-2 max-sm:grid-cols-1"> </div>
<span className="v-mono text-[11px] text-[var(--text-muted)]">06 capabilities</span>
</div>
<div className="mt-9 grid grid-cols-3 gap-3.5 max-md:grid-cols-2 max-sm:grid-cols-1">
{features.map((feature) => ( {features.map((feature) => (
<div <div key={feature.title} className="s-card">
key={feature.title} <div className="s-cardhead !mb-0">
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-5 transition-colors hover:border-[var(--border-default)]" <span className="s-ibox"><feature.icon size={13.5} /></span>
> <h3 className="s-t">{feature.title}</h3>
<feature.icon size={18} className="text-[var(--accent-primary)]" /> </div>
<h3 className="mt-3 text-[14.5px] font-semibold text-[var(--text-primary)]">{feature.title}</h3> <p className="mt-2 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
<p className="mt-1.5 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
<section id="templates" className="border-t border-[var(--border-subtle)]"> {/* ── Templates ── */}
<section id="templates" className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
<div className="mx-auto max-w-6xl px-5 py-16"> <div className="mx-auto max-w-6xl px-5 py-16">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between gap-6">
<div> <div>
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2> <p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<p className="mt-1 text-sm text-[var(--text-secondary)]"> <i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
catalog
</p>
<h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2>
<p className="mt-1.5 text-[13px] text-[var(--text-secondary)]">
Production-ready presets — deploy in one click from the console. Production-ready presets — deploy in one click from the console.
</p> </p>
</div> </div>
<Link <Link
to={`/templates${demo}`} to={`/templates${demo}`}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline" className="s-chip !normal-case !tracking-normal"
> >
Browse all <ArrowRight size={13} /> Browse all <ArrowRight size={11} />
</Link> </Link>
</div> </div>
<div className="mt-8 grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2"> <div className="mt-8 grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
@@ -170,22 +244,20 @@ export function LandingPage() {
<Link <Link
key={template.id} key={template.id}
to={`/templates?template=${template.id}${isDemoMode ? '&demo=1' : ''}`} 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="s-card group"
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
{template.logo ? ( {template.logo ? (
<img src={template.logo} alt="" className="h-7 w-7 rounded-md" /> <img src={template.logo} alt="" className="h-7 w-7 rounded-[6px]" />
) : ( ) : (
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--surface-muted)] text-[var(--accent-primary)]"> <span className="s-ibox !h-7 !w-7"><LayoutTemplate size={13.5} /></span>
<LayoutTemplate size={14} />
</span>
)} )}
<span className="truncate text-[13px] font-semibold text-[var(--text-primary)]">{template.name}</span> <span className="truncate text-[13px] font-semibold text-[var(--text-primary)]">{template.name}</span>
</div> </div>
<p className="mt-2 line-clamp-2 text-[11.5px] leading-relaxed text-[var(--text-tertiary)]"> <p className="mt-2 line-clamp-2 text-[11.5px] leading-relaxed text-[var(--text-tertiary)]">
{template.description} {template.description}
</p> </p>
<span className="mt-2 inline-block rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--text-tertiary)]"> <span className="s-chip mt-2" style={{ cursor: 'default' }}>
{template.category || 'app'} {template.category || 'app'}
</span> </span>
</Link> </Link>
@@ -199,10 +271,15 @@ export function LandingPage() {
</div> </div>
</section> </section>
<section id="docs" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60"> {/* ── Docs ── */}
<section id="docs" className="border-b border-[var(--border-subtle)]">
<div className="mx-auto max-w-6xl px-5 py-16"> <div className="mx-auto max-w-6xl px-5 py-16">
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2> <p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<p className="mt-1 text-sm text-[var(--text-secondary)]"> <i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
reference
</p>
<h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2>
<p className="mt-1.5 text-[13px] text-[var(--text-secondary)]">
Guides and references — synced from GitHub, cached locally, always available. Guides and references — synced from GitHub, cached locally, always available.
</p> </p>
<div className="mt-8"> <div className="mt-8">
@@ -213,10 +290,10 @@ export function LandingPage() {
</main> </main>
<footer className="border-t border-[var(--border-subtle)]"> <footer className="border-t border-[var(--border-subtle)]">
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[12px] text-[var(--text-tertiary)]"> <div className="v-mono mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[11.5px] text-[var(--text-tertiary)]">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<img src="/containr.svg" alt="" className="h-4 w-4 rounded" /> <img src="/containr.svg" alt="" className="h-4 w-4 rounded" />
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${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link> <Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link>
@@ -228,12 +305,3 @@ export function LandingPage() {
</div> </div>
); );
} }
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5">
<p className="text-[22px] font-bold tabular-nums text-[var(--text-primary)]">{value}</p>
<p className="mt-0.5 text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
</div>
);
}
@@ -19,8 +19,10 @@ import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { formatBytes, formatRelative } from '@/lib/time'; import { formatBytes, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components'; import { DemoRestricted } from '@/shared/components';
import { GhostBtn, QuietBtn, SPill } from '@/shared/components/sentry';
import { import {
ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle, ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle,
Activity, Cpu, ListOrdered, Server,
} from 'lucide-react'; } from 'lucide-react';
const TIME_RANGES = [ const TIME_RANGES = [
@@ -129,8 +131,7 @@ export function NodeDetailPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5">
<div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2"> <div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2">
<Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1"> <Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1">
<ArrowLeft size={12} /> usage <ArrowLeft size={12} /> usage
@@ -140,7 +141,7 @@ export function NodeDetailPage() {
</div> </div>
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
<div> <div>
<h1 className="v-title"> <h1 className="v-title font-headline">
{rename === null ? agent.name : rename} {rename === null ? agent.name : rename}
{isAdmin && rename === null && ( {isAdmin && rename === null && (
<button <button
@@ -163,7 +164,7 @@ export function NodeDetailPage() {
if (e.key === 'Escape') setRename(null); if (e.key === 'Escape') setRename(null);
}} }}
autoFocus autoFocus
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-1 text-sm text-[var(--text-primary)]" className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-1 text-sm text-[var(--text-primary)]"
/> />
<button onClick={() => updateMutation.mutate({ name: rename })} className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">save</button> <button onClick={() => updateMutation.mutate({ name: rename })} className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">save</button>
<button onClick={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button> <button onClick={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button>
@@ -209,7 +210,7 @@ export function NodeDetailPage() {
}} }}
autoFocus autoFocus
placeholder="eu-west, gpu" placeholder="eu-west, gpu"
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-52" className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-52"
/> />
<button <button
onClick={() => updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) })} onClick={() => updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) })}
@@ -247,7 +248,7 @@ export function NodeDetailPage() {
}} }}
autoFocus autoFocus
placeholder="apps.node1.example.com — empty clears" placeholder="apps.node1.example.com — empty clears"
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64" className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64"
/> />
<button <button
onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })} onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })}
@@ -258,17 +259,9 @@ export function NodeDetailPage() {
)} )}
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 flex-wrap">
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online <SPill tone={online ? 'ok' : 'err'}>{agent.status}</SPill>
? 'border-[var(--success)]/40 text-[var(--success)]' {!agent.schedulable && <SPill tone="warn">cordoned</SPill>}
: 'border-[var(--error)]/40 text-[var(--error)]'}`}>
{agent.status}
</span>
{!agent.schedulable && (
<span className="v-mono text-[11px] px-2 py-1 rounded-md border border-[var(--warning)]/40 text-[var(--warning)]">
cordoned
</span>
)}
{isAdmin && ( {isAdmin && (
<> <>
<label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune"> <label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
@@ -280,81 +273,79 @@ export function NodeDetailPage() {
/> />
auto-prune auto-prune
</label> </label>
<button <QuietBtn
onClick={() => cordonMutation.mutate(agent.schedulable)} onClick={() => cordonMutation.mutate(agent.schedulable)}
disabled={cordonMutation.isPending} disabled={cordonMutation.isPending}
title={agent.schedulable title={agent.schedulable
? 'Block new placements — running replicas stay' ? 'Block new placements — running replicas stay'
: 'Re-open for placement'} : 'Re-open for placement'}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
{agent.schedulable ? 'cordon' : 'uncordon'} {agent.schedulable ? 'cordon' : 'uncordon'}
</button> </QuietBtn>
<button <QuietBtn
onClick={() => setConfirmDrain(true)} onClick={() => setConfirmDrain(true)}
disabled={drainMutation.isPending || !online} disabled={drainMutation.isPending || !online}
title={online title={online
? 'Cordon + remove all service containers + unpin services (they redeploy locally)' ? 'Cordon + remove all service containers + unpin services (they redeploy locally)'
: 'Node must be online to drain'} : 'Node must be online to drain'}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--warning)]/40 text-[var(--warning)] hover:bg-[var(--warning)]/10 disabled:opacity-50" className="!text-[var(--warning)] hover:!border-[var(--warning)]/60"
> >
drain drain
</button> </QuietBtn>
<button <GhostBtn
onClick={() => upgradeMutation.mutate()} onClick={() => upgradeMutation.mutate()}
disabled={upgradeMutation.isPending || !online} disabled={upgradeMutation.isPending || !online}
title={online title={online
? `Upgrade agent to the server's bundled binary (server ${upgradeMutation.data?.version ?? 'build'}) — the agent restarts itself` ? `Upgrade agent to the server's bundled binary (server ${upgradeMutation.data?.version ?? 'build'}) — the agent restarts itself`
: 'Node must be online to upgrade'} : 'Node must be online to upgrade'}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
{upgradeMutation.isPending ? 'upgrading…' : 'upgrade agent'} {upgradeMutation.isPending ? 'upgrading…' : 'upgrade agent'}
</button> </GhostBtn>
<button <QuietBtn
onClick={() => setConfirmVolumes(true)} onClick={() => setConfirmVolumes(true)}
disabled={pruneMutation.isPending} disabled={pruneMutation.isPending}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
prune now prune now
</button> </QuietBtn>
<button <QuietBtn
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--error)]/40 text-[var(--error)] hover:bg-[var(--error)]/10" className="!text-[var(--error)] hover:!border-[var(--error)]/60"
> >
remove remove
</button> </QuietBtn>
</> </>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="px-4 py-6 sm:px-8 space-y-6"> <div className="px-4 py-6 sm:px-8 space-y-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="cpu" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} /> <Stat label="cpu" icon={<Cpu />} value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
<Stat label="memory" value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} /> <Stat label="memory" icon={<Server />} value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
<Stat label="load 1m" value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} /> <Stat label="load 1m" icon={<Activity />} value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} />
<Stat label="containers" value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" /> <Stat label="containers" icon={<ListOrdered />} value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
</div> </div>
<section className="panel p-5"> <section className="s-card !p-0">
<div className="flex items-center justify-between mb-4"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">telemetry</h2> <span className="s-ibox"><Activity /></span>
<div className="flex gap-1"> <h2 className="s-t">Telemetry</h2>
<span className="s-trail">
<span className="flex gap-1">
{TIME_RANGES.map((r) => ( {TIME_RANGES.map((r) => (
<button <button
key={r.value} key={r.value}
onClick={() => setTimeRange(r.value)} onClick={() => setTimeRange(r.value)}
className={`v-mono text-[11px] px-2.5 py-1 rounded-md border ${timeRange === r.value className={`s-chip v-mono ${timeRange === r.value ? 'on' : ''}`}
? 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}
> >
{r.label} {r.label}
</button> </button>
))} ))}
</span>
</span>
</div> </div>
</div> <div className="p-4">
{metricsQuery.isLoading ? ( {metricsQuery.isLoading ? (
<p className="text-xs text-[var(--text-muted)]">Loading metrics…</p> <p className="text-xs text-[var(--text-muted)]">Loading metrics…</p>
) : metrics.length < 2 ? ( ) : metrics.length < 2 ? (
@@ -365,12 +356,17 @@ export function NodeDetailPage() {
<Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} /> <Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} />
</div> </div>
)} )}
</div>
</section> </section>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<section className="panel p-0"> <section className="s-card !p-0">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">containers</h2> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]"> <span className="s-ibox"><Server /></span>
<h2 className="s-t">Containers</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{containers.length}</span></span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{containers.length === 0 && ( {containers.length === 0 && (
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p> <p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p>
)} )}
@@ -380,15 +376,24 @@ export function NodeDetailPage() {
<p className="truncate text-sm text-[var(--text-primary)]">{c.name ?? c.id}</p> <p className="truncate text-sm text-[var(--text-primary)]">{c.name ?? c.id}</p>
<p className="truncate v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p> <p className="truncate v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p>
</div> </div>
<span className="v-mono text-[11px] text-[var(--text-secondary)]">{c.status ?? '—'}</span> {/* Persisted query cache can replay the raw status object — flatten defensively. */}
<span className="v-mono text-[11px] text-[var(--text-secondary)]">
{typeof c.status === 'string'
? c.status
: (c.status as { state?: string } | undefined)?.state ?? '—'}
</span>
</div> </div>
))} ))}
</div> </div>
</section> </section>
<section className="panel p-0"> <section className="s-card !p-0">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">command history</h2> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]"> <span className="s-ibox"><ListOrdered /></span>
<h2 className="s-t">Command History</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{commands.length}</span></span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{commands.length === 0 && ( {commands.length === 0 && (
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p> <p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p>
)} )}
@@ -466,12 +471,19 @@ function meshAddresses(agent: { metadata: Record<string, unknown> }): [string, s
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== ''); .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '');
} }
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) { function Stat({ label, icon, value, sub }: { label: string; icon: React.ReactNode; value: string; sub?: string }) {
return ( return (
<div className="panel p-4"> <div className="s-stat">
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p> <div className="px-4 pb-1 pt-4">
<p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{value}</p> <div className="flex items-center gap-2.5">
{sub && <p className="mt-0.5 v-mono text-[11px] text-[var(--text-muted)]">{sub}</p>} <span className="s-ibox">{icon}</span>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{label}</span>
</div>
<p className="mt-3 font-headline text-[24px] font-bold leading-none tabular-nums text-[var(--text-primary)]">{value}</p>
</div>
<div className="s-stat-foot mt-3">
<span>{sub ?? '—'}</span>
</div>
</div> </div>
); );
} }
@@ -510,24 +522,20 @@ function ConfirmModal({ title, body, confirmLabel, danger, extra, onCancel, onCo
pending: boolean; pending: boolean;
}) { }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onCancel}>
<div className="panel w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}> <div className="s-card w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3> <h3 className="s-t">{title}</h3>
<p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p> <p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p>
{extra && <div className="mt-3">{extra}</div>} {extra && <div className="mt-3">{extra}</div>}
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
<button onClick={onCancel} className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)]"> <QuietBtn onClick={onCancel}>cancel</QuietBtn>
cancel <GhostBtn
</button>
<button
onClick={onConfirm} onClick={onConfirm}
disabled={pending} disabled={pending}
className={`v-mono text-[11px] px-3 py-1.5 rounded-md border disabled:opacity-50 ${danger className={danger ? '!text-[var(--error)] !border-[var(--error)]/40 !bg-[var(--error)]/10' : undefined}
? 'border-[var(--error)]/50 text-[var(--error)] hover:bg-[var(--error)]/10'
: 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)] hover:bg-[var(--accent-primary)]/10'}`}
> >
{pending ? 'working…' : confirmLabel} {pending ? 'working…' : confirmLabel}
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
@@ -4,28 +4,10 @@ import { getOperations, type OperationsView } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components'; import { DemoRestricted } from '@/shared/components';
import { SPageHead, SPill, SStat, Ticks } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { Activity, AlertTriangle, Clock, Database, Loader2, Timer } from 'lucide-react'; import { Activity, AlertTriangle, Clock, Database, Loader2, Timer } from 'lucide-react';
function statusClass(status: string): string {
switch (status) {
case 'deployed':
case 'completed':
case 'success':
return 'text-[var(--success)]';
case 'failed':
case 'cancelled':
case 'rolled_back':
return 'text-[var(--error)]';
case 'building':
case 'deploying':
case 'in_progress':
case 'running':
return 'text-[var(--accent-primary)]';
default:
return 'text-[var(--warning)]';
}
}
function Section({ title, icon: Icon, count, children }: { function Section({ title, icon: Icon, count, children }: {
title: string; title: string;
icon: typeof Activity; icon: typeof Activity;
@@ -33,13 +15,15 @@ function Section({ title, icon: Icon, count, children }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="panel p-5"> <section className="s-card overflow-hidden">
<div className="flex items-center gap-2 mb-4"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Icon size={16} className="text-[var(--accent-primary)]" /> <span className="s-ibox"><Icon /></span>
<h2 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h2> <h2 className="s-t">{title}</h2>
<span className="text-xs text-[var(--text-muted)] mono">({count})</span> <span className="s-trail">
<span className="s-chip" style={{ cursor: 'default' }}>{count}</span>
</span>
</div> </div>
{children} <div className="p-3">{children}</div>
</section> </section>
); );
} }
@@ -74,13 +58,8 @@ export function OperationsPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5"> <SPageHead title="Operations" titleAccent="_" sub="Live view of deploys, queues, cron runs, and backups" />
<h1 className="v-title">Operations<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
Live view of deploys, queues, cron runs, and backups
</p>
</div>
</div> </div>
{opsQuery.isLoading ? ( {opsQuery.isLoading ? (
@@ -89,23 +68,11 @@ export function OperationsPage() {
</div> </div>
) : ( ) : (
<div className="w-full px-4 py-6 sm:px-8 space-y-6"> <div className="w-full px-4 py-6 sm:px-8 space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 gap-3.5 md:grid-cols-4">
<div className="panel-soft p-4"> <SStat icon={<Activity />} label="In Flight" value={active.length} foot="deploying now" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">In flight</p> <SStat icon={<Clock />} label="Queued" value={queuedTotal} foot="waiting in queue" />
<p className="mt-1 text-2xl font-semibold text-[var(--accent-primary)]">{active.length}</p> <SStat icon={<AlertTriangle />} label="Failed 24h" value={failed.length} foot="need attention" />
</div> <SStat icon={<Database />} label="Backups 24h" value={backups.length} foot="completed runs" />
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Queued</p>
<p className="mt-1 text-2xl font-semibold text-[var(--warning)]">{queuedTotal}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed 24h</p>
<p className="mt-1 text-2xl font-semibold text-[var(--error)]">{failed.length}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Backups 24h</p>
<p className="mt-1 text-2xl font-semibold text-[var(--text-secondary)]">{backups.length}</p>
</div>
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
@@ -125,7 +92,7 @@ export function OperationsPage() {
<span className="text-sm text-[var(--text-primary)]">{d.service_name}</span> <span className="text-sm text-[var(--text-primary)]">{d.service_name}</span>
<span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span> <span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span>
</div> </div>
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span> <SPill tone={statusTone(d.status)}>{d.status}</SPill>
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span> <span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
</button> </button>
))} ))}
@@ -152,7 +119,7 @@ export function OperationsPage() {
<p className="text-[11px] text-[var(--error)]/80 truncate">{d.error}</p> <p className="text-[11px] text-[var(--error)]/80 truncate">{d.error}</p>
)} )}
</div> </div>
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span> <SPill tone={statusTone(d.status)}>{d.status}</SPill>
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span> <span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
</button> </button>
))} ))}
@@ -167,9 +134,9 @@ export function OperationsPage() {
<div className="space-y-1.5"> <div className="space-y-1.5">
{cron.map((r) => ( {cron.map((r) => (
<div key={r.id} className="flex items-center gap-3 px-3 py-1.5 text-xs"> <div key={r.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<span className={`mono ${statusClass(r.status)}`}>{r.status}</span> <SPill tone={statusTone(r.status)}>{r.status}</SPill>
<span className="text-[var(--text-primary)]">{r.job_name}</span> <span className="text-[var(--text-primary)]">{r.job_name}</span>
<span className="mono text-[var(--text-muted)]">{r.schedule}</span> <span className="v-mono text-[var(--text-muted)]">{r.schedule}</span>
{r.error && <span className="text-[var(--error)]/80 truncate">{r.error}</span>} {r.error && <span className="text-[var(--error)]/80 truncate">{r.error}</span>}
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(r.started_at)}</span> <span className="ml-auto text-[var(--text-muted)]">{formatRelative(r.started_at)}</span>
</div> </div>
@@ -185,7 +152,7 @@ export function OperationsPage() {
<div className="space-y-1.5"> <div className="space-y-1.5">
{backups.map((b) => ( {backups.map((b) => (
<div key={b.id} className="flex items-center gap-3 px-3 py-1.5 text-xs"> <div key={b.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<span className={`mono ${statusClass(b.status)}`}>{b.status}</span> <SPill tone={statusTone(b.status)}>{b.status}</SPill>
<span className="text-[var(--text-primary)]">{b.database_name}</span> <span className="text-[var(--text-primary)]">{b.database_name}</span>
<span className="text-[var(--text-muted)]">{b.size}</span> <span className="text-[var(--text-muted)]">{b.size}</span>
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(b.created_at)}</span> <span className="ml-auto text-[var(--text-muted)]">{formatRelative(b.created_at)}</span>
@@ -198,14 +165,15 @@ export function OperationsPage() {
{queue.length > 0 && ( {queue.length > 0 && (
<Section title="Deploy queue depth" icon={Clock} count={queue.length}> <Section title="Deploy queue depth" icon={Clock} count={queue.length}>
<div className="space-y-1.5"> <div className="space-y-2">
{queue.map((q) => ( {queue.map((q) => (
<div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs mono"> <div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<span className="text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span> <span className="v-mono text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span>
<span className={q.running ? 'text-[var(--accent-primary)]' : 'text-[var(--text-muted)]'}> <SPill tone={q.running ? 'info' : 'off'}>{q.running ? 'running' : 'idle'}</SPill>
{q.running ? 'running' : 'idle'} <span className="flex-1 max-w-[220px]">
<Ticks pct={Math.min(100, q.queued * 25)} count={32} warn={!q.running} />
</span> </span>
<span className="text-[var(--warning)]">{q.queued} queued</span> <span className="v-mono text-[var(--warning)]">{q.queued} queued</span>
</div> </div>
))} ))}
</div> </div>
@@ -13,10 +13,31 @@ import {
Search, Search,
FolderOpen, FolderOpen,
X, X,
Cpu,
MemoryStick,
HardDrive,
Rocket,
RefreshCw,
Server,
} 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'; import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects } from '@/lib/demo-data'; import { demoProjects } from '@/lib/demo-data';
import {
GhostBtn,
IconBtn,
QuietBtn,
SAnomaly,
SCard,
SPageHead,
SPill,
SSection,
SStat,
STable,
Ticks,
type SCol,
} from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
const demoDeploys = [ const demoDeploys = [
{ id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' }, { id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' },
@@ -25,6 +46,8 @@ const demoDeploys = [
{ id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' }, { id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' },
]; ];
type DeployRow = { id: string; name: string; project?: string; status: string; when: string };
function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' { function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' {
if (stats.service_count === 0) return 'idle'; if (stats.service_count === 0) return 'idle';
if (stats.running_services === stats.service_count) return 'healthy'; if (stats.running_services === stats.service_count) return 'healthy';
@@ -32,19 +55,6 @@ function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' |
return 'critical'; return 'critical';
} }
function healthBadge(health: ReturnType<typeof getHealthStatus>) {
switch (health) {
case 'healthy':
return { label: 'RUNNING', cls: 'v-st-ok' };
case 'idle':
return { label: 'IDLE', cls: '' };
case 'degraded':
return { label: 'DEGRADED', cls: 'v-st-warn' };
case 'critical':
return { label: 'DOWN', cls: 'v-st-fail' };
}
}
function formatRelative(date?: string): string { function formatRelative(date?: string): string {
if (!date) return '—'; if (!date) return '—';
const diff = Date.now() - new Date(date).getTime(); const diff = Date.now() - new Date(date).getTime();
@@ -54,6 +64,7 @@ function formatRelative(date?: string): string {
return `${Math.floor(diff / 86_400_000)}d ago`; return `${Math.floor(diff / 86_400_000)}d ago`;
} }
/* Mini service topology preview inside each project card. */
function MiniCanvas({ project }: { project: ProjectEntity }) { function MiniCanvas({ project }: { project: ProjectEntity }) {
const count = project.stats.service_count; const count = project.stats.service_count;
const running = project.stats.running_services; const running = project.stats.running_services;
@@ -84,40 +95,61 @@ function MiniCanvas({ project }: { project: ProjectEntity }) {
function ProjectCard({ project, href }: { project: ProjectEntity; href: string }) { function ProjectCard({ project, href }: { project: ProjectEntity; href: string }) {
const navigate = useNavigate(); const navigate = useNavigate();
const health = getHealthStatus(project.stats); const health = getHealthStatus(project.stats);
const badge = healthBadge(health); const envLabel =
const envOk = health === 'healthy'; health === 'healthy' ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
return ( return (
<article <article
className="group panel p-4 cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)]" className="s-card group cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)]"
onClick={() => navigate(href)} onClick={() => navigate(href)}
> >
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead">
<h3 className="truncate text-[14.5px] font-bold tracking-tight text-[var(--text-primary)]"> <IboxStub />
{project.name} <h3 className="s-t truncate">{project.name}</h3>
</h3> <span className="s-trail">
<span className={`v-st ml-auto ${badge.cls}`}>{badge.label}</span> <SPill
tone={
health === 'healthy' ? 'ok' : health === 'idle' ? 'off' : health === 'critical' ? 'err' : 'warn'
}
>
{health === 'healthy' ? 'Running' : health === 'idle' ? 'Idle' : health === 'critical' ? 'Down' : 'Degraded'}
</SPill>
</span>
</div> </div>
<div className="px-4">
<MiniCanvas project={project} /> <MiniCanvas project={project} />
<div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]"> </div>
<div className="s-stat-foot mt-3.5">
<span className="flex items-center gap-2">
<i <i
className="inline-block h-1.5 w-1.5 rounded-full" className="inline-block h-1.5 w-1.5 rounded-full"
style={{ background: envOk ? 'var(--success)' : health === 'idle' ? 'var(--text-tertiary)' : health === 'critical' ? 'var(--error)' : 'var(--warning)' }} style={{
background:
health === 'healthy'
? 'var(--success)'
: health === 'idle'
? 'var(--text-tertiary)'
: health === 'critical'
? 'var(--error)'
: 'var(--warning)',
}}
/> />
<span className="text-[var(--text-secondary)]"> {envLabel} · {project.stats.running_services}/{project.stats.service_count} online
{envLabel}
</span> </span>
<span>·</span> <span>{formatRelative(project.updatedAt)}</span>
<span>
{project.stats.running_services}/{project.stats.service_count} online
</span>
<span className="ml-auto">{formatRelative(project.updatedAt)}</span>
</div> </div>
</article> </article>
); );
} }
function IboxStub() {
return (
<span className="s-ibox">
<Server size={13.5} />
</span>
);
}
export function ProjectsPage() { export function ProjectsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -181,7 +213,7 @@ export function ProjectsPage() {
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0); const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0); const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
const host = hostQuery.data; const host = hostQuery.data;
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'HOST'; const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'Host';
const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0 const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100)) ? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
: null; : null;
@@ -190,9 +222,9 @@ export function ProjectsPage() {
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`; const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
const deployments = isDemoMode ? [] : deploysQuery.data ?? []; const deployments = isDemoMode ? [] : deploysQuery.data ?? [];
const deployCount = deployments.length; const deployCount = deployments.length;
const feed = isDemoMode const feed: DeployRow[] = isDemoMode
? demoDeploys ? demoDeploys
: deployments.slice(0, 5).map((d) => ({ : deployments.slice(0, 6).map((d) => ({
id: d.id, id: d.id,
name: d.serviceName || d.imageName || d.serviceId || d.id, name: d.serviceName || d.imageName || d.serviceId || d.id,
project: d.projectName, project: d.projectName,
@@ -200,120 +232,188 @@ export function ProjectsPage() {
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt), when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
})); }));
const statusClass = (status: string) => const statDefs = [
status === 'DEPLOYED' || status === 'SUCCESS' || status === 'SUCCEEDED' {
? 'v-st-ok' icon: <Cpu size={13.5} />,
: status === 'BUILDING' || status === 'RUNNING' || status === 'QUEUED' label: `${hostLabel} CPU`,
? 'v-st-run' value: cpuPct ?? '—',
: status === 'FAILED' || status === 'CANCELLED' unit: cpuPct !== null ? '%' : undefined,
? 'v-st-fail' ticks: cpuPct,
: 'v-st-off'; foot: isDemoMode || host ? `${host?.cpu.cores ?? 16} cores · load avg` : 'no telemetry',
},
{
icon: <MemoryStick size={13.5} />,
label: `${hostLabel} Memory`,
value: memPct ?? '—',
unit: memPct !== null ? '%' : undefined,
ticks: memPct,
foot: isDemoMode
? '9.8G / 13G used'
: host
? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)} used`
: 'no telemetry',
},
{
icon: <HardDrive size={13.5} />,
label: `${hostLabel} Disk`,
value: diskPct ?? '—',
unit: diskPct !== null ? '%' : undefined,
ticks: diskPct,
foot: isDemoMode
? '338G of 465G'
: host
? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}`
: 'no telemetry',
},
{
icon: <Rocket size={13.5} />,
label: 'Deployments',
value: isDemoMode ? 18 : deployCount,
unit: 'recent',
ticks: null,
foot: isDemoMode
? '17 succeeded'
: `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} succeeded`,
},
];
const deployCols: SCol<DeployRow>[] = [
{
key: 'service',
label: 'Service',
width: '2.4fr',
sortValue: (r) => r.name,
render: (r) => (
<span className="text-[var(--text-primary)] font-medium">
{r.name}
{r.project ? (
<span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{r.project}</span>
) : null}
</span>
),
},
{
key: 'status',
label: 'Status',
width: '1fr',
sortValue: (r) => r.status,
render: (r) => <SPill tone={statusTone(r.status)}>{r.status}</SPill>,
},
{
key: 'when',
label: 'When',
width: '0.6fr',
sortable: false,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-tertiary)]">{r.when}</span>,
},
];
const anomalies =
(diskPct ?? 0) > 85 || (memPct ?? 0) > 85
? [
...(diskPct ?? 0) > 85 ? [`Disk usage at ${diskPct}% — prune images or expand volume`] : [],
...(memPct ?? 0) > 85 ? [`Memory pressure at ${memPct}% — consider rebalancing services`] : [],
]
: [];
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="w-full px-4 py-6 sm:px-8"> <div className="w-full px-4 py-6 sm:px-7">
{/* Page head */} <SPageHead
<div className="mb-6 flex flex-wrap items-center justify-between gap-x-5 gap-y-3"> title="Projects"
<div> titleAccent="_"
<h1 className="v-title">Projects<span className="v-cursor">_</span></h1> sub={
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]"> <>
<b className="text-[var(--text-secondary)]">{String(projects.length).padStart(2, '0')}</b> projects ·{' '} {String(projects.length).padStart(2, '0')} projects · {String(totalServices).padStart(2, '0')} services ·{' '}
<b className="text-[var(--text-secondary)]">{String(totalServices).padStart(2, '0')}</b> services ·{' '} </>
<b className="text-[var(--text-secondary)]">{runningServices}</b> online }
</p> subAccent={`${runningServices} online`}
</div> actions={
<div className="flex items-center gap-2.5"> <>
<div className="search-box" style={{ width: 'min(240px, 42vw)' }}> <div className="search-box" style={{ width: 'min(230px, 40vw)' }}>
<Search size={13} /> <Search size={13} />
<input <input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="search projects…" placeholder="Search projects"
className="v-mono"
style={{ fontSize: 11.5 }}
/> />
</div> </div>
<IconBtn title="Refresh" onClick={() => { projectsQuery.refetch(); deploysQuery.refetch(); hostQuery.refetch(); }}>
<RefreshCw size={14} />
</IconBtn>
{signedIn && ( {signedIn && (
<button onClick={openCreate} className="v-btn"> <GhostBtn onClick={openCreate}>+ New Project</GhostBtn>
+ new project
</button>
)} )}
</div> </>
</div> }
/>
{/* Host stat row */} {/* Host telemetry */}
<div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="mb-4 grid grid-cols-2 gap-3.5 lg:grid-cols-4">
<div className="v-stat"> {statDefs.map((s) => (
<div className="v-k"><span>{hostLabel}.CPU</span><span className="v-live" /></div> <SStat
<div className="v-v">{cpuPct ?? '—'}<span>%</span></div> key={s.label}
{isDemoMode ? ( icon={s.icon}
<div className="v-spark"> label={s.label}
{[28, 44, 36, 58, 42, 66, 50, 34, 56, 40, 30, 20].map((h, i) => ( value={s.value}
<i key={i} style={{ height: `${h}%` }} /> unit={s.unit}
foot={s.foot}
delta={s.label === 'Deployments' && feed.length > 0 ? { dir: 'up', text: `+${feed.length}` } : undefined}
extra={s.ticks !== null && s.ticks !== undefined ? <Ticks pct={s.ticks} count={44} warn={s.ticks > 85} /> : undefined}
/>
))} ))}
</div> </div>
) : null}
<div className="v-d"> {anomalies.length > 0 && (
{isDemoMode || host ? ( <div className="mb-4">
<><b style={{ color: 'var(--success)' }}>healthy</b> · {host?.cpu.cores ?? 16} cores</> <SAnomaly
) : 'no telemetry'} icon={
</div> <svg width="12.5" height="12.5" viewBox="0 0 16 16" fill="none" stroke="var(--warning)" strokeWidth="1.5">
</div> <path d="M8 2.4 2.2 13h11.6z" />
<div className="v-stat"> <path d="M8 6.6v3M8 11.4v.3" />
<div className="v-k"><span>{hostLabel}.MEM</span><span className="v-live" /></div> </svg>
<div className="v-v">{memPct ?? '—'}<span>%</span></div> }
<div className="v-meter"><i className={memPct !== null && memPct > 85 ? 'r' : 'y'} style={{ width: `${memPct ?? 0}%` }} /></div> title={<><b className="text-[var(--text-primary)] font-semibold">{anomalies.length}</b> resource anomaly{anomalies.length > 1 ? 's' : ''} detected</>}
<div className="v-d">{isDemoMode ? '9.8G / 13G' : host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)}` : 'no telemetry'}</div> items={anomalies}
</div> />
<div className="v-stat">
<div className="v-k"><span>{hostLabel}.DISK</span><span className="v-live" /></div>
<div className="v-v">{diskPct ?? '—'}<span>%</span></div>
<div className="v-meter"><i className="r" style={{ width: `${diskPct ?? 0}%` }} /></div>
<div className="v-d">{isDemoMode ? '338G of 465G · high' : host ? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}` : 'no telemetry'}</div>
</div>
<div className="v-stat">
<div className="v-k"><span>DEPLOYS</span></div>
<div className="v-v">{isDemoMode ? 18 : deployCount}</div>
<div className="v-meter"><i style={{ width: '94%' }} /></div>
<div className="v-d"><b style={{ color: 'var(--success)' }}>{isDemoMode ? '17 ok' : `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} recent`}</b></div>
</div>
</div> </div>
)}
{/* Projects grid */} {/* Projects grid */}
<p className="v-sect">PROJECTS <span className="v-sect-r">{filteredProjects.length} records</span></p> <SSection right={`${filteredProjects.length} records`}>Projects</SSection>
{!isDemoMode && projectsQuery.isLoading && ( {!isDemoMode && projectsQuery.isLoading && (
<div className="grid grid-cols-1 gap-3.5 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid grid-cols-1 gap-3.5 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="panel h-[210px] animate-pulse" /> <div key={i} className="s-card h-[210px] animate-pulse" />
))} ))}
</div> </div>
)} )}
{!isDemoMode && projectsQuery.isError && ( {!isDemoMode && projectsQuery.isError && (
<div className="panel p-8 text-center"> <div className="s-card p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--error-soft)]"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--error-soft)]">
<X size={24} className="text-[var(--error)]" /> <X size={22} className="text-[var(--error)]" />
</div> </div>
<p className="text-lg font-medium text-[var(--text-primary)]">Failed to load projects</p> <p className="text-[15px] font-semibold text-[var(--text-primary)]">Failed to load projects</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">Check API connectivity and try again</p> <p className="mt-1.5 text-[12px] text-[var(--text-tertiary)]">Check API connectivity and try again</p>
</div> </div>
)} )}
{filteredProjects.length === 0 && !projectsQuery.isLoading && !projectsQuery.isError ? ( {filteredProjects.length === 0 && !projectsQuery.isLoading && !projectsQuery.isError ? (
<div className="panel p-12 text-center"> <div className="s-card p-12 text-center">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]"> <div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl border border-[var(--border-default)] bg-[var(--bg-overlay)]">
<FolderOpen size={28} className="text-[var(--accent-primary)]" /> <FolderOpen size={24} className="text-[var(--accent-primary)]" />
</div> </div>
<p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p> <p className="font-headline text-[17px] font-bold text-[var(--text-primary)]">No projects yet</p>
<p className="mx-auto mt-2 max-w-md text-[var(--text-secondary)]"> <p className="mx-auto mt-2 max-w-md text-[12.5px] text-[var(--text-secondary)]">
{signedIn {signedIn
? 'Create your first project to start deploying services with visual topology management.' ? 'Create your first project to start deploying services with visual topology management.'
: 'No public projects yet. Sign in to create one — an admin approves it before it goes public.'} : 'No public projects yet. Sign in to create one — an admin approves it before it goes public.'}
</p> </p>
<button onClick={openCreate} className="v-btn mt-6"> <GhostBtn onClick={openCreate} className="mt-6">
{signedIn ? '+ create project' : 'sign in to create'} {signedIn ? '+ New Project' : 'Sign in to create'}
</button> </GhostBtn>
</div> </div>
) : null} ) : null}
@@ -326,24 +426,23 @@ export function ProjectsPage() {
)} )}
{/* Deploy feed */} {/* Deploy feed */}
<p className="v-sect mt-7">RECENT DEPLOYMENTS <span className="v-sect-r">latest</span></p> <div className="mt-6">
<div className="v-tbl"> <SCard
<table> icon={<Rocket size={13.5} />}
<thead> title="Recent Deployments"
<tr><th>SERVICE</th><th>STATUS</th><th style={{ textAlign: 'right' }}>WHEN</th></tr> accent={feed.length > 0 ? `(${feed.length})` : undefined}
</thead> trail={
<tbody> <IconBtn title="Refresh" onClick={() => deploysQuery.refetch()}>
{feed.length === 0 ? ( <RefreshCw size={13} />
<tr><td colSpan={3} className="text-center" style={{ color: 'var(--text-tertiary)' }}>no deployments yet</td></tr> </IconBtn>
) : feed.map((d) => ( }
<tr key={d.id}> pad={false}
<td className="v-lead">{d.name}{d.project ? <span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{d.project}</span> : null}</td> >
<td><span className={`v-st ${statusClass(d.status)}`}>{d.status}</span></td> <STable cols={deployCols} rows={feed} rowKey={(r) => r.id} />
<td style={{ textAlign: 'right' }}>{d.when}</td> {feed.length === 0 && (
</tr> <p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">No deployments yet.</p>
))} )}
</tbody> </SCard>
</table>
</div> </div>
</div> </div>
@@ -351,58 +450,55 @@ export function ProjectsPage() {
{isCreateOpen && ( {isCreateOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreateOpen(false)} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreateOpen(false)} />
<div className="relative w-full max-w-lg panel p-6"> <div className="s-card relative w-full max-w-lg p-6">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Create new project</h2> <h2 className="font-headline text-[19px] font-bold tracking-tight text-[var(--text-primary)]">New project</h2>
<p className="mt-1 text-sm text-[var(--text-secondary)]"> <p className="mt-1 text-[12.5px] text-[var(--text-secondary)]">
Projects organize your services and provide a visual canvas for topology management. Projects organize your services and provide a visual canvas for topology management.
</p> </p>
<p className="mt-2 rounded-[var(--radius-md)] bg-[var(--warning-soft)] px-3 py-2 text-xs text-[var(--warning)]"> <div className="s-inset mt-3 text-[11.5px] text-[var(--warning)]">
New projects stay private until a platform admin approves them for public listing. New projects stay private until a platform admin approves them for public listing.
</p> </div>
<div className="mt-6 space-y-4"> <div className="mt-5 space-y-4">
<div> <div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]"> <label className="mb-2 block text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
Project Name Project Name
</label> </label>
<input <input
value={form.name} value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
placeholder="my-awesome-project" placeholder="my-awesome-project"
className="h-11 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]" className="h-10 w-full rounded-lg border border-[var(--border-default)] bg-[var(--bg-overlay)] px-3.5 text-[13px] text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]"> <label className="mb-2 block text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
Description <span className="normal-case text-[var(--text-muted)]">(optional)</span> Description <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label> </label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
placeholder="Describe your project..." placeholder="Describe your project…"
rows={3} rows={3}
className="w-full resize-none rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-3 text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]" className="w-full resize-none rounded-lg border border-[var(--border-default)] bg-[var(--bg-overlay)] px-3.5 py-3 text-[13px] text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:outline-none"
/> />
</div> </div>
</div> </div>
{createProjectMutation.isError && ( {createProjectMutation.isError && (
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]"> <div className="s-inset mt-4 text-[12px] text-[var(--error)]">
{(createProjectMutation.error as Error).message} {(createProjectMutation.error as Error).message}
</div> </div>
)} )}
<div className="mt-6 flex justify-end gap-3"> <div className="mt-6 flex justify-end gap-2.5">
<button onClick={() => setCreateOpen(false)} className="v-btn-ghost"> <QuietBtn onClick={() => setCreateOpen(false)}>Cancel</QuietBtn>
Cancel <GhostBtn
</button>
<button
onClick={() => createProjectMutation.mutate()} onClick={() => createProjectMutation.mutate()}
disabled={!form.name.trim() || createProjectMutation.isPending} disabled={!form.name.trim() || createProjectMutation.isPending}
className="v-btn"
> >
{createProjectMutation.isPending ? 'Creating...' : 'Create Project'} {createProjectMutation.isPending ? 'Creating…' : 'Create Project'}
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
+138 -164
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
@@ -104,6 +104,7 @@ 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, DemoRestricted } from '@/shared/components'; import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart, DemoRestricted } from '@/shared/components';
import { SPageHead } from '@/shared/components/sentry';
import { import {
Clock, Clock,
Activity, Activity,
@@ -148,13 +149,10 @@ import {
Send, Send,
} from 'lucide-react'; } from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) { function SecondaryPageHeader({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
return ( return (
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5"> <SPageHead title={title} titleAccent="_" sub={description} actions={actions} />
<h1 className="v-title">{title}<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">{description}</p>
</div>
</div> </div>
); );
} }
@@ -180,15 +178,17 @@ function StatCard({
}; };
return ( return (
<div className="panel p-5"> <div className="s-stat">
<div className="flex items-start justify-between mb-3"> <div className="px-4 pb-1 pt-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center"> <div className="flex items-center gap-2.5">
<Icon size={18} className="text-[var(--text-tertiary)]" /> <span className="s-ibox"><Icon /></span>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
</div> </div>
<p className={`font-headline mt-4 text-[30px] font-bold leading-none tracking-tight ${colorClasses[color]}`}>{value}</p>
</div>
<div className="s-stat-foot mt-4">
<span>{description}</span>
</div> </div>
<p className={`text-2xl font-semibold ${colorClasses[color]}`}>{value}</p>
<p className="text-xs font-medium text-[var(--text-muted)] mt-1">{title}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-2">{description}</p>
</div> </div>
); );
} }
@@ -450,14 +450,14 @@ export function UsagePage() {
<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>
) : !isDemoMode && buildsQuery.isError ? ( ) : !isDemoMode && buildsQuery.isError ? (
<div className="panel p-8 text-center"> <div className="s-card 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)]" />
</div> </div>
<p className="text-sm text-[var(--error)]">Failed to load usage data</p> <p className="text-sm text-[var(--error)]">Failed to load usage data</p>
</div> </div>
) : !signedIn ? ( ) : !signedIn ? (
<div className="panel p-8 text-center"> <div className="s-card p-8 text-center">
<p className="text-sm text-[var(--text-secondary)]">Sign in to view build activity and capacity.</p> <p className="text-sm text-[var(--text-secondary)]">Sign in to view build activity and capacity.</p>
</div> </div>
) : ( ) : (
@@ -490,14 +490,12 @@ export function UsagePage() {
/> />
</div> </div>
<section className="panel p-6"> <section className="s-card p-5">
<div className="mb-5 flex items-center justify-between gap-3"> <div className="mb-5 flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]"> <span className="s-ibox !w-9 !h-9"><Server /></span>
<Server size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Host Monitoring</h2> <h2 className="s-t">Host Monitoring</h2>
<p className="text-xs text-[var(--text-tertiary)]"> <p className="text-xs text-[var(--text-tertiary)]">
{host {host
? `${host.hostname} · ${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}${host.dockerAvailable ? '' : ' · Docker unavailable'}` ? `${host.hostname} · ${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}${host.dockerAvailable ? '' : ' · Docker unavailable'}`
@@ -578,13 +576,11 @@ export function UsagePage() {
)} )}
</section> </section>
<section className="panel p-6"> <section className="s-card p-5">
<div className="mb-5 flex items-center gap-3"> <div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--surface-muted)]"> <span className="s-ibox !w-9 !h-9"><Radio /></span>
<Radio size={18} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Node Agents</h2> <h2 className="s-t">Node Agents</h2>
<p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p> <p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p>
</div> </div>
</div> </div>
@@ -678,8 +674,8 @@ export function UsagePage() {
<button <button
onClick={() => createTokenMutation.mutate()} onClick={() => createTokenMutation.mutate()}
disabled={createTokenMutation.isPending} disabled={createTokenMutation.isPending}
className="h-8 px-3 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] transition-all disabled:opacity-50" className="s-btn-accent h-8 disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{createTokenMutation.isPending ? 'Issuing…' : 'Issue token'} {createTokenMutation.isPending ? 'Issuing…' : 'Issue token'}
</button> </button>
@@ -688,7 +684,7 @@ export function UsagePage() {
{issuedToken && ( {issuedToken && (
<div className="mb-3 rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3"> <div className="mb-3 rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span> <span className="v-mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
<button <button
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')} onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0" className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
@@ -706,11 +702,11 @@ export function UsagePage() {
</p> </p>
)} )}
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre> <pre className="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre>
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre> <pre className="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre>
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]"> <div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p> <p>Agent endpoint: <span className="v-mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
<p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p> <p>Use an issued token above, or set <span className="v-mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
<p>Agent sends host resources, polls pending Docker commands, and reports command results.</p> <p>Agent sends host resources, polls pending Docker commands, and reports command results.</p>
</div> </div>
@@ -876,13 +872,11 @@ export function PeoplePage() {
/> />
</div> </div>
<section className="panel p-6"> <section className="s-card p-5">
<div className="mb-5 flex items-center gap-3"> <div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]"> <span className="s-ibox !w-9 !h-9"><UserPlus /></span>
<UserPlus size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Manual User Creation</h2> <h2 className="s-t">Manual User Creation</h2>
<p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p> <p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p>
</div> </div>
</div> </div>
@@ -921,8 +915,8 @@ export function PeoplePage() {
<button <button
type="submit" type="submit"
disabled={createUserMutation.isPending} disabled={createUserMutation.isPending}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 text-sm font-semibold text-[var(--accent-on)] disabled:opacity-60" className="s-btn-accent h-10 justify-center disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
> >
{createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />} {createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
Create Create
@@ -1057,14 +1051,12 @@ function GitProvidersSection() {
const selectedType = gitProviderTypes.find((t) => t.value === form.name)!; const selectedType = gitProviderTypes.find((t) => t.value === form.name)!;
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><GitBranch /></span>
<GitBranch size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Git Providers</h2> <h2 className="s-t">Git Providers</h2>
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p> <p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
</div> </div>
</div> </div>
@@ -1073,8 +1065,8 @@ function GitProvidersSection() {
<button <button
onClick={() => provisionMutation.mutate()} onClick={() => provisionMutation.mutate()}
disabled={provisionMutation.isPending} disabled={provisionMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all disabled:opacity-50" className="s-btn-accent h-9 disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
<GitBranch size={14} /> <GitBranch size={14} />
{provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'} {provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'}
@@ -1090,8 +1082,8 @@ function GitProvidersSection() {
)} )}
<button <button
onClick={() => setFormOpen((open) => !open)} onClick={() => setFormOpen((open) => !open)}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all" className="s-btn-accent h-9"
style={{ background: 'var(--accent-primary)' }}
> >
<Link2 size={14} /> <Link2 size={14} />
{formOpen ? 'Close' : 'Connect'} {formOpen ? 'Close' : 'Connect'}
@@ -1140,12 +1132,10 @@ function GitProvidersSection() {
{providers.map((provider) => ( {providers.map((provider) => (
<div <div
key={provider.id} key={provider.id}
className="flex items-center justify-between p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]" className="s-inset flex items-center justify-between"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] flex items-center justify-center"> <span className="s-ibox"><GitBranch /></span>
<GitBranch size={14} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<p className="text-sm font-medium text-[var(--text-primary)]">{provider.display_name}</p> <p className="text-sm font-medium text-[var(--text-primary)]">{provider.display_name}</p>
<p className="text-xs text-[var(--text-muted)] capitalize">{provider.name.replace('_', ' ')}</p> <p className="text-xs text-[var(--text-muted)] capitalize">{provider.name.replace('_', ' ')}</p>
@@ -1201,7 +1191,7 @@ function GitProvidersSection() {
type="password" type="password"
value={form.accessToken} value={form.accessToken}
onChange={(e) => setForm((p) => ({ ...p, accessToken: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, accessToken: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono focus:border-[var(--accent-primary)] transition-colors"
placeholder="••••••••••••••••" placeholder="••••••••••••••••"
/> />
<p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p> <p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p>
@@ -1214,7 +1204,7 @@ function GitProvidersSection() {
<input <input
value={form.apiUrl} value={form.apiUrl}
onChange={(e) => setForm((p) => ({ ...p, apiUrl: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, apiUrl: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono focus:border-[var(--accent-primary)] transition-colors"
placeholder="https://gitea.example.com" placeholder="https://gitea.example.com"
/> />
</div> </div>
@@ -1228,8 +1218,8 @@ function GitProvidersSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={!form.accessToken.trim() || createMutation.isPending} disabled={!form.accessToken.trim() || createMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Connect Provider Connect Provider
@@ -1274,7 +1264,7 @@ export function GitHubAppCallbackPage() {
{!code ? ( {!code ? (
<> <>
<AlertCircle size={28} className="mx-auto text-[var(--error)]" /> <AlertCircle size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Missing code</h1> <h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Missing code</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]"> <p className="mt-2 text-sm text-[var(--text-tertiary)]">
GitHub did not return an app code. Start the setup again from Git providers. GitHub did not return an app code. Start the setup again from Git providers.
</p> </p>
@@ -1282,14 +1272,14 @@ export function GitHubAppCallbackPage() {
) : convertMutation.isError ? ( ) : convertMutation.isError ? (
<> <>
<AlertCircle size={28} className="mx-auto text-[var(--error)]" /> <AlertCircle size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1> <h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]"> <p className="mt-2 text-sm text-[var(--text-tertiary)]">
{convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'} {convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'}
</p> </p>
<button <button
type="button" type="button"
onClick={() => navigate('/settings', { replace: true })} onClick={() => navigate('/settings', { replace: true })}
className="mt-6 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-sm font-semibold text-[var(--accent-on)]" className="s-btn-accent mt-6"
> >
Back to settings Back to settings
</button> </button>
@@ -1297,7 +1287,7 @@ export function GitHubAppCallbackPage() {
) : ( ) : (
<> <>
<Loader2 size={28} className="mx-auto animate-spin text-[var(--accent-primary)]" /> <Loader2 size={28} className="mx-auto animate-spin text-[var(--accent-primary)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1> <h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]">Exchanging the app credentials…</p> <p className="mt-2 text-sm text-[var(--text-tertiary)]">Exchanging the app credentials…</p>
</> </>
)} )}
@@ -1353,13 +1343,11 @@ function PlatformSettingsSection() {
}; };
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Cloud /></span>
<Cloud size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Platform</h2> <h2 className="s-t">Platform</h2>
<p className="text-xs text-[var(--text-tertiary)]">Owner only - values set here override environment variables</p> <p className="text-xs text-[var(--text-tertiary)]">Owner only - values set here override environment variables</p>
</div> </div>
</div> </div>
@@ -1412,8 +1400,8 @@ function PlatformSettingsSection() {
<button <button
onClick={saveToken} onClick={saveToken}
disabled={saveMutation.isPending || !tokenDraft.trim()} disabled={saveMutation.isPending || !tokenDraft.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ background: 'var(--accent-primary)' }}
> >
Save Save
</button> </button>
@@ -1475,8 +1463,8 @@ function PlatformSettingsSection() {
saveMutation.mutate({ branding: b }); saveMutation.mutate({ branding: b });
}} }}
disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0} disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ background: 'var(--accent-primary)' }}
> >
Save branding Save branding
</button> </button>
@@ -1548,13 +1536,11 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
]; ];
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Key /></span>
<Key size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2> <h2 className="s-t">Personal Access Tokens</h2>
<p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p> <p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p>
</div> </div>
</div> </div>
@@ -1588,8 +1574,8 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !tokenName.trim()} disabled={createMutation.isPending || !tokenName.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Creating…' : 'Create token'} {createMutation.isPending ? 'Creating…' : 'Create token'}
</button> </button>
@@ -1598,7 +1584,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
{issuedToken ? ( {issuedToken ? (
<div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3"> <div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span> <span className="v-mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
<button <button
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')} onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0" className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
@@ -1608,7 +1594,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
</button> </button>
</div> </div>
<p className="mt-1 text-xs text-[var(--text-tertiary)]"> <p className="mt-1 text-xs text-[var(--text-tertiary)]">
Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token. Shown once - store it now. Use with <span className="v-mono">containr auth login --token</span> or as a Bearer token.
</p> </p>
</div> </div>
) : null} ) : null}
@@ -1630,7 +1616,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
<div key={token.id} className="flex items-center justify-between gap-2 text-xs"> <div key={token.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0"> <div className="min-w-0">
<span className="text-[var(--text-primary)]">{token.name}</span> <span className="text-[var(--text-primary)]">{token.name}</span>
<span className="ml-2 mono text-[var(--text-muted)]">{token.key_prefix}…</span> <span className="ml-2 v-mono text-[var(--text-muted)]">{token.key_prefix}…</span>
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span> <span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span>
<span className="ml-2 text-[var(--text-muted)]"> <span className="ml-2 text-[var(--text-muted)]">
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'} {token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
@@ -1692,13 +1678,11 @@ function RegistriesSection() {
const registries = registriesQuery.data ?? []; const registries = registriesQuery.data ?? [];
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Archive /></span>
<Archive size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2> <h2 className="s-t">Registry credentials</h2>
<p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p> <p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p>
</div> </div>
</div> </div>
@@ -1733,8 +1717,8 @@ function RegistriesSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !name.trim() || !host.trim()} disabled={createMutation.isPending || !name.trim() || !host.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Adding…' : 'Add registry'} {createMutation.isPending ? 'Adding…' : 'Add registry'}
</button> </button>
@@ -1760,7 +1744,7 @@ function RegistriesSection() {
<div key={r.id} className="flex items-center justify-between gap-2 text-xs"> <div key={r.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0"> <div className="min-w-0">
<span className="text-[var(--text-primary)]">{r.name}</span> <span className="text-[var(--text-primary)]">{r.name}</span>
<span className="ml-2 mono text-[var(--text-muted)]">{r.host}</span> <span className="ml-2 v-mono text-[var(--text-muted)]">{r.host}</span>
{r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null} {r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null}
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]"> <span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
{r.has_password ? 'password set' : 'anonymous'} {r.has_password ? 'password set' : 'anonymous'}
@@ -1828,13 +1812,11 @@ function BackupTargetsSection() {
const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all'; const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all';
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><HardDrive /></span>
<HardDrive size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Backup targets</h2> <h2 className="s-t">Backup targets</h2>
<p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p> <p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p>
</div> </div>
</div> </div>
@@ -1853,8 +1835,8 @@ function BackupTargetsSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()} disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Checking…' : 'Add target'} {createMutation.isPending ? 'Checking…' : 'Add target'}
</button> </button>
@@ -1880,7 +1862,7 @@ function BackupTargetsSection() {
<div key={t.id} className="flex items-center justify-between gap-2 text-xs"> <div key={t.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0"> <div className="min-w-0">
<span className="text-[var(--text-primary)]">{t.name}</span> <span className="text-[var(--text-primary)]">{t.name}</span>
<span className="ml-2 mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span> <span className="ml-2 v-mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span>
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]"> <span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
{t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'} {t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'}
</span> </span>
@@ -1961,13 +1943,11 @@ function NotificationChannelsSection() {
const channels = channelsQuery.data ?? []; const channels = channelsQuery.data ?? [];
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Bell /></span>
<Bell size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Push notifications</h2> <h2 className="s-t">Push notifications</h2>
<p className="text-xs text-[var(--text-tertiary)]">Relay notifications to a self-hosted ntfy or Gotify server</p> <p className="text-xs text-[var(--text-tertiary)]">Relay notifications to a self-hosted ntfy or Gotify server</p>
</div> </div>
</div> </div>
@@ -1998,8 +1978,8 @@ function NotificationChannelsSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !endpoint.trim()} disabled={createMutation.isPending || !endpoint.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Adding…' : 'Add channel'} {createMutation.isPending ? 'Adding…' : 'Add channel'}
</button> </button>
@@ -2027,7 +2007,7 @@ function NotificationChannelsSection() {
<span className="rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)] uppercase"> <span className="rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)] uppercase">
{ch.kind} {ch.kind}
</span> </span>
<span className="ml-2 mono text-[var(--text-primary)] break-all">{ch.endpoint}</span> <span className="ml-2 v-mono text-[var(--text-primary)] break-all">{ch.endpoint}</span>
{testResult[ch.id] === 'ok' ? ( {testResult[ch.id] === 'ok' ? (
<span className="ml-2 text-[var(--success)]">delivered</span> <span className="ml-2 text-[var(--success)]">delivered</span>
) : testResult[ch.id] ? ( ) : testResult[ch.id] ? (
@@ -2148,13 +2128,11 @@ export function SettingsPage() {
<div className="w-full px-4 py-6 sm:px-8"> <div className="w-full px-4 py-6 sm:px-8">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Profile Section */} {/* Profile Section */}
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><User /></span>
<User size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Profile</h2> <h2 className="s-t">Profile</h2>
<p className="text-xs text-[var(--text-tertiary)]">Backed by GET/PUT /user/profile</p> <p className="text-xs text-[var(--text-tertiary)]">Backed by GET/PUT /user/profile</p>
</div> </div>
</div> </div>
@@ -2196,8 +2174,8 @@ export function SettingsPage() {
<button <button
onClick={() => updateProfileMutation.mutate()} onClick={() => updateProfileMutation.mutate()}
disabled={!hasProfileChanges || updateProfileMutation.isPending} disabled={!hasProfileChanges || updateProfileMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{updateProfileMutation.isPending ? ( {updateProfileMutation.isPending ? (
<> <>
@@ -2248,13 +2226,11 @@ export function SettingsPage() {
<GitProvidersSection /> <GitProvidersSection />
{/* Audit Logs Section */} {/* Audit Logs Section */}
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><ScrollText /></span>
<ScrollText size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Audit Logs</h2> <h2 className="s-t">Audit Logs</h2>
<p className="text-xs text-[var(--text-tertiary)]">Every authenticated action recorded by the platform</p> <p className="text-xs text-[var(--text-tertiary)]">Every authenticated action recorded by the platform</p>
</div> </div>
</div> </div>
@@ -2271,13 +2247,11 @@ export function SettingsPage() {
</section> </section>
{/* Runtime & Local State Section */} {/* Runtime & Local State Section */}
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Settings /></span>
<Settings size={18} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Runtime & Local State</h2> <h2 className="s-t">Runtime & Local State</h2>
<p className="text-xs text-[var(--text-tertiary)]">API endpoints, cookie-session mode, and storage diagnostics</p> <p className="text-xs text-[var(--text-tertiary)]">API endpoints, cookie-session mode, and storage diagnostics</p>
</div> </div>
</div> </div>
@@ -2288,7 +2262,7 @@ export function SettingsPage() {
<Key size={14} className="text-[var(--text-tertiary)]" /> <Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Base</span> <span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Base</span>
</div> </div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p> <p className="v-mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p>
</div> </div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"> <div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
@@ -2296,7 +2270,7 @@ export function SettingsPage() {
<Key size={14} className="text-[var(--text-tertiary)]" /> <Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Auth Base</span> <span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Auth Base</span>
</div> </div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</p> <p className="v-mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</p>
</div> </div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"> <div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
@@ -2350,7 +2324,7 @@ export function SettingsPage() {
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">Cached Keys</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">Cached Keys</p>
<ul className="space-y-1"> <ul className="space-y-1">
{storageSummary.canvasKeys.slice(0, 5).map((key) => ( {storageSummary.canvasKeys.slice(0, 5).map((key) => (
<li key={key} className="mono text-xs text-[var(--text-tertiary)] truncate"> <li key={key} className="v-mono text-xs text-[var(--text-tertiary)] truncate">
{key} {key}
</li> </li>
))} ))}
@@ -2522,7 +2496,7 @@ export function DatabasesPage() {
<button <button
type="button" type="button"
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 text-[12.5px] font-semibold text-[var(--accent-on)]" className="s-btn-accent h-9 text-[12.5px]"
> >
<Database size={13} /> New database <Database size={13} /> New database
</button> </button>
@@ -2540,7 +2514,7 @@ export function DatabasesPage() {
<button <button
type="button" type="button"
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
className="mt-4 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]" className="s-btn-accent mt-4 text-[12.5px]"
> >
<Database size={13} /> Create your first database <Database size={13} /> Create your first database
</button> </button>
@@ -2574,7 +2548,7 @@ export function DatabasesPage() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span> <span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span> <span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] v-mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
{db.provider === 'external' && ( {db.provider === 'external' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]">external</span> <span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]">external</span>
)} )}
@@ -2594,7 +2568,7 @@ export function DatabasesPage() {
<div> <div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connection URL</p> <p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connection URL</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="mono flex-1 truncate rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs text-[var(--text-primary)]"> <code className="v-mono flex-1 truncate rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs text-[var(--text-primary)]">
{db.connection_url || '—'} {db.connection_url || '—'}
</code> </code>
{db.connection_url && ( {db.connection_url && (
@@ -2617,7 +2591,7 @@ export function DatabasesPage() {
<button <button
onClick={() => testByIdMutation.mutate(db.id ?? '')} onClick={() => testByIdMutation.mutate(db.id ?? '')}
disabled={testByIdMutation.isPending} disabled={testByIdMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" className="s-btn-quiet"
> >
<RefreshCw size={11} /> Test connection <RefreshCw size={11} /> Test connection
</button> </button>
@@ -2634,21 +2608,21 @@ export function DatabasesPage() {
<button <button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })} onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
disabled={running || actionMutation.isPending} disabled={running || actionMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" className="s-btn-quiet"
> >
<Play size={11} /> Start <Play size={11} /> Start
</button> </button>
<button <button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'stop' })} onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'stop' })}
disabled={!running || actionMutation.isPending} disabled={!running || actionMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" className="s-btn-quiet"
> >
<Square size={11} /> Stop <Square size={11} /> Stop
</button> </button>
<button <button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'restart' })} onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'restart' })}
disabled={!running || actionMutation.isPending} disabled={!running || actionMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" className="s-btn-quiet"
> >
<RefreshCw size={11} /> Restart <RefreshCw size={11} /> Restart
</button> </button>
@@ -2665,7 +2639,7 @@ export function DatabasesPage() {
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)} onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
disabled={!db.connection_url} disabled={!db.connection_url}
title={db.connection_url ? 'Inject connection URL into a service' : 'No connection URL available'} title={db.connection_url ? 'Inject connection URL into a service' : 'No connection URL available'}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" className="s-btn-quiet"
> >
<Link2 size={11} /> Bind <Link2 size={11} /> Bind
</button> </button>
@@ -2697,7 +2671,7 @@ export function DatabasesPage() {
] as const).map(([label, value]) => ( ] as const).map(([label, value]) => (
<div key={label} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2"> <div key={label} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2">
<p className="text-xs text-[var(--text-muted)]">{label}</p> <p className="text-xs text-[var(--text-muted)]">{label}</p>
<p className="mono text-sm text-[var(--text-primary)]">{value}</p> <p className="v-mono text-sm text-[var(--text-primary)]">{value}</p>
</div> </div>
))} ))}
</div> </div>
@@ -2743,8 +2717,8 @@ export function DatabasesPage() {
<button <button
onClick={() => backupMutation.mutate(db.id ?? '')} onClick={() => backupMutation.mutate(db.id ?? '')}
disabled={!running || backupMutation.isPending} disabled={!running || backupMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-40 transition-all" className="s-btn-accent disabled:opacity-40"
style={{ background: 'var(--accent-primary)' }}
> >
<Archive size={11} /> <Archive size={11} />
{backupMutation.isPending ? 'Starting…' : 'New backup'} {backupMutation.isPending ? 'Starting…' : 'New backup'}
@@ -2764,7 +2738,7 @@ export function DatabasesPage() {
{backups.map((b) => ( {backups.map((b) => (
<div key={b.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs"> <div key={b.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs">
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span> <span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
<span className="mono text-[var(--text-muted)]">{b.size}</span> <span className="v-mono text-[var(--text-muted)]">{b.size}</span>
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span> <span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
{b.remote && <span className="rounded bg-[var(--surface-card)] px-1.5 py-0.5 text-[var(--accent-primary)]" title="Also shipped to the configured backup target">offsite</span>} {b.remote && <span className="rounded bg-[var(--surface-card)] px-1.5 py-0.5 text-[var(--accent-primary)]" title="Also shipped to the configured backup target">offsite</span>}
{b.status === 'completed' && ( {b.status === 'completed' && (
@@ -2994,7 +2968,7 @@ export function DatabasesPage() {
(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) || (createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ||
(createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0) (createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0)
} }
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null} {(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null}
{createMode === 'managed' ? 'Create database' : 'Register database'} {createMode === 'managed' ? 'Create database' : 'Register database'}
@@ -3041,10 +3015,10 @@ function BindDatabasePanel({
const services = servicesQuery.data ?? []; const services = servicesQuery.data ?? [];
return ( return (
<div className="rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] p-4 space-y-3"> <div className="s-card space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-medium text-[var(--text-primary)]">Bind to service</p> <p className="s-t">Bind to service</p>
<button onClick={onClose} className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]">Close</button> <button onClick={onClose} className="v-mono text-[11px] text-[var(--text-muted)] hover:text-[var(--text-primary)]">close</button>
</div> </div>
<p className="text-xs text-[var(--text-muted)]"> <p className="text-xs text-[var(--text-muted)]">
Injects this database's connection URL as an environment variable on the selected service. Injects this database's connection URL as an environment variable on the selected service.
@@ -3082,13 +3056,13 @@ function BindDatabasePanel({
<input <input
value={varKey} value={varKey}
onChange={(e) => setVarKey(e.target.value)} onChange={(e) => setVarKey(e.target.value)}
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)] w-40" className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs v-mono text-[var(--text-primary)] w-40"
/> />
</label> </label>
<button <button
onClick={() => bindMutation.mutate()} onClick={() => bindMutation.mutate()}
disabled={!serviceId || !varKey.trim() || bindMutation.isPending} disabled={!serviceId || !varKey.trim() || bindMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-40" className="s-btn-accent disabled:opacity-40"
> >
{bindMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind {bindMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind
</button> </button>
@@ -3129,7 +3103,7 @@ function BackupScheduleRow({
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder="cron schedule, e.g. 0 3 * * *" placeholder="cron schedule, e.g. 0 3 * * *"
className="w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)]" className="w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs v-mono text-[var(--text-primary)]"
/> />
<button <button
onClick={() => mutation.mutate(value.trim())} onClick={() => mutation.mutate(value.trim())}
@@ -3297,17 +3271,17 @@ export function HighAvailabilityPage() {
</div> </div>
{isAdmin ? ( {isAdmin ? (
<div className="panel p-6"> <div className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Zap size={18} className="text-[var(--accent-primary)]" /> <Zap size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Manager controls</h2> <h2 className="s-t">Manager controls</h2>
</div> </div>
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<button <button
type="button" type="button"
disabled={toggleMutation.isPending || statusQuery.isLoading} disabled={toggleMutation.isPending || statusQuery.isLoading}
onClick={() => toggleMutation.mutate(!status?.enabled)} onClick={() => toggleMutation.mutate(!status?.enabled)}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{status?.enabled ? 'Disable HA manager' : 'Enable HA manager'} {status?.enabled ? 'Disable HA manager' : 'Enable HA manager'}
</button> </button>
@@ -3338,10 +3312,10 @@ export function HighAvailabilityPage() {
</div> </div>
) : null} ) : null}
<div className="panel p-6"> <div className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<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="s-t">Active alerts</h2>
</div> </div>
{!isDemoMode && 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>
@@ -3365,7 +3339,7 @@ export function HighAvailabilityPage() {
type="button" type="button"
disabled={resolveMutation.isPending} disabled={resolveMutation.isPending}
onClick={() => alert.id && resolveMutation.mutate(alert.id)} onClick={() => alert.id && resolveMutation.mutate(alert.id)}
className="ml-4 shrink-0 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)] disabled:opacity-50" className="s-btn-quiet"
> >
Resolve Resolve
</button> </button>
@@ -3376,17 +3350,17 @@ export function HighAvailabilityPage() {
)} )}
</div> </div>
<div className="panel p-6"> <div className="s-card p-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ShieldCheck size={18} className="text-[var(--accent-primary)]" /> <ShieldCheck size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Failover policies</h2> <h2 className="s-t">Failover policies</h2>
</div> </div>
{policyForm === null && isAdmin && ( {policyForm === null && isAdmin && (
<button <button
type="button" type="button"
onClick={() => setPolicyForm({ serviceId: '', strategy: 'active_passive', minHealthyNodes: '1', maxFailures: '3', enabled: true, editing: false })} onClick={() => setPolicyForm({ serviceId: '', strategy: 'active_passive', minHealthyNodes: '1', maxFailures: '3', enabled: true, editing: false })}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium" className="s-btn-accent "
> >
New policy New policy
</button> </button>
@@ -3480,14 +3454,14 @@ export function HighAvailabilityPage() {
type="button" type="button"
disabled={savePolicyMutation.isPending || !policyForm.serviceId} disabled={savePolicyMutation.isPending || !policyForm.serviceId}
onClick={() => savePolicyMutation.mutate()} onClick={() => savePolicyMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{savePolicyMutation.isPending ? 'Saving…' : 'Save policy'} {savePolicyMutation.isPending ? 'Saving…' : 'Save policy'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setPolicyForm(null)} onClick={() => setPolicyForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]" className="s-btn-quiet"
> >
Cancel Cancel
</button> </button>
@@ -3526,7 +3500,7 @@ export function HighAvailabilityPage() {
enabled: policy.enabled ?? true, enabled: policy.enabled ?? true,
editing: true, editing: true,
})} })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)]" className="s-btn-quiet"
> >
Edit Edit
</button> </button>
@@ -3553,10 +3527,10 @@ export function HighAvailabilityPage() {
)} )}
</div> </div>
<div className="panel p-6"> <div className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<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="s-t">Health check results</h2>
</div> </div>
{!isDemoMode && 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>
@@ -3741,7 +3715,7 @@ export function SecurityPage() {
type="button" type="button"
disabled={scanMutation.isPending} disabled={scanMutation.isPending}
onClick={() => scanMutation.mutate()} onClick={() => scanMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{scanMutation.isPending ? 'Starting…' : 'Run scan'} {scanMutation.isPending ? 'Starting…' : 'Run scan'}
</button> </button>
@@ -3784,10 +3758,10 @@ export function SecurityPage() {
)} )}
{effectiveProjectId && ( {effectiveProjectId && (
<div className="panel p-6"> <div className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<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="s-t">Findings</h2>
</div> </div>
{!isDemoMode && 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>
@@ -3826,7 +3800,7 @@ export function SecurityPage() {
type="button" type="button"
disabled={vulnMutation.isPending} disabled={vulnMutation.isPending}
onClick={() => vulnMutation.mutate({ id: v.id!, status: 'ignored' })} onClick={() => vulnMutation.mutate({ id: v.id!, status: 'ignored' })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] disabled:opacity-50" className="s-btn-quiet"
> >
Ignore Ignore
</button> </button>
@@ -3840,10 +3814,10 @@ export function SecurityPage() {
)} )}
{effectiveProjectId && ( {effectiveProjectId && (
<div className="panel p-6"> <div className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<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="s-t">Scan history</h2>
</div> </div>
{!isDemoMode && 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>
@@ -34,8 +34,7 @@ export function ComponentShowcase() {
}; };
return ( return (
<div className="min-h-screen p-8" style={{ background: '#16171c' }}> <div className="min-h-screen p-8" style={{ background: 'var(--bg-void)' }}>
<div className="ambient-glow" />
<div className="w-full relative z-10"> <div className="w-full relative z-10">
{/* Header */} {/* Header */}
@@ -91,25 +90,25 @@ export function ComponentShowcase() {
Line Charts Line Charts
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Basic Line Chart (CPU Style) Basic Line Chart (CPU Style)
</h3> </h3>
<LineChart <LineChart
data={sampleLineData} data={sampleLineData}
color="#b4e34a" color="#38bdf8"
height={120} height={120}
showDots={true} showDots={true}
smooth={true} smooth={true}
/> />
</div> </div>
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Line Chart (No Dots) Line Chart (No Dots)
</h3> </h3>
<LineChart <LineChart
data={sampleLineData} data={sampleLineData}
color="#7ab8ff" color="var(--accent-primary)"
height={120} height={120}
showDots={false} showDots={false}
smooth={true} smooth={true}
@@ -123,8 +122,8 @@ export function ComponentShowcase() {
Area Charts Area Charts
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Area Chart (Active Users Style) Area Chart (Active Users Style)
</h3> </h3>
<LineAreaChart <LineAreaChart
@@ -134,8 +133,8 @@ export function ComponentShowcase() {
height={150} height={150}
/> />
</div> </div>
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Area Chart (Success Color) Area Chart (Success Color)
</h3> </h3>
<LineAreaChart <LineAreaChart
@@ -153,20 +152,20 @@ export function ComponentShowcase() {
Multi-Line & Bar Charts Multi-Line & Bar Charts
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Multi-Line Chart (Performance Style) Multi-Line Chart (Performance Style)
</h3> </h3>
<MultiLineChart <MultiLineChart
datasets={[ datasets={[
{ data: sampleLineData.map(v => v + 10), color: '#7ab8ff', fillOpacity: 0.15 }, { data: sampleLineData.map(v => v + 10), color: 'var(--accent-primary)', fillOpacity: 0.15 },
{ data: sampleLineData, color: '#f2c94c', fillOpacity: 0.15 } { data: sampleLineData, color: 'var(--warning)', fillOpacity: 0.15 }
]} ]}
height={120} height={120}
/> />
</div> </div>
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Bar Chart (Timeline Style) Bar Chart (Timeline Style)
</h3> </h3>
<BarChart <BarChart
@@ -184,23 +183,23 @@ export function ComponentShowcase() {
Donut Charts Donut Charts
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="panel p-6 flex flex-col items-center"> <div className="s-card p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
RAM Usage (65%) RAM Usage (65%)
</h3> </h3>
<DonutChart percentage={65} color="#f2c94c" size={160} /> <DonutChart percentage={65} color="var(--warning)" size={160} />
</div> </div>
<div className="panel p-6 flex flex-col items-center"> <div className="s-card p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Disk Usage (82%) Disk Usage (82%)
</h3> </h3>
<DonutChart percentage={82} color="#b4e34a" size={160} /> <DonutChart percentage={82} color="#38bdf8" size={160} />
</div> </div>
<div className="panel p-6 flex flex-col items-center"> <div className="s-card p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Network (45%) Network (45%)
</h3> </h3>
<DonutChart percentage={45} color="#7ab8ff" size={160} /> <DonutChart percentage={45} color="var(--accent-primary)" size={160} />
</div> </div>
</div> </div>
</section> </section>
@@ -222,7 +221,7 @@ export function ComponentShowcase() {
status="good" status="good"
statusText="Good" statusText="Good"
icon={<Cpu size={16} />} icon={<Cpu size={16} />}
chart={<LineChart data={sampleLineData} color="#b4e34a" height={76} />} chart={<LineChart data={sampleLineData} color="#38bdf8" height={76} />}
onClick={() => handleCardClick('CPU Usage')} onClick={() => handleCardClick('CPU Usage')}
animationDelay={0.04} animationDelay={0.04}
/> />
@@ -233,7 +232,7 @@ export function ComponentShowcase() {
status="average" status="average"
statusText="Average" statusText="Average"
icon={<MemoryStick size={16} />} icon={<MemoryStick size={16} />}
chart={<LineChart data={sampleLineData.map(v => v + 20)} color="#f2c94c" height={76} />} chart={<LineChart data={sampleLineData.map(v => v + 20)} color="var(--warning)" height={76} />}
onClick={() => handleCardClick('Memory')} onClick={() => handleCardClick('Memory')}
animationDelay={0.09} animationDelay={0.09}
/> />
@@ -276,7 +275,7 @@ export function ComponentShowcase() {
value="12.5K" value="12.5K"
subtitle="Queries per minute" subtitle="Queries per minute"
icon={<Database size={16} />} icon={<Database size={16} />}
chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="#7ab8ff" height={130} />} chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="var(--accent-primary)" height={130} />}
details={ details={
<div className="flex items-center gap-3 text-xs"> <div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -313,8 +312,8 @@ export function ComponentShowcase() {
upSpeed={10.4} upSpeed={10.4}
downSpeed={5.2} downSpeed={5.2}
datasets={[ datasets={[
{ data: sampleLineData.map(v => v + 20), color: '#7ab8ff', fillOpacity: 0.15 }, { data: sampleLineData.map(v => v + 20), color: 'var(--accent-primary)', fillOpacity: 0.15 },
{ data: sampleLineData.map(v => v + 10), color: '#f2c94c', fillOpacity: 0.15 } { data: sampleLineData.map(v => v + 10), color: 'var(--warning)', fillOpacity: 0.15 }
]} ]}
onClick={() => handleCardClick('Performance')} onClick={() => handleCardClick('Performance')}
animationDelay={0.09} animationDelay={0.09}
@@ -331,7 +330,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Badges & Indicators Badges & Indicators
</h2> </h2>
<div className="panel p-6 space-y-4"> <div className="s-card p-6 space-y-4">
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<span className="badge-active"> <span className="badge-active">
<span className="live-dot" /> <span className="live-dot" />
@@ -358,7 +357,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Buttons Buttons
</h2> </h2>
<div className="panel p-6 space-y-4"> <div className="s-card p-6 space-y-4">
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<button className="btn-stop"> <button className="btn-stop">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
@@ -387,7 +386,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Pills & Tabs Pills & Tabs
</h2> </h2>
<div className="panel p-6 space-y-6"> <div className="s-card p-6 space-y-6">
<div> <div>
<p className="text-sm text-[var(--text-secondary)] mb-3">Pill Group</p> <p className="text-sm text-[var(--text-secondary)] mb-3">Pill Group</p>
<div className="pill-group"> <div className="pill-group">
@@ -420,9 +419,9 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Search & Input Search & Input
</h2> </h2>
<div className="panel p-6 space-y-4"> <div className="s-card p-6 space-y-4">
<div className="search-box"> <div className="search-box">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round">
<circle cx="11" cy="11" r="8"/> <circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/> <line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg> </svg>
@@ -435,7 +434,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Toast Notifications Toast Notifications
</h2> </h2>
<div className="panel p-6"> <div className="s-card p-6">
<div className="flex gap-3 flex-wrap"> <div className="flex gap-3 flex-wrap">
<button <button
onClick={() => showToast('Operation completed successfully!', 'success')} onClick={() => showToast('Operation completed successfully!', 'success')}
File diff suppressed because it is too large. Load diff
@@ -78,8 +78,8 @@ export function ServiceMetricsDashboard() {
userData, userData,
perf: perfPercent, perf: perfPercent,
perfData: [ perfData: [
{ data: perfData1, color: '#7ab8ff', fillOpacity: 0.15 }, { data: perfData1, color: 'var(--accent-primary)', fillOpacity: 0.15 },
{ data: perfData2, color: '#f2c94c', fillOpacity: 0.15 } { data: perfData2, color: 'var(--warning)', fillOpacity: 0.15 }
], ],
upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1), upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1),
downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1), downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1),
@@ -107,9 +107,8 @@ export function ServiceMetricsDashboard() {
}; };
return ( return (
<div className="min-h-screen" style={{ background: '#16171c' }}> <div className="min-h-screen" style={{ background: 'var(--bg-void)' }}>
{/* Ambient glow */} {/* Ambient glow */}
<div className="ambient-glow" />
<div className="flex min-h-screen relative"> <div className="flex min-h-screen relative">
{/* Sidebar - self.html exact match */} {/* Sidebar - self.html exact match */}
@@ -117,8 +116,8 @@ export function ServiceMetricsDashboard() {
className="hidden md:flex shrink-0 flex-col items-center border-r" className="hidden md:flex shrink-0 flex-col items-center border-r"
style={{ style={{
width: '64px', width: '64px',
background: '#111217', background: 'var(--bg-surface)',
borderRight: '1px solid rgba(255,255,255,0.07)', borderRight: '1px solid var(--border-subtle)',
padding: '16px 0', padding: '16px 0',
gap: '5px' gap: '5px'
}} }}
@@ -169,10 +168,10 @@ export function ServiceMetricsDashboard() {
style={{ style={{
width: '34px', width: '34px',
height: '34px', height: '34px',
background: '#22233a', background: 'var(--accent-primary-soft)',
fontSize: '11px', fontSize: '11px',
fontWeight: 700, fontWeight: 700,
color: '#9295a4', color: 'var(--text-tertiary)',
marginTop: '4px', marginTop: '4px',
letterSpacing: '-0.3px' letterSpacing: '-0.3px'
}} }}
@@ -188,8 +187,8 @@ export function ServiceMetricsDashboard() {
className="shrink-0 flex items-center" className="shrink-0 flex items-center"
style={{ style={{
height: '52px', height: '52px',
background: '#111217', background: 'var(--bg-surface)',
borderBottom: '1px solid rgba(255,255,255,0.07)', borderBottom: '1px solid var(--border-subtle)',
padding: '0 22px', padding: '0 22px',
gap: '14px' gap: '14px'
}} }}
@@ -201,22 +200,22 @@ export function ServiceMetricsDashboard() {
<div className="ml-auto flex items-center" style={{ gap: '8px' }}> <div className="ml-auto flex items-center" style={{ gap: '8px' }}>
<button <button
className="rounded-[9px] border bg-transparent text-[#9295a4] font-medium cursor-pointer" className="rounded-[9px] border bg-transparent text-[var(--text-tertiary)] font-medium cursor-pointer"
style={{ style={{
height: '32px', height: '32px',
padding: '0 14px', padding: '0 14px',
border: '1px solid rgba(255,255,255,0.1)', border: '1px solid var(--border-default)',
fontSize: '13px' fontSize: '13px'
}} }}
> >
Support Support
</button> </button>
<button <button
className="rounded-[9px] border-none flex items-center text-[#e8e9f0] font-medium cursor-pointer" className="rounded-[9px] border-none flex items-center text-[var(--text-primary)] font-medium cursor-pointer"
style={{ style={{
height: '32px', height: '32px',
padding: '0 14px', padding: '0 14px',
background: 'rgba(255,255,255,0.08)', background: 'var(--bg-overlay)',
fontSize: '13px', fontSize: '13px',
gap: '6px' gap: '6px'
}} }}
@@ -229,10 +228,10 @@ export function ServiceMetricsDashboard() {
style={{ style={{
width: '32px', width: '32px',
height: '32px', height: '32px',
border: '1px solid rgba(255,255,255,0.1)' border: '1px solid var(--border-default)'
}} }}
> >
<Bell size={15} color="#9295a4" /> <Bell size={15} />
</button> </button>
</div> </div>
</header> </header>
@@ -245,22 +244,22 @@ export function ServiceMetricsDashboard() {
style={{ style={{
gap: '6px', gap: '6px',
padding: '14px 0 10px', padding: '14px 0 10px',
color: '#6b6e7d', color: 'var(--text-tertiary)',
fontSize: '13px' fontSize: '13px'
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/> <polyline points="15 18 9 12 15 6"/>
</svg> </svg>
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
style={{ color: '#6b6e7d' }} style={{ color: 'var(--text-tertiary)' }}
className="hover:text-[#9295a4] transition-colors" className="hover:text-[var(--text-tertiary)] transition-colors"
> >
Servers Servers
</button> </button>
<span style={{ opacity: 0.4 }}>/</span> <span style={{ opacity: 0.4 }}>/</span>
<span style={{ color: '#9295a4' }}>[NuFest] - App Project</span> <span style={{ color: 'var(--text-tertiary)' }}>[NuFest] - App Project</span>
</div> </div>
{/* Project Header */} {/* Project Header */}
@@ -280,7 +279,7 @@ export function ServiceMetricsDashboard() {
</div> </div>
<div> <div>
<div className="flex items-center" style={{ gap: '10px' }}> <div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}> <span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--text-primary)' }}>
[NuFest] - App Project [NuFest] - App Project
</span> </span>
<span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}> <span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}>
@@ -293,8 +292,8 @@ export function ServiceMetricsDashboard() {
href="https://nufest-dth.app" href="https://nufest-dth.app"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center hover:text-[#9295a4] transition-colors" className="flex items-center hover:text-[var(--text-tertiary)] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }} style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
> >
https://nufest-dth.app https://nufest-dth.app
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -304,8 +303,8 @@ export function ServiceMetricsDashboard() {
</svg> </svg>
</a> </a>
<button <button
className="flex items-center hover:text-[#9295a4] transition-colors" className="flex items-center hover:text-[var(--text-tertiary)] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', gap: '4px' }} style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', gap: '4px' }}
> >
Project Information Project Information
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -344,7 +343,7 @@ export function ServiceMetricsDashboard() {
<div <div
className="flex" className="flex"
style={{ style={{
borderBottom: '1px solid rgba(255,255,255,0.07)', borderBottom: '1px solid var(--border-subtle)',
marginBottom: '18px' marginBottom: '18px'
}} }}
> >
@@ -365,16 +364,16 @@ export function ServiceMetricsDashboard() {
{/* Metrics Header */} {/* Metrics Header */}
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}> <div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span> <span style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>Metrics</span>
<div className="flex items-center" style={{ gap: '8px' }}> <div className="flex items-center" style={{ gap: '8px' }}>
<button <button
className="rounded-[9px] border flex items-center font-medium cursor-pointer" className="rounded-[9px] border flex items-center font-medium cursor-pointer"
style={{ style={{
height: '32px', height: '32px',
padding: '0 12px', padding: '0 12px',
border: '1px solid rgba(255,255,255,0.09)', border: '1px solid var(--border-default)',
background: 'rgba(255,255,255,0.04)', background: 'var(--bg-elevated)',
color: '#9295a4', color: 'var(--text-tertiary)',
fontSize: '12.5px', fontSize: '12.5px',
gap: '6px' gap: '6px'
}} }}
@@ -408,7 +407,7 @@ export function ServiceMetricsDashboard() {
status="good" status="good"
statusText="Good" statusText="Good"
icon={<Cpu size={16} />} icon={<Cpu size={16} />}
chart={<LineChart data={metrics.cpuData} color="#b4e34a" height={76} />} chart={<LineChart data={metrics.cpuData} color="#38bdf8" height={76} />}
animationDelay={0.04} animationDelay={0.04}
/> />
@@ -418,23 +417,23 @@ export function ServiceMetricsDashboard() {
<div className="card-icon"> <div className="card-icon">
<MemoryStick size={16} /> <MemoryStick size={16} />
</div> </div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span> <span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>RAM Usage</span>
</div> </div>
<div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}> <div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: 'var(--text-primary)' }}>
{metrics.ram}% {metrics.ram}%
</div> </div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}> <div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 4 }}>
<span style={{ color: '#f2c94c', fontWeight: 700 }}>Average</span> Daily usage <span style={{ color: 'var(--warning)', fontWeight: 700 }}>Average</span> Daily usage
</div> </div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
<DonutChart percentage={metrics.ram} color="#f2c94c" /> <DonutChart percentage={metrics.ram} color="var(--warning)" />
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}> <div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
<div style={{ fontSize: 10.5, color: '#6b6e7d', marginBottom: 1 }}>Used</div> <div style={{ fontSize: 10.5, color: 'var(--text-tertiary)', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: 12.5, fontWeight: 700, color: '#e8e9f0' }}>{metrics.ramUsedGB} GB / 8GB</div> <div style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-primary)' }}>{metrics.ramUsedGB} GB / 8GB</div>
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4 }}>
<span style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span> <span style={{ fontSize: 13, color: 'var(--text-tertiary)', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn"> <div className="arrow-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" /> <polyline points="9 18 15 12 9 6" />
@@ -9,6 +9,7 @@ import {
getSetupStatus, getSetupStatus,
updatePlatformSettings, updatePlatformSettings,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
// Marker the GitHub App callback reads so the wizard flow resumes here // Marker the GitHub App callback reads so the wizard flow resumes here
// instead of the settings page. // instead of the settings page.
@@ -39,7 +40,7 @@ export function SetupWizardPage() {
mutationFn: completeSetup, mutationFn: completeSetup,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['setup-status'] }); queryClient.invalidateQueries({ queryKey: ['setup-status'] });
navigate('/projects', { replace: true }); navigate('/', { replace: true });
}, },
}); });
@@ -53,7 +54,7 @@ export function SetupWizardPage() {
// Wizard is optional once the instance is already configured. // Wizard is optional once the instance is already configured.
if (status && !status.needs_setup && !override) { if (status && !status.needs_setup && !override) {
return <Navigate to="/projects" replace />; return <Navigate to="/" replace />;
} }
const steps: { key: Step; label: string }[] = [ const steps: { key: Step; label: string }[] = [
@@ -92,9 +93,12 @@ export function SetupWizardPage() {
</div> </div>
{step === 'account' ? ( {step === 'account' ? (
<section className="panel p-6"> <section className="s-card">
<h1 className="text-xl font-bold text-[var(--text-primary)]">Welcome to Containr</h1> <div className="s-cardhead mb-1">
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <span className="s-ibox"><Rocket /></span>
<h1 className="s-t">Welcome to Containr</h1>
</div>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
First boot detected. Create the admin account — registration closes to the public First boot detected. Create the admin account — registration closes to the public
right after, so this account owns the instance. right after, so this account owns the instance.
</p> </p>
@@ -105,8 +109,7 @@ export function SetupWizardPage() {
</p> </p>
<Link <Link
to="/auth/sign-in?redirect=/setup" to="/auth/sign-in?redirect=/setup"
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]" className="mt-4 s-btn-accent inline-flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
> >
Sign in <ChevronRight size={14} /> Sign in <ChevronRight size={14} />
</Link> </Link>
@@ -114,8 +117,7 @@ export function SetupWizardPage() {
) : !status?.has_users ? ( ) : !status?.has_users ? (
<Link <Link
to="/auth/sign-up?redirect=/setup" to="/auth/sign-up?redirect=/setup"
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]" className="mt-4 s-btn-accent inline-flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
> >
Create admin account <ChevronRight size={14} /> Create admin account <ChevronRight size={14} />
</Link> </Link>
@@ -127,9 +129,9 @@ export function SetupWizardPage() {
{step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null} {step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null}
{step === 'done' ? ( {step === 'done' ? (
<section className="panel p-6 text-center"> <section className="s-card text-center">
<Rocket size={28} className="mx-auto text-[var(--accent-primary)]" /> <div className="s-ibox mx-auto !h-10 !w-10"><ShieldCheck /></div>
<h1 className="mt-4 text-xl font-bold text-[var(--text-primary)]">All set</h1> <h1 className="mt-4 font-headline text-lg font-bold text-[var(--text-primary)]">All set</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <p className="mt-2 text-sm text-[var(--text-secondary)]">
Deploy your first service from a git repo, an image, or a template — the dashboard is Deploy your first service from a git repo, an image, or a template — the dashboard is
ready. ready.
@@ -141,12 +143,10 @@ export function SetupWizardPage() {
: 'Failed to save setup state'} : 'Failed to save setup state'}
</p> </p>
) : null} ) : null}
<button <GhostBtn
type="button"
onClick={() => completeMutation.mutate()} onClick={() => completeMutation.mutate()}
disabled={completeMutation.isPending} disabled={completeMutation.isPending}
className="mt-5 inline-flex items-center gap-2 h-10 px-6 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50" className="mt-5 !h-10 !px-6"
style={{ background: 'var(--accent-primary)' }}
> >
{completeMutation.isPending ? ( {completeMutation.isPending ? (
<Loader2 size={14} className="animate-spin" /> <Loader2 size={14} className="animate-spin" />
@@ -154,7 +154,7 @@ export function SetupWizardPage() {
<ShieldCheck size={15} /> <ShieldCheck size={15} />
)} )}
Enter Containr Enter Containr
</button> </GhostBtn>
</section> </section>
) : null} ) : null}
</div> </div>
@@ -185,12 +185,12 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
}); });
return ( return (
<section className="panel p-6"> <section className="s-card">
<div className="flex items-center gap-3 mb-3"> <div className="s-cardhead mb-1">
<Github size={20} className="text-[var(--text-primary)]" /> <span className="s-ibox"><Github /></span>
<h1 className="text-xl font-bold text-[var(--text-primary)]">Connect a git provider</h1> <h1 className="s-t">Connect a Git Provider</h1>
</div> </div>
<p className="text-sm text-[var(--text-secondary)]"> <p className="mt-1 text-sm text-[var(--text-secondary)]">
Provision the instance GitHub App — repo push events then trigger deployments and preview Provision the instance GitHub App — repo push events then trigger deployments and preview
environments automatically. Self-hosted GitLab/Gitea can be added later in Settings. environments automatically. Self-hosted GitLab/Gitea can be added later in Settings.
</p> </p>
@@ -201,24 +201,15 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
: 'Could not start GitHub App provisioning'} : 'Could not start GitHub App provisioning'}
</p> </p>
) : null} ) : null}
<div className="mt-5 flex items-center gap-3"> <div className="mt-5 flex items-center gap-2">
<button <GhostBtn
type="button"
onClick={() => provisionMutation.mutate()} onClick={() => provisionMutation.mutate()}
disabled={provisionMutation.isPending} disabled={provisionMutation.isPending}
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{provisionMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null} {provisionMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
Set up GitHub App Set up GitHub App
</button> </GhostBtn>
<button <QuietBtn onClick={onNext}>Skip for now</QuietBtn>
type="button"
onClick={onNext}
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Skip for now
</button>
</div> </div>
</section> </section>
); );
@@ -237,9 +228,12 @@ function TunnelStep({ onNext }: { onNext: () => void }) {
}); });
return ( return (
<section className="panel p-6"> <section className="s-card">
<h1 className="text-xl font-bold text-[var(--text-primary)]">Public access (optional)</h1> <div className="s-cardhead mb-1">
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <span className="s-ibox"><ShieldCheck /></span>
<h1 className="s-t">Public Access (optional)</h1>
</div>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one, Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one,
Containr stays reachable on this network — you can add it later in Settings. Containr stays reachable on this network — you can add it later in Settings.
</p> </p>
@@ -248,31 +242,22 @@ function TunnelStep({ onNext }: { onNext: () => void }) {
value={token} value={token}
onChange={(e) => setToken(e.target.value)} onChange={(e) => setToken(e.target.value)}
placeholder="eyJhIjo… tunnel token" placeholder="eyJhIjo… tunnel token"
className="mt-4 w-full h-10 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)] transition-colors" className="mt-4 w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] v-mono text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-colors"
/> />
{saveMutation.isError ? ( {saveMutation.isError ? (
<p className="mt-3 text-xs text-[var(--error)]"> <p className="mt-3 text-xs text-[var(--error)]">
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'} {saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'}
</p> </p>
) : null} ) : null}
<div className="mt-5 flex items-center gap-3"> <div className="mt-5 flex items-center gap-2">
<button <GhostBtn
type="button"
onClick={() => (token.trim() ? saveMutation.mutate() : onNext())} onClick={() => (token.trim() ? saveMutation.mutate() : onNext())}
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null} {saveMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
{token.trim() ? 'Save tunnel token' : 'Continue'} {token.trim() ? 'Save tunnel token' : 'Continue'}
</button> </GhostBtn>
<button <QuietBtn onClick={onNext}>Skip</QuietBtn>
type="button"
onClick={onNext}
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Skip
</button>
</div> </div>
</section> </section>
); );
@@ -17,6 +17,7 @@ import {
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { Database, GitBranch, Package, Timer, Trash2, X } from 'lucide-react'; import { Database, GitBranch, Package, Timer, Trash2, X } from 'lucide-react';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
import { import {
canvasFromConfig, canvasFromConfig,
configFromCanvas, configFromCanvas,
@@ -57,7 +58,7 @@ function ServiceBuilderNodeView({ data, selected }: NodeProps<ServiceBuilderNode
</div> </div>
<div className="mt-1 flex items-center justify-between gap-2"> <div className="mt-1 flex items-center justify-between gap-2">
<span className="text-[10.5px] uppercase tracking-wide text-[var(--text-tertiary)]">{meta.label}</span> <span className="text-[10.5px] uppercase tracking-wide text-[var(--text-tertiary)]">{meta.label}</span>
<span className="mono max-w-[120px] truncate text-[10.5px] text-[var(--text-secondary)]"> <span className="v-mono max-w-[120px] truncate text-[10.5px] text-[var(--text-secondary)]">
{data.svc.runtime || data.svc.repo || '—'} {data.svc.runtime || data.svc.repo || '—'}
</span> </span>
</div> </div>
@@ -120,8 +121,8 @@ function BuilderInner({
id: `${dep}->${svc.key}`, id: `${dep}->${svc.key}`,
source: dep, source: dep,
target: svc.key, target: svc.key,
markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(180,227,74,0.55)' }, markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(56, 189, 248,0.55)' },
style: { stroke: 'rgba(180,227,74,0.42)', strokeWidth: 1.6 }, style: { stroke: 'rgba(56, 189, 248,0.42)', strokeWidth: 1.6 },
className: 'edge-premium', className: 'edge-premium',
})), })),
), ),
@@ -239,7 +240,7 @@ function BuilderInner({
key={t} key={t}
type="button" type="button"
onClick={() => addService(t)} onClick={() => addService(t)}
className="canvas-pill inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]" className="s-chip !py-1.5"
> >
<Icon size={12} className={meta.cls} /> <Icon size={12} className={meta.cls} />
{meta.label} {meta.label}
@@ -263,7 +264,7 @@ function BuilderInner({
deleteKeyCode={['Backspace', 'Delete']} deleteKeyCode={['Backspace', 'Delete']}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
> >
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="rgba(255,255,255,0.07)" /> <Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="var(--tint-07)" />
</ReactFlow> </ReactFlow>
<div className="canvas-pill absolute bottom-3 left-3 z-10 px-3 py-1.5 text-[10.5px] text-[var(--text-tertiary)]"> <div className="canvas-pill absolute bottom-3 left-3 z-10 px-3 py-1.5 text-[10.5px] text-[var(--text-tertiary)]">
drag = move · connect handles = depends_on · double-click = add web · Del = remove drag = move · connect handles = depends_on · double-click = add web · Del = remove
@@ -295,21 +296,16 @@ function BuilderInner({
</div> </div>
) : null} ) : null}
<div className="flex gap-2"> <div className="flex gap-2">
<button <QuietBtn onClick={onCancel} className="flex-1 justify-center">
type="button"
onClick={onCancel}
className="flex-1 rounded-[var(--radius-md)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Back to JSON Back to JSON
</button> </QuietBtn>
<button <GhostBtn
type="button"
disabled={errors.length > 0 || services.size === 0} disabled={errors.length > 0 || services.size === 0}
onClick={() => onApply(configFromCanvas(builderNodes, config))} onClick={() => onApply(configFromCanvas(builderNodes, config))}
className="flex-1 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50" className="flex-1 justify-center"
> >
Apply to JSON Apply to JSON
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
@@ -409,7 +405,7 @@ function Inspector({
<label className={`${LABEL_CLS} mt-3`}>Environment (KEY=value, one per line)</label> <label className={`${LABEL_CLS} mt-3`}>Environment (KEY=value, one per line)</label>
<textarea <textarea
className={`${FIELD_CLS} mono min-h-[70px] resize-y`} className={`${FIELD_CLS} v-mono min-h-[70px] resize-y`}
defaultValue={envText} defaultValue={envText}
spellCheck={false} spellCheck={false}
onBlur={(e) => { onBlur={(e) => {
@@ -425,7 +421,7 @@ function Inspector({
<label className={`${LABEL_CLS} mt-3`}>Volumes (source:target[:ro], one per line)</label> <label className={`${LABEL_CLS} mt-3`}>Volumes (source:target[:ro], one per line)</label>
<textarea <textarea
className={`${FIELD_CLS} mono min-h-[50px] resize-y`} className={`${FIELD_CLS} v-mono min-h-[50px] resize-y`}
defaultValue={volText} defaultValue={volText}
spellCheck={false} spellCheck={false}
onBlur={(e) => { onBlur={(e) => {
@@ -18,6 +18,7 @@ import {
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data'; import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data';
import { GhostBtn, QuietBtn, SPageHead } from '@/shared/components/sentry';
import { import {
Search, Search,
Filter, Filter,
@@ -489,53 +490,37 @@ export function TemplatesPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
{/* Header */} <div className="w-full px-4 pt-6 sm:px-8">
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm"> <SPageHead
<div className="w-full px-8 py-4"> title="Template Catalog"
<div className="flex items-center justify-between gap-4"> titleAccent="_"
<div> sub={`${filteredTemplates.length} templates · deploy services from pre-configured stacks`}
<h1 className="v-title">Template Catalog<span className="v-cursor">_</span></h1> actions={signedIn && !isDemoMode ? (
<p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p>
</div>
<div className="flex items-center gap-3 text-sm text-[var(--text-tertiary)]">
<Layers size={16} />
<span>{filteredTemplates.length} templates</span>
{signedIn && !isDemoMode ? (
<> <>
<button <QuietBtn
type="button"
onClick={() => { onClick={() => {
setComposeYaml(''); setComposeYaml('');
setComposeWarnings([]); setComposeWarnings([]);
setComposeError(null); setComposeError(null);
setComposeOpen(true); setComposeOpen(true);
}} }}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3.5 text-[12.5px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
> >
<FileCode size={14} /> Import Compose <FileCode size={13} /> Import Compose
</button> </QuietBtn>
<button <GhostBtn onClick={openCreateEditor}>
type="button" <Plus size={13} /> New template
onClick={openCreateEditor} </GhostBtn>
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
<Plus size={14} /> New template
</button>
</> </>
) : null} ) : undefined}
</div> />
</div>
</div>
</div> </div>
{/* Demo Mode Banner */} {/* Demo Mode Banner */}
{isDemoMode && ( {isDemoMode && (
<div className="w-full px-8 py-4"> <div className="w-full px-8 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50"> <div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]"> <Sparkles size={13} />
<Sparkles size={16} /> Demo mode active — using sample data
<span>Demo mode active — using sample data</span>
</div>
</div> </div>
</div> </div>
)} )}
@@ -544,12 +529,12 @@ export function TemplatesPage() {
<div className="w-full px-4 py-6 sm:px-8"> <div className="w-full px-4 py-6 sm:px-8">
<div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6"> <div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6">
{/* Template List */} {/* Template List */}
<section className="panel overflow-hidden"> <section className="s-card overflow-hidden">
<div className="p-4 border-b border-[var(--border-subtle)]"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<div className="flex items-center gap-2 mb-4"> <span className="s-ibox"><Filter /></span>
<Filter size={16} className="text-[var(--text-tertiary)]" /> <span className="s-t">Filters</span>
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div> </div>
<div className="p-4 border-b border-[var(--border-subtle)]">
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
@@ -614,21 +599,21 @@ export function TemplatesPage() {
{filteredTemplates.map((template) => { {filteredTemplates.map((template) => {
const selected = template.id === selectedTemplateId; const selected = template.id === selectedTemplateId;
const Icon = categoryIcon(template.category); const Icon = categoryIcon(template.category);
const categoryColor = template.category === 'database' ? '#7ab8ff' : template.category === 'frontend' ? '#f2c94c' : template.category === 'web' ? 'var(--accent-primary)' : '#9295a4'; const categoryColor = template.category === 'database' ? 'var(--info)' : template.category === 'frontend' ? 'var(--warning)' : template.category === 'web' ? 'var(--accent-primary)' : 'var(--text-tertiary)';
return ( return (
<button <button
key={template.id} key={template.id}
onClick={() => selectTemplate(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)]/60 bg-[var(--accent-primary-soft)]'
: 'border-[var(--border-subtle)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)]/50 card-lift' : 'border-[var(--border-subtle)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)]/50 card-lift'
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <div
className={`w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-300 ${ className={`w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-300 ${
selected ? 'ring-2 ring-white/20' : '' selected ? 'ring-1 ring-[var(--accent-primary)]/40' : ''
}`} }`}
style={{ style={{
background: selected ? categoryColor : `${categoryColor}20`, background: selected ? categoryColor : `${categoryColor}20`,
@@ -691,12 +676,10 @@ export function TemplatesPage() {
</section> </section>
{/* Template Detail */} {/* Template Detail */}
<section className="panel p-6"> <section className="s-card p-5">
{!selectedTemplateId ? ( {!selectedTemplateId ? (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center"> <div className="s-ibox mx-auto mb-4 !w-14 !h-14"><Layers size={22} /></div>
<Layers size={28} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-muted)]">Select a template to view details and deploy</p> <p className="text-sm text-[var(--text-muted)]">Select a template to view details and deploy</p>
</div> </div>
) : null} ) : null}
@@ -722,15 +705,15 @@ export function TemplatesPage() {
{/* Header */} {/* Header */}
<div className="flex items-start justify-between gap-4 mb-6"> <div className="flex items-start justify-between gap-4 mb-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <div className="s-ibox !w-14 !h-14 !rounded-xl text-[var(--accent-primary)]">
{(() => { {(() => {
const Icon = categoryIcon(selectedDetail.template.category); const Icon = categoryIcon(selectedDetail.template.category);
return <Icon size={24} className="text-[var(--accent-primary)]" />; return <Icon size={24} />;
})()} })()}
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">{selectedDetail.template.name}</h2> <h2 className="font-headline text-lg font-semibold text-[var(--text-primary)]">{selectedDetail.template.name}</h2>
{selectedDetail.template.isOfficial && ( {selectedDetail.template.isOfficial && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-[var(--warning-soft)] text-[var(--warning)] text-xs font-medium"> <span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-[var(--warning-soft)] text-[var(--warning)] text-xs font-medium">
<Star size={10} className="fill-[var(--warning)]" /> <Star size={10} className="fill-[var(--warning)]" />
@@ -738,7 +721,7 @@ export function TemplatesPage() {
</span> </span>
)} )}
{!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && ( {!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
<span className="px-2 py-0.5 rounded-full bg-[var(--info-soft)] text-[var(--info)] text-xs font-medium"> <span className="s-chip !text-[var(--info)] !border-[color-mix(in_srgb,var(--info)_30%,transparent)] !bg-[var(--info-soft)] v-mono !text-[10.5px]">
Public Public
</span> </span>
)} )}
@@ -747,10 +730,10 @@ export function TemplatesPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]"> <span className="s-chip v-mono !text-[10.5px]">
{categoryLabel(selectedDetail.template.category)} {categoryLabel(selectedDetail.template.category)}
</span> </span>
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]"> <span className="s-chip v-mono !text-[10.5px]">
{selectedDetail.config.runtime || 'n/a'} {selectedDetail.config.runtime || 'n/a'}
</span> </span>
{canManageTemplate(selectedDetail.template) ? ( {canManageTemplate(selectedDetail.template) ? (
@@ -777,25 +760,25 @@ export function TemplatesPage() {
{/* Config Grid */} {/* Config Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="panel-soft p-4"> <div className="s-inset">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</p>
<p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p> <p className="v-mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p>
</div> </div>
<div className="panel-soft p-4"> <div className="s-inset">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</p>
<p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p> <p className="v-mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p>
</div> </div>
<div className="panel-soft p-4"> <div className="s-inset">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Build</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Build</p>
<p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p> <p className="v-mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p>
</div> </div>
<div className="panel-soft p-4"> <div className="s-inset">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Start</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Start</p>
<p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p> <p className="v-mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p>
</div> </div>
</div> </div>
<div className="panel-soft p-4 mb-6"> <div className="s-inset mb-6">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">How Templates Work</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">How Templates Work</p>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Template defaults are merged with your variable inputs. Creating from template saves a stopped service in the selected project, then you deploy it from the service detail page. Template defaults are merged with your variable inputs. Creating from template saves a stopped service in the selected project, then you deploy it from the service detail page.
@@ -808,7 +791,7 @@ export function TemplatesPage() {
</div> </div>
{isGraphTemplate ? ( {isGraphTemplate ? (
<div className="panel-soft p-4 mb-6"> <div className="s-inset mb-6">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Network size={14} className="text-[var(--accent-primary)]" /> <Network size={14} className="text-[var(--accent-primary)]" />
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]"> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
@@ -824,14 +807,14 @@ export function TemplatesPage() {
) : ( ) : (
<Box size={13} className="text-[var(--text-tertiary)] shrink-0" /> <Box size={13} className="text-[var(--text-tertiary)] shrink-0" />
)} )}
<span className="mono text-[var(--text-primary)] truncate">{member.name || member.key}</span> <span className="v-mono text-[var(--text-primary)] truncate">{member.name || member.key}</span>
{member.dependsOn && member.dependsOn.length > 0 ? ( {member.dependsOn && member.dependsOn.length > 0 ? (
<span className="text-[10px] text-[var(--text-tertiary)]"> <span className="text-[10px] text-[var(--text-tertiary)]">
after {member.dependsOn.join(', ')} after {member.dependsOn.join(', ')}
</span> </span>
) : null} ) : null}
</div> </div>
<span className="mono text-[11px] text-[var(--text-tertiary)] truncate"> <span className="v-mono text-[11px] text-[var(--text-tertiary)] truncate">
{member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'} {member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'}
</span> </span>
</div> </div>
@@ -845,13 +828,11 @@ export function TemplatesPage() {
{/* Deploy Section */} {/* Deploy Section */}
<div className="border-t border-[var(--border-subtle)] pt-6"> <div className="border-t border-[var(--border-subtle)] pt-6">
<div className="flex items-center gap-3 mb-4"> <div className="s-cardhead !mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--success-soft)] flex items-center justify-center"> <span className="s-ibox"><Play /></span>
<Play size={18} className="text-[var(--success)]" />
</div>
<div> <div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3> <span className="s-t">Deploy from Template</span>
<p className="text-sm text-[var(--text-secondary)]"> <p className="v-mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5">
{isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'} {isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'}
</p> </p>
</div> </div>
@@ -868,7 +849,7 @@ export function TemplatesPage() {
</p> </p>
<Link <Link
to="/auth/sign-in" to="/auth/sign-in"
className="mt-3 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]" className="s-btn-accent mt-3 text-[12.5px]"
> >
Sign in <ArrowRight size={13} /> Sign in <ArrowRight size={13} />
</Link> </Link>
@@ -974,7 +955,7 @@ export function TemplatesPage() {
<span> <span>
{lastDeployment.created {lastDeployment.created
? `Stack deployed — ${lastDeployment.created.length} resources` ? `Stack deployed — ${lastDeployment.created.length} resources`
: <>Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created</>} : <>Service <span className="v-mono font-medium">{lastDeployment.serviceName}</span> created</>}
</span> </span>
<Link <Link
to={lastDeployment.created to={lastDeployment.created
@@ -990,7 +971,7 @@ export function TemplatesPage() {
{lastDeployment.created.map((m) => ( {lastDeployment.created.map((m) => (
<div key={m.id} className="flex items-center gap-2 text-xs"> <div key={m.id} className="flex items-center gap-2 text-xs">
{m.kind === 'database' ? <Database size={11} /> : <Box size={11} />} {m.kind === 'database' ? <Database size={11} /> : <Box size={11} />}
<span className="mono">{m.name}</span> <span className="v-mono">{m.name}</span>
<span className="opacity-70">({m.kind})</span> <span className="opacity-70">({m.kind})</span>
</div> </div>
))} ))}
@@ -1003,8 +984,8 @@ export function TemplatesPage() {
<button <button
onClick={() => deployMutation.mutate()} onClick={() => deployMutation.mutate()}
disabled={isDeployDisabled} disabled={isDeployDisabled}
className="flex items-center gap-2 h-11 px-6 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-11 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{deployMutation.isPending ? ( {deployMutation.isPending ? (
<> <>
@@ -1137,7 +1118,7 @@ export function TemplatesPage() {
value={editorJson} value={editorJson}
onChange={(e) => setEditorJson(e.target.value)} onChange={(e) => setEditorJson(e.target.value)}
spellCheck={false} spellCheck={false}
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none" className="v-mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/> />
)} )}
{composeWarnings.length > 0 ? ( {composeWarnings.length > 0 ? (
@@ -1175,7 +1156,7 @@ export function TemplatesPage() {
type="button" type="button"
onClick={submitEditor} onClick={submitEditor}
disabled={saveTemplateMutation.isPending} disabled={saveTemplateMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{saveTemplateMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null} {saveTemplateMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
{editingTemplate ? 'Save changes' : 'Create template'} {editingTemplate ? 'Save changes' : 'Create template'}
@@ -1227,7 +1208,7 @@ export function TemplatesPage() {
onChange={(e) => setComposeYaml(e.target.value)} onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'} placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none" className="v-mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/> />
) : ( ) : (
<div className="flex min-h-[320px] flex-1 flex-col gap-3 bg-[var(--bg-void)] p-4"> <div className="flex min-h-[320px] flex-1 flex-col gap-3 bg-[var(--bg-void)] p-4">
@@ -1238,7 +1219,7 @@ export function TemplatesPage() {
onChange={(e) => setGitRepo(e.target.value)} onChange={(e) => setGitRepo(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder="owner/repo or https://github.com/owner/repo.git" placeholder="owner/repo or https://github.com/owner/repo.git"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]" className="v-mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/> />
</label> </label>
<label className="text-xs text-[var(--text-tertiary)]"> <label className="text-xs text-[var(--text-tertiary)]">
@@ -1248,7 +1229,7 @@ export function TemplatesPage() {
onChange={(e) => setGitPath(e.target.value)} onChange={(e) => setGitPath(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder="deploy/compose.yml" placeholder="deploy/compose.yml"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]" className="v-mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/> />
</label> </label>
<label className="text-xs text-[var(--text-tertiary)]"> <label className="text-xs text-[var(--text-tertiary)]">
@@ -1258,7 +1239,7 @@ export function TemplatesPage() {
onChange={(e) => setGitRef(e.target.value)} onChange={(e) => setGitRef(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder="main" placeholder="main"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]" className="v-mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/> />
</label> </label>
<p className="text-[11px] text-[var(--text-tertiary)]"> <p className="text-[11px] text-[var(--text-tertiary)]">
@@ -1283,7 +1264,7 @@ export function TemplatesPage() {
type="button" type="button"
onClick={() => composeImportMutation.mutate()} onClick={() => composeImportMutation.mutate()}
disabled={(composeMode === 'paste' ? !composeYaml.trim() : !gitRepo.trim()) || composeImportMutation.isPending} disabled={(composeMode === 'paste' ? !composeYaml.trim() : !gitRepo.trim()) || composeImportMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />} {composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
Convert to template Convert to template
@@ -8,11 +8,12 @@ import {
testWebhook, testWebhook,
updateWebhook, updateWebhook,
type OutboundWebhook, type OutboundWebhook,
type WebhookDelivery,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { DemoRestricted, useToast } from '@/shared/components'; import { DemoRestricted, useToast } from '@/shared/components';
import { GhostBtn, QuietBtn, SPageHead, SPill } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { import {
Webhook, Webhook,
Plus, Plus,
@@ -30,17 +31,6 @@ const inputClass =
const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*']; const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*'];
function statusClass(status: WebhookDelivery['status']): string {
switch (status) {
case 'success':
return 'text-[var(--success)]';
case 'failed':
return 'text-[var(--error)]';
default:
return 'text-[var(--warning)]';
}
}
function WebhookRow({ function WebhookRow({
webhook, webhook,
onDelete, onDelete,
@@ -63,59 +53,50 @@ function WebhookRow({
}); });
return ( return (
<div className="panel p-4"> <div className="s-card !p-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 px-4 py-3">
<button onClick={() => setExpanded((v) => !v)} className="text-[var(--text-tertiary)]"> <span className="s-ibox shrink-0"><Webhook /></span>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--text-primary)]">{webhook.name}</span> <span className="text-sm font-semibold text-[var(--text-primary)]">{webhook.name}</span>
{!webhook.enabled && ( {!webhook.enabled && <SPill tone="off">disabled</SPill>}
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-muted)]">
disabled
</span>
)}
</div> </div>
<p className="text-xs text-[var(--text-muted)] mono truncate">{webhook.url}</p> <p className="v-mono text-[10.5px] text-[var(--text-muted)] truncate">{webhook.url}</p>
</div> </div>
<div className="hidden md:flex gap-1"> <div className="hidden md:flex gap-1.5">
{webhook.events.map((ev) => ( {webhook.events.map((ev) => (
<span <span key={ev} className="s-chip">{ev}</span>
key={ev}
className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] mono"
>
{ev}
</span>
))} ))}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setExpanded((v) => !v)}
className="s-icon-btn"
title={expanded ? 'Collapse' : 'Deliveries'}
>
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</button>
<button <button
onClick={onTest} onClick={onTest}
disabled={testing || !webhook.enabled} disabled={testing || !webhook.enabled}
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--accent-primary)] disabled:opacity-40 transition-colors" className="s-icon-btn disabled:opacity-40"
title="Send test ping" title="Send test ping"
> >
{testing ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />} {testing ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />}
</button>
<button
onClick={onToggle}
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors text-xs"
>
{webhook.enabled ? 'Disable' : 'Enable'}
</button> </button>
<QuietBtn onClick={onToggle}>{webhook.enabled ? 'Disable' : 'Enable'}</QuietBtn>
<button <button
onClick={onDelete} onClick={onDelete}
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--error)] transition-colors" className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)]"
title="Delete webhook" title="Delete webhook"
> >
<Trash2 size={15} /> <Trash2 size={13} />
</button> </button>
</div> </div>
</div> </div>
{expanded && ( {expanded && (
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3"> <div className="border-t border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 px-4 py-3">
<p className="text-[11px] uppercase tracking-wider text-[var(--text-muted)] mb-2"> <p className="v-mono text-[10px] uppercase tracking-[0.12em] text-[var(--text-muted)] mb-2">
Recent deliveries Recent deliveries
</p> </p>
{deliveriesQuery.isLoading ? ( {deliveriesQuery.isLoading ? (
@@ -123,10 +104,10 @@ function WebhookRow({
) : (deliveriesQuery.data ?? []).length === 0 ? ( ) : (deliveriesQuery.data ?? []).length === 0 ? (
<p className="text-xs text-[var(--text-muted)]">No deliveries yet.</p> <p className="text-xs text-[var(--text-muted)]">No deliveries yet.</p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1.5">
{(deliveriesQuery.data ?? []).slice(0, 20).map((d) => ( {(deliveriesQuery.data ?? []).slice(0, 20).map((d) => (
<div key={d.id} className="flex items-center gap-3 text-xs mono"> <div key={d.id} className="flex items-center gap-3 text-[11px] v-mono">
<span className={statusClass(d.status)}>{d.status}</span> <SPill tone={statusTone(d.status)}>{d.status}</SPill>
<span className="text-[var(--text-secondary)]">{d.event}</span> <span className="text-[var(--text-secondary)]">{d.event}</span>
{d.response_status != null && ( {d.response_status != null && (
<span className="text-[var(--text-muted)]">HTTP {d.response_status}</span> <span className="text-[var(--text-muted)]">HTTP {d.response_status}</span>
@@ -207,46 +188,41 @@ export function WebhooksPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5 flex items-center justify-between"> <SPageHead
<div> title="Webhooks"
<h1 className="v-title">Webhooks<span className="v-cursor">_</span></h1> titleAccent="_"
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]"> sub="Signed HTTP delivery for every platform event — deploys, restarts, deletes"
Signed HTTP delivery for every platform event — deploys, restarts, deletes actions={
</p> <GhostBtn onClick={() => setCreating(true)}>
</div> <Plus size={13} /> New webhook
<button </GhostBtn>
onClick={() => setCreating(true)} }
className="flex items-center gap-1.5 h-9 px-4 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)]" />
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={15} />
New webhook
</button>
</div>
</div> </div>
<div className="w-full px-4 py-6 sm:px-8 space-y-4 max-w-4xl"> <div className="w-full px-4 sm:px-8 space-y-4 max-w-4xl">
{secret && ( {secret && (
<div className="panel-soft p-4 flex items-center gap-3"> <div className="s-inset p-4 flex items-center gap-3">
<span className="s-ibox shrink-0"><Webhook /></span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--text-primary)]">Signing secret — shown once</p> <p className="text-sm font-medium text-[var(--text-primary)]">Signing secret — shown once</p>
<p className="text-xs text-[var(--text-muted)]"> <p className="v-mono text-[11px] text-[var(--text-muted)]">
Verify deliveries with <span className="mono">X-Containr-Signature</span> Verify deliveries with <span className="v-mono">X-Containr-Signature</span>
</p> </p>
<code className="block mt-2 text-xs mono text-[var(--accent-primary)] break-all">{secret}</code> <code className="block mt-2 v-mono text-xs text-[var(--accent-primary)] break-all">{secret}</code>
</div> </div>
<button <button
onClick={() => { onClick={() => {
navigator.clipboard?.writeText(secret); navigator.clipboard?.writeText(secret);
toast.showToast('Secret copied', 'success'); toast.showToast('Secret copied', 'success');
}} }}
className="p-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]" className="s-icon-btn"
> >
<Copy size={15} /> <Copy size={13} />
</button> </button>
<button onClick={() => setSecret(null)} className="p-2 text-[var(--text-tertiary)]"> <button onClick={() => setSecret(null)} className="s-icon-btn">
<X size={15} /> <X size={13} />
</button> </button>
</div> </div>
)} )}
@@ -254,12 +230,12 @@ export function WebhooksPage() {
{webhooksQuery.isLoading ? ( {webhooksQuery.isLoading ? (
<Loader2 size={18} className="animate-spin text-[var(--text-muted)]" /> <Loader2 size={18} className="animate-spin text-[var(--text-muted)]" />
) : (webhooksQuery.data ?? []).length === 0 ? ( ) : (webhooksQuery.data ?? []).length === 0 ? (
<div className="panel p-8 text-center"> <div className="s-card py-10 text-center">
<Webhook size={28} className="mx-auto text-[var(--text-muted)] mb-3" /> <div className="s-ibox mx-auto mb-3"><Webhook /></div>
<p className="text-sm text-[var(--text-secondary)]">No webhooks yet.</p> <p className="text-sm text-[var(--text-secondary)]">No webhooks yet.</p>
<p className="text-xs text-[var(--text-muted)] mt-1"> <p className="v-mono text-[11px] text-[var(--text-muted)] mt-1">
Subscribe an HTTP endpoint to platform events like <span className="mono">service.*</span> or{' '} Subscribe an HTTP endpoint to platform events like <span className="v-mono">service.*</span> or{' '}
<span className="mono">deployment.fail</span>. <span className="v-mono">deployment.fail</span>.
</p> </p>
</div> </div>
) : ( ) : (
@@ -283,8 +259,11 @@ export function WebhooksPage() {
{creating && ( {creating && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreating(false)} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreating(false)} />
<div className="relative w-full max-w-md panel p-6"> <div className="relative w-full max-w-md s-card">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">New webhook</h3> <div className="s-cardhead mb-4">
<span className="s-ibox"><Webhook /></span>
<h3 className="s-t">New Webhook</h3>
</div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
@@ -304,7 +283,7 @@ export function WebhooksPage() {
<input <input
value={form.url} value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
className={`${inputClass} w-full mono`} className={`${inputClass} w-full v-mono`}
placeholder="https://example.com/hooks/containr" placeholder="https://example.com/hooks/containr"
/> />
</div> </div>
@@ -317,30 +296,21 @@ export function WebhooksPage() {
<button <button
key={ev} key={ev}
onClick={() => toggleEvent(ev)} onClick={() => toggleEvent(ev)}
className={`px-2.5 py-1 rounded-[var(--radius-md)] border text-xs mono transition-colors ${ className={`s-chip v-mono ${form.events.includes(ev) ? 'on' : ''}`}
form.events.includes(ev)
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
}`}
> >
{ev} {ev}
</button> </button>
))} ))}
</div> </div>
<p className="mt-2 text-[11px] text-[var(--text-muted)]"> <p className="mt-2 v-mono text-[10.5px] text-[var(--text-muted)]">
Events are <span className="mono">resource.action</span> — wildcards like{' '} Events are <span className="v-mono">resource.action</span> — wildcards like{' '}
<span className="mono">service.*</span> match the whole resource. <span className="v-mono">service.*</span> match the whole resource.
</p> </p>
</div> </div>
</div> </div>
<div className="mt-6 flex justify-end gap-2"> <div className="mt-6 flex justify-end gap-2">
<button <QuietBtn onClick={() => setCreating(false)}>Cancel</QuietBtn>
onClick={() => setCreating(false)} <GhostBtn
className="h-10 px-4 rounded-[var(--radius-md)] text-sm text-[var(--text-secondary)]"
>
Cancel
</button>
<button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={ disabled={
createMutation.isPending || createMutation.isPending ||
@@ -348,12 +318,10 @@ export function WebhooksPage() {
!form.url.trim() || !form.url.trim() ||
form.events.length === 0 form.events.length === 0
} }
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending && <Loader2 size={14} className="animate-spin" />} {createMutation.isPending && <Loader2 size={13} className="animate-spin" />}
Create Create
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
@@ -167,12 +167,12 @@ function toFlowEdges(
}, },
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
color: 'rgba(180, 227, 74, 0.55)', color: 'color-mix(in srgb, var(--accent-primary) 55%, transparent)',
width: 13, width: 13,
height: 13, height: 13,
}, },
style: { style: {
stroke: 'rgba(180, 227, 74, 0.42)', stroke: 'color-mix(in srgb, var(--accent-primary) 42%, transparent)',
strokeWidth: 1.6, strokeWidth: 1.6,
}, },
className: 'edge-premium', className: 'edge-premium',
@@ -198,7 +198,7 @@ function ZoomControls({ onTidy }: { onTidy: () => void }) {
<button <button
type="button" type="button"
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })} onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
className="canvas-pill-btn mono w-12 text-[11px]" className="canvas-pill-btn v-mono w-12 text-[11px]"
title="Reset zoom (0)" title="Reset zoom (0)"
> >
{Math.round(zoom * 100)}% {Math.round(zoom * 100)}%
@@ -873,7 +873,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const minimapNodeColor = useCallback( const minimapNodeColor = useCallback(
(node: CanvasNode) => { (node: CanvasNode) => {
if (node.type === 'groupNode') { if (node.type === 'groupNode') {
return 'rgba(255,255,255,0.08)'; return 'var(--tint-08)';
} }
return serviceAccent((node.data as ServiceNodeData).service); return serviceAccent((node.data as ServiceNodeData).service);
}, },
@@ -922,14 +922,14 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
selectionKeyCode="Shift" selectionKeyCode="Shift"
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
> >
<Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="rgba(255,255,255,0.055)" /> <Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="var(--tint-05)" />
<MiniMap <MiniMap
position="bottom-right" position="bottom-right"
pannable pannable
zoomable zoomable
nodeColor={minimapNodeColor} nodeColor={minimapNodeColor}
bgColor="rgba(14, 15, 18, 0.92)" bgColor="var(--surface-glass)"
maskColor="rgba(12, 13, 15, 0.72)" maskColor="var(--surface-glass)"
className="canvas-minimap hidden md:block" className="canvas-minimap hidden md:block"
/> />
</ReactFlow> </ReactFlow>
@@ -937,21 +937,19 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
{/* Floating: env filter + group (top-left) */} {/* Floating: env filter + group (top-left) */}
<div className="absolute left-4 top-4 flex items-center gap-2"> <div className="absolute left-4 top-4 flex items-center gap-2">
{environments.length > 1 && ( {environments.length > 1 && (
<div className="canvas-pill flex items-center gap-0.5 p-1"> <div className="canvas-pill !rounded-[9px] p-1">
<span className="s-seg">
{['all', ...environments].map((env) => ( {['all', ...environments].map((env) => (
<button <button
key={env} key={env}
type="button" type="button"
onClick={() => setEnvFilter(env)} onClick={() => setEnvFilter(env)}
className={`px-2.5 py-1 rounded-full text-[11px] font-medium capitalize transition-all ${ className={`!w-auto !h-auto px-2.5 py-1 capitalize v-mono text-[10.5px] ${envFilter === env ? 'on' : ''}`}
envFilter === env
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
> >
{env} {env}
</button> </button>
))} ))}
</span>
</div> </div>
)} )}
{!readOnly && ( {!readOnly && (
@@ -1018,12 +1016,12 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
{/* Empty state */} {/* Empty state */}
{services.length === 0 && ( {services.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center pointer-events-auto"> <div className="text-center pointer-events-auto s-card !py-8 !px-10">
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center"> <div className="s-ibox mx-auto mb-4 !h-11 !w-11">
<Rocket size={24} className="text-[var(--accent-primary)]" /> <Rocket />
</div> </div>
<h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3> <h3 className="font-headline text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
<p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs"> <p className="v-mono text-[11.5px] text-[var(--text-tertiary)] mt-1.5 mb-5 max-w-xs">
Add a service from a git repo or Docker image - it runs on a private network with the rest of this Add a service from a git repo or Docker image - it runs on a private network with the rest of this
project. project.
</p> </p>
@@ -1031,10 +1029,9 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
<button <button
type="button" type="button"
onClick={onAddService} onClick={onAddService}
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg" className="s-btn-accent inline-flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
> >
<Plus size={15} /> <Plus size={13} />
Add Service Add Service
</button> </button>
)} )}
@@ -1,6 +1,7 @@
import type { ServiceEntity } from '@/lib/api-client'; import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import type { ServiceVariable } from '../auto-connections'; import type { ServiceVariable } from '../auto-connections';
import { GhostBtn, QuietBtn, SPill } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { ServiceIcon } from './ServiceIcon'; import { ServiceIcon } from './ServiceIcon';
import { serviceAccent } from './service-visuals'; import { serviceAccent } from './service-visuals';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
@@ -63,28 +64,11 @@ function statusLabel(status: string): string {
} }
} }
function statusColor(status: string): string {
switch (status) {
case 'running':
case 'deployed':
return 'var(--success)';
case 'failed':
return 'var(--error)';
case 'building':
case 'deploying':
case 'pending':
case 'rolling_back':
return 'var(--warning)';
default:
return 'var(--text-tertiary)';
}
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p> <p className="v-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-muted)]">{title}</p>
{action} {action}
</div> </div>
{children} {children}
@@ -163,48 +147,49 @@ export function ServiceInspector({
return ( return (
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden"> <aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden">
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<div <span
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0" className="s-ibox flex-shrink-0"
style={{ background: `${accent}1c`, color: accent }} style={{ background: `${accent}14`, color: accent, borderColor: `${accent}33` }}
> >
<ServiceIcon service={service} size={16} /> <ServiceIcon service={service} size={15} />
</div> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3> <h3 className="text-[13px] font-semibold text-[var(--text-primary)] truncate leading-tight">{service.name}</h3>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]"> <div className="flex items-center gap-1.5 mt-1">
<span className={`status-dot ${serviceStatusClass(service.status)}`} /> <SPill tone={statusTone(service.status)}>{statusLabel(service.status)}</SPill>
<span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span> <span className="v-mono text-[10px] text-[var(--text-tertiary)]">
<span>· {service.type}</span> {service.type}
{service.environment && service.environment !== 'production' && <span>· {service.environment}</span>} {service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</span>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]" className="s-icon-btn"
title="Close (Esc)" title="Close (Esc)"
> >
<X size={14} /> <X size={12} />
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4"> <div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{(publicUrl || service.port) && ( {(publicUrl || service.port) && (
<Section title="Address"> <Section title="Address">
<div className="flex items-center gap-1.5 group"> <div className="s-inset flex items-center gap-1.5 px-2.5 py-2 group">
<Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" /> <Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? ( {publicUrl ? (
<a <a
href={publicUrl} href={publicUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all" className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
> >
{publicUrl.replace(/^https?:\/\//, '')} {publicUrl.replace(/^https?:\/\//, '')}
</a> </a>
) : ( ) : (
<span className="mono text-[11px] text-[var(--text-secondary)]"> <span className="v-mono text-[11px] text-[var(--text-secondary)]">
{service.name}:{service.port} {service.name}:{service.port}
</span> </span>
)} )}
@@ -218,7 +203,7 @@ export function ServiceInspector({
</button> </button>
</div> </div>
{publicUrl && service.port ? ( {publicUrl && service.port ? (
<p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4"> <p className="v-mono text-[10.5px] text-[var(--text-tertiary)] mt-1 pl-1">
{service.name}:{service.port} {service.name}:{service.port}
</p> </p>
) : null} ) : null}
@@ -266,13 +251,13 @@ export function ServiceInspector({
{variables.slice(0, 6).map((variable) => ( {variables.slice(0, 6).map((variable) => (
<div <div
key={variable.key} key={variable.key}
className="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50" className="s-inset !rounded-[6px] flex items-center gap-2 px-2 py-1.5"
> >
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" /> <KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium"> <span className="v-mono text-[10px] text-[var(--text-primary)] truncate font-medium">
{variable.key} {variable.key}
</span> </span>
<span className="mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}> <span className="v-mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
{variable.isSecret ? '••••••••' : variable.value} {variable.isSecret ? '••••••••' : variable.value}
</span> </span>
</div> </div>
@@ -299,7 +284,7 @@ export function ServiceInspector({
{visibleConfig.map(([label, value]) => ( {visibleConfig.map(([label, value]) => (
<div key={label} className="flex items-baseline gap-2"> <div key={label} className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt> <dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}> <dd className="v-mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
{value} {value}
</dd> </dd>
</div> </div>
@@ -307,7 +292,7 @@ export function ServiceInspector({
{(service.replicas ?? 0) > 0 && ( {(service.replicas ?? 0) > 0 && (
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt> <dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd> <dd className="v-mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
</div> </div>
)} )}
</dl> </dl>
@@ -318,7 +303,7 @@ export function ServiceInspector({
{connections.length === 0 ? ( {connections.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]"> <p className="text-[11px] text-[var(--text-muted)]">
No inferred connections. Reference another service via{' '} No inferred connections. Reference another service via{' '}
<span className="mono">{'{{variable}}'}</span> placeholders in env vars. <span className="v-mono">{'{{variable}}'}</span> placeholders in env vars.
</p> </p>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -327,13 +312,13 @@ export function ServiceInspector({
key={conn.id} key={conn.id}
type="button" type="button"
onClick={() => onSelectPeer(conn.peerId)} onClick={() => onSelectPeer(conn.peerId)}
className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors" className="s-inset !rounded-[6px] w-full text-left px-2.5 py-2 hover:border-[var(--border-default)] transition-colors"
> >
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]"> <div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" /> <Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span> <span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
</div> </div>
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4"> <p className="v-mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
{conn.reasons.join(' · ')} {conn.reasons.join(' · ')}
</p> </p>
</button> </button>
@@ -350,65 +335,58 @@ export function ServiceInspector({
</div> </div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2"> <div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
<button <GhostBtn
type="button"
onClick={() => onOpenService(service.id)} onClick={() => onOpenService(service.id)}
className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium" className="w-full justify-center !h-9"
style={{ background: 'var(--accent-primary)' }}
> >
<ArrowUpRight size={14} /> <ArrowUpRight size={14} />
Open service Open service
</button> </GhostBtn>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.deploy} onClick={actions.deploy}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40" className="justify-center"
> >
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />} {actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
Deploy Deploy
</button> </QuietBtn>
{service.status !== 'running' ? ( {service.status !== 'running' ? (
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.start} onClick={actions.start}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40" className="justify-center"
> >
<Play size={11} /> <Play size={11} />
Start Start
</button> </QuietBtn>
) : ( ) : (
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.restart} onClick={actions.restart}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40" className="justify-center"
> >
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />} {actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
Restart Restart
</button> </QuietBtn>
)} )}
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly || service.status !== 'running'} disabled={actions.pending || readOnly || service.status !== 'running'}
onClick={actions.stop} onClick={actions.stop}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40" className="justify-center"
> >
<Square size={11} /> <Square size={11} />
Stop Stop
</button> </QuietBtn>
</div> </div>
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.remove} onClick={actions.remove}
className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40" className="w-full justify-center !text-[var(--error)] hover:!border-[var(--error)]/50"
> >
<Trash2 size={11} /> <Trash2 size={11} />
Delete service Delete service
</button> </QuietBtn>
</div> </div>
</aside> </aside>
); );
@@ -1,10 +1,11 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import type { ServiceEntity } from '@/lib/api-client'; import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react'; import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { serviceIcon, serviceAccent } from './service-visuals'; import { serviceIcon, serviceAccent } from './service-visuals';
import { ServiceIcon } from './ServiceIcon'; import { ServiceIcon } from './ServiceIcon';
import { SPill } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
export type ServiceNodeData = { export type ServiceNodeData = {
service: ServiceEntity; service: ServiceEntity;
@@ -65,10 +66,10 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
return ( return (
<div <div
className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${ className={`w-full rounded-[10px] border transition-[border-color,box-shadow] duration-150 group relative overflow-hidden ${
selected selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]' ? 'border-[var(--accent-primary)] bg-[var(--surface-card)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px' : 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)]'
}`} }`}
> >
<div <div
@@ -86,39 +87,39 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
style={{ background: stateColor }} style={{ background: stateColor }}
/> />
<div className="relative px-3.5 pt-3.5 pb-3"> <div className="relative">
<div className="flex items-center gap-3 mb-2.5"> <div className="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5">
<div <div
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200" className="w-8 h-8 rounded-[7px] border border-[var(--border-subtle)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
style={{ style={{
background: selected && !brand ? typeColor : `${typeColor}1c`, background: selected && !brand ? typeColor : `${typeColor}14`,
color: selected && !brand ? 'var(--bg-void)' : typeColor, color: selected && !brand ? 'var(--bg-void)' : typeColor,
}} }}
> >
{isTransient ? ( {isTransient ? (
<Loader2 size={16} className="animate-spin" /> <Loader2 size={14} className="animate-spin" />
) : ( ) : (
<ServiceIcon service={service} size={17} /> <ServiceIcon service={service} size={15} />
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight"> <h4 className="font-semibold text-[12.5px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
{service.name} {service.name}
</h4> </h4>
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5"> <p className="v-mono text-[10px] text-[var(--text-tertiary)] truncate mt-0.5">
{service.type} {service.type}
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''} {service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</p> </p>
</div> </div>
{replicas > 1 && ( {replicas > 1 && (
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-semibold text-[var(--text-secondary)]"> <span className="s-chip">
<Layers size={9} /> <Layers size={9} />
{replicas} {replicas}
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2 mb-2.5 px-2.5 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/60 border border-[var(--border-subtle)]/60 group/addr"> <div className="mx-3 mb-2.5 s-inset !rounded-[6px] flex items-center gap-2 px-2.5 py-1.5 group/addr">
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" /> <ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? ( {publicUrl ? (
<a <a
@@ -126,12 +127,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline" className="v-mono text-[10.5px] text-[var(--accent-primary)] truncate hover:underline"
> >
{publicUrl.replace(/^https?:\/\//, '')} {publicUrl.replace(/^https?:\/\//, '')}
</a> </a>
) : ( ) : (
<span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address"> <span className="v-mono text-[10.5px] text-[var(--text-secondary)] truncate" title="Internal network address">
{internalAddr} {internalAddr}
</span> </span>
)} )}
@@ -148,15 +149,10 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
</button> </button>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between px-3.5 py-2 border-t border-[var(--border-subtle)]">
<div className="flex items-center gap-1.5"> <SPill tone={statusTone(service.status)}>{statusLabel(service.status)}</SPill>
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
{statusLabel(service.status)}
</span>
</div>
{service.image && ( {service.image && (
<span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}> <span className="v-mono text-[9.5px] text-[var(--text-muted)] truncate max-w-[110px]" title={service.image}>
{service.image} {service.image}
</span> </span>
)} )}
@@ -191,25 +187,25 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
return ( return (
<div <div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative" className="h-full w-full rounded-[12px] border border-dashed transition-all duration-200 relative"
style={{ style={{
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)', borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)', background: isHovered ? 'var(--accent-primary-soft)' : 'var(--tint-03)',
}} }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<div className="relative p-4"> <div className="relative p-4">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div <span
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200" className="s-ibox !h-7 !w-7 transition-colors duration-200"
style={{ style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)', background: isHovered ? 'var(--accent-primary-soft)' : undefined,
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)', color: isHovered ? 'var(--accent-primary)' : undefined,
}} }}
> >
<Box size={14} /> <Box />
</div> </span>
{editing ? ( {editing ? (
<input <input
autoFocus autoFocus
@@ -227,7 +223,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
/> />
) : ( ) : (
<h3 <h3
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight" className="font-headline text-sm font-semibold text-[var(--text-primary)] tracking-tight"
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setDraft(data.title); setDraft(data.title);
@@ -241,7 +237,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
</div> </div>
<p <p
className="text-[10px] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200" className="v-mono text-[10px] uppercase tracking-[0.12em] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200"
style={{ opacity: isHovered ? 1 : 0 }} style={{ opacity: isHovered ? 1 : 0 }}
> >
Drag services here to group them Drag services here to group them
@@ -18,15 +18,15 @@ export function serviceTypeIcon(type: string, size = 18) {
export function serviceTypeColor(type: string): string { export function serviceTypeColor(type: string): string {
switch (type) { switch (type) {
case 'web': case 'web':
return '#7ab8ff'; return 'var(--info)';
case 'database': case 'database':
return '#b4e34a'; return '#38bdf8';
case 'worker': case 'worker':
return '#f2c94c'; return 'var(--warning)';
case 'cron': case 'cron':
return '#f2994a'; return 'var(--accent-secondary)';
default: default:
return '#9295a4'; return 'var(--text-tertiary)';
} }
} }
@@ -10,17 +10,26 @@ interface MetricsDashboardProps {
isDemoMode?: boolean; isDemoMode?: boolean;
} }
const cardStyle: React.CSSProperties = {
background: 'var(--surface-card, rgba(255,255,255,0.03))',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.09))',
borderRadius: '14px',
padding: '18px 20px',
};
function healthLabel(pct: number): { text: string; color: string } { function healthLabel(pct: number): { text: string; color: string } {
if (pct < 50) return { text: 'Good', color: 'var(--success, #5ee6a0)' }; if (pct < 50) return { text: 'Good', color: 'var(--success)' };
if (pct < 80) return { text: 'Average', color: 'var(--warning, #f2c94c)' }; if (pct < 80) return { text: 'Average', color: 'var(--warning)' };
return { text: 'High', color: 'var(--error, #ff6b6b)' }; return { text: 'High', color: 'var(--error)' };
}
function Tile({ icon: Icon, label, children }: {
icon: React.ElementType;
label: string;
children: React.ReactNode;
}) {
return (
<div className="s-card !p-0 overflow-hidden">
<div className="s-cardhead">
<span className="s-ibox"><Icon /></span>
<span className="s-t">{label}</span>
</div>
<div className="px-4 pb-4 pt-3">{children}</div>
</div>
);
} }
export function MetricsDashboard({ services, isDemoMode = false }: MetricsDashboardProps) { export function MetricsDashboard({ services, isDemoMode = false }: MetricsDashboardProps) {
@@ -79,8 +88,8 @@ export function MetricsDashboard({ services, isDemoMode = false }: MetricsDashbo
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="text-[15px] font-bold text-[var(--text-primary)]">Metrics</span> <span className="v-mono text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">Metrics</span>
<span className="text-xs text-[var(--text-muted)]"> <span className="v-mono text-[10.5px] text-[var(--text-muted)]">
{isDemoMode {isDemoMode
? 'sample data' ? 'sample data'
: reporting > 0 : reporting > 0
@@ -91,112 +100,96 @@ export function MetricsDashboard({ services, isDemoMode = false }: MetricsDashbo
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{/* CPU */} {/* CPU */}
<div style={cardStyle}> <Tile icon={Cpu} label="CPU Usage">
<div className="flex items-center gap-2.5 mb-3"> <div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
<span className="card-icon"><Cpu size={16} /></span> {avgCpu.toFixed(1)}<span className="text-[13px] font-medium text-[var(--text-tertiary)]">%</span>
<span className="text-sm font-semibold text-[var(--text-primary)]">CPU Usage</span>
</div> </div>
<div className="text-4xl font-black tracking-tight text-[var(--text-primary)]"> <div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
{avgCpu.toFixed(1)}% <span style={{ color: cpuHealth.color }}>{cpuHealth.text}</span>
{' '}· avg across running services
</div> </div>
<div className="mt-1 text-xs text-[var(--text-tertiary)]"> <div style={{ height: 56, marginTop: 12 }}>
<span style={{ color: cpuHealth.color, fontWeight: 700 }}>{cpuHealth.text}</span>
{' '}avg across running services
</div>
<div style={{ height: 64, marginTop: 12 }}>
{cpuHistory.length > 1 ? ( {cpuHistory.length > 1 ? (
<LineMetricChart data={cpuHistory} color="var(--accent-primary)" fillOpacity={0.15} showArea height={64} /> <LineMetricChart data={cpuHistory} color="var(--accent-primary)" fillOpacity={0.15} showArea height={56} />
) : ( ) : (
<div className="h-full rounded bg-[var(--surface-muted)] opacity-40" /> <div className="h-full rounded bg-[var(--surface-muted)] opacity-40" />
)} )}
</div> </div>
</div> </Tile>
{/* Memory */} {/* Memory */}
<div style={cardStyle}> <Tile icon={HardDrive} label="Memory">
<div className="flex items-center gap-2.5 mb-3"> <div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
<span className="card-icon"><HardDrive size={16} /></span> {memPct.toFixed(0)}<span className="text-[13px] font-medium text-[var(--text-tertiary)]">%</span>
<span className="text-sm font-semibold text-[var(--text-primary)]">Memory</span>
</div> </div>
<div className="text-4xl font-black tracking-tight text-[var(--text-primary)]"> <div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
{memPct.toFixed(0)}% <span style={{ color: memHealth.color }}>{memHealth.text}</span>
</div> {' '}· {formatBytes(memUsed)} used
<div className="mt-1 text-xs text-[var(--text-tertiary)]">
<span style={{ color: memHealth.color, fontWeight: 700 }}>{memHealth.text}</span>
{' '}{formatBytes(memUsed)} used
</div> </div>
<div className="mt-2 flex justify-center"> <div className="mt-2 flex justify-center">
<DonutChart percentage={memPct} size={110} thickness={12} /> <DonutChart percentage={memPct} size={100} thickness={11} />
</div> </div>
<div className="mt-1 text-center text-xs text-[var(--text-tertiary)] mono"> <div className="mt-1 text-center v-mono text-[10.5px] text-[var(--text-tertiary)]">
{formatBytes(memUsed)} / {memLimit > 0 ? formatBytes(memLimit) : '∞'} {formatBytes(memUsed)} / {memLimit > 0 ? formatBytes(memLimit) : '∞'}
</div> </div>
</div> </Tile>
{/* Network */} {/* Network */}
<div style={cardStyle}> <Tile icon={ArrowDownUp} label="Network">
<div className="flex items-center gap-2.5 mb-3"> <div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
<span className="card-icon"><ArrowDownUp size={16} /></span>
<span className="text-sm font-semibold text-[var(--text-primary)]">Network</span>
</div>
<div className="text-4xl font-black tracking-tight text-[var(--text-primary)]">
{formatBytes(netRx)} {formatBytes(netRx)}
</div> </div>
<div className="mt-1 text-xs text-[var(--text-tertiary)]"> <div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
<span style={{ color: 'var(--accent-primary)', fontWeight: 700 }}>RX</span> <span className="text-[var(--accent-primary)]">RX</span>
{' '}total · TX {formatBytes(netTx)} {' '}total · TX {formatBytes(netTx)}
</div> </div>
<div style={{ height: 64, marginTop: 12 }}> <div style={{ height: 56, marginTop: 12 }}>
{netHistory.rx.length > 1 ? ( {netHistory.rx.length > 1 ? (
<LineMetricChart data={netHistory.rx} color="#7ab8ff" fillOpacity={0.12} showArea height={64} /> <LineMetricChart data={netHistory.rx} color="var(--accent-primary)" fillOpacity={0.12} showArea height={56} />
) : ( ) : (
<div className="h-full rounded bg-[var(--surface-muted)] opacity-40" /> <div className="h-full rounded bg-[var(--surface-muted)] opacity-40" />
)} )}
</div> </div>
<div className="mt-2 text-[11px] text-[var(--text-muted)] mono"> <div className="mt-2 v-mono text-[10.5px] text-[var(--text-muted)]">
{netHistory.rx.length > 0 {netHistory.rx.length > 0
? `↓ ${formatBytes(netHistory.rx[netHistory.rx.length - 1])}/s · ↑ ${formatBytes(netHistory.tx[netHistory.tx.length - 1])}/s` ? `↓ ${formatBytes(netHistory.rx[netHistory.rx.length - 1])}/s · ↑ ${formatBytes(netHistory.tx[netHistory.tx.length - 1])}/s`
: 'rate accumulating…'} : 'rate accumulating…'}
</div> </div>
</div> </Tile>
{/* Instances */} {/* Instances */}
<div style={cardStyle}> <Tile icon={Boxes} label="Instances">
<div className="flex items-center gap-2.5 mb-3"> <div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
<span className="card-icon"><Boxes size={16} /></span>
<span className="text-sm font-semibold text-[var(--text-primary)]">Instances</span>
</div>
<div className="text-4xl font-black tracking-tight text-[var(--text-primary)]">
{isDemoMode ? liveServices.length : instanceCount} {isDemoMode ? liveServices.length : instanceCount}
</div> </div>
<div className="mt-1 text-xs text-[var(--text-tertiary)]"> <div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
<span style={{ color: 'var(--success, #5ee6a0)', fontWeight: 700 }}>Running</span> <span className="text-[var(--success)]">Running</span>
{' '}containers across {liveServices.length} service{liveServices.length === 1 ? '' : 's'} {' '}across {liveServices.length} service{liveServices.length === 1 ? '' : 's'}
</div> </div>
<ul className="mt-3 space-y-1.5"> <ul className="mt-3 space-y-1.5">
{liveServices.slice(0, 4).map((service, i) => { {liveServices.slice(0, 4).map((service, i) => {
const m = samples.find((s) => s.service_id === service.id); const m = samples.find((s) => s.service_id === service.id);
return ( return (
<li key={service.id} className="flex items-center justify-between text-xs"> <li key={service.id} className="flex items-center justify-between text-[11.5px]">
<span className="truncate text-[var(--text-secondary)]"> <span className="truncate text-[var(--text-secondary)]">
<span className="mr-1.5 inline-block h-1.5 w-1.5 rounded-full bg-[var(--success,#5ee6a0)] align-middle" /> <span className="mr-1.5 inline-block h-1.5 w-1.5 rounded-full bg-[var(--success)] align-middle" />
{service.name} {service.name}
</span> </span>
<span className="mono text-[var(--text-muted)]"> <span className="v-mono text-[10.5px] text-[var(--text-muted)]">
{m ? `${m.cpu_percent.toFixed(1)}%` : i < 3 ? '—' : ''} {m ? `${m.cpu_percent.toFixed(1)}%` : i < 3 ? '—' : ''}
</span> </span>
</li> </li>
); );
})} })}
{liveServices.length > 4 && ( {liveServices.length > 4 && (
<li className="text-[11px] text-[var(--text-muted)]">+{liveServices.length - 4} more</li> <li className="v-mono text-[10.5px] text-[var(--text-muted)]">+{liveServices.length - 4} more</li>
)} )}
</ul> </ul>
</div> </Tile>
</div> </div>
{noData && ( {noData && (
<p className="mt-3 text-xs text-[var(--text-muted)]"> <p className="mt-3 v-mono text-[11px] text-[var(--text-muted)]">
Metrics collector is not reporting yet — check that the node agent is online. Metrics collector is not reporting yet — check that the node agent is online.
</p> </p>
)} )}
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { listProjectVariables, updateProjectVariables } from '@/lib/api-client'; import { listProjectVariables, updateProjectVariables } from '@/lib/api-client';
import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react'; import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react';
import { QuietBtn } from '@/shared/components/sentry';
type Row = { key: string; value: string; isSecret: boolean }; type Row = { key: string; value: string; isSecret: boolean };
@@ -42,26 +43,23 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]"> <div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div> <div>
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Shared Variables</h3> <h3 className="s-t">Shared Variables</h3>
<p className="mt-1 text-xs text-[var(--text-muted)]"> <p className="mt-1 text-xs text-[var(--text-muted)]">
Reference from any service as{' '} Reference from any service as{' '}
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next <code className="v-mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
deploy/redeploy — not live-updated. deploy/redeploy — not live-updated.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <QuietBtn onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}>
onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<Plus size={12} /> <Plus size={12} />
Add Add
</button> </QuietBtn>
<button <button
onClick={() => saveMutation.mutate(rows)} onClick={() => saveMutation.mutate(rows)}
disabled={!dirty || saveMutation.isPending} disabled={!dirty || saveMutation.isPending}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-50 transition-all" className="s-btn-accent disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />} {saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
Save Save
@@ -83,7 +81,7 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
value={row.key} value={row.key}
onChange={(e) => updateRow(i, { key: e.target.value })} onChange={(e) => updateRow(i, { key: e.target.value })}
placeholder="KEY" placeholder="KEY"
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all" className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] v-mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/> />
<input <input
value={row.value} value={row.value}
@@ -91,13 +89,13 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
type={row.isSecret ? 'password' : 'text'} type={row.isSecret ? 'password' : 'text'}
placeholder="value" placeholder="value"
autoComplete="off" autoComplete="off"
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all" className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] v-mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/> />
<button <button
type="button" type="button"
title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'} title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'}
onClick={() => updateRow(i, { isSecret: !row.isSecret })} onClick={() => updateRow(i, { isSecret: !row.isSecret })}
className={`w-9 h-9 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${ className={`w-9 h-9 rounded-lg border flex items-center justify-center transition-colors ${
row.isSecret row.isSecret
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]' ? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]' : 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
@@ -108,7 +106,7 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
<button <button
type="button" type="button"
onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))} onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))}
className="w-9 h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors" className="w-9 h-9 rounded-lg border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
@@ -50,7 +50,9 @@ import {
GitBranch, GitBranch,
Container, Container,
FileUp, FileUp,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'activity' | 'settings'; type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'activity' | 'settings';
@@ -146,12 +148,10 @@ function ServiceCreateDialog(props: {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={props.onClose} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={props.onClose} />
<div className="relative w-full max-w-lg panel p-6"> <div className="relative w-full max-w-lg s-card !p-6">
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-2.5 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Plus /></span>
<Plus size={20} className="text-[var(--accent-primary)]" /> <h3 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Add Service</h3>
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Add Service</h3>
</div> </div>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Deploy a new service to this project. Configure runtime settings after creation. Deploy a new service to this project. Configure runtime settings after creation.
@@ -202,7 +202,7 @@ function ServiceCreateDialog(props: {
</div> </div>
{isDatabase ? ( {isDatabase ? (
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50"> <div className="s-inset space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
@@ -287,12 +287,12 @@ function ServiceCreateDialog(props: {
<input <input
value={form.image ?? ''} value={form.image ?? ''}
onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm" className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all v-mono text-sm"
placeholder="ghcr.io/org/app:latest" placeholder="ghcr.io/org/app:latest"
/> />
</div> </div>
) : ( ) : (
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50"> <div className="s-inset space-y-3">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Provider Provider
@@ -326,7 +326,7 @@ function ServiceCreateDialog(props: {
<input <input
value={repoSearch} value={repoSearch}
onChange={(e) => setRepoSearch(e.target.value)} onChange={(e) => setRepoSearch(e.target.value)}
className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono mb-2" className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono mb-2"
placeholder="Search repositories..." placeholder="Search repositories..."
/> />
{repositoriesQuery.isLoading ? ( {repositoriesQuery.isLoading ? (
@@ -357,7 +357,7 @@ function ServiceCreateDialog(props: {
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]' : 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'
}`} }`}
> >
<span className="mono text-xs truncate">{repo.full_name}</span> <span className="v-mono text-xs truncate">{repo.full_name}</span>
<span className="text-[10px] text-[var(--text-muted)] ml-2 shrink-0">{repo.default_branch}</span> <span className="text-[10px] text-[var(--text-muted)] ml-2 shrink-0">{repo.default_branch}</span>
</button> </button>
))} ))}
@@ -380,7 +380,7 @@ function ServiceCreateDialog(props: {
<select <select
value={form.git_branch ?? ''} value={form.git_branch ?? ''}
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono"
> >
<option value="">Select branch</option> <option value="">Select branch</option>
{branches.map((branch) => ( {branches.map((branch) => (
@@ -391,7 +391,7 @@ function ServiceCreateDialog(props: {
<input <input
value={form.git_branch ?? ''} value={form.git_branch ?? ''}
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono"
placeholder={selectedRepo?.default_branch ?? 'main'} placeholder={selectedRepo?.default_branch ?? 'main'}
/> />
)} )}
@@ -403,7 +403,7 @@ function ServiceCreateDialog(props: {
<input <input
value={form.build_path ?? ''} value={form.build_path ?? ''}
onChange={(e) => setForm((p) => ({ ...p, build_path: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, build_path: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono"
placeholder="/ (repo root)" placeholder="/ (repo root)"
/> />
</div> </div>
@@ -419,7 +419,7 @@ function ServiceCreateDialog(props: {
<input <input
value={form.command ?? ''} value={form.command ?? ''}
onChange={(e) => setForm((p) => ({ ...p, command: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, command: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm" className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all v-mono text-sm"
placeholder="npm run start" placeholder="npm run start"
/> />
</div> </div>
@@ -434,7 +434,7 @@ function ServiceCreateDialog(props: {
<div className="mt-6 flex justify-end gap-3"> <div className="mt-6 flex justify-end gap-3">
<button <button
onClick={props.onClose} onClick={props.onClose}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)] text-sm font-medium hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors" className="s-btn-quiet"
> >
Cancel Cancel
</button> </button>
@@ -454,8 +454,8 @@ function ServiceCreateDialog(props: {
database: isDatabase ? { engine: dbEngine, plan: dbPlan } : undefined, database: isDatabase ? { engine: dbEngine, plan: dbPlan } : undefined,
}) })
} }
className="px-5 py-2 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{props.loading ? 'Creating...' : isDatabase ? 'Create Database' : 'Create Service'} {props.loading ? 'Creating...' : isDatabase ? 'Create Database' : 'Create Service'}
</button> </button>
@@ -689,17 +689,17 @@ export function ProjectWorkspacePage() {
if ((!isDemoMode && projectQuery.isError) || !project) { if ((!isDemoMode && projectQuery.isError) || !project) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-8"> <div className="min-h-screen flex items-center justify-center p-8">
<div className="panel p-8 text-center max-w-md"> <div className="s-card p-8 text-center max-w-md">
<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">
<X size={24} className="text-[var(--error)]" /> <X size={24} className="text-[var(--error)]" />
</div> </div>
<p className="text-lg font-medium text-[var(--text-primary)]">Project not found</p> <p className="font-headline text-lg font-medium text-[var(--text-primary)]">Project not found</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <p className="mt-2 text-sm text-[var(--text-secondary)]">
This project may have been deleted or you don't have access. This project may have been deleted or you don't have access.
</p> </p>
<button <button
onClick={() => navigate(isDemoMode ? '/projects?demo=1' : '/projects')} onClick={() => navigate(isDemoMode ? '/projects?demo=1' : '/projects')}
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors" className="s-btn-quiet"
> >
Back to Projects Back to Projects
</button> </button>
@@ -724,13 +724,13 @@ export function ProjectWorkspacePage() {
<span className="hidden sm:inline">Projects</span> <span className="hidden sm:inline">Projects</span>
</button> </button>
<div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" /> <div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" />
<h1 className="v-title text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1> <h1 className="v-title font-headline text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
<span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0"> <span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0">
{services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running {services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running
</span> </span>
{/* View tabs */} {/* View tabs */}
<nav className="flex items-center gap-0.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-0.5 mx-2 overflow-x-auto min-w-0"> <nav className="flex items-center gap-0.5 mx-2 overflow-x-auto min-w-0 self-stretch [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => { {viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
const active = activeView === item.key; const active = activeView === item.key;
const Icon = item.icon; const Icon = item.icon;
@@ -742,11 +742,7 @@ export function ProjectWorkspacePage() {
next.set('view', item.key); next.set('view', item.key);
return next; return next;
})} })}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${ className={`tab ${active ? 'active' : ''} !py-0 h-full !text-[11.5px]`}
active
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
> >
<Icon size={13} /> <Icon size={13} />
<span className="hidden md:inline">{item.label}</span> <span className="hidden md:inline">{item.label}</span>
@@ -765,29 +761,21 @@ export function ProjectWorkspacePage() {
</span> </span>
<button <button
onClick={() => setCommandPaletteOpen(true)} onClick={() => setCommandPaletteOpen(true)}
className="hidden lg:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors" className="s-chip hidden lg:flex"
> >
<Search size={12} /> <Search size={12} />
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd> <kbd className="px-1 py-px rounded border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[10px] font-mono">⌘K</kbd>
</button> </button>
{!isDemoMode && signedIn && ( {!isDemoMode && signedIn && (
<> <>
<button <QuietBtn onClick={() => setImportOpen(true)} title="Deploy a docker-compose file into this project">
onClick={() => setImportOpen(true)} <FileUp size={13} />
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] hover:text-[var(--text-primary)] transition-colors"
title="Deploy a docker-compose file into this project"
>
<FileUp size={14} />
<span className="hidden sm:inline">Compose</span> <span className="hidden sm:inline">Compose</span>
</button> </QuietBtn>
<button <GhostBtn onClick={openAddService}>
onClick={openAddService} <Plus size={13} />
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={14} />
<span className="hidden sm:inline">Add Service</span> <span className="hidden sm:inline">Add Service</span>
</button> </GhostBtn>
</> </>
)} )}
</div> </div>
@@ -818,11 +806,9 @@ export function ProjectWorkspacePage() {
<div className="flex-1 min-h-0 overflow-y-auto"> <div className="flex-1 min-h-0 overflow-y-auto">
{isDemoMode && ( {isDemoMode && (
<div className="w-full px-8 pt-6"> <div className="w-full px-8 pt-6">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50"> <div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]"> <Sparkles size={13} />
<Sparkles size={16} /> Demo mode active — using sample data for preview
<span>Demo mode active - using sample data for preview</span>
</div>
</div> </div>
</div> </div>
)} )}
@@ -831,50 +817,37 @@ export function ProjectWorkspacePage() {
{activeView === 'observability' && ( {activeView === 'observability' && (
<div className="space-y-6"> <div className="space-y-6">
{/* Health Summary */} {/* Health Summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
<div className="panel-soft p-4"> {([
<div className="flex items-center gap-2"> { label: 'Healthy', color: 'var(--success)', value: runningServices, pulse: true },
<div className="w-2 h-2 rounded-full bg-[var(--success)] live-pulse" /> { label: 'Failed', color: 'var(--error)', value: services.filter((s) => s.status === 'failed').length, pulse: false },
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Healthy</p> { label: 'Building', color: 'var(--warning)', value: services.filter((s) => s.status === 'building').length, pulse: true },
]).map((s) => (
<div key={s.label} className="s-card">
<div className="s-cardhead !py-2.5">
<span className={`w-1.5 h-1.5 rounded-full ${s.pulse ? 'animate-pulse' : ''}`} style={{ background: s.color }} />
<span className="text-[11px] text-[var(--text-tertiary)]">{s.label}</span>
</div> </div>
<p className="mt-2 text-3xl font-semibold text-[var(--success)]">{runningServices}</p> <div className="px-4 pb-3.5">
<span className="font-headline text-[28px] font-bold leading-none" style={{ color: s.color }}>{s.value}</span>
</div> </div>
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--error)]" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--error)]">
{services.filter((s) => s.status === 'failed').length}
</p>
</div>
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--warning)] animate-pulse" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Building</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--warning)]">
{services.filter((s) => s.status === 'building').length}
</p>
</div> </div>
))}
</div> </div>
{/* Metrics Dashboard */} {/* Metrics Dashboard */}
<MetricsDashboard services={visibleServices} isDemoMode={isDemoMode} /> <MetricsDashboard services={visibleServices} isDemoMode={isDemoMode} />
{/* Service List */} {/* Service List */}
<div className="panel p-6"> <div className="s-card">
<div className="flex items-center justify-between mb-3"> <div className="s-cardhead">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Services</h3> <span className="s-ibox"><Layers /></span>
<span className="s-t">Services</span>
{!isDemoMode && environments.length > 0 && ( {!isDemoMode && environments.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="s-trail flex-wrap">
<button <button
onClick={() => setEnvFilter(null)} onClick={() => setEnvFilter(null)}
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${ className={`s-chip !py-1 !px-2.5 ${envFilter === null ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`}
envFilter === null
? 'bg-[var(--accent-primary)] text-white'
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
}`}
> >
All All
</button> </button>
@@ -882,11 +855,7 @@ export function ProjectWorkspacePage() {
<button <button
key={env.id} key={env.id}
onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)} onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)}
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${ className={`s-chip !py-1 !px-2.5 ${envFilter === env.name ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`}
envFilter === env.name
? 'bg-[var(--accent-primary)] text-white'
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
}`}
> >
{env.name} ({env.service_count}) {env.name} ({env.service_count})
</button> </button>
@@ -895,6 +864,7 @@ export function ProjectWorkspacePage() {
</div> </div>
)} )}
</div> </div>
<div className="px-4 pb-4">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]">
{visibleServices.length === 0 ? ( {visibleServices.length === 0 ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
@@ -914,9 +884,7 @@ export function ProjectWorkspacePage() {
className="w-full flex items-center justify-between px-4 py-3 hover:bg-[var(--surface-muted)]/50 transition-colors text-left" className="w-full flex items-center justify-between px-4 py-3 hover:bg-[var(--surface-muted)]/50 transition-colors text-left"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center"> <span className="s-ibox"><Box /></span>
<Box size={14} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<p className="text-sm font-medium text-[var(--text-primary)]">{service.name}</p> <p className="text-sm font-medium text-[var(--text-primary)]">{service.name}</p>
<p className="text-xs text-[var(--text-tertiary)]">{service.type}</p> <p className="text-xs text-[var(--text-tertiary)]">{service.type}</p>
@@ -929,31 +897,29 @@ export function ProjectWorkspacePage() {
</div> </div>
</div> </div>
</div> </div>
</div>
)} )}
{activeView === 'logs' && ( {activeView === 'logs' && (
<div className="panel p-6"> <div className="s-card">
<div className="flex items-center justify-between mb-4"> <div className="s-cardhead">
<div className="flex items-center gap-3"> <span className="s-ibox"><FileText /></span>
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Logs</h2> <div className="s-t">Logs</div>
<p className="text-sm text-[var(--text-secondary)]">Aggregated service output</p> <div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Aggregated service output</div>
</div>
</div> </div>
{!isDemoMode && ( {!isDemoMode && (
<button <div className="s-trail">
onClick={() => workspaceLogsQuery.refetch()} <QuietBtn onClick={() => workspaceLogsQuery.refetch()}>
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors" <RefreshCw size={13} />
>
Refresh Refresh
</button> </QuietBtn>
</div>
)} )}
</div> </div>
<div className="px-4 pb-4">
<div className="mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] overflow-x-auto max-h-[600px] overflow-y-auto"> <div className="v-mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] overflow-x-auto max-h-[600px] overflow-y-auto">
{services.length === 0 ? ( {services.length === 0 ? (
<p className="text-[var(--text-muted)]">No services available.</p> <p className="text-[var(--text-muted)]">No services available.</p>
) : isDemoMode ? ( ) : isDemoMode ? (
@@ -995,19 +961,19 @@ export function ProjectWorkspacePage() {
)} )}
</div> </div>
</div> </div>
</div>
)} )}
{activeView === 'activity' && ( {activeView === 'activity' && (
<div className="panel p-6"> <div className="s-card">
<div className="flex items-center gap-3 mb-4"> <div className="s-cardhead">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox"><Clock /></span>
<Clock size={20} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Activity</h2> <div className="s-t">Activity</div>
<p className="text-sm text-[var(--text-secondary)]">Project timeline — deploys, config, membership</p> <div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project timeline — deploys, config, membership</div>
</div> </div>
</div> </div>
<div className="px-4 pb-4">
<div className="divide-y divide-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-[var(--radius-md)]"> <div className="divide-y divide-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-[var(--radius-md)]">
{activityQuery.isLoading ? ( {activityQuery.isLoading ? (
<p className="px-4 py-6 text-xs text-[var(--text-muted)]">Loading…</p> <p className="px-4 py-6 text-xs text-[var(--text-muted)]">Loading…</p>
@@ -1031,39 +997,39 @@ export function ProjectWorkspacePage() {
)} )}
</div> </div>
</div> </div>
</div>
)} )}
{activeView === 'settings' && ( {activeView === 'settings' && (
<div className="panel p-6"> <div className="s-card">
<div className="flex items-center gap-3 mb-6"> <div className="s-cardhead">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox"><Settings /></span>
<Settings size={20} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Settings</h2> <div className="s-t">Settings</div>
<p className="text-sm text-[var(--text-secondary)]">Project configuration</p> <div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project configuration</div>
</div> </div>
</div> </div>
<div className="px-4 pb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="panel-soft p-4"> <div className="s-inset">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Project ID</p> <p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Project ID</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p> <p className="v-mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p>
</div> </div>
<div className="panel-soft p-4"> <div className="s-inset">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p> <p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(project.createdAt)}</p> <p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(project.createdAt)}</p>
</div> </div>
<div className="panel-soft p-4 md:col-span-2"> <div className="s-inset md:col-span-2">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Canvas Storage Key</p> <p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Canvas Storage Key</p>
<p className="mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p> <p className="v-mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p>
</div> </div>
</div> </div>
{!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />} {!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />}
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]"> <div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3> <h3 className="s-t mb-3">Local Data</h3>
<button <button
onClick={() => { onClick={() => {
clearCanvasMetadata(project.id); clearCanvasMetadata(project.id);
@@ -1076,6 +1042,7 @@ export function ProjectWorkspacePage() {
</button> </button>
</div> </div>
</div> </div>
</div>
)} )}
</section> </section>
</div> </div>
@@ -1101,12 +1068,10 @@ export function ProjectWorkspacePage() {
{importOpen && ( {importOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setImportOpen(false)} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setImportOpen(false)} />
<div className="relative w-full max-w-2xl panel p-6"> <div className="relative w-full max-w-2xl s-card !p-6">
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-2.5 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><FileUp /></span>
<FileUp size={20} className="text-[var(--accent-primary)]" /> <h3 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
</div> </div>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo, Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo,
@@ -1116,7 +1081,7 @@ export function ProjectWorkspacePage() {
value={composeYaml} value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)} onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false} spellCheck={false}
className="mt-5 w-full h-72 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm resize-none" className="mt-5 w-full h-72 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all v-mono text-sm resize-none"
placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n db:\n image: postgres:16-alpine\n environment:\n POSTGRES_PASSWORD: secret'} placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n db:\n image: postgres:16-alpine\n environment:\n POSTGRES_PASSWORD: secret'}
/> />
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
@@ -1129,8 +1094,8 @@ export function ProjectWorkspacePage() {
<button <button
onClick={() => composeImportMutation.mutate(composeYaml)} onClick={() => composeImportMutation.mutate(composeYaml)}
disabled={composeImportMutation.isPending || composeYaml.trim().length === 0} disabled={composeImportMutation.isPending || composeYaml.trim().length === 0}
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 transition-all flex items-center gap-2" className="s-btn-accent h-10 disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />} {composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />}
Deploy stack Deploy stack
@@ -1159,6 +1124,9 @@ export function ProjectWorkspacePage() {
return next; return next;
}); });
}} }}
navTargets={viewItems
.filter((item) => signedIn || !item.authOnly)
.map((item) => ({ label: `Go to ${item.label}`, target: item.key }))}
/> />
{/* Error Banner */} {/* Error Banner */}
+715 -194
View File
File diff suppressed because it is too large. Load diff
+10 -2
View File
@@ -1239,9 +1239,17 @@ export type AgentContainer = {
created_at?: string; created_at?: string;
}; };
type RawAgentContainer = Omit<AgentContainer, 'status'> & {
status?: string | { state?: string; health?: string };
};
export async function listAgentContainers(id: string): Promise<AgentContainer[]> { export async function listAgentContainers(id: string): Promise<AgentContainer[]> {
const payload = await requestJson<{ containers?: AgentContainer[] }>(`/agents/${id}/containers`); const payload = await requestJson<{ containers?: RawAgentContainer[] }>(`/agents/${id}/containers`);
return payload.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 = { export type AgentCommand = {
+5
View File
@@ -13,6 +13,11 @@ export function formatRelative(iso?: string): string {
const hour = 60 * minute; const hour = 60 * minute;
const day = 24 * hour; 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) { if (diffMs < minute) {
return 'just now'; return 'just now';
} }
+2 -2
View File
@@ -77,12 +77,12 @@ export function CardHeader({ title, description, icon, action, className = '', .
<div className={`flex items-start justify-between gap-4 ${className}`} {...props}> <div className={`flex items-start justify-between gap-4 ${className}`} {...props}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{icon && ( {icon && (
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-white/5" style={{ background: 'rgba(255,255,255,0.07)' }}> <div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-[var(--tint-05)]" style={{ background: 'var(--tint-07)' }}>
{icon} {icon}
</div> </div>
)} )}
<div> <div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3> <h3 className="font-headline text-base font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3>
{description && ( {description && (
<p className="text-sm text-[var(--text-secondary)] mt-0.5">{description}</p> <p className="text-sm text-[var(--text-secondary)] mt-0.5">{description}</p>
)} )}
@@ -68,7 +68,7 @@ function gradientId(prefix: string, color: string, index = 0) {
export function LineChart({ export function LineChart({
data, data,
color = '#b4e34a', color = '#38bdf8',
fillColor = 'transparent', fillColor = 'transparent',
height = 76, height = 76,
showDots = true, showDots = true,
@@ -181,7 +181,7 @@ export function MultiLineChart({
export function LineMetricChart({ export function LineMetricChart({
data, data,
color = '#b4e34a', color = '#38bdf8',
fillOpacity = 0.15, fillOpacity = 0.15,
height = 76, height = 76,
showArea = false, showArea = false,
@@ -204,8 +204,8 @@ export function DualLineChart({
data1, data1,
data2, data2,
height = 58, height = 58,
color1 = '#7ab8ff', color1 = 'var(--accent-primary)',
color2 = '#f2c94c', color2 = 'var(--warning)',
fillOpacity1 = 0.15, fillOpacity1 = 0.15,
fillOpacity2 = 0.15, fillOpacity2 = 0.15,
}: DualLineChartProps) { }: DualLineChartProps) {
@@ -1,24 +1,25 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Command } from 'cmdk'; import { Command } from 'cmdk';
import { import {
ArrowRight,
Box, Box,
CornerDownRight,
Database, Database,
Github,
Image,
Clock, Clock,
Search, Search,
Settings,
FileText,
Activity,
LayoutGrid,
} from 'lucide-react'; } from 'lucide-react';
export interface PaletteTarget {
label: string;
target: string;
description?: string;
}
interface CommandItem { interface CommandItem {
id: string; id: string;
label: string; label: string;
description?: string; description?: string;
icon: typeof Box; icon: typeof Box;
shortcut?: string;
action: () => void; action: () => void;
category?: string; category?: string;
} }
@@ -27,14 +28,39 @@ interface CommandPaletteProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onAddService?: (type: string) => 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({ export function CommandPalette({
open, open,
onClose, onClose,
onAddService, onAddService,
onNavigate, onNavigate,
navTargets,
openTargets,
}: CommandPaletteProps) { }: CommandPaletteProps) {
if (!open) return null; if (!open) return null;
@@ -43,6 +69,8 @@ export function CommandPalette({
onClose={onClose} onClose={onClose}
onAddService={onAddService} onAddService={onAddService}
onNavigate={onNavigate} onNavigate={onNavigate}
navTargets={navTargets}
openTargets={openTargets}
/> />
); );
} }
@@ -51,6 +79,8 @@ function CommandPaletteContent({
onClose, onClose,
onAddService, onAddService,
onNavigate, onNavigate,
navTargets,
openTargets,
}: Omit<CommandPaletteProps, 'open'>) { }: Omit<CommandPaletteProps, 'open'>) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -66,42 +96,35 @@ function CommandPaletteContent({
return () => document.removeEventListener('keydown', down); return () => document.removeEventListener('keydown', down);
}, [onClose]); }, [onClose]);
const commands: CommandItem[] = [ 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', id: 'add-web',
label: 'Add Web Service', label: 'Add Web Service',
description: 'Deploy a web application', description: 'Deploy a web application',
icon: Box, icon: Box,
shortcut: 'W',
category: 'Create', category: 'Create',
action: () => { action: () => { onAddService('web'); onClose(); },
onAddService?.('web');
onClose();
},
}, },
{ {
id: 'add-worker', id: 'add-worker',
label: 'Add Worker Service', label: 'Add Worker Service',
description: 'Deploy a background worker', description: 'Deploy a background worker',
icon: Clock, icon: Clock,
shortcut: 'K',
category: 'Create', category: 'Create',
action: () => { action: () => { onAddService('worker'); onClose(); },
onAddService?.('worker');
onClose();
},
}, },
{ {
id: 'add-database', id: 'add-database',
label: 'Add Database', label: 'Add Database',
description: 'Provision a PostgreSQL database', description: 'Provision a PostgreSQL database',
icon: Database, icon: Database,
shortcut: 'D',
category: 'Create', category: 'Create',
action: () => { action: () => { onAddService('database'); onClose(); },
onAddService?.('database');
onClose();
},
}, },
{ {
id: 'add-cron', id: 'add-cron',
@@ -109,76 +132,33 @@ function CommandPaletteContent({
description: 'Schedule a recurring task', description: 'Schedule a recurring task',
icon: Clock, icon: Clock,
category: 'Create', category: 'Create',
action: () => { action: () => { onAddService('cron'); onClose(); },
onAddService?.('cron');
onClose();
}, },
}, );
{ }
id: 'connect-github',
label: 'Connect GitHub Repo', if (onNavigate) {
description: 'Link a repository for auto-deploy', (navTargets ?? APP_NAV).forEach((t, i) =>
icon: Github, commands.push({
category: 'Connect', id: `nav-${i}`,
action: () => { label: t.label,
onClose(); description: t.description,
}, icon: CornerDownRight,
},
{
id: 'deploy-image',
label: 'Deploy Docker Image',
description: 'Deploy from a container registry',
icon: Image,
category: 'Connect',
action: () => {
onClose();
},
},
{
id: 'goto-canvas',
label: 'Go to Canvas',
icon: LayoutGrid,
shortcut: 'C',
category: 'Navigate', category: 'Navigate',
action: () => { action: () => { onNavigate(t.target); onClose(); },
onNavigate?.('canvas'); }),
onClose(); );
}, (openTargets ?? []).forEach((t, i) =>
}, commands.push({
{ id: `open-${i}`,
id: 'goto-logs', label: t.label,
label: 'Go to Logs', description: t.description,
icon: FileText, icon: ArrowRight,
shortcut: 'L', category: 'Open',
category: 'Navigate', action: () => { onNavigate(t.target); onClose(); },
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();
},
},
];
const filteredCommands = commands.filter( const filteredCommands = commands.filter(
(cmd) => (cmd) =>
@@ -197,6 +177,10 @@ function CommandPaletteContent({
{} as Record<string, CommandItem[]> {} as Record<string, CommandItem[]>
); );
const placeholder = onNavigate
? 'Search pages, projects, actions…'
: 'What would you like to create?';
return ( return (
<div className="fixed inset-0 z-50"> <div className="fixed inset-0 z-50">
<div <div
@@ -205,7 +189,7 @@ function CommandPaletteContent({
/> />
<div className="absolute left-1/2 top-[20%] -translate-x-1/2 w-full max-w-xl"> <div className="absolute left-1/2 top-[20%] -translate-x-1/2 w-full max-w-xl">
<Command <Command
className="panel overflow-hidden" className="s-card !p-0 overflow-hidden shadow-2xl shadow-black/50"
loop loop
> >
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--border-subtle)]"> <div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--border-subtle)]">
@@ -213,7 +197,7 @@ function CommandPaletteContent({
<Command.Input <Command.Input
value={search} value={search}
onValueChange={setSearch} onValueChange={setSearch}
placeholder="What would you like to create?" placeholder={placeholder}
className="flex-1 bg-transparent text-[var(--text-primary)] placeholder:text-[var(--text-muted)] text-sm outline-none" className="flex-1 bg-transparent text-[var(--text-primary)] placeholder:text-[var(--text-muted)] text-sm outline-none"
/> />
<kbd className="px-2 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-mono text-[var(--text-muted)]"> <kbd className="px-2 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-mono text-[var(--text-muted)]">
@@ -228,7 +212,7 @@ function CommandPaletteContent({
{Object.entries(groupedCommands).map(([category, items]) => ( {Object.entries(groupedCommands).map(([category, items]) => (
<Command.Group key={category} heading={category} className="mb-2"> <Command.Group key={category} heading={category} className="mb-2">
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-[var(--text-muted)]"> <div className="px-2 py-1.5 v-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-muted)]">
{category} {category}
</div> </div>
{items.map((item) => { {items.map((item) => {
@@ -238,20 +222,15 @@ function CommandPaletteContent({
key={item.id} key={item.id}
value={item.label} value={item.label}
onSelect={item.action} onSelect={item.action}
className="flex items-center gap-3 px-3 py-2.5 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" 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"
> >
<Icon size={18} className="text-[var(--text-muted)]" /> <span className="s-ibox !h-7 !w-7"><Icon size={13.5} /></span>
<div className="flex-1"> <div className="flex-1">
<div className="text-sm font-medium">{item.label}</div> <div className="text-sm font-medium">{item.label}</div>
{item.description && ( {item.description && (
<div className="text-xs text-[var(--text-muted)]">{item.description}</div> <div className="text-xs text-[var(--text-muted)]">{item.description}</div>
)} )}
</div> </div>
{item.shortcut && (
<kbd className="px-2 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-mono text-[var(--text-muted)]">
{item.shortcut}
</kbd>
)}
</Command.Item> </Command.Item>
); );
})} })}
@@ -8,15 +8,15 @@ import { Sparkles } from 'lucide-react';
export function DemoRestricted({ feature }: { feature: string }) { export function DemoRestricted({ feature }: { feature: string }) {
return ( return (
<div className="flex min-h-[60vh] items-center justify-center p-8"> <div className="flex min-h-[60vh] items-center justify-center p-8">
<div className="panel max-w-md p-8 text-center"> <div className="s-card max-w-md text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]"> <div className="s-ibox mx-auto mb-4 !h-10 !w-10">
<Sparkles size={22} className="text-[var(--accent-primary)]" /> <Sparkles size={18} />
</div> </div>
<p className="text-lg font-semibold text-[var(--text-primary)]">Not part of the demo</p> <p className="font-headline text-[17px] font-semibold text-[var(--text-primary)]">Not part of the demo</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <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. {feature} needs a live Containr session. The demo covers sample projects, services, and platform views only.
</p> </p>
<Link to="/projects?demo=1" className="v-btn mt-5 inline-flex"> <Link to="/projects?demo=1" className="s-btn-accent mt-5 inline-flex">
Back to demo projects Back to demo projects
</Link> </Link>
</div> </div>
@@ -17,8 +17,8 @@ export function DonutChart({
percentage, percentage,
size = 160, size = 160,
thickness = 16, thickness = 16,
color = '#b4e34a', color = '#38bdf8',
trackColor = 'rgba(255, 255, 255, 0.07)', trackColor = 'var(--tint-07)',
segments = 28, segments = 28,
gap = 0.048, gap = 0.048,
animated = true, animated = true,
@@ -1,4 +1,5 @@
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
interface EnhancedMetricCardProps { interface EnhancedMetricCardProps {
title: string; title: string;
@@ -15,6 +16,42 @@ interface EnhancedMetricCardProps {
horizontal?: boolean; horizontal?: boolean;
} }
const statusColors: Record<string, string> = {
good: 'var(--success)',
average: 'var(--warning)',
warning: 'var(--warning)',
};
function CardHead({ icon, title }: { icon: ReactNode; title: string }) {
return (
<div className="s-cardhead">
<span className="s-ibox">{icon}</span>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)] whitespace-nowrap">{title}</span>
<span className="ml-auto text-[var(--text-tertiary)]">
<MoreHorizontal size={14} />
</span>
</div>
);
}
function CardFoot({ label, onClick }: { label: string; onClick?: () => void }) {
return (
<div className="s-stat-foot mt-auto">
<span
onClick={onClick}
className="cursor-pointer hover:text-[var(--text-secondary)] transition-colors"
>
{label}
</span>
{onClick && (
<button className="s-icon-btn !w-[26px] !h-[26px]" onClick={onClick} title={label}>
<ChevronRight size={12} />
</button>
)}
</div>
);
}
export function EnhancedMetricCard({ export function EnhancedMetricCard({
title, title,
value, value,
@@ -29,75 +66,23 @@ export function EnhancedMetricCard({
animationDelay = 0, animationDelay = 0,
horizontal = false, horizontal = false,
}: EnhancedMetricCardProps) { }: EnhancedMetricCardProps) {
const statusColors: Record<string, string> = {
good: '#5ee6a0',
average: '#f2c94c',
warning: '#f2c94c',
};
// Horizontal layout for cards like Active User
if (horizontal) { if (horizontal) {
return ( return (
<div <div className={`s-card flex flex-row overflow-hidden ${className}`} style={{ animationDelay: `${animationDelay}s` }}>
className={`card ${className}`} <div className="flex flex-1 flex-col">
style={{ <CardHead icon={icon} title={title} />
display: 'flex', <div className="px-4">
flexDirection: 'row', <div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
padding: 0,
overflow: 'hidden',
animationDelay: `${animationDelay}s`,
}}
>
<div style={{
flex: 1,
padding: '20px 18px 18px 20px',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div>
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value} {value}
</div> </div>
{subtitle && ( {subtitle && <div className="mt-1 text-[11.5px] text-[var(--text-tertiary)]">{subtitle}</div>}
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div> {details && <div className="mt-3">{details}</div>}
)}
{details && <div style={{ marginTop: 12 }}>{details}</div>}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 'auto',
paddingTop: 14,
}}>
<span
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
onClick={onClick}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div> </div>
<div className="mt-4" />
<CardFoot label="Details" onClick={onClick} />
</div> </div>
{chart && ( {chart && (
<div style={{ <div className="flex w-1/2 items-end py-3 pr-3.5">
width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
{chart} {chart}
</div> </div>
)} )}
@@ -105,64 +90,25 @@ export function EnhancedMetricCard({
); );
} }
// Standard vertical card layout - matching self.html exactly
return ( return (
<div <div className={`s-card flex flex-col ${className}`} style={{ animationDelay: `${animationDelay}s` }}>
className={`card ${className}`} <CardHead icon={icon} title={title} />
style={{ <div className="px-4 pb-1">
display: 'flex', <div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div>
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value} {value}
</div> </div>
{(subtitle || statusText) && ( {(subtitle || statusText) && (
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}> <div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
{statusText && status && ( {statusText && status && (
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span> <span className="font-semibold" style={{ color: statusColors[status] }}>{statusText}</span>
)}{' '} )}{' '}
{subtitle} {subtitle}
</div> </div>
)} )}
{chart && <div className="mt-3 mb-1">{chart}</div>}
{chart && <div style={{ marginTop: 12, marginBottom: 6 }}>{chart}</div>} {details && <div className="mt-3">{details}</div>}
{details && <div style={{ marginTop: 16 }}>{details}</div>}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 4,
marginTop: 'auto'
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div> </div>
<CardFoot label="Details" onClick={onClick} />
</div> </div>
); );
} }
@@ -186,119 +132,57 @@ export function CacheMetricCard({
const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100); const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100);
return ( return (
<div <div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}>
className="card" <CardHead
style={{ icon={
display: 'flex', <svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /> <polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg> </svg>
</div> }
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Cache</span> title="Cache"
</div>
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{totalMB} MB
</div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<span style={{ color: '#f2c94c', fontWeight: 700 }}>{Math.round(totalMB * 0.625)}MB Average</span> cached images and files
</div>
{/* Segmented Bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, margin: '16px 0 15px', height: 32 }}>
<div
className="cache-seg"
style={{
width: `${cachePercent}%`,
background: '#ff6b5b',
borderRadius: '10px 4px 4px 10px'
}}
/>
<div
className="cache-seg"
style={{
width: `${nonCachePercent}%`,
background: '#8c6ef0',
borderRadius: '5px'
}}
/>
<div
className="cache-seg"
style={{
flex: 1,
background: 'rgba(255,255,255,0.07)',
borderRadius: '4px 10px 10px 4px'
}}
/> />
<div className="px-4">
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{totalMB} <span className="text-[12px] font-medium text-[var(--text-tertiary)]">MB</span>
</div>
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
<span className="font-semibold text-[var(--warning)]">{Math.round(totalMB * 0.625)}MB average</span> cached images and files
</div> </div>
{/* Stats Row */} {/* Segmented bar */}
<div style={{ <div className="my-4 flex h-8 items-center gap-1">
display: 'grid', <div className="h-full rounded-l-md" style={{ width: `${cachePercent}%`, background: 'var(--error)' }} />
gridTemplateColumns: '1fr auto 1fr auto 1fr', <div className="h-full" style={{ width: `${nonCachePercent}%`, background: 'var(--accent-tertiary)' }} />
gap: 0, <div className="h-full flex-1 rounded-r-md bg-[var(--tint-07)]" />
alignItems: 'stretch'
}}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
<div className="stat-dot" style={{ background: '#ff6b5b' }} /> Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 15, fontWeight: 800 }}>{cacheMB} MB</span>
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{cachePercent}%</span>
</div>
</div>
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
<div className="stat-dot" style={{ background: '#8c6ef0' }} /> Non-Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 15, fontWeight: 800 }}>{nonCacheMB} MB</span>
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{nonCachePercent}%</span>
</div>
</div>
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>Total</div>
<div style={{ fontSize: 15, fontWeight: 800 }}>{totalMB * 5} GB</div>
</div>
</div> </div>
<div style={{ {/* Stats row */}
display: 'flex', <div className="grid grid-cols-[1fr_auto_1fr_auto_1fr] items-stretch">
alignItems: 'center', {[
justifyContent: 'space-between', { dot: 'var(--error)', label: 'Cache', value: `${cacheMB} MB`, pct: `${cachePercent}%` },
paddingTop: 16 { dot: 'var(--accent-tertiary)', label: 'Non-Cache', value: `${nonCacheMB} MB`, pct: `${nonCachePercent}%` },
}}> { dot: null, label: 'Total', value: `${totalMB * 5} GB`, pct: '' },
<span ].map((s, i) => (
onClick={onClick} <div key={s.label} className="contents">
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }} {i > 0 && <div className="mx-4 w-px bg-[var(--border-subtle)]" />}
> <div>
Details <div className="mb-1 flex items-center gap-1.5 text-[10.5px] text-[var(--text-tertiary)]">
</span> {s.dot && <i className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />}
<div className="arrow-btn" onClick={onClick}> {s.label}
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round"> </div>
<polyline points="9 18 15 12 9 6" /> <div className="flex items-baseline gap-1">
</svg> <span className="text-[14px] font-bold text-[var(--text-primary)]">{s.value}</span>
{s.pct && <span className="text-[10.5px] text-[var(--text-tertiary)]">{s.pct}</span>}
</div> </div>
</div> </div>
</div> </div>
))}
</div>
</div>
<div className="mt-4" />
<CardFoot label="Details" onClick={onClick} />
</div>
); );
} }
@@ -319,86 +203,47 @@ export function PerformanceMetricCard({
animationDelay = 0, animationDelay = 0,
}: PerformanceMetricCardProps) { }: PerformanceMetricCardProps) {
const status = percentage >= 85 ? 'Good' : percentage >= 70 ? 'Average' : 'Warning'; const status = percentage >= 85 ? 'Good' : percentage >= 70 ? 'Average' : 'Warning';
const statusColor = percentage >= 85 ? '#5ee6a0' : percentage >= 70 ? '#f2c94c' : '#ff6f5c'; const statusColor = percentage >= 85 ? 'var(--success)' : percentage >= 70 ? 'var(--warning)' : 'var(--error)';
return ( return (
<div <div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}>
className="card" <CardHead
style={{ icon={
display: 'flex', <svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" /> <line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /> <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg> </svg>
</div> }
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Performance</span> title="Performance"
</div> />
<div className="flex flex-1 items-start gap-4 px-4">
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16, flex: 1 }}> <div className="flex-1">
<div style={{ flex: 1 }}> <div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{percentage}% {percentage}%
</div> </div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}> <div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
<span style={{ color: statusColor, fontWeight: 700 }}>{status}</span> Last scan on {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} <span className="font-semibold" style={{ color: statusColor }}>{status}</span>
{' '}Last scan {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-2">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8 }}> <div className="h-[58px] w-[134px] rounded-lg bg-[var(--tint-03)]" />
{/* Chart placeholder - will be replaced with actual chart */} <div className="flex flex-col items-end gap-1">
<div style={{ width: 134, height: 58, background: 'rgba(255,255,255,0.03)', borderRadius: 8 }} /> {[{ v: upSpeed, up: false }, { v: downSpeed, up: true }].map((s, i) => (
<div key={i} className="flex items-center gap-1 text-[11px] font-medium text-[var(--accent-primary)]">
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}> <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<div className="speed-row" style={{ color: '#7ab8ff' }}> <line x1="12" y1={s.up ? 5 : 19} x2="12" y2={s.up ? 19 : 5} />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> {s.up ? <polyline points="19 12 12 5 5 12" /> : <polyline points="5 12 12 19 19 12" />}
<line x1="12" y1="19" x2="12" y2="5"/>
<polyline points="5 12 12 19 19 12"/>
</svg> </svg>
<span>{upSpeed}</span> Mbps <span>{s.v}</span> Mbps
</div> </div>
<div className="speed-row" style={{ color: 'var(--accent-primary)' }}> ))}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<polyline points="19 12 12 5 5 12"/>
</svg>
<span>{downSpeed}</span> Mbps
</div> </div>
</div> </div>
</div> </div>
</div> <div className="mt-4" />
<CardFoot label="Check Speed" onClick={onClick} />
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 14
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Check Speed
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div> </div>
); );
} }
@@ -61,9 +61,9 @@ export class ErrorBoundary extends Component<Props, State> {
return ( return (
<div className="min-h-screen bg-[var(--bg-void)] flex items-center justify-center p-4"> <div className="min-h-screen bg-[var(--bg-void)] flex items-center justify-center p-4">
<div className="max-w-md w-full bg-[var(--bg-primary)] border border-[var(--border-subtle)] rounded-lg p-6 space-y-4"> <div className="s-card max-w-md w-full space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-xl font-semibold text-[var(--text-primary)]"> <h2 className="font-headline text-xl font-semibold text-[var(--text-primary)]">
Something went wrong Something went wrong
</h2> </h2>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
@@ -72,24 +72,18 @@ export class ErrorBoundary extends Component<Props, State> {
</div> </div>
{import.meta.env.DEV && this.state.error && ( {import.meta.env.DEV && this.state.error && (
<div className="bg-[var(--surface-muted)] border border-[var(--border-subtle)] rounded p-3"> <div className="s-inset">
<p className="text-xs font-mono text-[var(--error)] break-all"> <p className="v-mono text-[11px] text-[var(--error)] break-all">
{this.state.error.toString()} {this.state.error.toString()}
</p> </p>
</div> </div>
)} )}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button onClick={this.handleReset} className="s-btn-accent flex-1 justify-center">
onClick={this.handleReset}
className="flex-1 px-4 py-2 bg-[var(--accent-primary-soft)] text-[var(--text-primary)] rounded hover:opacity-90 transition-opacity"
>
Try Again Try Again
</button> </button>
<button <button onClick={() => window.location.reload()} className="s-btn-quiet flex-1 justify-center">
onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-[var(--surface-muted)] text-[var(--text-primary)] border border-[var(--border-subtle)] rounded hover:bg-[var(--surface-soft)] transition-colors"
>
Reload Page Reload Page
</button> </button>
</div> </div>
+50 -119
View File
@@ -1,4 +1,5 @@
import { type ReactNode, useEffect, useRef, useState } from 'react'; import { type ReactNode, useEffect, useRef, useState } from 'react';
import { ChevronRight } from 'lucide-react';
interface MetricCardProps { interface MetricCardProps {
title: string; title: string;
@@ -15,6 +16,12 @@ interface MetricCardProps {
horizontal?: boolean; horizontal?: boolean;
} }
const statusColors: Record<string, string> = {
good: 'var(--success)',
average: 'var(--warning)',
warning: 'var(--warning)',
};
export function MetricCard({ export function MetricCard({
title, title,
value, value,
@@ -49,140 +56,64 @@ export function MetricCard({
return () => observer.disconnect(); return () => observer.disconnect();
}, [animationDelay]); }, [animationDelay]);
const statusColors: Record<string, string> = { const reveal = {
good: '#5ee6a0', opacity: isVisible ? 1 : 0,
average: '#f2c94c', transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
warning: '#f2c94c', transition: 'all 0.5s ease',
}; } as const;
const footer = (
<div className="s-stat-foot">
<span
onClick={onClick}
style={{ cursor: onClick ? 'pointer' : 'default' }}
>
{statusText && status ? (
<b style={{ color: statusColors[status], fontWeight: 600 }}>{statusText}</b>
) : null}{' '}
{subtitle ?? 'Details'}
</span>
{onClick ? (
<button className="s-icon-btn !h-6 !w-6" onClick={onClick} title="Details">
<ChevronRight size={11} />
</button>
) : null}
</div>
);
// Horizontal layout for cards like Active User
if (horizontal) { if (horizontal) {
return ( return (
<div <div ref={cardRef} className={`s-stat ${className}`} style={{ ...reveal, display: 'flex' }}>
ref={cardRef} <div className="flex-1 px-4 pt-4 pb-2 flex flex-col">
className={`panel ${className}`} <div className="flex items-center gap-2.5">
style={{ <span className="s-ibox">{icon}</span>
display: 'flex', <span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
flexDirection: 'row',
padding: 0,
overflow: 'hidden',
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
transition: 'all 0.5s ease',
}}
>
<div style={{
flex: 1,
padding: '20px 18px 18px 20px',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div> </div>
<div style={{ <div className="mt-4 font-headline text-[30px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value} {value}
</div> </div>
{subtitle && ( {details ? <div className="mt-3">{details}</div> : null}
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div> <div className="mt-auto">{footer}</div>
)}
{details && <div style={{ marginTop: 12 }}>{details}</div>}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 'auto',
paddingTop: 14,
}}>
<span
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
onClick={onClick}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div> </div>
</div> {chart ? (
</div> <div className="w-1/2 flex items-end pr-3 pb-10 pt-3">{chart}</div>
{chart && ( ) : null}
<div style={{
width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
{chart}
</div>
)}
</div> </div>
); );
} }
// Standard vertical card layout - matching self.html exactly
return ( return (
<div <div ref={cardRef} className={`s-stat ${className}`} style={reveal}>
ref={cardRef} <div className="flex items-center gap-2.5 px-4 pt-4">
className={`panel ${className}`} <span className="s-ibox">{icon}</span>
style={{ <span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
display: 'flex',
flexDirection: 'column',
padding: 20,
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
transition: 'all 0.5s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div> </div>
<div className="px-4 mt-4 font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value} {value}
</div> </div>
{chart ? <div className="px-4 mt-3">{chart}</div> : null}
{(subtitle || statusText) && ( {details ? <div className="px-4 mt-3">{details}</div> : null}
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}> <div className="mt-4">{footer}</div>
{statusText && status && (
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span>
)}{' '}
{subtitle}
</div>
)}
{chart && <div style={{ marginTop: 12, marginBottom: 6 }}>{chart}</div>}
{details && <div style={{ marginTop: 16 }}>{details}</div>}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4, marginTop: 'auto' }}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div> </div>
); );
} }
@@ -1,4 +1,5 @@
import { Check, X, Loader2, Box, HelpCircle, type LucideIcon } from 'lucide-react'; import { Check, X, Loader2, Box, HelpCircle, type LucideIcon } from 'lucide-react';
import type { PillTone } from './sentry-utils';
type ServiceStatus = 'running' | 'building' | 'failed' | 'stopped' | 'unknown'; type ServiceStatus = 'running' | 'building' | 'failed' | 'stopped' | 'unknown';
@@ -12,43 +13,29 @@ interface StatusBadgeProps {
const statusConfig: Record< const statusConfig: Record<
ServiceStatus, ServiceStatus,
{ color: string; bg: string; Icon: LucideIcon; label: string; animate?: boolean; glow?: string } { tone: PillTone; Icon: LucideIcon; label: string; animate?: boolean }
> = { > = {
running: { running: { tone: 'ok', Icon: Check, label: 'Active' },
color: 'var(--success)', building: { tone: 'warn', Icon: Loader2, label: 'Building', animate: true },
bg: 'var(--success-soft)', failed: { tone: 'err', Icon: X, label: 'Failed' },
Icon: Check, stopped: { tone: 'off', Icon: Box, label: 'Stopped' },
label: 'Active', unknown: { tone: 'off', Icon: HelpCircle, label: 'Unknown' },
animate: true, };
glow: 'rgba(61, 214, 140, 0.4)',
}, const TONE_CLS: Record<PillTone, string> = {
building: { ok: 'text-[var(--success)] bg-[var(--success-soft)] border-[color-mix(in_srgb,var(--success)_16%,transparent)]',
color: 'var(--warning)', warn: 'text-[var(--warning)] bg-[var(--warning-soft)] border-[color-mix(in_srgb,var(--warning)_16%,transparent)]',
bg: 'var(--warning-soft)', err: 'text-[var(--error)] bg-[var(--error-soft)] border-[color-mix(in_srgb,var(--error)_16%,transparent)]',
Icon: Loader2, info: 'text-[var(--info)] bg-[var(--info-soft)] border-[color-mix(in_srgb,var(--info)_16%,transparent)]',
label: 'Building', off: 'text-[var(--text-tertiary)] bg-[var(--tint-06)] border-[var(--border-subtle)]',
animate: true, };
glow: 'rgba(255, 112, 67, 0.4)',
}, const TONE_DOT: Record<PillTone, string> = {
failed: { ok: 'var(--success)',
color: 'var(--error)', warn: 'var(--warning)',
bg: 'var(--error-soft)', err: 'var(--error)',
Icon: X, info: 'var(--info)',
label: 'Failed', off: 'var(--text-tertiary)',
glow: 'rgba(255, 107, 91, 0.4)',
},
stopped: {
color: 'var(--text-muted)',
bg: 'var(--surface-muted)',
Icon: Box,
label: 'Stopped',
},
unknown: {
color: 'var(--text-muted)',
bg: 'var(--surface-muted)',
Icon: HelpCircle,
label: 'Unknown',
},
}; };
export function StatusBadge({ export function StatusBadge({
@@ -62,36 +49,26 @@ export function StatusBadge({
const { Icon } = config; const { Icon } = config;
const sizeClasses = { const sizeClasses = {
sm: 'px-2 py-0.5 text-[10px] gap-1.5', sm: 'px-2 py-[2px] text-[10px] gap-1.5',
md: 'px-2.5 py-1 text-xs gap-1.5', md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5',
lg: 'px-3 py-1.5 text-sm gap-2', lg: 'px-2.5 py-1 text-[11.5px] gap-1.5',
}; };
const iconSizes = { const iconSizes = { sm: 10, md: 11, lg: 12 };
sm: 10,
md: 12,
lg: 14,
};
return ( return (
<div <div
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-200 ${sizeClasses[size]} ${className}`} className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[config.tone]} ${className}`}
style={{
background: config.bg,
color: config.color,
borderColor: `${config.color}20`,
boxShadow: status === 'running' && pulse ? `0 0 12px ${config.glow}` : 'none',
}}
> >
{status === 'running' && pulse && ( {status === 'running' && pulse && (
<span <span
className="w-1.5 h-1.5 rounded-full live-pulse" className="w-1.5 h-1.5 rounded-full live-pulse"
style={{ background: config.color, boxShadow: `0 0 6px ${config.color}` }} style={{ background: TONE_DOT[config.tone] }}
/> />
)} )}
<Icon <Icon
size={iconSizes[size]} size={iconSizes[size]}
className={config.animate && status === 'building' ? 'animate-spin' : ''} className={config.animate ? 'animate-spin' : ''}
/> />
{showLabel && <span>{config.label}</span>} {showLabel && <span>{config.label}</span>}
</div> </div>
@@ -106,27 +83,19 @@ interface LiveIndicatorProps {
export function LiveIndicator({ isLive, label, size = 'md' }: LiveIndicatorProps) { export function LiveIndicator({ isLive, label, size = 'md' }: LiveIndicatorProps) {
const sizeClasses = { const sizeClasses = {
sm: 'px-2 py-0.5 text-[10px] gap-1.5', sm: 'px-2 py-[2px] text-[10px] gap-1.5',
md: 'px-2.5 py-1 text-xs gap-2', md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5',
lg: 'px-3 py-1.5 text-sm gap-2', lg: 'px-2.5 py-1 text-[11.5px] gap-1.5',
}; };
const tone: PillTone = isLive ? 'ok' : 'off';
return ( return (
<div <div
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-300 ${sizeClasses[size]}`} className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[tone]}`}
style={{
background: isLive ? 'var(--success-soft)' : 'var(--surface-muted)',
color: isLive ? 'var(--success)' : 'var(--text-muted)',
borderColor: isLive ? 'rgba(61, 214, 140, 0.2)' : 'rgba(255, 255, 255, 0.05)',
boxShadow: isLive ? '0 0 12px rgba(61, 214, 140, 0.3)' : 'none',
}}
> >
<span <span
className={`w-1.5 h-1.5 rounded-full ${isLive ? 'live-pulse' : ''}`} className={`w-1.5 h-1.5 rounded-full ${isLive ? 'live-pulse' : ''}`}
style={{ style={{ background: TONE_DOT[tone] }}
background: isLive ? 'var(--success)' : 'var(--text-muted)',
boxShadow: isLive ? '0 0 6px var(--success)' : 'none',
}}
/> />
{label || (isLive ? 'Active' : 'Stopped')} {label || (isLive ? 'Active' : 'Stopped')}
</div> </div>
@@ -12,3 +12,4 @@ export { useToast } from '../hooks/use-toast';
export type { ToastType, ToastContextValue } from '../hooks/use-toast'; export type { ToastType, ToastContextValue } from '../hooks/use-toast';
export * from './BrandWordmark'; export * from './BrandWordmark';
export * from './FilterBar'; export * from './FilterBar';
export * from './sentry';
@@ -0,0 +1,18 @@
/* Non-component helpers for the Sentry kit — kept out of sentry.tsx so the
component file stays fast-refresh clean. */
export type PillTone = 'ok' | 'warn' | 'err' | 'off' | 'info';
export function statusTone(status: string): PillTone {
const s = status.toLowerCase();
if (['running', 'healthy', 'online', 'active', 'deployed', 'succeeded', 'success', 'live'].includes(s)) return 'ok';
if (['degraded', 'warning', 'building', 'deploying', 'pending', 'scheduled', 'queued', 'sleeping'].includes(s)) return 'warn';
if (['failed', 'critical', 'error', 'offline', 'stopped'].includes(s)) return s === 'stopped' || s === 'offline' ? 'off' : 'err';
return 'info';
}
/* CSS var color helper for charts. */
export function cssVar(name: string, fallback = ''): string {
if (typeof window === 'undefined') return fallback;
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
@@ -0,0 +1,372 @@
import { useState, type ReactNode } from 'react';
import { ChevronDown, MoreHorizontal } from 'lucide-react';
import type { PillTone } from './sentry-utils';
export type { PillTone };
/* ============================================================
Sentry kit — React bindings for the 23-sentry.html language.
Every page composes these; no page invents its own chrome.
============================================================ */
export function Ibox({ children }: { children: ReactNode }) {
return <span className="s-ibox">{children}</span>;
}
/* ---------- buttons ---------- */
type BtnProps = {
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
title?: string;
className?: string;
type?: 'button' | 'submit';
};
export function GhostBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
return (
<button type={type} className={`s-btn-accent ${className}`} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
);
}
export function QuietBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
return (
<button type={type} className={`s-btn-quiet ${className}`} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
);
}
export function IconBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
return (
<button type={type} className={`s-icon-btn ${className}`} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
);
}
/* Filter/sort chip with trailing chevron — the `.sel` in sentry. */
export function SelectChip({ children, onClick, title, icon }: BtnProps & { icon?: ReactNode }) {
return (
<button className="s-chip" onClick={onClick} title={title}>
{icon}
{children}
<ChevronDown size={10} style={{ opacity: 0.55 }} />
</button>
);
}
/* Segmented icon buttons — chart-type switchers. */
export function Seg({ options, value, onChange }: {
options: { key: string; icon: ReactNode; title?: string }[];
value: string;
onChange: (key: string) => void;
}) {
return (
<span className="s-seg">
{options.map((o) => (
<button
key={o.key}
className={o.key === value ? 'on' : ''}
onClick={() => onChange(o.key)}
title={o.title}
>
{o.icon}
</button>
))}
</span>
);
}
/* ---------- stat card ---------- */
export function SStat({ icon, label, value, unit, foot, delta, extra, onMenu }: {
icon: ReactNode;
label: string;
value: ReactNode;
unit?: string;
foot: ReactNode;
delta?: { dir: 'up' | 'down'; text: string };
extra?: ReactNode;
onMenu?: () => void;
}) {
return (
<div className="s-stat">
<div className="flex items-center gap-2.5 px-4 pt-4">
<Ibox>{icon}</Ibox>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)] whitespace-nowrap">{label}</span>
<button
className="ml-auto text-[var(--text-tertiary)] hover:text-[var(--text-primary)] leading-none"
onClick={onMenu}
title="More"
>
<MoreHorizontal size={14} />
</button>
</div>
<div className="flex items-baseline gap-1.5 px-4 mt-4">
<span className="font-headline text-[34px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{value}
</span>
{unit && <span className="text-[12px] font-medium text-[var(--text-tertiary)]">{unit}</span>}
</div>
{extra && <div className="px-4 mt-3">{extra}</div>}
<div className="s-stat-foot mt-3.5">
<span>{foot}</span>
{delta && <SDelta dir={delta.dir}>{delta.text}</SDelta>}
</div>
</div>
);
}
export function SDelta({ dir, children }: { dir: 'up' | 'down'; children: ReactNode }) {
return (
<span className={`s-delta ${dir}`}>
{dir === 'up' ? '▲' : '▼'}{children}
</span>
);
}
/* ---------- card ---------- */
export function SCard({ icon, title, accent, trail, children, pad = true, className = '' }: {
icon?: ReactNode;
title?: ReactNode;
accent?: ReactNode;
trail?: ReactNode;
children: ReactNode;
pad?: boolean;
className?: string;
}) {
return (
<div className={`s-card ${className}`}>
{(title !== undefined || icon) && (
<div className="s-cardhead">
{icon && <Ibox>{icon}</Ibox>}
<span className="s-t">{title}{accent && <> <span className="s-ta">{accent}</span></>}</span>
{trail && <span className="s-trail">{trail}</span>}
</div>
)}
<div className={pad ? 'px-4 pb-4' : ''}>{children}</div>
</div>
);
}
/* ---------- ticks / bars ---------- */
export function Ticks({ pct, count = 56, warn = false }: { pct: number; count?: number; warn?: boolean }) {
const on = Math.round((Math.max(0, Math.min(100, pct)) / 100) * count);
return (
<span className="s-ticks" role="img" aria-label={`${pct}%`}>
{Array.from({ length: count }, (_, i) => (
<i key={i} className={i < on ? (warn ? 'warn' : 'on') : ''} />
))}
</span>
);
}
/* Ascending mini bar glyph used inside table metric cells. */
export function MiniBars({ tone = 'var(--accent-primary)' }: { tone?: string }) {
return (
<span className="inline-flex items-end gap-[1.5px] h-3" aria-hidden>
{[9, 5, 12, 7].map((h, i) => (
<i key={i} className="w-[2.5px] rounded-[0.5px]" style={{ height: h, background: tone }} />
))}
</span>
);
}
/* ---------- status pill ---------- */
const PILL_CLS: Record<PillTone, string> = {
ok: 'text-[var(--success)] bg-[var(--success-soft)] border-[color-mix(in_srgb,var(--success)_16%,transparent)]',
warn: 'text-[var(--warning)] bg-[var(--warning-soft)] border-[color-mix(in_srgb,var(--warning)_16%,transparent)]',
err: 'text-[var(--error)] bg-[var(--error-soft)] border-[color-mix(in_srgb,var(--error)_16%,transparent)]',
info: 'text-[var(--info)] bg-[var(--info-soft)] border-[color-mix(in_srgb,var(--info)_16%,transparent)]',
off: 'text-[var(--text-tertiary)] bg-[var(--tint-06)] border-[var(--border-subtle)]',
};
export function SPill({ tone, children }: { tone: PillTone; children: ReactNode }) {
return (
<span className={`inline-block text-[10.5px] font-medium rounded-[5px] px-2 py-[3.5px] border ${PILL_CLS[tone]}`}>
{children}
</span>
);
}
/* ---------- inset callout ---------- */
export function SAnomaly({ icon, title, items }: { icon?: ReactNode; title: ReactNode; items: ReactNode[] }) {
return (
<div className="s-inset">
<div className="flex items-center gap-2 pb-2 mb-2 border-b border-[var(--border-subtle)] text-[11.5px] text-[var(--text-secondary)]">
{icon}
<span>{title}</span>
</div>
{items.map((it, i) => (
<div key={i} className="s-anomaly-item">{it}</div>
))}
</div>
);
}
/* ---------- data table (inset header bar, sortable) ---------- */
export type SCol<T> = {
key: string;
label: ReactNode;
width?: string;
sortable?: boolean;
sortValue?: (row: T) => string | number;
render: (row: T) => ReactNode;
};
const CARET = (
<svg viewBox="0 0 7 10" width="7" height="10" className="shrink-0">
<path d="M3.5 .8 5.8 3.6H1.2zM3.5 9.2 1.2 6.4h4.6z" fill="currentColor" opacity="0.45" />
</svg>
);
export function STable<T extends { id?: string | number }>({ cols, rows, rowKey, selectable, onRowClick }: {
cols: SCol<T>[];
rows: T[];
rowKey?: (row: T, i: number) => string | number;
selectable?: boolean;
onRowClick?: (row: T) => void;
}) {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<1 | -1>(1);
const [checked, setChecked] = useState<Set<number>>(new Set());
const allCols: SCol<T>[] = selectable
? [{ key: '$sel', label: '', width: '30px', render: () => null }, ...cols]
: cols;
const template = allCols.map((c) => c.width ?? '1fr').join(' ');
const sorted = [...rows];
const sc = cols.find((c) => c.key === sortKey);
if (sc?.sortValue) {
sorted.sort((a, b) => {
const x = sc.sortValue!(a); const y = sc.sortValue!(b);
return (x > y ? 1 : x < y ? -1 : 0) * sortDir;
});
}
const toggleAll = () =>
setChecked(checked.size === rows.length ? new Set() : new Set(rows.map((_, i) => i)));
return (
<div className="s-dt pb-2.5">
<div className="s-dt-head" style={{ gridTemplateColumns: template }}>
{allCols.map((c) =>
c.key === '$sel' ? (
<span key="$sel" className="s-dt-h">
<span className={`s-ck ${checked.size === rows.length && rows.length > 0 ? 'on' : ''}`} onClick={toggleAll} />
</span>
) : (
<span
key={c.key}
className={`s-dt-h ${c.sortable !== false ? 'sortable' : ''} ${sortKey === c.key ? 'on' : ''}`}
onClick={() => {
if (c.sortable === false) return;
setSortDir(sortKey === c.key ? (sortDir === 1 ? -1 : 1) : 1);
setSortKey(c.key);
}}
>
{c.label}{c.sortable !== false && CARET}
</span>
),
)}
</div>
{sorted.map((row, i) => {
const key = rowKey ? rowKey(row, i) : row.id ?? i;
const ri = rows.indexOf(row);
return (
<div
key={key}
className={`s-dt-row ${onRowClick ? 'clickable' : ''}`}
style={{ gridTemplateColumns: template }}
onClick={() => onRowClick?.(row)}
>
{allCols.map((c) =>
c.key === '$sel' ? (
<span key="$sel">
<span
className={`s-ck ${checked.has(ri) ? 'on' : ''}`}
onClick={(e) => {
e.stopPropagation();
setChecked((prev) => {
const n = new Set(prev);
if (n.has(ri)) n.delete(ri); else n.add(ri);
return n;
});
}}
/>
</span>
) : (
<span key={c.key} className="min-w-0">{c.render(row)}</span>
),
)}
</div>
);
})}
</div>
);
}
/* ---------- page header (Welcome back … + actions) ---------- */
export function SPageHead({ title, titleAccent, sub, subAccent, actions }: {
title: ReactNode;
titleAccent?: ReactNode;
sub?: ReactNode;
subAccent?: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="flex items-center gap-4 mb-5 flex-wrap">
<div>
<h1 className="font-headline text-[24px] font-bold tracking-[-0.02em] text-[var(--text-primary)]">
{title}{titleAccent && <> <em className="not-italic text-[var(--accent-primary)]">{titleAccent}</em></>}
</h1>
{sub && (
<div className="text-[12px] text-[var(--text-tertiary)] mt-[3px]">
{sub}{subAccent && <> <b className="text-[var(--accent-primary)] font-semibold">{subAccent}</b></>}
</div>
)}
</div>
{actions && <div className="ml-auto flex items-center gap-2.5">{actions}</div>}
</div>
);
}
/* Section label — small caps with accent square. */
export function SSection({ children, right }: { children: ReactNode; right?: ReactNode }) {
return (
<div className="flex items-center justify-between mb-3 mt-1">
<div className="flex items-center gap-2 text-[10.5px] font-medium uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<span className="w-[5px] h-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
{children}
</div>
{right && <span className="v-mono text-[10.5px] text-[var(--text-muted)]">{right}</span>}
</div>
);
}
/* Legend row for charts. */
export function SLegend({ items }: { items: { color: string; label: string }[] }) {
return (
<div className="flex gap-5 px-4 pb-2.5 pt-0.5 text-[11.5px] font-medium text-[var(--text-secondary)]">
{items.map((it) => (
<span key={it.label}>
<i className="inline-block w-[7px] h-[7px] rounded-[1.5px] mr-2" style={{ background: it.color }} />
{it.label}
</span>
))}
</div>
);
}