mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e128b7460d | ||
|
|
5b1cf0402c | ||
|
|
9271d0e3ac |
No files matched your search
@@ -1,3 +1,11 @@
|
|||||||
|
> [!IMPORTANT]
|
||||||
|
> **This project is archived.** Containr's goal — Railway's workflow on servers
|
||||||
|
> you own — is being done better by [Ployz](https://github.com/getployz/ployz)
|
||||||
|
> (Apache-2.0 engine + AGPL dashboard, self-hostable). Use that. This repo stays
|
||||||
|
> up as a reference; the canvas implementation in
|
||||||
|
> `app/frontend/src/features/workspace/canvas/` (group frames, context menus,
|
||||||
|
> inferred edges with reasons) may still be worth a look.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./containr.svg" alt="Containr" width="120">
|
<img src="./containr.svg" alt="Containr" width="120">
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 |
@@ -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>
|
||||||
|
|||||||
@@ -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=<token>
|
Use it as a bearer token — e.g. <code className="text-[var(--text-secondary)]">CONTAINR_TOKEN=<token>
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 = {
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
<polyline points="3 6 5 6 21 6" />
|
||||||
padding: 20,
|
<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" />
|
||||||
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"/>
|
|
||||||
<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',
|
<circle cx="12" cy="12" r="10" />
|
||||||
padding: 20,
|
<line x1="2" y1="12" x2="22" y2="12" />
|
||||||
animationDelay: `${animationDelay}s`,
|
<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" />
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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"/>
|
|
||||||
<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"/>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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',
|
|
||||||
average: '#f2c94c',
|
|
||||||
warning: '#f2c94c',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Horizontal layout for cards like Active User
|
|
||||||
if (horizontal) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={cardRef}
|
|
||||||
className={`panel ${className}`}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
padding: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
opacity: isVisible ? 1 : 0,
|
opacity: isVisible ? 1 : 0,
|
||||||
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
|
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
|
||||||
transition: 'all 0.5s ease',
|
transition: 'all 0.5s ease',
|
||||||
}}
|
} as const;
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<div className="s-stat-foot">
|
||||||
|
<span
|
||||||
|
onClick={onClick}
|
||||||
|
style={{ cursor: onClick ? 'pointer' : 'default' }}
|
||||||
>
|
>
|
||||||
<div style={{
|
{statusText && status ? (
|
||||||
flex: 1,
|
<b style={{ color: statusColors[status], fontWeight: 600 }}>{statusText}</b>
|
||||||
padding: '20px 18px 18px 20px',
|
) : null}{' '}
|
||||||
display: 'flex',
|
{subtitle ?? 'Details'}
|
||||||
flexDirection: 'column',
|
</span>
|
||||||
}}>
|
{onClick ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
<button className="s-icon-btn !h-6 !w-6" onClick={onClick} title="Details">
|
||||||
<div className="card-icon">{icon}</div>
|
<ChevronRight size={11} />
|
||||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
);
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: 900,
|
if (horizontal) {
|
||||||
letterSpacing: '-1.5px',
|
return (
|
||||||
lineHeight: 1,
|
<div ref={cardRef} className={`s-stat ${className}`} style={{ ...reveal, display: 'flex' }}>
|
||||||
color: '#e8e9f0',
|
<div className="flex-1 px-4 pt-4 pb-2 flex flex-col">
|
||||||
}}>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="s-ibox">{icon}</span>
|
||||||
|
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 font-headline text-[30px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||||
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user