Overhaul: real metrics, Git integration UI, open-source hygiene, root cleanup

Backend:
- GET /services/:id/metrics with real Docker container stats
- DELETE /git/providers/:id and GET repo branches endpoint

Frontend:
- Service detail shows real telemetry or honest empty states
- Live status distinguishes idle/polling/reconnecting
- Git providers section in Settings; repo+branch pickers in Add Service
- Real logo, compact Projects header, canvas fitView fix
- index.css deduplicated (1725 -> 1384 lines)
- Fixed smoke tests, lint errors, ToastProvider/context unification

Repo hygiene:
- Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README
- Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore
- Removed legacy root src/internal/cmd and stale configs/docs/artifacts
- Moved guides and design references under docs/

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-15 18:59:40 +02:00
1 parent 355a97bab4
commit 9f3b196032
499 files changed
+4471 -94235

No files matched your search

+8 -5
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '@/shared/components';
import App from './App';
function renderApp(initialPath: string) {
@@ -16,9 +17,11 @@ function renderApp(initialPath: string) {
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialPath]}>
<App />
</MemoryRouter>
<ToastProvider>
<MemoryRouter initialEntries={[initialPath]}>
<App />
</MemoryRouter>
</ToastProvider>
</QueryClientProvider>,
);
}
@@ -29,14 +32,14 @@ describe('App smoke routes', () => {
expect(await screen.findByRole('heading', { name: /^projects$/i })).toBeInTheDocument();
expect(screen.getByText(/demo mode active/i)).toBeInTheDocument();
expect(screen.getByText(/visual topology mapping and real-time observability/i)).toBeInTheDocument();
expect(screen.getByText(/deploy, manage, and monitor containerized services/i)).toBeInTheDocument();
});
it('renders builds page in demo mode', async () => {
renderApp('/builds?demo=1');
expect(await screen.findByRole('heading', { name: /build pipeline/i })).toBeInTheDocument();
expect(screen.getByText(/offline/i)).toBeInTheDocument();
expect(screen.getByText(/polling/i)).toBeInTheDocument();
});
it('renders templates page in demo mode', async () => {
+277 -206
View File
@@ -1,22 +1,35 @@
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import {
FolderKanban,
Hammer,
LayoutTemplate,
ChartBar,
Users,
Settings,
BookOpen,
Container,
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Bell,
BookOpen,
ChartBar,
CheckCircle2,
Container,
FolderKanban,
LayoutTemplate,
LogOut,
Moon,
RefreshCw,
Search,
Zap,
Activity,
Settings,
Sun,
UploadCloud,
Users,
} from 'lucide-react';
import {
getUpgradeStatus,
listAuditLogs,
listBuilds,
pullUpgradeImage,
} from '@/lib/api-client';
import { signOutAuthSession } from '@/lib/auth-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { useToast } from '@/shared/components';
const navItems = [
{ label: 'Projects', href: '/projects', icon: FolderKanban },
{ label: 'Builds', href: '/builds', icon: Hammer },
{ label: 'Templates', href: '/templates', icon: LayoutTemplate },
{ label: 'Usage', href: '/usage', icon: ChartBar },
{ label: 'People', href: '/people', icon: Users },
@@ -24,240 +37,299 @@ const navItems = [
{ label: 'Docs', href: '/docs', icon: BookOpen },
];
// Developer/Demo pages
const demoNavItems = [
{ label: 'Metrics Demo', href: '/metrics-demo', icon: Activity },
{ label: 'Showcase', href: '/showcase', icon: Zap },
];
type ThemeMode = 'dark' | 'light';
function getInitialTheme(): ThemeMode {
if (typeof window === 'undefined') {
return 'dark';
}
return localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark';
}
export function PlatformShell() {
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const isDemoMode = new URLSearchParams(location.search).get('demo') === '1';
const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target);
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
const [notificationsOpen, setNotificationsOpen] = useState(false);
const isActiveRoute = (itemHref: string) =>
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const upgradeQuery = useQuery({
queryKey: ['upgrade-status'],
queryFn: getUpgradeStatus,
enabled: !isDemoMode,
});
const buildsQuery = useQuery({
queryKey: ['shell-build-notifications'],
queryFn: () => listBuilds({ page: 1, limit: 5 }),
enabled: !isDemoMode,
});
const auditQuery = useQuery({
queryKey: ['shell-audit-notifications'],
queryFn: () => listAuditLogs({ page: 1, limit: 5 }),
enabled: !isDemoMode,
});
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem('containr.theme', theme);
}, [theme]);
const pullMutation = useMutation({
mutationFn: pullUpgradeImage,
onSuccess: (status) => {
queryClient.invalidateQueries({ queryKey: ['upgrade-status'] });
showToast('success', 'Upgrade image pulled', status.digest || status.imageRef || status.message);
},
onError: (error) => {
showToast('error', 'Upgrade failed', error instanceof Error ? error.message : 'Unable to pull image');
},
});
const notifications = useMemo(() => {
const builds = (buildsQuery.data?.builds ?? []).map((build) => ({
id: `build-${build.id}`,
title: `${build.status} build`,
body: build.imageName || build.serviceId || build.id,
}));
const audits = (auditQuery.data ?? []).map((log) => ({
id: `audit-${log.id}`,
title: log.action,
body: `${log.resource}${log.resourceId ? ` / ${log.resourceId}` : ''}`,
}));
return [...builds, ...audits].slice(0, 6);
}, [auditQuery.data, buildsQuery.data?.builds]);
const signOut = async () => {
try {
await signOutAuthSession();
} finally {
queryClient.clear();
navigate('/auth/sign-in', { replace: true });
}
};
const isActiveRoute = (itemHref: string) =>
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
const userName = sessionQuery.data?.user.name ?? 'Account';
const userEmail = sessionQuery.data?.user.email ?? 'Local session';
const initials = userName
.split(' ')
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase() || 'C';
return (
<div className="app-shell min-h-screen">
{/* Ambient glow background */}
<div className="ambient-glow" />
<div className="flex min-h-screen relative">
{/* Desktop Sidebar - self.html exact match: 64px width */}
<aside
className="hidden md:flex shrink-0 flex-col items-center border-r"
style={{
width: '64px',
background: '#111217',
borderRight: '1px solid rgba(255,255,255,0.07)',
padding: '16px 0',
gap: '5px'
<div className="relative flex min-h-screen">
<aside
className="hidden h-screen shrink-0 flex-col border-r md:flex"
style={{
width: '232px',
background: 'var(--bg-base)',
borderRightColor: 'var(--border-subtle)',
}}
>
{/* Logo - exact self.html: 38px circular pink */}
<div
className="rounded-full flex items-center justify-center"
style={{
width: '38px',
height: '38px',
background: '#e8316a',
marginBottom: '14px'
}}
<NavLink
to={href('/projects')}
className="flex h-[58px] shrink-0 items-center gap-3 border-b px-4"
style={{ borderBottomColor: 'var(--border-subtle)' }}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
</svg>
</div>
{/* Nav Items - 40x40 icon-only */}
{navItems.map((item) => {
const isActive = isActiveRoute(item.href);
const Icon = item.icon;
return (
<NavLink
key={item.href}
to={href(item.href)}
className={`nav-item ${isActive ? 'active' : ''}`}
title={item.label}
>
<Icon size={18} />
</NavLink>
);
})}
{/* Separator */}
<div style={{ width: '32px', height: '1px', background: 'rgba(255,255,255,0.07)', margin: '8px 0' }} />
{/* Demo/Developer Items */}
{demoNavItems.map((item) => {
const isActive = isActiveRoute(item.href);
const Icon = item.icon;
return (
<NavLink
key={item.href}
to={href(item.href)}
className={`nav-item ${isActive ? 'active' : ''}`}
title={item.label}
>
<Icon size={18} />
</NavLink>
);
})}
{/* Spacer */}
<div className="flex-1" />
{/* Bottom icons - Settings */}
<NavLink to="/settings" className="nav-item" title="Settings">
<Settings size={18} />
<img src="/containr.svg" alt="Containr" className="h-9 w-9 rounded-[var(--radius-md)]" />
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--text-primary)]">Containr</p>
<p className="text-[11px] text-[var(--text-tertiary)]">Deploy platform</p>
</div>
</NavLink>
<NavLink to="/docs" className="nav-item" title="Help">
<BookOpen size={18} />
</NavLink>
{/* User Avatar - exact self.html: 34px */}
<div
className="rounded-full flex items-center justify-center cursor-pointer"
style={{
width: '34px',
height: '34px',
background: '#22233a',
fontSize: '11px',
fontWeight: 700,
color: '#9295a4',
marginTop: '4px',
letterSpacing: '-0.3px'
}}
title="User"
>
w.
<nav className="flex min-h-0 flex-1 flex-col gap-1 px-3 py-4">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = isActiveRoute(item.href);
return (
<NavLink
key={item.href}
to={href(item.href)}
className={`flex h-10 items-center gap-3 rounded-[var(--radius-md)] px-3 text-sm font-medium transition-colors ${
isActive
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]'
}`}
>
<Icon size={17} />
{item.label}
</NavLink>
);
})}
</nav>
<div className="shrink-0 border-t p-3" style={{ borderTopColor: 'var(--border-subtle)' }}>
<NavLink
to={href('/settings')}
className="mb-2 flex items-center gap-3 rounded-[var(--radius-md)] p-2 hover:bg-[var(--surface-muted)]"
>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--surface-card-hover)] text-xs font-semibold text-[var(--text-primary)]">
{initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{userName}</p>
<p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p>
</div>
</NavLink>
<div className="grid grid-cols-2 gap-2">
<NavLink
to={href('/settings')}
className="inline-flex h-9 items-center justify-center gap-2 rounded-[var(--radius-md)] border text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
style={{ borderColor: 'var(--border-subtle)' }}
>
<Settings size={14} />
Settings
</NavLink>
<button
type="button"
onClick={() => void signOut()}
className="inline-flex h-9 items-center justify-center gap-2 rounded-[var(--radius-md)] border text-xs font-medium text-[var(--error)] hover:bg-[var(--error-soft)]"
style={{ borderColor: 'var(--error-soft)' }}
>
<LogOut size={14} />
Sign out
</button>
</div>
</div>
</aside>
{/* Main Content */}
<div className="min-w-0 flex-1 relative z-10 flex flex-col min-h-screen overflow-hidden">
{/* Topbar - self.html exact match: 52px height */}
<header
className="shrink-0 flex items-center"
style={{
height: '52px',
background: '#111217',
borderBottom: '1px solid rgba(255,255,255,0.07)',
padding: '0 22px',
gap: '14px'
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
<header
className="hidden h-[58px] shrink-0 items-center border-b px-5 md:flex"
style={{
background: 'var(--bg-base)',
borderBottomColor: 'var(--border-subtle)',
}}
>
{/* Search Box */}
<div className="search-box">
<NavLink to={href('/projects')} className="mr-4 flex items-center gap-2 text-[var(--text-primary)]">
<div className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border-subtle)]">
<Container size={15} />
</div>
<span className="text-sm font-semibold">Containr</span>
</NavLink>
<div className="search-box max-w-[420px]">
<Search size={14} />
<input type="text" placeholder="Search logs..." />
<input type="text" placeholder="Search projects, builds, logs..." />
</div>
{/* Right side */}
<div className="ml-auto flex items-center" style={{ gap: '8px' }}>
<button
className="rounded-[9px] border bg-transparent text-[#9295a4] font-medium cursor-pointer"
style={{
height: '32px',
padding: '0 14px',
border: '1px solid rgba(255,255,255,0.1)',
fontSize: '13px',
fontFamily: 'inherit'
}}
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
style={{ borderColor: 'var(--border-subtle)' }}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
Support
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
</button>
<button
className="rounded-[9px] border-none flex items-center text-[#e8e9f0] font-medium cursor-pointer"
style={{
height: '32px',
padding: '0 14px',
background: 'rgba(255,255,255,0.08)',
fontSize: '13px',
fontFamily: 'inherit',
gap: '6px'
}}
<button
type="button"
onClick={() => pullMutation.mutate()}
disabled={isDemoMode || 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-white disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: '#e8316a' }}
title={upgradeQuery.data?.message || 'Pull latest configured image'}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="17 11 12 6 7 11"/>
<polyline points="17 18 12 13 7 18"/>
</svg>
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
Upgrade
</button>
<button
className="rounded-[9px] border flex items-center justify-center cursor-pointer bg-transparent"
style={{
width: '32px',
height: '32px',
border: '1px solid rgba(255,255,255,0.1)'
}}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="2" strokeLinecap="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</button>
<div className="relative">
<button
type="button"
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)]"
style={{ borderColor: 'var(--border-subtle)' }}
aria-label="Notifications"
>
<Bell size={15} />
{notifications.length > 0 ? (
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-[var(--accent-primary)]" />
) : null}
</button>
{notificationsOpen ? (
<div className="absolute right-0 top-10 z-50 w-80 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-3 shadow-2xl">
<div className="mb-2 flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p>
<p className="text-xs text-[var(--text-tertiary)]">Build and audit activity</p>
</div>
<button
type="button"
onClick={() => {
queryClient.invalidateQueries({ queryKey: ['shell-build-notifications'] });
queryClient.invalidateQueries({ queryKey: ['shell-audit-notifications'] });
}}
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
aria-label="Refresh notifications"
>
<RefreshCw size={13} />
</button>
</div>
<div className="space-y-2">
{notifications.length > 0 ? (
notifications.map((item) => (
<div key={item.id} className="rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3">
<p className="text-sm font-medium text-[var(--text-primary)]">{item.title}</p>
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
</div>
))
) : (
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-sm text-[var(--text-secondary)]">
<CheckCircle2 size={15} className="text-[var(--success)]" />
No recent events
</div>
)}
</div>
</div>
) : null}
</div>
</div>
</header>
{/* Mobile Header */}
<header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/70 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 gap-3">
<div
className="rounded-xl flex items-center justify-center"
style={{
width: '36px',
height: '36px',
background: '#e8316a'
}}
>
<Container size={16} className="text-white" />
<NavLink to={href('/projects')} 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-white">
<Container size={16} />
</div>
<span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span>
</div>
<div className="flex items-center gap-2">
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)] transition-all">
<Search size={16} />
</button>
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)] transition-all relative">
<Bell size={16} />
<div className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-[var(--accent-primary)]" />
</button>
</div>
</NavLink>
<button
type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
className="h-9 w-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)]"
>
{theme === 'dark' ? <Sun size={15} className="mx-auto" /> : <Moon size={15} className="mx-auto" />}
</button>
</div>
<nav className="flex gap-1 px-3 pb-3 overflow-x-auto scrollbar-hide">
<nav className="flex gap-1 overflow-x-auto px-3 pb-3">
{navItems.map((item) => {
const isActive = isActiveRoute(item.href);
const Icon = item.icon;
const isActive = isActiveRoute(item.href);
return (
<NavLink
key={item.href}
to={href(item.href)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all ${
className={`flex items-center gap-1.5 whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
isActive
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
}`}
>
<Icon size={14} />
{item.label}
</NavLink>
);
})}
{/* Demo items */}
{demoNavItems.map((item) => {
const isActive = isActiveRoute(item.href);
const Icon = item.icon;
return (
<NavLink
key={item.href}
to={href(item.href)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all ${
isActive
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]'
}`}
>
<Icon size={14} />
@@ -268,7 +340,6 @@ export function PlatformShell() {
</nav>
</header>
{/* Page Content - scrollable */}
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
+40 -84
View File
@@ -1,16 +1,14 @@
import { useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { AlertCircle, ArrowRight, Github, Gitlab, KeyRound, Loader2, Mail, Shield, User2, Workflow } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, ArrowRight, Github, Loader2, Mail, User2 } from 'lucide-react';
import {
AuthError,
startBitbucketSignIn,
startGitLabSignIn,
requestMagicLinkInvite,
getAuthBootstrap,
startGoogleSignIn,
signInWithEmail,
signUpWithEmail,
startGiteaSignIn,
startGitHubSignIn,
} from '@/lib/auth-client';
import { useAuthSession } from '@/lib/use-auth-session';
@@ -48,7 +46,7 @@ function AuthCanvas() {
Better Auth
</h1>
<p className="mt-4 max-w-md text-sm leading-relaxed text-[var(--text-secondary)]">
Email/password, invite magic links, and OAuth providers (GitHub, GitLab, Bitbucket, Gitea) are handled by a dedicated Better Auth service with cookie sessions.
Email/password plus GitHub and Google sign-in run through secure cookie sessions. First account bootstraps platform ownership; every later user is created from inside Containr.
</p>
</div>
@@ -132,18 +130,24 @@ export function SignInPage() {
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
const sessionQuery = useAuthSession();
const bootstrapQuery = useQuery({
queryKey: ['auth-bootstrap'],
queryFn: getAuthBootstrap,
});
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [magicEmail, setMagicEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isMagicLoading, setIsMagicLoading] = useState(false);
if (sessionQuery.data) {
return <Navigate to={redirectPath} replace />;
}
if (bootstrapQuery.data?.mode === 'register') {
return <Navigate to={`/auth/sign-up?redirect=${encodeURIComponent(redirectPath)}`} replace />;
}
const submitEmailPassword = async (event: FormEvent) => {
event.preventDefault();
setError(null);
@@ -162,48 +166,32 @@ export function SignInPage() {
}
};
const submitMagicLink = async (event: FormEvent) => {
event.preventDefault();
const signInWithGitHubProvider = async () => {
setError(null);
setInfo(null);
setIsMagicLoading(true);
try {
await requestMagicLinkInvite(magicEmail.trim(), buildOAuthCallbackURL(redirectPath));
setInfo('Magic invite link sent. Check your email inbox.');
await startGitHubSignIn(buildOAuthCallbackURL(redirectPath));
} catch (exception) {
const message = exception instanceof AuthError ? exception.message : 'Failed to send magic link';
const message = exception instanceof AuthError ? exception.message : 'GitHub sign-in failed';
setError(message);
} finally {
setIsMagicLoading(false);
}
};
const signInWithGitHubProvider = async () => {
const signInWithGoogleProvider = async () => {
setError(null);
await startGitHubSignIn(buildOAuthCallbackURL(redirectPath));
};
const signInWithGiteaProvider = async () => {
setError(null);
await startGiteaSignIn(buildOAuthCallbackURL(redirectPath));
};
const signInWithGitLabProvider = async () => {
setError(null);
await startGitLabSignIn(buildOAuthCallbackURL(redirectPath));
};
const signInWithBitbucketProvider = async () => {
setError(null);
await startBitbucketSignIn(buildOAuthCallbackURL(redirectPath));
try {
await startGoogleSignIn(buildOAuthCallbackURL(redirectPath));
} catch (exception) {
const message = exception instanceof AuthError ? exception.message : 'Google sign-in failed';
setError(message);
}
};
return (
<AuthLayout>
<AuthCard title="Sign In" subtitle="Use your Containr account, invite magic link, or provider OAuth.">
<AuthCard title="Sign In" subtitle="Use email/password, GitHub, or Google.">
{error ? <AuthErrorNotice message={error} /> : null}
{info ? <AuthInfoNotice message={info} /> : null}
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null}
<form className="space-y-3" onSubmit={submitEmailPassword}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
@@ -256,58 +244,17 @@ export function SignInPage() {
</button>
<button
type="button"
onClick={() => void signInWithGitLabProvider()}
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)]"
>
<Gitlab size={15} />
GitLab
</button>
<button
type="button"
onClick={() => void signInWithBitbucketProvider()}
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)]"
>
<Workflow size={15} />
Bitbucket
</button>
<button
type="button"
onClick={() => void signInWithGiteaProvider()}
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)]"
>
<Shield size={15} />
Gitea
<span className="text-sm font-semibold">G</span>
Google
</button>
</div>
<form className="mt-4 space-y-2" onSubmit={submitMagicLink}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Magic Link Invite
<input
type="email"
autoComplete="email"
value={magicEmail}
onChange={(event) => setMagicEmail(event.target.value)}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="[email protected]"
/>
</label>
<button
type="submit"
disabled={isMagicLoading}
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)] disabled:opacity-60"
>
{isMagicLoading ? <Loader2 size={15} className="animate-spin" /> : <KeyRound size={15} />}
Send Invite Link
</button>
</form>
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span>No account yet?</span>
<Link to={`/auth/sign-up?redirect=${encodeURIComponent(redirectPath)}`} className="inline-flex items-center gap-1 text-[var(--accent-primary)] hover:underline">
Create one <ArrowRight size={12} />
</Link>
<span>Need access?</span>
<span>Ask platform owner to create account.</span>
</div>
</AuthCard>
</AuthLayout>
@@ -321,6 +268,10 @@ export function SignUpPage() {
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
const sessionQuery = useAuthSession();
const bootstrapQuery = useQuery({
queryKey: ['auth-bootstrap'],
queryFn: getAuthBootstrap,
});
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -331,6 +282,10 @@ export function SignUpPage() {
return <Navigate to={redirectPath} replace />;
}
if (bootstrapQuery.data?.mode === 'login') {
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
}
const submitSignUp = async (event: FormEvent) => {
event.preventDefault();
setError(null);
@@ -350,8 +305,9 @@ export function SignUpPage() {
return (
<AuthLayout>
<AuthCard title="Create Account" subtitle="Provision your operator account with email/password auth.">
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap.">
{error ? <AuthErrorNotice message={error} /> : null}
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
<form className="space-y-3" onSubmit={submitSignUp}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
@@ -126,8 +126,11 @@ export function BuildsPage() {
[isDemoMode, buildsQuery.data?.builds],
);
const subscribedBuildIds = useMemo(() => builds.map((build) => build.id), [builds]);
const liveConnected = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
const subscribedBuildIds = useMemo(
() => (isDemoMode ? [] : builds.map((build) => build.id)),
[isDemoMode, builds],
);
const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
if (selectedBuild && channel === `build:${selectedBuild.id}`) {
@@ -149,9 +152,6 @@ export function BuildsPage() {
queryFn: () => getBuildLogs(selectedBuild!.id),
});
const isCancellingBuild = (buildId: string): boolean =>
cancelMutation.isPending && cancelMutation.variables === buildId;
// Stats
const stats = useMemo(() => {
const running = builds.filter(b => b.status === 'running' || b.status === 'pending').length;
@@ -173,9 +173,9 @@ export function BuildsPage() {
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm">
<div className={`w-2 h-2 rounded-full ${liveConnected ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={liveConnected ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
{liveConnected ? 'Live' : 'Offline'}
<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)]'}>
{liveStatus === 'live' ? 'Live' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling'}
</span>
</div>
<button
@@ -226,41 +226,36 @@ export function ProjectsPage() {
return (
<div className="min-h-screen relative">
{/* Hero Section - Premium ambient design */}
<div className="relative overflow-hidden border-b border-[var(--border-subtle)]">
{/* Solid ambient background */}
<div className="absolute inset-0 bg-[#e8316a]/5" />
<div className="absolute top-0 left-1/4 w-96 h-96 bg-[var(--accent-primary)]/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-[var(--accent-secondary)]/5 rounded-full blur-3xl" />
<div className="relative mx-auto w-full max-w-[1400px] px-6 py-12 md:py-16 lg:py-20">
<div className="max-w-2xl">
<div className="flex items-center gap-3 mb-4">
<div
className="flex items-center justify-center rounded-xl shadow-lg ring-1 ring-white/10"
style={{ width: '44px', height: '44px', background: '#e8316a' }}
>
<FolderOpen size={20} className="text-white" />
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="w-1.5 h-1.5 rounded-full bg-[var(--success)] live-pulse" />
<span className="text-[10px] font-semibold uppercase tracking-widest text-[var(--text-tertiary)]">
Workspace
</span>
</div>
{/* Header */}
<div className="border-b border-[var(--border-subtle)]">
<div className="mx-auto w-full max-w-[1400px] px-6 py-5 flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="flex items-center justify-center rounded-[var(--radius-md)]"
style={{ width: '36px', height: '36px', background: '#e8316a' }}
>
<FolderOpen size={17} className="text-white" />
</div>
<h1 className="font-headline text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-[var(--text-primary)]">
Projects
</h1>
<p className="mt-4 text-lg text-[var(--text-secondary)] max-w-xl leading-relaxed">
Deploy, manage, and monitor your containerized services with visual topology mapping and real-time observability.
</p>
<div>
<h1 className="font-headline text-xl font-semibold tracking-tight text-[var(--text-primary)]">
Projects
</h1>
<p className="text-xs text-[var(--text-tertiary)]">
Deploy, manage, and monitor containerized services
</p>
</div>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="w-1.5 h-1.5 rounded-full bg-[var(--success)] live-pulse" />
<span className="text-[10px] font-semibold uppercase tracking-widest text-[var(--text-tertiary)]">
Workspace
</span>
</div>
</div>
</div>
{/* Main Content */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-8">
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<div className="relative flex-1">
+531 -53
View File
@@ -3,9 +3,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {
getApiBaseUrl,
getAgentPublicBaseUrl,
getHostMonitoring,
createUser,
createGitProvider,
deleteGitProvider,
getCurrentUserProfile,
listAuditLogs,
listAgents,
listBuilds,
listGitProviders,
listProjects,
listTemplates,
updateCurrentUserProfile,
@@ -21,6 +28,7 @@ import {
FileText,
Settings,
User,
UserPlus,
Key,
Database,
RefreshCw,
@@ -34,6 +42,11 @@ import {
Box,
Terminal,
Radio,
Server,
HardDrive,
GitBranch,
Link2,
Unlink,
} from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
@@ -120,15 +133,51 @@ function endpointStateBadge(isLoading: boolean, isError: boolean): {
return { label: 'Available', toneClass: 'text-[var(--ok)]' };
}
function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
function formatUptime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) {
return '—';
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
if (days > 0) {
return `${days}d ${hours}h`;
}
return `${hours}h ${Math.floor((seconds % 3600) / 60)}m`;
}
export function UsagePage() {
const queryClient = useQueryClient();
const buildsQuery = useQuery({
queryKey: ['usage-builds'],
queryFn: () => listBuilds({ page: 1, limit: 100 }),
});
const hostQuery = useQuery({
queryKey: ['usage-host-monitoring'],
queryFn: getHostMonitoring,
refetchInterval: 15_000,
});
const agentsQuery = useQuery({
queryKey: ['usage-agents'],
queryFn: listAgents,
refetchInterval: 15_000,
});
const builds = buildsQuery.data?.builds ?? [];
const liveConnected = useBuildUpdates(
const liveStatus = useBuildUpdates(
builds.map((build) => build.id),
() => {
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
@@ -156,6 +205,15 @@ export function UsagePage() {
const avgBuildMinutes = averageBuildDurationMs / 60_000;
const activeBuildPressure = runningBuilds + pendingBuilds;
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
const host = hostQuery.data;
const agents = agentsQuery.data ?? [];
const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length;
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
const agentEndpoint = getAgentPublicBaseUrl();
const connectCommand = `CONTAINR_API_URL=${getApiBaseUrl().replace(/\/api\/v1$/, '')} \\
CONTAINR_AGENT_AUTH_TOKEN=<token> \\
containr-agent`;
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
@@ -197,9 +255,9 @@ export function UsagePage() {
/>
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="flex items-center gap-2 mb-6">
<div className={`w-2 h-2 rounded-full ${liveConnected ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={`text-sm ${liveConnected ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
{liveConnected ? 'Live sync active' : 'Offline'}
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
{liveStatus === 'live' ? 'Live sync active' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling for updates'}
</span>
</div>
@@ -216,28 +274,165 @@ export function UsagePage() {
<p className="text-sm text-[var(--error)]">Failed to load usage data</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Runtime Hours"
value={totalBuildHours > 0 ? `${totalBuildHours.toFixed(1)}h` : '—'}
description={runtimeBody}
icon={Clock}
color={completedDurationsMs.length > 0 ? 'success' : 'default'}
/>
<StatCard
title="Build Activity"
value={String(builds.length)}
description={buildActivityBody}
icon={Activity}
color={failedBuilds > 0 ? 'warning' : 'success'}
/>
<StatCard
title="Capacity"
value={String(activeBuildPressure)}
description={capacityBody}
icon={Gauge}
color={activeBuildPressure > 0 ? 'warning' : 'default'}
/>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Runtime Hours"
value={totalBuildHours > 0 ? `${totalBuildHours.toFixed(1)}h` : '—'}
description={runtimeBody}
icon={Clock}
color={completedDurationsMs.length > 0 ? 'success' : 'default'}
/>
<StatCard
title="Build Activity"
value={String(builds.length)}
description={buildActivityBody}
icon={Activity}
color={failedBuilds > 0 ? 'warning' : 'success'}
/>
<StatCard
title="Capacity"
value={String(activeBuildPressure)}
description={capacityBody}
icon={Gauge}
color={activeBuildPressure > 0 ? 'warning' : 'default'}
/>
</div>
<section className="panel p-6">
<div className="mb-5 flex items-center justify-between 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)]">
<Server size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Host Monitoring</h2>
<p className="text-xs text-[var(--text-tertiary)]">Autoscaling base signal from this Containr host.</p>
</div>
</div>
<button
onClick={() => {
void hostQuery.refetch();
void agentsQuery.refetch();
}}
className="inline-flex h-9 items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 text-sm"
>
<RefreshCw size={14} />
Refresh
</button>
</div>
{hostQuery.isError ? (
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
Host monitoring unavailable.
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<StatCard
title="Host"
value={host?.hostname || '—'}
description={host ? `${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}` : 'Loading host identity.'}
icon={Server}
color={host?.dockerAvailable ? 'success' : 'warning'}
/>
<StatCard
title="CPU Load"
value={host ? host.load.load1m.toFixed(2) : '—'}
description={host ? `${host.cpu.cores} cores · 5m ${host.load.load5m.toFixed(2)} · 15m ${host.load.load15m.toFixed(2)}` : 'Loading CPU load.'}
icon={Gauge}
color={host && host.load.load1m > host.cpu.cores ? 'warning' : 'default'}
/>
<StatCard
title="Memory"
value={host ? `${host.memory.usagePercent.toFixed(0)}%` : '—'}
description={host ? `${formatBytes(host.memory.used)} used · ${formatBytes(host.memory.available)} free` : 'Loading memory telemetry.'}
icon={Activity}
color={host && host.memory.usagePercent > 80 ? 'warning' : 'success'}
/>
<StatCard
title="Disk"
value={host ? `${host.storage.usagePercent.toFixed(0)}%` : '—'}
description={host ? `${formatBytes(host.storage.used)} used · ${formatBytes(host.storage.available)} free on ${host.storage.path}` : 'Loading disk telemetry.'}
icon={HardDrive}
color={host && host.storage.usagePercent > 80 ? 'warning' : 'success'}
/>
</div>
)}
</section>
<section className="panel p-6">
<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)]">
<Radio size={18} className="text-[var(--text-tertiary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Node Agents</h2>
<p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr]">
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
title="Registered"
value={String(agents.length)}
description={agentsQuery.isError ? 'Agent API unavailable.' : 'Nodes connected through token heartbeats.'}
icon={Radio}
color={agents.length > 0 ? 'success' : 'default'}
/>
<StatCard
title="Online"
value={String(onlineAgents)}
description="Online and connecting agents can receive queued commands."
icon={Activity}
color={onlineAgents > 0 ? 'success' : 'default'}
/>
<StatCard
title="Remote Memory"
value={formatBytes(totalAgentMemory)}
description={`${formatBytes(availableAgentMemory)} currently free across agents.`}
icon={Database}
color={totalAgentMemory > 0 ? 'success' : 'default'}
/>
</div>
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--border-subtle)]">
{agents.length > 0 ? (
agents.slice(0, 5).map((agent) => (
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{agent.name}</p>
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
</div>
<div className="text-right">
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
</div>
</div>
))
) : (
<div className="px-4 py-6 text-sm text-[var(--text-tertiary)]">
No remote agents registered yet.
</div>
)}
</div>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4">
<div className="mb-3 flex items-center gap-2">
<Terminal size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connect Node</span>
</div>
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{connectCommand}</pre>
<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>Set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKEN</span> to a value accepted by the backend.</p>
<p>Agent sends host resources, polls pending Docker commands, and reports command results.</p>
</div>
</div>
</div>
</section>
</div>
)}
</div>
@@ -246,10 +441,30 @@ export function UsagePage() {
}
export function PeoplePage() {
const queryClient = useQueryClient();
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
});
const [newUserName, setNewUserName] = useState('');
const [newUserEmail, setNewUserEmail] = useState('');
const [newUserPassword, setNewUserPassword] = useState('');
const createUserMutation = useMutation({
mutationFn: () =>
createUser({
name: newUserName.trim(),
email: newUserEmail.trim(),
password: newUserPassword,
}),
onSuccess: () => {
setNewUserName('');
setNewUserEmail('');
setNewUserPassword('');
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
queryClient.invalidateQueries({ queryKey: ['people-audit-logs'] });
},
});
const projectsQuery = useQuery({
queryKey: ['people-projects'],
@@ -312,28 +527,95 @@ export function PeoplePage() {
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading team data...</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Members"
value={profileQuery.data ? '1' : '—'}
description={membersBody}
icon={Users}
color={profileQuery.data ? 'success' : 'default'}
/>
<StatCard
title="Roles"
value={projectsQuery.data?.length ? String(projectsQuery.data.length) : '—'}
description={rolesBody}
icon={Shield}
color="default"
/>
<StatCard
title="Audit Trail"
value={auditLogsQuery.data ? String(auditLogsQuery.data.length) : '—'}
description={auditBody}
icon={FileText}
color={auditLogsQuery.data?.length ? 'success' : 'default'}
/>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Members"
value={profileQuery.data ? '1+' : '—'}
description={membersBody}
icon={Users}
color={profileQuery.data ? 'success' : 'default'}
/>
<StatCard
title="Roles"
value={projectsQuery.data?.length ? String(projectsQuery.data.length) : '—'}
description={rolesBody}
icon={Shield}
color="default"
/>
<StatCard
title="Audit Trail"
value={auditLogsQuery.data ? String(auditLogsQuery.data.length) : '—'}
description={auditBody}
icon={FileText}
color={auditLogsQuery.data?.length ? 'success' : 'default'}
/>
</div>
<section className="panel p-6">
<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)]">
<UserPlus size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Manual User Creation</h2>
<p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p>
</div>
</div>
<form
className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]"
onSubmit={(event) => {
event.preventDefault();
createUserMutation.mutate();
}}
>
<input
value={newUserName}
onChange={(event) => setNewUserName(event.target.value)}
required
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
placeholder="Name"
/>
<input
value={newUserEmail}
onChange={(event) => setNewUserEmail(event.target.value)}
type="email"
required
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
placeholder="[email protected]"
/>
<input
value={newUserPassword}
onChange={(event) => setNewUserPassword(event.target.value)}
type="password"
minLength={8}
required
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
placeholder="Temporary password"
/>
<button
type="submit"
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-white disabled:opacity-60"
style={{ background: '#e8316a' }}
>
{createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
Create
</button>
</form>
{createUserMutation.isError ? (
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
{createUserMutation.error instanceof Error ? createUserMutation.error.message : 'Failed to create user'}
</div>
) : null}
{createUserMutation.isSuccess ? (
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--success-soft)] px-4 py-3 text-sm text-[var(--success)]">
User created. Share credentials through secure channel.
</div>
) : null}
</section>
</div>
)}
</div>
@@ -341,6 +623,196 @@ export function PeoplePage() {
);
}
const gitProviderTypes = [
{ value: 'github', label: 'GitHub', tokenHint: 'Personal access token with repo scope' },
{ value: 'gitlab', label: 'GitLab', tokenHint: 'Personal access token with read_api scope' },
{ value: 'gitea', label: 'Gitea', tokenHint: 'Access token from your Gitea instance' },
{ value: 'bitbucket', label: 'Bitbucket', tokenHint: 'App password with repository read' },
] as const;
function GitProvidersSection() {
const queryClient = useQueryClient();
const providersQuery = useQuery({
queryKey: ['git-providers'],
queryFn: listGitProviders,
});
const [form, setForm] = useState({
name: 'github' as (typeof gitProviderTypes)[number]['value'],
displayName: '',
accessToken: '',
apiUrl: '',
});
const [formOpen, setFormOpen] = useState(false);
const createMutation = useMutation({
mutationFn: () =>
createGitProvider({
name: form.name,
display_name: form.displayName.trim() || gitProviderTypes.find((t) => t.value === form.name)!.label,
access_token: form.accessToken.trim(),
api_url: form.apiUrl.trim() || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
setForm({ name: 'github', displayName: '', accessToken: '', apiUrl: '' });
setFormOpen(false);
},
});
const deleteMutation = useMutation({
mutationFn: (providerId: string) => deleteGitProvider(providerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
},
});
const providers = providersQuery.data ?? [];
const selectedType = gitProviderTypes.find((t) => t.value === form.name)!;
return (
<section className="panel p-6">
<div className="flex items-center justify-between mb-4">
<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">
<GitBranch size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Git Providers</h2>
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
</div>
</div>
<button
onClick={() => setFormOpen((open) => !open)}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg transition-all"
style={{ background: '#e8316a' }}
>
<Link2 size={14} />
{formOpen ? 'Close' : 'Connect'}
</button>
</div>
{providersQuery.isLoading ? (
<div className="py-6 text-center">
<Loader2 size={20} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : providersQuery.isError ? (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
Failed to load providers
</div>
) : providers.length === 0 ? (
<div className="py-6 text-center">
<p className="text-sm text-[var(--text-secondary)]">No Git providers connected</p>
<p className="text-xs text-[var(--text-muted)] mt-1">
Connect GitHub, GitLab, Gitea, or Bitbucket to deploy services from source
</p>
</div>
) : (
<div className="space-y-2">
{providers.map((provider) => (
<div
key={provider.id}
className="flex items-center justify-between p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"
>
<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">
<GitBranch size={14} className="text-[var(--text-tertiary)]" />
</div>
<div>
<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>
</div>
</div>
<button
onClick={() => deleteMutation.mutate(provider.id)}
disabled={deleteMutation.isPending}
className="flex items-center gap-2 h-8 px-3 rounded-[var(--radius-sm)] border border-[var(--error-soft)] text-[var(--error)] text-xs font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
>
<Unlink size={12} />
Disconnect
</button>
</div>
))}
</div>
)}
{formOpen && (
<div className="mt-4 pt-4 border-t border-[var(--border-subtle)] space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Provider
</label>
<select
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value as typeof form.name }))}
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"
>
{gitProviderTypes.map((type) => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Display Name
</label>
<input
value={form.displayName}
onChange={(e) => setForm((p) => ({ ...p, displayName: 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"
placeholder={selectedType.label}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Access Token
</label>
<input
type="password"
value={form.accessToken}
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"
placeholder="••••••••••••••••"
/>
<p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p>
</div>
{form.name === 'gitea' && (
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Instance URL
</label>
<input
value={form.apiUrl}
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"
placeholder="https://gitea.example.com"
/>
</div>
)}
{createMutation.isError && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to connect provider'}
</div>
)}
<div className="flex justify-end pt-1">
<button
onClick={() => createMutation.mutate()}
disabled={!form.accessToken.trim() || createMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: '#e8316a' }}
>
{createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Connect Provider
</button>
</div>
</div>
)}
</section>
);
}
export function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -502,6 +974,9 @@ export function SettingsPage() {
)}
</section>
{/* Git Providers Section */}
<GitProvidersSection />
{/* Runtime & Local State Section */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
@@ -701,7 +1176,7 @@ export function DocsPage() {
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Generated Types</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">frontend/src/generated/api-types.ts</p>
<p className="mono text-xs text-[var(--text-primary)]">app/frontend/src/generated/api-types.ts</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
@@ -715,7 +1190,7 @@ export function DocsPage() {
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">UI References</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">docs/references/self.html</p>
<p className="mono text-xs text-[var(--text-primary)]">docs/references/dashboard.png • projects.png</p>
</div>
</div>
</section>
@@ -735,13 +1210,16 @@ export function DocsPage() {
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)]">
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">
{`# Regenerate frontend API types
npm --prefix frontend run generate:api
npm --prefix app/frontend run generate:api
# Frontend type-check + build
npm --prefix frontend run build:check
npm --prefix app/frontend run build:check
# Backend API tests
cd backend && go test ./internal/api/...`}
cd app/backend && go test ./internal/api/...
# Build remote node agent
cd app/backend && go build -o bin/containr-agent ./cmd/agent`}
</pre>
</div>
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
@@ -7,21 +7,18 @@ import {
getProjectById,
getDeploymentLogs,
getServiceById,
getServiceMetrics,
listDeployments,
listServiceLogs,
rollbackDeployment,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data';
import { formatDate, formatRelative, seededMetric } from '@/lib/time';
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
import {
ArrowLeft,
Activity,
FileText,
Settings,
Sliders,
Play,
RotateCw,
Trash2,
Check,
X,
Loader2,
@@ -171,23 +168,50 @@ export function ServiceDetailPage() {
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
const metricsQuery = useQuery({
queryKey: ['service-metrics', serviceId],
queryFn: () => getServiceMetrics(serviceId),
enabled: !isDemoMode && Boolean(serviceId),
refetchInterval: 5000,
});
// Accumulate live samples into a small history for the timeline charts.
const [metricHistory, setMetricHistory] = useState<{ cpu: number[]; memory: number[] }>({ cpu: [], memory: [] });
useEffect(() => {
const sample = metricsQuery.data;
if (!sample || sample.status !== 'ok') {
return;
}
const memPercent = sample.memory_limit_bytes > 0
? (sample.memory_usage_bytes / sample.memory_limit_bytes) * 100
: 0;
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
setMetricHistory((prev) => ({
cpu: [...prev.cpu, Math.min(100, sample.cpu_percent)].slice(-24),
memory: [...prev.memory, Math.min(100, memPercent)].slice(-24),
}));
}, [metricsQuery.data]);
const liveMetrics = isDemoMode ? null : metricsQuery.data ?? null;
const hasLiveTelemetry = Boolean(liveMetrics && liveMetrics.status === 'ok' && liveMetrics.instances.length > 0);
const runningInstances = liveMetrics?.instances.filter((instance) => instance.state === 'running').length ?? 0;
const memoryPercent = liveMetrics && liveMetrics.memory_limit_bytes > 0
? (liveMetrics.memory_usage_bytes / liveMetrics.memory_limit_bytes) * 100
: 0;
// Demo mode renders seeded values so the layout can be previewed without a backend.
const metricSet = useMemo(() => {
if (!service) {
if (!service || !isDemoMode) {
return null;
}
const cpu = seededMetric(`${service.id}:cpu`, 12, 78);
const memory = seededMetric(`${service.id}:mem`, 24, 91);
const req = seededMetric(`${service.id}:req`, 120, 5100);
const latency = seededMetric(`${service.id}:lat`, 17, 210);
return {
cpu,
memory,
req,
latency,
cpu: seededMetric(`${service.id}:cpu`, 12, 78),
memory: seededMetric(`${service.id}:mem`, 24, 91),
req: seededMetric(`${service.id}:req`, 120, 5100),
latency: seededMetric(`${service.id}:lat`, 17, 210),
};
}, [service]);
}, [service, isDemoMode]);
const deploymentSummary = useMemo(() => {
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
@@ -372,30 +396,29 @@ export function ServiceDetailPage() {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="CPU"
value={`${metricSet?.cpu ?? 0}%`}
hint="Current utilization"
value={isDemoMode ? `${metricSet?.cpu ?? 0}%` : hasLiveTelemetry ? `${liveMetrics!.cpu_percent.toFixed(1)}%` : '—'}
hint={isDemoMode ? 'Current utilization' : hasLiveTelemetry ? `${runningInstances} running container${runningInstances === 1 ? '' : 's'}` : 'No running containers'}
icon={Cpu}
trend={metricSet && metricSet.cpu > 60 ? 'up' : metricSet && metricSet.cpu < 30 ? 'down' : 'stable'}
trend={hasLiveTelemetry && liveMetrics!.cpu_percent > 60 ? 'up' : 'stable'}
/>
<MetricCard
label="Memory"
value={`${metricSet?.memory ?? 0}%`}
hint="Container footprint"
value={isDemoMode ? `${metricSet?.memory ?? 0}%` : hasLiveTelemetry ? formatBytes(liveMetrics!.memory_usage_bytes) : '—'}
hint={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${memoryPercent.toFixed(0)}% of ${formatBytes(liveMetrics!.memory_limit_bytes)}` : 'No running containers'}
icon={MemoryStick}
trend={metricSet && metricSet.memory > 70 ? 'up' : 'stable'}
trend={hasLiveTelemetry && memoryPercent > 70 ? 'up' : 'stable'}
/>
<MetricCard
label="Requests"
value={`${metricSet?.req ?? 0}`}
hint="Last 60 minutes"
label="Network"
value={isDemoMode ? `${metricSet?.req ?? 0}` : hasLiveTelemetry ? `${formatBytes(liveMetrics!.network_rx_bytes)}` : '—'}
hint={isDemoMode ? 'Requests, last 60 minutes' : hasLiveTelemetry ? `RX total · TX ${formatBytes(liveMetrics!.network_tx_bytes)}` : 'No traffic recorded'}
icon={Zap}
/>
<MetricCard
label="Latency"
value={`${metricSet?.latency ?? 0}ms`}
hint="P95 estimate"
label="Instances"
value={isDemoMode ? `${metricSet?.latency ?? 0}ms` : hasLiveTelemetry ? `${runningInstances}` : '0'}
hint={isDemoMode ? 'P95 latency estimate' : hasLiveTelemetry ? `${liveMetrics!.instances.length} container${liveMetrics!.instances.length === 1 ? '' : 's'} discovered` : 'Deploy to create containers'}
icon={Timer}
trend={metricSet && metricSet.latency > 150 ? 'up' : 'stable'}
/>
</div>
@@ -458,6 +481,20 @@ export function ServiceDetailPage() {
{activeSection === 'metrics' && (
<div className="space-y-6">
{/* Metrics Grid with enhanced cards */}
{!isDemoMode && !hasLiveTelemetry && (
<div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Activity size={22} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-base font-medium text-[var(--text-primary)]">No telemetry yet</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
{liveMetrics?.status === 'docker_unavailable'
? 'Docker is not reachable on this host, so container metrics cannot be collected.'
: 'Metrics appear once this service has running containers. Deploy it to start collecting.'}
</p>
</div>
)}
{(isDemoMode || hasLiveTelemetry) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="panel p-5 group hover:border-[var(--accent-primary)]/30 transition-all duration-300">
<div className="flex items-center justify-between mb-4">
@@ -467,28 +504,36 @@ export function ServiceDetailPage() {
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">CPU Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">Last 24 intervals</p>
<p className="text-xs text-[var(--text-tertiary)]">
{isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'}
</p>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-xs text-[var(--text-tertiary)]">Click to expand</span>
</div>
</div>
<div className="flex h-32 items-end gap-1">
{Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer group/bar"
style={{
height: `${value}%`,
background: '#ff7043',
}}
title={`${value}%`}
/>
);
})}
{isDemoMode
? Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: '#ff7043' }}
title={`${value}%`}
/>
);
})
: metricHistory.cpu.map((value, i) => (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: '#ff7043' }}
title={`${value.toFixed(1)}%`}
/>
))}
{!isDemoMode && metricHistory.cpu.length === 0 && (
<p className="self-center text-xs text-[var(--text-muted)]">Collecting first sample...</p>
)}
</div>
</div>
@@ -496,32 +541,77 @@ export function ServiceDetailPage() {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="card-icon">
<Zap size={18} />
<MemoryStick size={18} />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Request Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">Last 24 intervals</p>
<p className="text-sm font-medium text-[var(--text-primary)]">Memory Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">
{isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'}
</p>
</div>
</div>
</div>
<div className="flex h-32 items-end gap-1">
{Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:req:${i}`, 18, 96);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
style={{
height: `${value}%`,
background: '#3dd68c',
}}
title={`${value}%`}
/>
);
})}
{isDemoMode
? Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:req:${i}`, 18, 96);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: '#3dd68c' }}
title={`${value}%`}
/>
);
})
: metricHistory.memory.map((value, i) => (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: '#3dd68c' }}
title={`${value.toFixed(1)}%`}
/>
))}
{!isDemoMode && metricHistory.memory.length === 0 && (
<p className="self-center text-xs text-[var(--text-muted)]">Collecting first sample...</p>
)}
</div>
</div>
</div>
)}
{/* Instance list — real container states */}
{!isDemoMode && hasLiveTelemetry && (
<div className="panel p-5">
<div className="flex items-center gap-3 mb-4">
<div className="card-icon">
<Layers size={18} />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Instances</p>
<p className="text-xs text-[var(--text-tertiary)]">Containers backing this service</p>
</div>
</div>
<div className="space-y-2">
{liveMetrics!.instances.map((instance) => (
<div
key={instance.container_id}
className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]"
>
<div className="flex items-center gap-3 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${instance.state === 'running' ? 'bg-[var(--success)]' : 'bg-[var(--text-muted)]'}`} />
<span className="mono text-xs text-[var(--text-primary)] truncate">{instance.name}</span>
</div>
<div className="flex items-center gap-4 text-xs text-[var(--text-tertiary)] flex-shrink-0">
<span className="mono">{instance.cpu_percent.toFixed(1)}% CPU</span>
<span className="mono">{formatBytes(instance.memory_usage_bytes)}</span>
<span>{instance.started_at ? `up ${formatRelative(instance.started_at).replace(' ago', '')}` : instance.state}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Domain & Networking Panel */}
{service.status === 'running' && (
@@ -1,25 +1,21 @@
import { useMemo, useState, useEffect } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import {
Cpu,
MemoryStick,
Zap,
Users,
import { useParams, useNavigate } from 'react-router-dom';
import {
Cpu,
MemoryStick,
Zap,
Users,
Globe,
ArrowLeft,
Search,
Bell,
ChevronUp,
Activity,
FileText,
Sliders,
Settings as SettingsIcon
} from 'lucide-react';
import {
LineChart,
LineAreaChart,
DonutChart,
MultiLineChart,
import {
LineChart,
LineAreaChart,
DonutChart,
EnhancedMetricCard,
CacheMetricCard,
PerformanceMetricCard
@@ -49,7 +45,6 @@ const timePeriods = ['Day', 'Month', 'Year'];
export function ServiceMetricsDashboard() {
const { serviceId = 'demo-service' } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabKey>('metrics');
@@ -86,10 +81,10 @@ export function ServiceMetricsDashboard() {
{ data: perfData1, color: '#6c8ef0', fillOpacity: 0.15 },
{ data: perfData2, color: '#9c7ef0', fillOpacity: 0.15 }
],
upSpeed: (Math.random() * 5 + 8).toFixed(1),
downSpeed: (Math.random() * 3 + 4).toFixed(1),
upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1),
downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1),
};
}, [serviceId, timePeriod]);
}, [serviceId]);
// Auto-update metrics every 2 seconds
useEffect(() => {
@@ -14,11 +14,12 @@ import '@xyflow/react/dist/style.css';
import type { ServiceEntity } from '@/lib/api-client';
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
import {
DEFAULT_VIEWPORT,
type CanvasGroup,
type CanvasNodeLayout,
type ProjectCanvasMetadata,
} from '../model';
import { loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react';
@@ -152,6 +153,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const wrapperRef = useRef<HTMLDivElement | null>(null);
const persistTimeout = useRef<number | null>(null);
const hydratedRef = useRef(false);
const viewportRestoredRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
const [viewportTick, setViewportTick] = useState(0);
@@ -169,20 +171,34 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]);
useEffect(() => {
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
const metadata = loadCanvasMetadata(projectId, services);
const nextNodes = toFlowNodes(metadata, services, onOpenService);
viewportRestoredRef.current = false;
setNodes(nextNodes);
window.requestAnimationFrame(() => {
setViewport(metadata.viewport, { duration: 120 });
window.requestAnimationFrame(() => {
const isDefaultViewport =
metadata.viewport.x === DEFAULT_VIEWPORT.x &&
metadata.viewport.y === DEFAULT_VIEWPORT.y &&
metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom;
if (!hasStoredViewport || isDefaultViewport) {
void fitView({ padding: 0.15, duration: 120 });
} else {
void setViewport(metadata.viewport, { duration: 120 });
}
viewportRestoredRef.current = true;
});
});
hydratedRef.current = true;
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, services]);
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services]);
useEffect(() => {
if (!hydratedRef.current) {
if (!hydratedRef.current || !viewportRestoredRef.current) {
return;
}
@@ -414,7 +430,6 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
}}
onNodeDragStop={onNodeDragStop}
onMoveEnd={() => setViewportTick((value) => value + 1)}
fitView
panOnDrag
zoomOnScroll
minZoom={0.25}
@@ -4,6 +4,9 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
createService,
getProjectById,
listGitBranches,
listGitProviders,
listGitRepositories,
listServiceLogs,
listServiceVariables,
listServicesByProject,
@@ -31,6 +34,8 @@ import {
Sparkles,
Box,
Search,
GitBranch,
Container,
} from 'lucide-react';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
@@ -59,6 +64,44 @@ function ServiceCreateDialog(props: {
image: '',
command: '',
});
const [source, setSource] = useState<'image' | 'git'>('image');
const [providerId, setProviderId] = useState('');
const [repoFullName, setRepoFullName] = useState('');
const [repoSearch, setRepoSearch] = useState('');
const providersQuery = useQuery({
queryKey: ['git-providers'],
queryFn: listGitProviders,
enabled: props.open && source === 'git',
});
const repositoriesQuery = useQuery({
queryKey: ['git-repositories', providerId, repoSearch],
queryFn: () => listGitRepositories(providerId, repoSearch),
enabled: props.open && source === 'git' && Boolean(providerId),
});
const selectedRepo = useMemo(
() => (repositoriesQuery.data ?? []).find((repo) => repo.full_name === repoFullName) ?? null,
[repositoriesQuery.data, repoFullName],
);
const branchesQuery = useQuery({
queryKey: ['git-branches', providerId, repoFullName],
queryFn: () => {
const [owner, repo] = repoFullName.split('/');
return listGitBranches(providerId, owner, repo);
},
enabled: props.open && source === 'git' && Boolean(providerId) && Boolean(repoFullName),
});
const providers = providersQuery.data ?? [];
const repositories = repositoriesQuery.data ?? [];
const branches = branchesQuery.data ?? [];
const canSubmit =
Boolean(form.name.trim()) &&
(source === 'image' || Boolean(repoFullName));
if (!props.open) return null;
@@ -122,16 +165,169 @@ function ServiceCreateDialog(props: {
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
Source
</label>
<input
value={form.image ?? ''}
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"
placeholder="ghcr.io/org/app:latest"
/>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setSource('image')}
className={`flex items-center justify-center gap-2 h-10 rounded-[var(--radius-md)] border text-sm font-medium transition-colors ${
source === 'image'
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:border-[var(--border-default)]'
}`}
>
<Container size={14} />
Image
</button>
<button
type="button"
onClick={() => setSource('git')}
className={`flex items-center justify-center gap-2 h-10 rounded-[var(--radius-md)] border text-sm font-medium transition-colors ${
source === 'git'
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:border-[var(--border-default)]'
}`}
>
<GitBranch size={14} />
Git Repository
</button>
</div>
</div>
{source === 'image' ? (
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label>
<input
value={form.image ?? ''}
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"
placeholder="ghcr.io/org/app:latest"
/>
</div>
) : (
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Provider
</label>
<select
value={providerId}
onChange={(e) => {
setProviderId(e.target.value);
setRepoFullName('');
setForm((p) => ({ ...p, git_repo: '', git_branch: '' }));
}}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
>
<option value="">Select provider</option>
{providers.map((provider) => (
<option key={provider.id} value={provider.id}>{provider.display_name}</option>
))}
</select>
{!providersQuery.isLoading && providers.length === 0 && (
<p className="text-xs text-[var(--warn)] mt-1.5">
No providers connected — add one in Settings → Git Providers
</p>
)}
</div>
{providerId && (
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Repository
</label>
<input
value={repoSearch}
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"
placeholder="Search repositories..."
/>
{repositoriesQuery.isLoading ? (
<div className="py-3 text-center">
<Loader2 size={14} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : repositoriesQuery.isError ? (
<p className="text-xs text-[var(--error)] py-2">Failed to load repositories — check the provider token</p>
) : repositories.length === 0 ? (
<p className="text-xs text-[var(--text-muted)] py-2">No repositories found</p>
) : (
<div className="max-h-40 overflow-y-auto space-y-0.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-1">
{repositories.slice(0, 50).map((repo) => (
<button
key={repo.id}
type="button"
onClick={() => {
setRepoFullName(repo.full_name);
setForm((p) => ({
...p,
git_repo: repo.clone_url ?? repo.full_name,
git_branch: p.git_branch || repo.default_branch || '',
}));
}}
className={`w-full flex items-center justify-between px-3 py-2 rounded-[var(--radius-sm)] text-left text-sm transition-colors ${
repoFullName === repo.full_name
? 'bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-hover)]'
}`}
>
<span className="mono text-xs truncate">{repo.full_name}</span>
<span className="text-[10px] text-[var(--text-muted)] ml-2 shrink-0">{repo.default_branch}</span>
</button>
))}
</div>
)}
</div>
)}
{repoFullName && (
<>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Branch
</label>
{branchesQuery.isLoading ? (
<div className="py-2 text-center">
<Loader2 size={14} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : branches.length > 0 ? (
<select
value={form.git_branch ?? ''}
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"
>
<option value="">Select branch</option>
{branches.map((branch) => (
<option key={branch.name} value={branch.name}>{branch.name}</option>
))}
</select>
) : (
<input
value={form.git_branch ?? ''}
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"
placeholder={selectedRepo?.default_branch ?? 'main'}
/>
)}
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Build Path <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label>
<input
value={form.build_path ?? ''}
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"
placeholder="/ (repo root)"
/>
</div>
</>
)}
</div>
)}
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Command <span className="normal-case text-[var(--text-muted)]">(optional)</span>
@@ -159,8 +355,18 @@ function ServiceCreateDialog(props: {
Cancel
</button>
<button
disabled={!form.name.trim() || props.loading}
onClick={() => props.onSubmit({ ...form, name: form.name.trim(), image: form.image?.trim(), command: form.command?.trim() })}
disabled={!canSubmit || props.loading}
onClick={() =>
props.onSubmit({
...form,
name: form.name.trim(),
image: source === 'image' ? form.image?.trim() : '',
command: form.command?.trim(),
git_repo: source === 'git' ? form.git_repo?.trim() : '',
git_branch: source === 'git' ? form.git_branch?.trim() : '',
build_path: source === 'git' ? form.build_path?.trim() : '',
})
}
className="px-5 py-2 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: '#e8316a' }}
>
+77 -351
View File
@@ -84,6 +84,76 @@
--bg-0: var(--bg-void);
--bg-1: var(--bg-base);
--bg-2: var(--bg-surface);
--bg-primary: var(--bg-base);
}
:root[data-theme='light'] {
--bg-void: #f4f6fb;
--bg-base: #ffffff;
--bg-surface: #eef1f7;
--bg-elevated: #ffffff;
--bg-overlay: #ffffff;
--surface-card: #ffffff;
--surface-card-hover: #eef1f7;
--surface-muted: rgba(17, 18, 23, 0.05);
--surface-glass: rgba(255, 255, 255, 0.72);
--surface-base: #ffffff;
--text-primary: #151821;
--text-secondary: #4f5668;
--text-tertiary: #7b8498;
--text-muted: #7b8498;
--accent-primary: #d92d64;
--accent-primary-soft: rgba(217, 45, 100, 0.12);
--accent-primary-glow: rgba(217, 45, 100, 0.22);
--accent-secondary: #7357d8;
--accent-tertiary: #2f67d8;
--success: #168a55;
--success-soft: rgba(22, 138, 85, 0.12);
--success-glow: rgba(22, 138, 85, 0.2);
--warning: #c7511f;
--warning-soft: rgba(199, 81, 31, 0.12);
--warning-glow: rgba(199, 81, 31, 0.2);
--error: #c9362b;
--error-soft: rgba(201, 54, 43, 0.12);
--error-glow: rgba(201, 54, 43, 0.2);
--info: #2f67d8;
--info-soft: rgba(47, 103, 216, 0.12);
--border-subtle: rgba(21, 24, 33, 0.1);
--border-default: rgba(21, 24, 33, 0.16);
--border-strong: rgba(217, 45, 100, 0.35);
--border-glow: rgba(217, 45, 100, 0.45);
--shadow-sm: 0 1px 2px rgba(17, 18, 23, 0.08);
--shadow-md: 0 4px 12px rgba(17, 18, 23, 0.1);
--shadow-lg: 0 8px 30px rgba(17, 18, 23, 0.12);
--shadow-xl: 0 16px 48px rgba(17, 18, 23, 0.16);
--shadow-glow: 0 0 20px var(--accent-primary-glow);
--shadow-glow-lg: 0 0 40px var(--accent-primary-glow);
--text: var(--text-primary);
--text-dim: var(--text-secondary);
--line: var(--border-default);
--line-strong: var(--border-strong);
--accent: var(--accent-primary);
--accent-soft: var(--accent-primary-soft);
--ok: var(--success);
--warn: var(--warning);
--bad: var(--error);
--surface: var(--bg-elevated);
--surface-soft: var(--surface-card);
--surface-muted: var(--bg-surface);
--bg-0: var(--bg-void);
--bg-1: var(--bg-base);
--bg-2: var(--bg-surface);
--bg-primary: var(--bg-base);
}
* {
@@ -100,7 +170,7 @@ body,
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
color: var(--text-primary);
background: #16171c;
background: var(--bg-void);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -115,7 +185,7 @@ body {
.app-shell {
min-height: 100vh;
position: relative;
background: #16171c;
background: var(--bg-void);
}
/* Ambient glow effects for premium depth */
@@ -162,56 +232,6 @@ body {
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideOutRight {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(20px);
}
}
/* Animations from self.html */
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Live pulse animation for status indicators */
@keyframes livePulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.15);
}
}
.live-pulse {
animation: livePulse 2s ease-in-out infinite;
}
/* Subtle glow pulse for active elements */
@keyframes glowPulse {
0%, 100% {
@@ -226,22 +246,6 @@ body {
animation: glowPulse 3s ease-in-out infinite;
}
/* Slide in from right - for toasts and modals */
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slideInRight 0.3s ease-out;
}
/* Fade in scale - for modals and overlays */
@keyframes fadeInScale {
from {
@@ -270,28 +274,6 @@ body {
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideOutRight {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(20px);
}
}
a {
color: inherit;
text-decoration: none;
@@ -365,177 +347,6 @@ textarea {
border-radius: 16px;
}
/* Card icon - matching self.html */
.card-icon {
width: 34px;
height: 34px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.07);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-icon svg {
width: 16px;
height: 16px;
stroke: #9295a4;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Arrow button - matching self.html */
.arrow-btn {
width: 30px;
height: 30px;
border-radius: 9px;
background: #e8316a;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: box-shadow 0.15s, transform 0.15s;
}
.arrow-btn:hover {
transform: scale(1.07);
}
.arrow-btn svg {
width: 13px;
height: 13px;
stroke: white;
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Pill group - matching self.html */
.pill-group {
display: flex;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
overflow: hidden;
}
.pill {
padding: 5px 14px;
font-size: 12.5px;
font-weight: 500;
color: #6b6e7d;
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.pill.active {
background: rgba(255, 255, 255, 0.1);
color: #e8e9f0;
}
.pill:hover:not(.active) {
color: #9295a4;
}
/* Flag - matching self.html */
.flag {
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
background: rgba(255, 255, 255, 0.05);
}
/* Stat dot - matching self.html */
.stat-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* Cache segment - matching self.html */
.cache-seg {
height: 32px;
transition: opacity 0.2s, filter 0.2s;
cursor: pointer;
}
.cache-seg:hover {
filter: brightness(1.15);
}
/* Speed row - matching self.html */
.speed-row {
display: flex;
align-items: center;
gap: 5px;
font-size: 12.5px;
font-weight: 600;
}
.speed-row svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
}
/* Live indicator - matching self.html */
.live-dot {
width: 8px;
height: 8px;
background: #3dd68c;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
animation: pulse-dot 2s infinite;
}
.live-dot.stopped {
background: #6b6e7d;
animation: none;
}
/* Badge - matching self.html */
.badge-active {
padding: 2px 8px;
border-radius: 6px;
background: rgba(61, 214, 140, 0.15);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.7px;
color: #3dd68c;
text-transform: uppercase;
transition: all 0.3s;
display: inline-flex;
align-items: center;
}
.badge-stopped {
padding: 2px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.7px;
color: #6b6e7d;
text-transform: uppercase;
display: inline-flex;
align-items: center;
}
/* Subtle grid background - Railway-style dots */
.subtle-grid {
background-image:
@@ -589,18 +400,7 @@ textarea {
100% { transform: scale(2); opacity: 0; }
}
@keyframes pulse-dot {
0% { box-shadow: 0 0 0 0 rgba(61, 214, 140, 0.7); }
70% { box-shadow: 0 0 0 6px rgba(61, 214, 140, 0); }
100% { box-shadow: 0 0 0 0 rgba(61, 214, 140, 0); }
}
/* Card animations - matching self.html */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-up {
animation: fadeUp 0.4s ease both;
}
@@ -718,17 +518,6 @@ textarea {
100% { background-position: 200% 0; }
}
.shimmer {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.03) 25%,
rgba(255, 255, 255, 0.08) 50%,
rgba(255, 255, 255, 0.03) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* Hover lift effect for cards */
.hover-lift {
transition: transform 0.2s ease, box-shadow 0.2s ease;
@@ -778,18 +567,6 @@ textarea {
animation: gradient-rotate 3s ease infinite;
}
/* Ripple effect for buttons */
@keyframes ripple {
0% {
transform: scale(0);
opacity: 0.5;
}
100% {
transform: scale(4);
opacity: 0;
}
}
.ripple-container {
position: relative;
overflow: hidden;
@@ -1073,8 +850,8 @@ textarea {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
border-radius: 10px;
padding: 0 12px;
height: 34px;
@@ -1084,7 +861,7 @@ textarea {
.search-box svg {
width: 14px;
height: 14px;
stroke: #6b6e7d;
stroke: var(--text-tertiary);
fill: none;
stroke-width: 2;
stroke-linecap: round;
@@ -1095,14 +872,14 @@ textarea {
background: none;
border: none;
outline: none;
color: #9295a4;
color: var(--text-secondary);
font-size: 13px;
width: 100%;
font-family: inherit;
}
.search-box input::placeholder {
color: #6b6e7d;
color: var(--text-tertiary);
}
/* Tab - self.html exact match */
@@ -1172,47 +949,6 @@ textarea {
color: #9295a4;
}
/* Arrow Button - self.html style (pink circular) */
.arrow-btn {
width: 30px;
height: 30px;
border-radius: 9px;
background: var(--accent-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: box-shadow 0.15s, transform 0.15s;
}
.arrow-btn:hover {
transform: scale(1.07);
}
.arrow-btn svg {
width: 13px;
height: 13px;
stroke: white;
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Live Dot Pulse - self.html style */
@keyframes pulse-dot {
0% {
box-shadow: 0 0 0 0 rgba(61, 214, 140, 0.7);
}
70% {
box-shadow: 0 0 0 6px rgba(61, 214, 140, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(61, 214, 140, 0);
}
}
.live-dot {
width: 8px;
height: 8px;
@@ -1489,16 +1225,6 @@ textarea {
animation: ripple 0.4s ease-out;
}
/* Shimmer loading effect */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.shimmer {
background: linear-gradient(
90deg,
+440
View File
@@ -148,6 +148,105 @@ export type UpdateUserProfileInput = {
avatarUrl?: string;
};
export type CreateUserInput = {
name: string;
email: string;
password: string;
};
export type UpgradeStatus = {
imageRef: string;
registry: string;
dockerAvailable: boolean;
installed: boolean;
digest?: string;
size?: number;
authConfigured: boolean;
message: string;
};
export type SystemLoad = {
load1m: number;
load5m: number;
load15m: number;
};
export type HostMonitoring = {
hostname: string;
os: string;
architecture: string;
cpu: {
cores: number;
};
memory: {
total: number;
used: number;
available: number;
usagePercent: number;
};
storage: {
path: string;
total: number;
used: number;
available: number;
usagePercent: number;
};
load: SystemLoad;
uptimeSeconds: number;
dockerAvailable: boolean;
docker?: {
containers?: number;
images?: number;
driver?: string;
server?: string;
};
collectedAt: string;
};
export type AgentCapabilities = {
containerRuntimes: string[];
supportedArchitectures: string[];
maxContainers: number;
storageDriver: string;
networkPlugins: string[];
features: string[];
};
export type NodeResources = {
cpu: {
cores: number;
allocation: number;
usage: number;
};
memory: {
total: number;
allocated: number;
used: number;
available: number;
};
storage: {
total: number;
allocated: number;
used: number;
available: number;
};
};
export type NodeAgentEntity = {
id: string;
name: string;
hostname: string;
ipAddress: string;
port: number;
status: string;
version: string;
capabilities: AgentCapabilities;
resources: NodeResources;
lastHeartbeat?: string;
createdAt?: string;
updatedAt?: string;
};
export type AuditLogEntity = {
id: string;
userId: string;
@@ -256,6 +355,91 @@ type RawServiceLog = components['schemas']['ServiceLogEntry'];
type RawServiceLogsResponse = components['schemas']['ServiceLogsResponse'];
type RawDeploymentLogsResponse = components['schemas']['DeploymentLogsResponse'];
type RawRollbackDeploymentResponse = components['schemas']['RollbackDeploymentResponse'];
type RawUpgradeStatus = {
image_ref?: string;
registry?: string;
docker_available?: boolean;
installed?: boolean;
digest?: string;
size?: number;
auth_configured?: boolean;
message?: string;
};
type RawSystemLoad = {
load_1m?: number;
load_5m?: number;
load_15m?: number;
};
type RawHostMonitoring = {
hostname?: string;
os?: string;
architecture?: string;
cpu?: {
cores?: number;
};
memory?: {
total?: number;
used?: number;
available?: number;
usage_percent?: number;
};
storage?: {
path?: string;
total?: number;
used?: number;
available?: number;
usage_percent?: number;
};
load?: RawSystemLoad;
uptime_seconds?: number;
docker_available?: boolean;
docker?: {
containers?: number;
images?: number;
driver?: string;
server?: string;
};
collected_at?: string;
};
type RawNodeAgent = {
id?: string;
name?: string;
hostname?: string;
ip_address?: string;
port?: number;
status?: string;
version?: string;
capabilities?: {
container_runtimes?: string[];
supported_architectures?: string[];
max_containers?: number;
storage_driver?: string;
network_plugins?: string[];
features?: string[];
};
resources?: {
cpu?: {
cores?: number;
allocation?: number;
usage?: number;
};
memory?: {
total?: number;
allocated?: number;
used?: number;
available?: number;
};
storage?: {
total?: number;
allocated?: number;
used?: number;
available?: number;
};
};
last_heartbeat?: string;
created_at?: string;
updated_at?: string;
};
export type CreateProjectInput = components['schemas']['CreateProjectRequest'];
export type CreateServiceInput = components['schemas']['CreateServiceRequest'] & {
@@ -286,12 +470,17 @@ type JsonLike = Record<string, unknown>;
const rawBase = (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082';
const normalizedBase = rawBase.replace(/\/$/, '');
const API_ROOT = normalizedBase.replace(/\/api\/v1$/, '').replace(/\/api$/, '');
const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`;
export function getApiBaseUrl(): string {
return API_BASE;
}
export function getAgentPublicBaseUrl(): string {
return `${API_ROOT}/api/agents`;
}
function authHeaders(): HeadersInit {
return { 'Content-Type': 'application/json' };
}
@@ -669,6 +858,151 @@ export async function updateCurrentUserProfile(input: UpdateUserProfileInput): P
return profile;
}
export async function createUser(input: CreateUserInput): Promise<UserProfile> {
const payload = await requestJson<RawUserProfile | { user?: RawUserProfile }>(`/users`, {
method: 'POST',
body: JSON.stringify(input),
});
const rawProfile = (payload as { user?: RawUserProfile }).user ?? (payload as RawUserProfile);
const profile = normalizeUserProfile(rawProfile);
if (!profile) {
throw new ApiError('Create user response is invalid', 500);
}
return profile;
}
function normalizeUpgradeStatus(payload: RawUpgradeStatus): UpgradeStatus {
return {
imageRef: payload.image_ref ?? '',
registry: payload.registry ?? '',
dockerAvailable: Boolean(payload.docker_available),
installed: Boolean(payload.installed),
digest: payload.digest,
size: payload.size,
authConfigured: Boolean(payload.auth_configured),
message: payload.message ?? '',
};
}
function normalizeSystemLoad(payload?: RawSystemLoad): SystemLoad {
return {
load1m: payload?.load_1m ?? 0,
load5m: payload?.load_5m ?? 0,
load15m: payload?.load_15m ?? 0,
};
}
function normalizeHostMonitoring(payload: RawHostMonitoring): HostMonitoring {
return {
hostname: payload.hostname ?? '',
os: payload.os ?? '',
architecture: payload.architecture ?? '',
cpu: {
cores: payload.cpu?.cores ?? 0,
},
memory: {
total: payload.memory?.total ?? 0,
used: payload.memory?.used ?? 0,
available: payload.memory?.available ?? 0,
usagePercent: payload.memory?.usage_percent ?? 0,
},
storage: {
path: payload.storage?.path ?? '/',
total: payload.storage?.total ?? 0,
used: payload.storage?.used ?? 0,
available: payload.storage?.available ?? 0,
usagePercent: payload.storage?.usage_percent ?? 0,
},
load: normalizeSystemLoad(payload.load),
uptimeSeconds: payload.uptime_seconds ?? 0,
dockerAvailable: Boolean(payload.docker_available),
docker: payload.docker,
collectedAt: payload.collected_at ?? '',
};
}
function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
if (!agent.id || !agent.name) {
return null;
}
return {
id: agent.id,
name: agent.name,
hostname: agent.hostname ?? '',
ipAddress: agent.ip_address ?? '',
port: agent.port ?? 0,
status: agent.status ?? 'unknown',
version: agent.version ?? '',
capabilities: {
containerRuntimes: agent.capabilities?.container_runtimes ?? [],
supportedArchitectures: agent.capabilities?.supported_architectures ?? [],
maxContainers: agent.capabilities?.max_containers ?? 0,
storageDriver: agent.capabilities?.storage_driver ?? '',
networkPlugins: agent.capabilities?.network_plugins ?? [],
features: agent.capabilities?.features ?? [],
},
resources: {
cpu: {
cores: agent.resources?.cpu?.cores ?? 0,
allocation: agent.resources?.cpu?.allocation ?? 0,
usage: agent.resources?.cpu?.usage ?? 0,
},
memory: {
total: agent.resources?.memory?.total ?? 0,
allocated: agent.resources?.memory?.allocated ?? 0,
used: agent.resources?.memory?.used ?? 0,
available: agent.resources?.memory?.available ?? 0,
},
storage: {
total: agent.resources?.storage?.total ?? 0,
allocated: agent.resources?.storage?.allocated ?? 0,
used: agent.resources?.storage?.used ?? 0,
available: agent.resources?.storage?.available ?? 0,
},
},
lastHeartbeat: agent.last_heartbeat,
createdAt: agent.created_at,
updatedAt: agent.updated_at,
};
}
function normalizeAgentArray(raw: unknown): NodeAgentEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeAgent(entry as RawNodeAgent))
.filter((entry): entry is NodeAgentEntity => entry !== null);
}
export async function getUpgradeStatus(): Promise<UpgradeStatus> {
const payload = await requestJson<RawUpgradeStatus>(`/system/upgrade/status`);
return normalizeUpgradeStatus(payload);
}
export async function pullUpgradeImage(): Promise<UpgradeStatus> {
const payload = await requestJson<RawUpgradeStatus>(`/system/upgrade/pull`, {
method: 'POST',
body: JSON.stringify({}),
});
return normalizeUpgradeStatus(payload);
}
export async function getHostMonitoring(): Promise<HostMonitoring> {
const payload = await requestJson<RawHostMonitoring>(`/system/host`);
return normalizeHostMonitoring(payload);
}
export async function listAgents(): Promise<NodeAgentEntity[]> {
const payload = await requestJson<{ agents?: RawNodeAgent[] }>(`/agents`);
return normalizeAgentArray(payload.agents);
}
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
method: 'POST',
@@ -958,3 +1292,109 @@ export function serviceStatusClass(status: ServiceStatus): string {
return 'status-stopped';
}
}
export type ServiceInstanceMetrics = {
container_id: string;
name: string;
state: string;
cpu_percent: number;
memory_usage_bytes: number;
memory_limit_bytes: number;
network_rx_bytes: number;
network_tx_bytes: number;
started_at?: string;
};
export type ServiceMetrics = {
service_id: string;
status: 'ok' | 'no_containers' | 'docker_unavailable';
instances: ServiceInstanceMetrics[];
cpu_percent: number;
memory_usage_bytes: number;
memory_limit_bytes: number;
network_rx_bytes: number;
network_tx_bytes: number;
collected_at: string;
};
export async function getServiceMetrics(serviceId: string): Promise<ServiceMetrics> {
const payload = await requestJson<{ metrics?: ServiceMetrics }>(`/services/${serviceId}/metrics`);
if (!payload.metrics) {
throw new ApiError('Service metrics payload is invalid', 500);
}
return payload.metrics;
}
export type GitProviderEntity = {
id: string;
name: string;
display_name: string;
api_url: string;
created_at?: string;
};
export type GitRepositoryEntity = {
id: string;
provider_id: string;
name: string;
full_name: string;
description: string;
clone_url: string;
default_branch: string;
is_private: boolean;
};
export type GitBranchEntity = {
name: string;
protected: boolean;
};
export async function listGitProviders(): Promise<GitProviderEntity[]> {
const payload = await requestJson<{ providers?: GitProviderEntity[] }>('/git/providers');
return payload.providers ?? [];
}
export async function createGitProvider(input: {
name: 'github' | 'gitlab' | 'bitbucket' | 'gitea';
display_name: string;
access_token: string;
api_url?: string;
}): Promise<GitProviderEntity> {
const payload = await requestJson<GitProviderEntity>('/git/providers', {
method: 'POST',
body: JSON.stringify(input),
});
if (!payload.id) {
throw new ApiError('Provider response is invalid', 500);
}
return payload;
}
export async function deleteGitProvider(providerId: string): Promise<void> {
await requestJson(`/git/providers/${providerId}`, { method: 'DELETE' });
}
export async function listGitRepositories(
providerId: string,
search?: string,
): Promise<GitRepositoryEntity[]> {
const params = new URLSearchParams({ limit: '100' });
if (search?.trim()) {
params.set('search', search.trim());
}
const payload = await requestJson<{ repositories?: GitRepositoryEntity[] }>(
`/git/providers/${providerId}/repositories?${params.toString()}`,
);
return payload.repositories ?? [];
}
export async function listGitBranches(
providerId: string,
owner: string,
repo: string,
): Promise<GitBranchEntity[]> {
const payload = await requestJson<{ branches?: GitBranchEntity[] }>(
`/git/providers/${providerId}/repositories/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`,
);
return payload.branches ?? [];
}
+33 -28
View File
@@ -16,6 +16,12 @@ export type AuthSessionPayload = {
session: AuthSession;
};
export type AuthBootstrap = {
hasUsers: boolean;
userCount: number;
mode: 'register' | 'login';
};
export class AuthError extends Error {
readonly status: number;
@@ -33,6 +39,30 @@ export function getAuthBaseUrl(): string {
return AUTH_BASE;
}
export async function getAuthBootstrap(): Promise<AuthBootstrap> {
const apiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082').replace(/\/$/, '');
const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`;
const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, {
method: 'GET',
credentials: 'include',
});
const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
if (!response.ok) {
const message =
(payload?.message as string | undefined) ??
(payload?.error as string | undefined) ??
`Auth bootstrap request failed with status ${response.status}`;
throw new AuthError(message, response.status);
}
return {
hasUsers: Boolean(payload?.has_users),
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
mode: payload?.mode === 'register' ? 'register' : 'login',
};
}
function normalizeSessionPayload(payload: unknown): AuthSessionPayload | null {
if (!payload || typeof payload !== 'object') {
return null;
@@ -160,19 +190,6 @@ export async function signUpWithEmail(name: string, email: string, password: str
return normalizeSessionPayload(payload);
}
export async function requestMagicLinkInvite(email: string, callbackURL: string): Promise<void> {
await authRequest('/sign-in/magic-link', {
method: 'POST',
body: JSON.stringify({
email,
callbackURL,
metadata: {
invite: true,
},
}),
});
}
export async function startGitHubSignIn(callbackURL: string): Promise<void> {
const payload = await authRequest('/sign-in/social', {
method: 'POST',
@@ -189,11 +206,11 @@ export async function startGitHubSignIn(callbackURL: string): Promise<void> {
}
}
async function startOAuth2ProviderSignIn(providerId: string, callbackURL: string): Promise<void> {
const payload = await authRequest('/sign-in/oauth2', {
export async function startGoogleSignIn(callbackURL: string): Promise<void> {
const payload = await authRequest('/sign-in/social', {
method: 'POST',
body: JSON.stringify({
providerId,
provider: 'google',
callbackURL,
disableRedirect: true,
}),
@@ -205,18 +222,6 @@ async function startOAuth2ProviderSignIn(providerId: string, callbackURL: string
}
}
export async function startGitLabSignIn(callbackURL: string): Promise<void> {
await startOAuth2ProviderSignIn('gitlab', callbackURL);
}
export async function startBitbucketSignIn(callbackURL: string): Promise<void> {
await startOAuth2ProviderSignIn('bitbucket', callbackURL);
}
export async function startGiteaSignIn(callbackURL: string): Promise<void> {
await startOAuth2ProviderSignIn('gitea', callbackURL);
}
export async function signOutAuthSession(): Promise<void> {
await authRequest('/sign-out', {
method: 'POST',
+10
View File
@@ -36,6 +36,16 @@ export function formatDate(iso?: string): string {
return dt.toLocaleString();
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B';
}
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
const value = bytes / Math.pow(1024, index);
return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[index]}`;
}
export function seededMetric(seed: string, min: number, max: number): number {
let hash = 0;
for (let idx = 0; idx < seed.length; idx += 1) {
+7 -2
View File
@@ -17,10 +17,12 @@ function toWebSocketUrl(apiBase: string): string {
return `${wsBase}/ws`;
}
export type LiveSyncStatus = 'idle' | 'offline' | 'live';
export function useBuildUpdates(
buildIds: string[],
onBuildUpdate: (payload: BuildUpdatePayload) => void,
): boolean {
): LiveSyncStatus {
const [connected, setConnected] = useState(false);
const callbackRef = useRef(onBuildUpdate);
useEffect(() => {
@@ -127,5 +129,8 @@ export function useBuildUpdates(
};
}, [buildIdsKey, subscriptionIds]);
return subscriptionIds.length > 0 && connected;
if (subscriptionIds.length === 0) {
return 'idle';
}
return connected ? 'live' : 'offline';
}
@@ -184,7 +184,6 @@ export function CacheMetricCard({
}: CacheMetricCardProps) {
const cachePercent = Math.round((cacheMB / totalMB) * 100);
const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100);
const freePercent = 100 - cachePercent - nonCachePercent;
return (
<div
@@ -316,7 +315,6 @@ export function PerformanceMetricCard({
percentage,
upSpeed,
downSpeed,
datasets,
onClick,
animationDelay = 0,
}: PerformanceMetricCardProps) {
@@ -1,4 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
+8 -18
View File
@@ -1,8 +1,8 @@
import * as ToastPrimitive from '@radix-ui/react-toast';
import { createContext, useContext, useState, ReactNode } from 'react';
import { useState } from 'react';
import type { ReactNode } from 'react';
import { IconX, IconCheck, IconAlertCircle, IconInfoCircle } from '@tabler/icons-react';
type ToastType = 'success' | 'error' | 'info' | 'warning';
import { ToastContext, type ToastType } from '../hooks/use-toast';
interface Toast {
id: string;
@@ -11,20 +11,6 @@ interface Toast {
description?: string;
}
interface ToastContextValue {
showToast: (type: ToastType, title: string, description?: string) => void;
}
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}
interface ToastProviderProps {
children: ReactNode;
}
@@ -32,7 +18,11 @@ interface ToastProviderProps {
export function ToastProvider({ children }: ToastProviderProps) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = (type: ToastType, title: string, description?: string) => {
const showToast = (first: ToastType | string, second: ToastType | string, description?: string) => {
const toastTypes: ToastType[] = ['success', 'error', 'info', 'warning'];
const firstIsType = toastTypes.includes(first as ToastType);
const type = (firstIsType ? first : second) as ToastType;
const title = (firstIsType ? second : first) as string;
const id = Math.random().toString(36).substring(7);
setToasts((prev) => [...prev, { id, type, title, description }]);
@@ -6,3 +6,5 @@ export * from './DonutChart';
export * from './EnhancedMetricCard';
export * from './CommandPalette';
export * from './StatusBadge';
export { useToast } from '../hooks/use-toast';
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
+2 -2
View File
@@ -3,7 +3,7 @@ import { createContext, useContext } from 'react';
export type ToastType = 'success' | 'info' | 'warning' | 'error';
export interface ToastContextValue {
showToast: (message: string, type?: ToastType) => void;
showToast: (first: ToastType | string, second: ToastType | string, description?: string) => void;
}
export const ToastContext = createContext<ToastContextValue | null>(null);
@@ -11,7 +11,7 @@ export const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
throw new Error('useToast must be used within ToastProvider');
}
return context;
}