small fix, don't worry about it

This commit is contained in:
Tomas Dvorak committed 2026-04-10 12:02:36 +02:00
1 parent 08bd0c6e5c
commit 08cb5754f3
638 files changed
+57330 -34704

No files matched your search

+416
View File
@@ -0,0 +1,416 @@
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 {
AuthError,
startBitbucketSignIn,
startGitLabSignIn,
requestMagicLinkInvite,
signInWithEmail,
signUpWithEmail,
startGiteaSignIn,
startGitHubSignIn,
} from '@/lib/auth-client';
import { useAuthSession } from '@/lib/use-auth-session';
function sanitizeRedirect(raw: string | null): string {
if (!raw) {
return '/projects';
}
const value = raw.trim();
if (!value.startsWith('/') || value.startsWith('//')) {
return '/projects';
}
return value;
}
function buildOAuthCallbackURL(path: string): string {
const cleanPath = sanitizeRedirect(path);
return `${window.location.origin}${cleanPath}`;
}
function AuthCanvas() {
const bars = [32, 44, 28, 60, 52, 64, 36, 48, 54, 72, 66, 78];
return (
<div className="relative hidden border-r border-[var(--border-subtle)] bg-[var(--bg-base)]/70 backdrop-blur-2xl xl:flex xl:w-[46%]">
<div className="absolute inset-0 bg-[#e8316a]/10" />
<div className="relative z-10 flex h-full w-full flex-col justify-between p-10">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--text-muted)]">Containr Access</p>
<h1 className="mt-3 font-headline text-4xl font-semibold leading-tight text-[var(--text-primary)]">
Secure Sessions,
<br />
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.
</p>
</div>
<div className="panel p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-muted)]">Auth Health</p>
<span className="flex items-center gap-2 text-xs text-[var(--success)]">
<span className="live-pulse h-2 w-2 rounded-full bg-[var(--success)]" />
Live
</span>
</div>
<div className="grid grid-cols-12 items-end gap-1.5">
{bars.map((height, index) => (
<div
key={`bar-${index}`}
className="rounded-sm"
style={{ height: `${height}px`, background: '#e8316a' }}
/>
))}
</div>
</div>
</div>
</div>
);
}
function AuthCard({
title,
subtitle,
children,
}: {
title: string;
subtitle: string;
children: React.ReactNode;
}) {
return (
<div className="w-full max-w-[520px] rounded-[var(--radius-xl)] border border-[var(--border-subtle)] bg-[var(--surface-card)]/92 p-7 shadow-2xl shadow-black/35 backdrop-blur-xl md:p-8">
<div className="mb-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--text-muted)]">Containr</p>
<h2 className="mt-2 font-headline text-2xl font-semibold text-[var(--text-primary)]">{title}</h2>
<p className="mt-2 text-sm text-[var(--text-secondary)]">{subtitle}</p>
</div>
{children}
</div>
);
}
function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-[var(--bg-void)]">
<div className="ambient-glow" />
<div className="relative flex min-h-screen">
<AuthCanvas />
<div className="flex w-full items-center justify-center px-5 py-8 md:px-8">{children}</div>
</div>
</div>
);
}
function AuthErrorNotice({ message }: { message: string }) {
return (
<div className="mb-4 flex items-start gap-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] bg-[var(--error-soft)] px-3 py-2.5 text-sm text-[var(--error)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<span>{message}</span>
</div>
);
}
function AuthInfoNotice({ message }: { message: string }) {
return (
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--success-soft)] bg-[var(--success-soft)] px-3 py-2.5 text-sm text-[var(--success)]">
{message}
</div>
);
}
export function SignInPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
const sessionQuery = useAuthSession();
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 />;
}
const submitEmailPassword = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setInfo(null);
setIsSubmitting(true);
try {
await signInWithEmail(email.trim(), password);
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate(redirectPath, { replace: true });
} catch (exception) {
const message = exception instanceof AuthError ? exception.message : 'Failed to sign in';
setError(message);
} finally {
setIsSubmitting(false);
}
};
const submitMagicLink = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setInfo(null);
setIsMagicLoading(true);
try {
await requestMagicLinkInvite(magicEmail.trim(), buildOAuthCallbackURL(redirectPath));
setInfo('Magic invite link sent. Check your email inbox.');
} catch (exception) {
const message = exception instanceof AuthError ? exception.message : 'Failed to send magic link';
setError(message);
} finally {
setIsMagicLoading(false);
}
};
const signInWithGitHubProvider = 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));
};
return (
<AuthLayout>
<AuthCard title="Sign In" subtitle="Use your Containr account, invite magic link, or provider OAuth.">
{error ? <AuthErrorNotice message={error} /> : null}
{info ? <AuthInfoNotice message={info} /> : null}
<form className="space-y-3" onSubmit={submitEmailPassword}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Email
<input
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(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>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Password
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(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="Your password"
/>
</label>
<button
type="submit"
disabled={isSubmitting || sessionQuery.isPending}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-white shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: '#e8316a' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <Mail size={15} />}
Continue
</button>
</form>
<div className="my-5 h-px bg-[var(--border-subtle)]" />
<div className="grid gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => void signInWithGitHubProvider()}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
>
<Github size={15} />
GitHub
</button>
<button
type="button"
onClick={() => void signInWithGitLabProvider()}
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
</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>
</div>
</AuthCard>
</AuthLayout>
);
}
export function SignUpPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
const sessionQuery = useAuthSession();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (sessionQuery.data) {
return <Navigate to={redirectPath} replace />;
}
const submitSignUp = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await signUpWithEmail(name.trim(), email.trim(), password);
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate(redirectPath, { replace: true });
} catch (exception) {
const message = exception instanceof AuthError ? exception.message : 'Failed to create account';
setError(message);
} finally {
setIsSubmitting(false);
}
};
return (
<AuthLayout>
<AuthCard title="Create Account" subtitle="Provision your operator account with email/password auth.">
{error ? <AuthErrorNotice message={error} /> : null}
<form className="space-y-3" onSubmit={submitSignUp}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Name
<input
type="text"
value={name}
onChange={(event) => setName(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="Operator"
/>
</label>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
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>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
minLength={8}
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="At least 8 characters"
/>
</label>
<button
type="submit"
disabled={isSubmitting || sessionQuery.isPending}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-white shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: '#e8316a' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Create Account
</button>
</form>
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span>Already signed up?</span>
<Link to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} className="inline-flex items-center gap-1 text-[var(--accent-primary)] hover:underline">
Go to sign in <ArrowRight size={12} />
</Link>
</div>
</AuthCard>
</AuthLayout>
);
}
@@ -0,0 +1,475 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import {
cancelBuild,
getBuildLogs,
listBuilds,
type BuildEntity,
type BuildStatus,
} from '@/lib/api-client';
import { useBuildUpdates } from '@/lib/use-build-updates';
import { formatRelative } from '@/lib/time';
import {
Check,
X,
Loader2,
Clock,
RefreshCw,
FileText,
Box,
Sparkles,
Filter,
Circle,
HardDrive,
} from 'lucide-react';
const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
{ value: '', label: 'All statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'running', label: 'Running' },
{ value: 'success', label: 'Success' },
{ value: 'failed', label: 'Failed' },
{ value: 'cancelled', label: 'Cancelled' },
];
const demoBuilds: BuildEntity[] = [
{
id: 'build-demo-01',
projectId: 'project-demo',
serviceId: 'service-api',
status: 'success',
progress: 100,
startedAt: new Date(Date.now() - 20 * 60_000).toISOString(),
completedAt: new Date(Date.now() - 18 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/api',
imageTag: 'sha-1f2e3d4',
size: 156_000_000,
log: '[demo] Build finished successfully.',
metadata: { branch: 'main' },
},
{
id: 'build-demo-02',
projectId: 'project-demo',
serviceId: 'service-worker',
status: 'running',
progress: 54,
startedAt: new Date(Date.now() - 2 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/worker',
imageTag: 'sha-9a8b7c6',
size: 0,
log: '[demo] Building image layers...',
metadata: { branch: 'feature/queue' },
},
];
function StatusBadge({ status }: { status: BuildStatus }) {
const config = {
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
running: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
pending: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Clock, animate: false },
cancelled: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: X, animate: false },
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Circle, animate: false };
const { Icon } = config;
return (
<div
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
style={{ background: config.bg, color: config.color }}
>
<Icon size={12} className={config.animate ? 'animate-spin' : ''} />
{status}
</div>
);
}
function bytesToHumanReadable(bytes: number): string {
if (bytes <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
export function BuildsPage() {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1';
const [projectFilter, setProjectFilter] = useState('');
const [serviceFilter, setServiceFilter] = useState('');
const [statusFilter, setStatusFilter] = useState<'' | BuildStatus>('');
const [limit, setLimit] = useState(50);
const [selectedBuild, setSelectedBuild] = useState<BuildEntity | null>(null);
const buildsQuery = useQuery({
queryKey: ['builds-page', { projectFilter, serviceFilter, statusFilter, limit }],
enabled: !isDemoMode,
queryFn: () =>
listBuilds({
projectId: projectFilter || undefined,
serviceId: serviceFilter || undefined,
status: statusFilter || undefined,
page: 1,
limit,
}),
});
const builds = useMemo(
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
[isDemoMode, buildsQuery.data?.builds],
);
const subscribedBuildIds = useMemo(() => builds.map((build) => build.id), [builds]);
const liveConnected = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
if (selectedBuild && channel === `build:${selectedBuild.id}`) {
queryClient.invalidateQueries({ queryKey: ['build-logs', selectedBuild.id] });
}
});
const cancelMutation = useMutation({
mutationFn: (buildId: string) => cancelBuild(buildId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
},
});
const logsQuery = useQuery({
queryKey: ['build-logs', selectedBuild?.id],
enabled: Boolean(selectedBuild) && !isDemoMode,
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;
const success = builds.filter(b => b.status === 'success').length;
const failed = builds.filter(b => b.status === 'failed').length;
return { running, success, failed, total: builds.length };
}, [builds]);
return (
<div className="min-h-screen">
{/* Header */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-[var(--text-primary)]">Build Pipeline</h1>
<p className="text-sm text-[var(--text-secondary)]">Monitor build progress and manage jobs</p>
</div>
<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'}
</span>
</div>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
</div>
</div>
</div>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active — using sample data</span>
</div>
</div>
</div>
)}
{/* Stats Overview */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Box size={16} className="text-[var(--accent-primary)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--text-primary)]">{stats.total}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Total Builds</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--warning-soft)] flex items-center justify-center">
<Loader2 size={16} className="text-[var(--warning)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--warning)]">{stats.running}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">In Progress</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--success-soft)] flex items-center justify-center">
<Check size={16} className="text-[var(--success)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--success)]">{stats.success}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Successful</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--error-soft)] flex items-center justify-center">
<X size={16} className="text-[var(--error)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--error)]">{stats.failed}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Failed</p>
</div>
</div>
</div>
{/* Filters */}
<div className="mx-auto w-full max-w-[1400px] px-6">
<div className="panel p-4">
<div className="flex items-center gap-2 mb-4">
<Filter size={16} className="text-[var(--text-tertiary)]" />
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Status
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as '' | BuildStatus)}
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"
>
{statusOptions.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Project ID
</label>
<input
value={projectFilter}
onChange={(e) => setProjectFilter(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="project-123"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Service ID
</label>
<input
value={serviceFilter}
onChange={(e) => setServiceFilter(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="service-abc"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Page Size
</label>
<select
value={String(limit)}
onChange={(e) => setLimit(Number(e.target.value))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
</div>
</div>
{/* Build Table */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="panel overflow-hidden">
{!isDemoMode && buildsQuery.isLoading ? (
<div className="p-12 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading builds...</p>
</div>
) : null}
{!isDemoMode && buildsQuery.isError ? (
<div className="p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-sm text-[var(--error)]">Failed to load builds</p>
<p className="text-xs text-[var(--text-muted)] mt-1">{buildsQuery.error instanceof Error ? buildsQuery.error.message : 'Unknown error'}</p>
</div>
) : null}
{builds.length === 0 && !buildsQuery.isLoading ? (
<div className="p-12 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Box size={24} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-muted)]">No builds match current filters</p>
</div>
) : null}
{builds.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
{builds.map((build) => (
<div
key={build.id}
className="panel p-4 group hover:border-[var(--accent-primary)]/30 transition-all duration-300 card-lift cursor-pointer"
onClick={() => setSelectedBuild(build)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
build.status === 'success'
? 'bg-[var(--success-soft)]'
: build.status === 'failed'
? 'bg-[var(--error-soft)]'
: build.status === 'running'
? 'bg-[var(--warning-soft)]'
: 'bg-[var(--surface-muted)]'
}`}>
{build.status === 'success' && <Check size={18} className="text-[var(--success)]" />}
{build.status === 'failed' && <X size={18} className="text-[var(--error)]" />}
{build.status === 'running' && <Loader2 size={18} className="text-[var(--warning)] animate-spin" />}
{build.status === 'pending' && <Clock size={18} className="text-[var(--text-tertiary)]" />}
{build.status === 'cancelled' && <X size={18} className="text-[var(--text-tertiary)]" />}
</div>
<div>
<p className="mono text-sm font-medium text-[var(--text-primary)]">{build.id}</p>
<div className="flex items-center gap-2 mt-1">
<StatusBadge status={build.status} />
<span className="text-xs text-[var(--text-tertiary)]">
{build.startedAt ? formatRelative(build.startedAt) : '—'}
</span>
</div>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 rounded-lg hover:bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-all">
<FileText size={14} />
</button>
</div>
</div>
<div className="space-y-3">
{/* Image info */}
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-tertiary)]">Image</span>
<span className="mono text-[var(--text-secondary)]">
{build.imageName || '—'}:{build.imageTag || 'latest'}
</span>
</div>
{/* Progress bar */}
{(build.status === 'running' || build.status === 'pending') && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-tertiary)]">Progress</span>
<span className="text-[var(--text-secondary)] font-medium">{build.progress}%</span>
</div>
<div className="h-2 rounded-full bg-[var(--surface-muted)] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{ width: `${Math.max(0, Math.min(100, build.progress))}%`, background: '#e8316a' }}
/>
</div>
</div>
)}
{/* Size and service */}
<div className="flex items-center justify-between text-xs pt-2 border-t border-[var(--border-subtle)]">
<div className="flex items-center gap-1.5 text-[var(--text-tertiary)]">
<HardDrive size={10} />
<span>{bytesToHumanReadable(build.size)}</span>
</div>
<span className="mono text-[var(--text-tertiary)]">{build.serviceId || '—'}</span>
</div>
</div>
{/* Error message if present */}
{build.error && (
<div className="mt-3 p-2 rounded-lg bg-[var(--error-soft)]/50 border border-[var(--error)]/20">
<p className="text-xs text-[var(--error)] line-clamp-2">{build.error}</p>
</div>
)}
</div>
))}
</div>
) : null}
</div>
</div>
{/* Error Toast */}
{cancelMutation.isError && (
<div className="fixed bottom-4 right-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
{cancelMutation.error instanceof Error ? cancelMutation.error.message : 'Failed to cancel build.'}
</div>
)}
{/* Logs Modal */}
{selectedBuild && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setSelectedBuild(null)} />
<div className="relative w-full max-w-3xl 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">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Build Logs</h2>
<p className="mono text-xs text-[var(--text-tertiary)]">{selectedBuild.id}</p>
</div>
</div>
<button
onClick={() => setSelectedBuild(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
Close
</button>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 max-h-[400px] overflow-auto">
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all">
{isDemoMode
? selectedBuild.log || '[demo] No logs available.'
: logsQuery.isLoading
? 'Loading logs...'
: logsQuery.isError
? 'Failed to load logs.'
: logsQuery.data || '(empty logs)'}
</pre>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,408 @@
import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createProject, listProjects, type ProjectEntity, type ProjectStats } from '@/lib/api-client';
import {
Plus,
Search,
ArrowRight,
Layers,
Clock,
Sparkles,
FolderOpen,
X,
Check,
AlertTriangle
} from 'lucide-react';
const demoProjects: ProjectEntity[] = [
{
id: 'project-demo',
name: 'Demo Project',
description: 'Sample project with mock services for UI preview.',
createdAt: new Date(Date.now() - 14 * 86_400_000).toISOString(),
updatedAt: new Date().toISOString(),
stats: { service_count: 3, deployment_count: 12, running_services: 2 },
},
{
id: 'project-staging',
name: 'Staging Environment',
description: 'Pre-production environment for testing new releases.',
createdAt: new Date(Date.now() - 7 * 86_400_000).toISOString(),
updatedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
stats: { service_count: 1, deployment_count: 5, running_services: 1 },
},
];
function getHealthStatus(stats: ProjectStats): 'healthy' | 'degraded' | 'critical' {
if (stats.service_count === 0) return 'healthy';
if (stats.running_services === stats.service_count) return 'healthy';
if (stats.running_services >= stats.service_count / 2) return 'degraded';
return 'critical';
}
function healthConfig(health: ReturnType<typeof getHealthStatus>) {
switch (health) {
case 'healthy':
return {
label: 'Operational',
color: 'var(--success)',
bg: 'var(--success-soft)',
Icon: Check
};
case 'degraded':
return {
label: 'Degraded',
color: 'var(--warning)',
bg: 'var(--warning-soft)',
Icon: AlertTriangle
};
case 'critical':
return {
label: 'Critical',
color: 'var(--error)',
bg: 'var(--error-soft)',
Icon: X
};
}
}
function formatRelative(date?: string): string {
if (!date) return '—';
const now = Date.now();
const then = new Date(date).getTime();
const diff = now - then;
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}
function ProjectCard({ project, href }: { project: ProjectEntity; href: string }) {
const navigate = useNavigate();
const health = getHealthStatus(project.stats);
const config = healthConfig(health);
const Icon = config.Icon;
const healthPercent = project.stats.service_count === 0
? 100
: Math.round((project.stats.running_services / project.stats.service_count) * 100);
return (
<article
className="group panel p-0 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-[var(--shadow-lg)] hover:border-[var(--border-default)]"
onClick={() => navigate(href)}
>
{/* Header with solid accent */}
<div className="relative px-5 pt-5 pb-4">
<div className="absolute inset-0 h-28 bg-[#e8316a]/10" />
<div className="relative flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-headline text-lg font-semibold tracking-tight text-[var(--text-primary)] truncate group-hover:text-[var(--accent-primary)] transition-colors">
{project.name}
</h3>
<p className="mt-1.5 text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed">
{project.description || 'No description provided'}
</p>
</div>
<div
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium ring-1 ring-inset"
style={{ background: config.bg, color: config.color, borderColor: `${config.color}30` }}
>
<Icon size={12} />
{config.label}
</div>
</div>
</div>
{/* Service Topology Preview */}
<div className="px-5 py-3 border-y border-[var(--border-subtle)] bg-[var(--surface-muted)]/30">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(project.stats.service_count, 5) }).map((_, i) => (
<div
key={i}
className="w-8 h-8 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center transition-colors group-hover:border-[var(--border-default)]"
>
<Layers size={14} className="text-[var(--text-tertiary)]" />
</div>
))}
{project.stats.service_count > 5 && (
<div className="w-8 h-8 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)] flex items-center justify-center text-xs text-[var(--text-tertiary)] font-medium">
+{project.stats.service_count - 5}
</div>
)}
</div>
<span className="text-xs text-[var(--text-tertiary)] font-medium">
{project.stats.service_count} service{project.stats.service_count !== 1 ? 's' : ''}
</span>
</div>
</div>
{/* Stats Grid */}
<div className="p-5">
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] font-medium">Services</p>
<p className="mt-1.5 text-xl font-semibold text-[var(--text-primary)]">{project.stats.service_count}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] font-medium">Running</p>
<p className="mt-1.5 text-xl font-semibold text-[var(--success)]">{project.stats.running_services}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] font-medium">Health</p>
<p className="mt-1.5 text-xl font-semibold text-[var(--text-primary)]">{healthPercent}%</p>
</div>
</div>
{/* Health Progress Bar */}
<div className="mt-4">
<div className="h-1.5 rounded-full bg-[var(--surface-muted)] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${healthPercent}%`,
background: config.color
}}
/>
</div>
</div>
{/* Footer */}
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Clock size={12} />
<span>Updated {formatRelative(project.updatedAt)}</span>
</div>
<div className="flex items-center gap-1 text-xs font-medium text-[var(--accent-primary)] opacity-0 group-hover:opacity-100 transition-all duration-200 translate-x-2 group-hover:translate-x-0">
<span>Open</span>
<ArrowRight size={12} />
</div>
</div>
</div>
</article>
);
}
export function ProjectsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1';
const [search, setSearch] = useState('');
const [isCreateOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: '', description: '' });
const projectHref = (projectId: string) =>
isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`;
const projectsQuery = useQuery({
queryKey: ['projects'],
enabled: !isDemoMode,
queryFn: listProjects,
});
const createProjectMutation = useMutation({
mutationFn: () => createProject({ name: form.name.trim(), description: form.description.trim() || undefined }),
onSuccess: (project) => {
setCreateOpen(false);
setForm({ name: '', description: '' });
queryClient.invalidateQueries({ queryKey: ['projects'] });
navigate(projectHref(project.id));
},
});
const filteredProjects = useMemo(() => {
const source = isDemoMode ? demoProjects : projectsQuery.data ?? [];
if (!search.trim()) return source;
const needle = search.toLowerCase();
return source.filter((project) =>
project.name.toLowerCase().includes(needle) ||
project.description?.toLowerCase().includes(needle)
);
}, [isDemoMode, projectsQuery.data, search]);
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>
</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>
</div>
</div>
{/* Main Content */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-8">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<div className="relative flex-1">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search projects by name or description..."
className="w-full h-11 pl-11 pr-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
</div>
<button
onClick={() => setCreateOpen(true)}
className="flex items-center justify-center gap-2 h-11 px-5 rounded-[var(--radius-md)] text-white font-medium text-sm shadow-lg hover:shadow-xl transition-all duration-300"
style={{ background: '#e8316a' }}
>
<Plus size={16} />
<span>New Project</span>
</button>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="mb-6 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active — using sample data for preview</span>
</div>
</div>
)}
{/* Loading State */}
{!isDemoMode && projectsQuery.isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="panel h-[320px] animate-pulse bg-[var(--surface-card)]" />
))}
</div>
)}
{/* Error State */}
{!isDemoMode && projectsQuery.isError && (
<div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-lg font-medium text-[var(--text-primary)]">Failed to load projects</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">Check API connectivity and try again</p>
</div>
)}
{/* Empty State */}
{((isDemoMode && filteredProjects.length === 0) || (!isDemoMode && !projectsQuery.isLoading && !projectsQuery.isError && filteredProjects.length === 0)) && (
<div className="panel p-12 text-center">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FolderOpen size={28} className="text-[var(--accent-primary)]" />
</div>
<p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p>
<p className="mt-2 text-[var(--text-secondary)] max-w-md mx-auto">
Create your first project to start deploying services with visual topology management.
</p>
<button
onClick={() => setCreateOpen(true)}
className="mt-6 inline-flex items-center gap-2 px-5 py-2.5 rounded-[var(--radius-md)] border border-[var(--accent-primary)] text-[var(--accent-primary)] font-medium text-sm hover:bg-[var(--accent-primary-soft)] transition-colors"
>
<Plus size={16} />
Create Project
</button>
</div>
)}
{/* Project Grid */}
{((isDemoMode && filteredProjects.length > 0) || (!isDemoMode && !projectsQuery.isLoading && !projectsQuery.isError && filteredProjects.length > 0)) && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredProjects.map((project) => (
<ProjectCard key={project.id} project={project} href={projectHref(project.id)} />
))}
</div>
)}
</div>
{/* Create Modal */}
{isCreateOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreateOpen(false)} />
<div className="relative w-full max-w-lg panel p-6">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Create new project</h2>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Projects organize your services and provide a visual canvas for topology management.
</p>
<div className="mt-6 space-y-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Project Name
</label>
<input
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
placeholder="my-awesome-project"
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"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Description <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label>
<textarea
value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
placeholder="Describe your project..."
rows={3}
className="w-full px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all resize-none"
/>
</div>
</div>
{createProjectMutation.isError && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{(createProjectMutation.error as Error).message}
</div>
)}
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setCreateOpen(false)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)] text-sm font-medium hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors"
>
Cancel
</button>
<button
onClick={() => createProjectMutation.mutate()}
disabled={!form.name.trim() || createProjectMutation.isPending}
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' }}
>
{createProjectMutation.isPending ? 'Creating...' : 'Create Project'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,763 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {
getApiBaseUrl,
getCurrentUserProfile,
listAuditLogs,
listBuilds,
listProjects,
listTemplates,
updateCurrentUserProfile,
} from '@/lib/api-client';
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
import { useBuildUpdates } from '@/lib/use-build-updates';
import {
Clock,
Activity,
Gauge,
Users,
Shield,
FileText,
Settings,
User,
Key,
Database,
RefreshCw,
Trash2,
LogOut,
Check,
AlertCircle,
Loader2,
BookOpen,
Folder,
Box,
Terminal,
Radio,
} from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
return (
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<h1 className="text-xl font-semibold text-[var(--text-primary)]">{title}</h1>
<p className="text-sm text-[var(--text-secondary)] mt-1">{description}</p>
</div>
</div>
);
}
function StatCard({
title,
value,
description,
icon: Icon,
color = 'default',
}: {
title: string;
value: string;
description: string;
icon: typeof Clock;
color?: 'default' | 'success' | 'warning' | 'error';
}) {
const colorClasses = {
default: 'text-[var(--text-primary)]',
success: 'text-[var(--success)]',
warning: 'text-[var(--warning)]',
error: 'text-[var(--error)]',
};
return (
<div className="panel p-5">
<div className="flex items-start justify-between mb-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<Icon size={18} className="text-[var(--text-tertiary)]" />
</div>
</div>
<p className={`text-2xl font-semibold ${colorClasses[color]}`}>{value}</p>
<p className="text-xs font-medium text-[var(--text-muted)] mt-1">{title}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-2">{description}</p>
</div>
);
}
type LocalStorageSummary = {
canvasKeys: string[];
totalKeys: number;
};
function getLocalStorageSummary(): LocalStorageSummary {
if (typeof window === 'undefined') {
return { canvasKeys: [], totalKeys: 0 };
}
const canvasKeys: string[] = [];
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key && key.startsWith('containr.canvas.v1.')) {
canvasKeys.push(key);
}
}
canvasKeys.sort((left, right) => left.localeCompare(right));
return {
canvasKeys,
totalKeys: localStorage.length,
};
}
function endpointStateBadge(isLoading: boolean, isError: boolean): {
label: string;
toneClass: string;
} {
if (isLoading) {
return { label: 'Loading', toneClass: 'text-[var(--warn)]' };
}
if (isError) {
return { label: 'Unavailable', toneClass: 'text-[var(--bad)]' };
}
return { label: 'Available', toneClass: 'text-[var(--ok)]' };
}
export function UsagePage() {
const queryClient = useQueryClient();
const buildsQuery = useQuery({
queryKey: ['usage-builds'],
queryFn: () => listBuilds({ page: 1, limit: 100 }),
});
const builds = buildsQuery.data?.builds ?? [];
const liveConnected = useBuildUpdates(
builds.map((build) => build.id),
() => {
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
},
);
const successfulBuilds = builds.filter((build) => build.status === 'success').length;
const failedBuilds = builds.filter((build) => build.status === 'failed').length;
const runningBuilds = builds.filter((build) => build.status === 'running').length;
const pendingBuilds = builds.filter((build) => build.status === 'pending').length;
const completedDurationsMs = builds
.filter((build) => Boolean(build.startedAt && build.completedAt))
.map((build) => {
const started = new Date(build.startedAt as string).getTime();
const completed = new Date(build.completedAt as string).getTime();
return Math.max(0, completed - started);
})
.filter((duration) => Number.isFinite(duration) && duration > 0);
const totalBuildDurationMs = completedDurationsMs.reduce((sum, duration) => sum + duration, 0);
const averageBuildDurationMs =
completedDurationsMs.length > 0 ? totalBuildDurationMs / completedDurationsMs.length : 0;
const totalBuildHours = totalBuildDurationMs / 3_600_000;
const avgBuildMinutes = averageBuildDurationMs / 60_000;
const activeBuildPressure = runningBuilds + pendingBuilds;
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
let capacityBody = 'No active build pressure detected.';
if (buildsQuery.isLoading) {
buildActivityBody = 'Loading recent build activity from Containr API.';
runtimeBody = 'Loading build runtime metrics from Containr API.';
capacityBody = 'Loading active queue depth and service fan-out.';
} else if (buildsQuery.isError) {
buildActivityBody = 'Unable to fetch build telemetry right now. Check API connectivity and auth.';
runtimeBody = 'Unable to calculate runtime metrics while build telemetry is unavailable.';
capacityBody = 'Unable to evaluate queue pressure while build telemetry is unavailable.';
} else if (builds.length > 0) {
buildActivityBody = `${builds.length} recent builds, ${successfulBuilds} successful, ${failedBuilds} failed${runningBuilds > 0 ? `, ${runningBuilds} running` : ''}.`;
if (completedDurationsMs.length > 0) {
runtimeBody = `${totalBuildHours.toFixed(1)}h total build runtime across ${completedDurationsMs.length} completed builds (avg ${avgBuildMinutes.toFixed(1)}m).`;
} else {
runtimeBody = 'Builds exist, but completed duration samples are not available yet.';
}
if (activeBuildPressure > 0) {
capacityBody = `${activeBuildPressure} active jobs in queue (${runningBuilds} running, ${pendingBuilds} pending) across ${uniqueServices} service${uniqueServices === 1 ? '' : 's'}.`;
} else {
capacityBody = `No pending or running jobs. Last sampled ${builds.length} builds touched ${uniqueServices} service${uniqueServices === 1 ? '' : 's'}.`;
}
} else {
buildActivityBody = 'No builds recorded yet. Trigger a service deployment to populate this view.';
runtimeBody = 'No completed builds yet, so runtime totals are not available.';
capacityBody = 'Queue depth is empty because no build history is available yet.';
}
return (
<div className="min-h-screen">
<SecondaryPageHeader
title="Usage"
description="Platform usage metrics and operational summaries"
/>
<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'}
</span>
</div>
{buildsQuery.isLoading ? (
<div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p>
</div>
) : buildsQuery.isError ? (
<div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<AlertCircle size={24} className="text-[var(--error)]" />
</div>
<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>
)}
</div>
</div>
);
}
export function PeoplePage() {
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
});
const projectsQuery = useQuery({
queryKey: ['people-projects'],
queryFn: listProjects,
});
const auditLogsQuery = useQuery({
queryKey: ['people-audit-logs'],
queryFn: () => listAuditLogs({ page: 1, limit: 20 }),
});
let membersBody = 'Owner-only mode currently. Multi-user collaboration is planned.';
if (profileQuery.isLoading) {
membersBody = 'Loading authenticated profile from Containr API.';
} else if (profileQuery.isError) {
membersBody = 'Unable to fetch current user profile. Verify API connectivity and Better Auth session.';
} else if (profileQuery.data) {
membersBody = `${profileQuery.data.name} (${profileQuery.data.email}) is currently authenticated as platform owner.`;
}
let rolesBody = 'Predefined roles will map to service and project permissions.';
if (projectsQuery.isLoading) {
rolesBody = 'Loading project access footprint for current user.';
} else if (projectsQuery.isError) {
rolesBody = 'Project visibility check failed. Role scope cannot be calculated right now.';
} else {
const projectCount = projectsQuery.data?.length ?? 0;
rolesBody = `Current account can access ${projectCount} project${projectCount === 1 ? '' : 's'} in owner mode.`;
}
let auditBody = 'Human-readable action log for project and service changes.';
if (auditLogsQuery.isLoading) {
auditBody = 'Loading recent audit events from Containr API.';
} else if (auditLogsQuery.isError) {
auditBody = 'Unable to load recent audit events. Verify API connectivity and Better Auth session.';
} else {
const auditLogs = auditLogsQuery.data ?? [];
if (auditLogs.length > 0) {
const latest = auditLogs[0];
auditBody = `${auditLogs.length} recent events. Latest action: ${latest.action} on ${latest.resource}${
latest.resourceId ? ` (${latest.resourceId})` : ''
}.`;
} else {
auditBody = 'No audit events recorded yet for this account.';
}
}
return (
<div className="min-h-screen">
<SecondaryPageHeader
title="People"
description="Team management and access control"
/>
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
{profileQuery.isLoading ? (
<div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<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>
)}
</div>
</div>
);
}
export function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const profileQuery = useQuery({
queryKey: ['settings-profile'],
queryFn: getCurrentUserProfile,
});
const [nameDraft, setNameDraft] = useState<string | null>(null);
const [avatarDraft, setAvatarDraft] = useState<string | null>(null);
const [storageSummary, setStorageSummary] = useState<LocalStorageSummary>(() =>
getLocalStorageSummary(),
);
const resolvedNameDraft = nameDraft ?? profileQuery.data?.name ?? '';
const resolvedAvatarDraft = avatarDraft ?? profileQuery.data?.avatarUrl ?? '';
const hasProfileChanges = useMemo(() => {
if (!profileQuery.data) {
return false;
}
return (
resolvedNameDraft.trim() !== profileQuery.data.name ||
resolvedAvatarDraft.trim() !== (profileQuery.data.avatarUrl ?? '')
);
}, [profileQuery.data, resolvedAvatarDraft, resolvedNameDraft]);
const updateProfileMutation = useMutation({
mutationFn: () =>
updateCurrentUserProfile({
name: resolvedNameDraft.trim(),
avatarUrl: resolvedAvatarDraft.trim() || undefined,
}),
onSuccess: (profile) => {
queryClient.setQueryData(['settings-profile'], profile);
queryClient.setQueryData(['user-profile'], profile);
setNameDraft(null);
setAvatarDraft(null);
},
});
const refreshStorage = () => {
setStorageSummary(getLocalStorageSummary());
};
const clearCanvasCache = () => {
for (const key of storageSummary.canvasKeys) {
localStorage.removeItem(key);
}
refreshStorage();
};
const signOutLocalSession = async () => {
try {
await signOutAuthSession();
} catch {
// Continue with local cleanup even if remote sign-out call fails.
}
queryClient.removeQueries();
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate('/auth/sign-in', { replace: true });
refreshStorage();
};
return (
<div className="min-h-screen">
<SecondaryPageHeader
title="Settings"
description="Manage account profile and local configuration"
/>
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Profile Section */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<User size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Profile</h2>
<p className="text-xs text-[var(--text-tertiary)]">Backed by GET/PUT /user/profile</p>
</div>
</div>
{profileQuery.isLoading ? (
<div className="py-8 text-center">
<Loader2 size={20} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : profileQuery.isError ? (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
Failed to load profile
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Name
</label>
<input
value={resolvedNameDraft}
onChange={(e) => setNameDraft(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="Your name"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Avatar URL
</label>
<input
value={resolvedAvatarDraft}
onChange={(e) => setAvatarDraft(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="https://example.com/avatar.png"
/>
</div>
<div className="flex items-center gap-3 pt-2">
<button
onClick={() => updateProfileMutation.mutate()}
disabled={!hasProfileChanges || updateProfileMutation.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' }}
>
{updateProfileMutation.isPending ? (
<>
<Loader2 size={14} className="animate-spin" />
Saving...
</>
) : (
<>
<Check size={14} />
Save Profile
</>
)}
</button>
<button
onClick={() => profileQuery.refetch()}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
</div>
{updateProfileMutation.isError && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{updateProfileMutation.error instanceof Error ? updateProfileMutation.error.message : 'Failed to update profile'}
</div>
)}
{updateProfileMutation.isSuccess && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--success-soft)] text-sm text-[var(--success)] flex items-center gap-2">
<Check size={16} />
Profile updated successfully
</div>
)}
</div>
)}
</section>
{/* Runtime & Local State Section */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<Settings size={18} className="text-[var(--text-tertiary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Runtime & Local State</h2>
<p className="text-xs text-[var(--text-tertiary)]">API endpoints, cookie-session mode, and storage diagnostics</p>
</div>
</div>
<div className="space-y-3">
<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">
<Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Base</span>
</div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</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">
<Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Auth Base</span>
</div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</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">
<Shield size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Session Mode</span>
</div>
<p className="text-xs text-[var(--text-primary)]">HttpOnly Better Auth cookie (no local auth token)</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">
<Database size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Canvas Cache</span>
</div>
<p className="text-xs text-[var(--text-primary)]">
{storageSummary.canvasKeys.length} cached canvas entries ({storageSummary.totalKeys} total keys)
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 mt-4">
<button
onClick={refreshStorage}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
<button
onClick={clearCanvasCache}
disabled={storageSummary.canvasKeys.length === 0}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--warning-soft)] text-[var(--warning)] text-sm font-medium hover:bg-[var(--warning-soft)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Trash2 size={14} />
Clear Cache
</button>
<button
onClick={() => {
void signOutLocalSession();
}}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] transition-colors"
>
<LogOut size={14} />
Sign Out
</button>
</div>
{storageSummary.canvasKeys.length > 0 && (
<div className="mt-4 p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">Cached Keys</p>
<ul className="space-y-1">
{storageSummary.canvasKeys.slice(0, 5).map((key) => (
<li key={key} className="mono text-xs text-[var(--text-tertiary)] truncate">
{key}
</li>
))}
{storageSummary.canvasKeys.length > 5 && (
<li className="text-xs text-[var(--text-muted)]">
+{storageSummary.canvasKeys.length - 5} more...
</li>
)}
</ul>
</div>
)}
</section>
</div>
</div>
</div>
);
}
export function DocsPage() {
const profileQuery = useQuery({
queryKey: ['docs-profile'],
queryFn: getCurrentUserProfile,
});
const projectsQuery = useQuery({
queryKey: ['docs-projects'],
queryFn: listProjects,
});
const templatesQuery = useQuery({
queryKey: ['docs-templates'],
queryFn: () => listTemplates(),
});
const buildsQuery = useQuery({
queryKey: ['docs-builds'],
queryFn: () => listBuilds({ page: 1, limit: 1 }),
});
const profileStatus = endpointStateBadge(profileQuery.isLoading, profileQuery.isError);
const projectStatus = endpointStateBadge(projectsQuery.isLoading, projectsQuery.isError);
const templateStatus = endpointStateBadge(templatesQuery.isLoading, templatesQuery.isError);
const buildStatus = endpointStateBadge(buildsQuery.isLoading, buildsQuery.isError);
return (
<div className="min-h-screen">
<SecondaryPageHeader
title="Docs"
description="Operational references and API documentation"
/>
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
{/* API Status Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<User size={14} className={profileStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Profile</span>
</div>
<p className={`text-sm font-semibold ${profileStatus.toneClass}`}>{profileStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{profileQuery.data ? `${profileQuery.data.name} authenticated` : 'GET /user/profile'}
</p>
</div>
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<Folder size={14} className={projectStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Projects</span>
</div>
<p className={`text-sm font-semibold ${projectStatus.toneClass}`}>{projectStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{projectsQuery.data ? `${projectsQuery.data.length} projects` : 'GET /projects'}
</p>
</div>
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<Box size={14} className={templateStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Templates</span>
</div>
<p className={`text-sm font-semibold ${templateStatus.toneClass}`}>{templateStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{templatesQuery.data ? `${templatesQuery.data.length} templates` : 'GET /templates'}
</p>
</div>
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<Activity size={14} className={buildStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Builds</span>
</div>
<p className={`text-sm font-semibold ${buildStatus.toneClass}`}>{buildStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{buildsQuery.data ? `${buildsQuery.data.total} builds` : 'GET /builds'}
</p>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Repository Paths */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<BookOpen size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Repository Paths</h2>
<p className="text-xs text-[var(--text-tertiary)]">Source files and documentation</p>
</div>
</div>
<div className="space-y-3">
<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">
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Contract</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">docs/api/openapi.yaml</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">
<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>
</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">
<BookOpen size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Primary Guides</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">README.md • DOCKER_SETUP.md • docs/guides/</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">
<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>
</div>
</div>
</section>
{/* Common Commands */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<Terminal size={18} className="text-[var(--text-tertiary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Common Commands</h2>
<p className="text-xs text-[var(--text-tertiary)]">Development and build scripts</p>
</div>
</div>
<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
# Frontend type-check + build
npm --prefix frontend run build:check
# Backend API tests
cd backend && go test ./internal/api/...`}
</pre>
</div>
<div className="mt-4 p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<Radio size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Active API Base</span>
</div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p>
</div>
</section>
</div>
</div>
</div>
);
}
export { ComponentShowcase } from './pages/ComponentShowcase';
@@ -0,0 +1,472 @@
import { useState } from 'react';
import {
Cpu,
MemoryStick,
Zap,
Users,
Activity,
TrendingUp,
Database
} from 'lucide-react';
import {
LineChart,
LineAreaChart,
MultiLineChart,
DonutChart,
BarChart,
EnhancedMetricCard,
CacheMetricCard,
PerformanceMetricCard,
useToast
} from '@/shared/components';
// Sample data
const sampleLineData = [12, 19, 15, 25, 22, 30, 28, 35, 32, 38, 35, 42, 38, 45, 42, 48, 45, 52, 48, 55, 52, 58, 55, 60];
const sampleAreaData = [40, 45, 42, 50, 48, 55, 52, 60, 58, 65, 62, 70, 68, 75, 72];
const sampleBarData = [20, 35, 28, 45, 38, 52, 48, 60, 55, 68, 62, 75, 70, 82, 78, 88, 85, 92, 88, 95, 92, 98, 95, 100];
export function ComponentShowcase() {
const { showToast } = useToast();
const [activeTab, setActiveTab] = useState<'charts' | 'cards' | 'ui'>('charts');
const handleCardClick = (name: string) => {
showToast(`${name} clicked!`, 'info');
};
return (
<div className="min-h-screen p-8" style={{ background: '#16171c' }}>
<div className="ambient-glow" />
<div className="max-w-7xl mx-auto relative z-10">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center"
style={{ background: '#e8316a' }}
>
<Activity size={24} className="text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-[var(--text-primary)]">
Component Showcase
</h1>
<p className="text-sm text-[var(--text-secondary)]">
Production-grade components matching self.html design
</p>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-[var(--border-subtle)] gap-1">
<button
onClick={() => setActiveTab('charts')}
className={`tab ${activeTab === 'charts' ? 'active' : ''}`}
>
<Activity size={14} />
Charts
</button>
<button
onClick={() => setActiveTab('cards')}
className={`tab ${activeTab === 'cards' ? 'active' : ''}`}
>
<Cpu size={14} />
Metric Cards
</button>
<button
onClick={() => setActiveTab('ui')}
className={`tab ${activeTab === 'ui' ? 'active' : ''}`}
>
<Zap size={14} />
UI Elements
</button>
</div>
</div>
{/* Charts Tab */}
{activeTab === 'charts' && (
<div className="space-y-8">
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Line Charts
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Basic Line Chart (CPU Style)
</h3>
<LineChart
data={sampleLineData}
color="#ff7043"
height={120}
showDots={true}
smooth={true}
/>
</div>
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Line Chart (No Dots)
</h3>
<LineChart
data={sampleLineData}
color="#6c8ef0"
height={120}
showDots={false}
smooth={true}
/>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Area Charts
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Area Chart (Active Users Style)
</h3>
<LineAreaChart
data={sampleAreaData}
color="#e8316a"
fillOpacity={0.15}
height={150}
/>
</div>
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Area Chart (Success Color)
</h3>
<LineAreaChart
data={sampleAreaData}
color="#3dd68c"
fillOpacity={0.2}
height={150}
/>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Multi-Line & Bar Charts
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Multi-Line Chart (Performance Style)
</h3>
<MultiLineChart
datasets={[
{ data: sampleLineData.map(v => v + 10), color: '#6c8ef0', fillOpacity: 0.15 },
{ data: sampleLineData, color: '#9c7ef0', fillOpacity: 0.15 }
]}
height={120}
/>
</div>
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Bar Chart (Timeline Style)
</h3>
<BarChart
data={sampleBarData}
color="#3dd68c"
height={120}
gap={2}
/>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Donut Charts
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="panel p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
RAM Usage (65%)
</h3>
<DonutChart percentage={65} color="#9c7ef0" size={160} />
</div>
<div className="panel p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Disk Usage (82%)
</h3>
<DonutChart percentage={82} color="#ff7043" size={160} />
</div>
<div className="panel p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Network (45%)
</h3>
<DonutChart percentage={45} color="#6c8ef0" size={160} />
</div>
</div>
</section>
</div>
)}
{/* Metric Cards Tab */}
{activeTab === 'cards' && (
<div className="space-y-8">
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Standard Metric Cards
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<EnhancedMetricCard
title="CPU Usage"
value="12%"
subtitle="Daily usage"
status="good"
statusText="Good"
icon={<Cpu size={16} />}
chart={<LineChart data={sampleLineData} color="#ff7043" height={76} />}
onClick={() => handleCardClick('CPU Usage')}
animationDelay={0.04}
/>
<EnhancedMetricCard
title="Memory"
value="65%"
subtitle="Container footprint"
status="average"
statusText="Average"
icon={<MemoryStick size={16} />}
chart={<LineChart data={sampleLineData.map(v => v + 20)} color="#9c7ef0" height={76} />}
onClick={() => handleCardClick('Memory')}
animationDelay={0.09}
/>
<EnhancedMetricCard
title="Requests"
value="2.4K"
subtitle="Last 60 minutes"
icon={<Zap size={16} />}
chart={<BarChart data={sampleBarData} color="#3dd68c" height={76} gap={2} />}
onClick={() => handleCardClick('Requests')}
animationDelay={0.14}
/>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Horizontal Layout Cards
</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<EnhancedMetricCard
title="Active Users"
value="475 K"
subtitle="Users active right now"
icon={<Users size={16} />}
chart={<LineAreaChart data={sampleAreaData} color="#e8316a" height={130} />}
details={
<div className="flex items-center gap-1.5 flex-wrap">
{['🇨🇳', '🇮🇩', '🇲🇲', '🇲🇾', '🇯🇵', '🇮🇳', '🇰🇷', '🇵🇭'].map((flag, i) => (
<span key={i} className="flag">{flag}</span>
))}
</div>
}
horizontal
onClick={() => handleCardClick('Active Users')}
animationDelay={0.04}
/>
<EnhancedMetricCard
title="Database Queries"
value="12.5K"
subtitle="Queries per minute"
icon={<Database size={16} />}
chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="#6c8ef0" height={130} />}
details={
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
<span className="text-[var(--text-secondary)]">Read: 8.2K</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-[var(--warning)]" />
<span className="text-[var(--text-secondary)]">Write: 4.3K</span>
</div>
</div>
}
horizontal
onClick={() => handleCardClick('Database Queries')}
animationDelay={0.09}
/>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Specialized Cards
</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<CacheMetricCard
totalMB={352}
cacheMB={212}
nonCacheMB={85}
onClick={() => handleCardClick('Cache')}
animationDelay={0.04}
/>
<PerformanceMetricCard
percentage={89}
upSpeed={10.4}
downSpeed={5.2}
datasets={[
{ data: sampleLineData.map(v => v + 20), color: '#6c8ef0', fillOpacity: 0.15 },
{ data: sampleLineData.map(v => v + 10), color: '#9c7ef0', fillOpacity: 0.15 }
]}
onClick={() => handleCardClick('Performance')}
animationDelay={0.09}
/>
</div>
</section>
</div>
)}
{/* UI Elements Tab */}
{activeTab === 'ui' && (
<div className="space-y-8">
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Badges & Indicators
</h2>
<div className="panel p-6 space-y-4">
<div className="flex items-center gap-4 flex-wrap">
<span className="badge-active">
<span className="live-dot" />
Active
</span>
<span className="badge-stopped">Stopped</span>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--success-soft)] text-[var(--success)] text-xs font-medium">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
Healthy
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--warning-soft)] text-[var(--warning)] text-xs font-medium">
<div className="w-2 h-2 rounded-full bg-[var(--warning)]" />
Degraded
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--error-soft)] text-[var(--error)] text-xs font-medium">
<div className="w-2 h-2 rounded-full bg-[var(--error)]" />
Critical
</div>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Buttons
</h2>
<div className="panel p-6 space-y-4">
<div className="flex items-center gap-4 flex-wrap">
<button className="btn-stop">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
</svg>
STOP
</button>
<button className="btn-restart">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
</svg>
RESTART
</button>
<div className="arrow-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Pills & Tabs
</h2>
<div className="panel p-6 space-y-6">
<div>
<p className="text-sm text-[var(--text-secondary)] mb-3">Pill Group</p>
<div className="pill-group">
<div className="pill active">Day</div>
<div className="pill">Month</div>
<div className="pill">Year</div>
</div>
</div>
<div>
<p className="text-sm text-[var(--text-secondary)] mb-3">Tabs</p>
<div className="flex border-b border-[var(--border-subtle)]">
<button className="tab active">
<Activity size={14} />
Metrics
</button>
<button className="tab">
<Zap size={14} />
Requests
</button>
<button className="tab">
<TrendingUp size={14} />
Analytics
</button>
</div>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Search & Input
</h2>
<div className="panel p-6 space-y-4">
<div className="search-box">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" placeholder="Search logs..." />
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Toast Notifications
</h2>
<div className="panel p-6">
<div className="flex gap-3 flex-wrap">
<button
onClick={() => showToast('Operation completed successfully!', 'success')}
className="px-4 py-2 rounded-lg bg-[var(--success-soft)] text-[var(--success)] text-sm font-medium hover:bg-[var(--success-soft)]/80 transition-colors"
>
Show Success
</button>
<button
onClick={() => showToast('This is an informational message', 'info')}
className="px-4 py-2 rounded-lg bg-[var(--info-soft)] text-[var(--info)] text-sm font-medium hover:bg-[var(--info-soft)]/80 transition-colors"
>
Show Info
</button>
<button
onClick={() => showToast('Warning: Please check your settings', 'warning')}
className="px-4 py-2 rounded-lg bg-[var(--warning-soft)] text-[var(--warning)] text-sm font-medium hover:bg-[var(--warning-soft)]/80 transition-colors"
>
Show Warning
</button>
<button
onClick={() => showToast('Error: Operation failed', 'error')}
className="px-4 py-2 rounded-lg bg-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)]/80 transition-colors"
>
Show Error
</button>
</div>
</div>
</section>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,820 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
createDeployment,
deleteService,
getProjectById,
getDeploymentLogs,
getServiceById,
listDeployments,
listServiceLogs,
rollbackDeployment,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data';
import { formatDate, formatRelative, seededMetric } from '@/lib/time';
import {
ArrowLeft,
Activity,
FileText,
Settings,
Sliders,
Play,
RotateCw,
Trash2,
Check,
X,
Loader2,
Clock,
Cpu,
Layers,
MemoryStick,
Zap,
Timer,
Sparkles,
RefreshCw,
Box,
} from 'lucide-react';
type ServiceSection = 'metrics' | 'logs' | 'config' | 'settings';
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
{ key: 'metrics', label: 'Metrics', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText },
{ key: 'config', label: 'Config', icon: Sliders },
{ key: 'settings', label: 'Settings', icon: Settings },
];
function MetricCard({ label, value, hint, icon: Icon, trend }: { label: string; value: string; hint: string; icon: typeof Cpu; trend?: 'up' | 'down' | 'stable' }) {
return (
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Icon size={16} className="text-[var(--accent-primary)]" />
</div>
{trend && (
<div className={`text-xs ${trend === 'up' ? 'text-[var(--success)]' : trend === 'down' ? 'text-[var(--error)]' : 'text-[var(--text-tertiary)]'}`}>
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'}
</div>
)}
</div>
<p className="text-2xl font-semibold text-[var(--text-primary)]">{value}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">{label}</p>
<p className="text-[10px] text-[var(--text-tertiary)] mt-0.5">{hint}</p>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const config = {
running: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
deployed: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
building: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
pending: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
rolling_back: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: RefreshCw, animate: true },
stopped: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false },
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false };
const { Icon } = config;
return (
<div
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium"
style={{ background: config.bg, color: config.color }}
>
<Icon size={12} className={config.animate ? 'animate-spin' : ''} />
{status.replace('_', ' ')}
</div>
);
}
export function ServiceDetailPage() {
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1';
const [activeSection, setActiveSection] = useState<ServiceSection>('metrics');
const [logTail, setLogTail] = useState('100');
const projectQuery = useQuery({
queryKey: ['project', projectId],
queryFn: () => getProjectById(projectId),
enabled: Boolean(projectId) && !isDemoMode,
});
const serviceQuery = useQuery({
queryKey: ['service', serviceId],
queryFn: () => getServiceById(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
});
const deploymentsQuery = useQuery({
queryKey: ['service-deployments', serviceId],
queryFn: () => listDeployments(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
refetchInterval: 4000,
});
const serviceLogsQuery = useQuery({
queryKey: ['service-logs', serviceId, logTail],
queryFn: () => listServiceLogs(serviceId, { tail: logTail }),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'logs',
});
const latestDeployment = useMemo(() => {
if (isDemoMode) {
return null;
}
const deployments = deploymentsQuery.data ?? [];
return deployments[0] ?? null;
}, [deploymentsQuery.data, isDemoMode]);
const deploymentLogsQuery = useQuery({
queryKey: ['deployment-logs', latestDeployment?.id],
queryFn: () => getDeploymentLogs(latestDeployment!.id, { type: 'all' }),
enabled:
Boolean(latestDeployment?.id) &&
!isDemoMode &&
activeSection === 'logs',
});
const deployMutation = useMutation({
mutationFn: (trigger: 'manual' | 'restart') => createDeployment(serviceId, { trigger }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-deployments', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
},
});
const rollbackMutation = useMutation({
mutationFn: (deploymentId: string) => rollbackDeployment(deploymentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-deployments', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
},
});
const deleteServiceMutation = useMutation({
mutationFn: () => deleteService(serviceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
navigate(`/projects/${projectId}`);
},
});
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
const metricSet = useMemo(() => {
if (!service) {
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,
};
}, [service]);
const deploymentSummary = useMemo(() => {
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
const total = deployments.length;
const active = deployments.filter((deployment) =>
['pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
).length;
const failed = deployments.filter((deployment) => deployment.status === 'failed').length;
return { total, active, failed };
}, [deploymentsQuery.data, isDemoMode]);
const canRollback = (status: string): boolean => status === 'deployed' || status === 'failed';
if (!isDemoMode && (projectQuery.isLoading || serviceQuery.isLoading)) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3 text-[var(--text-secondary)]">
<Loader2 size={20} className="animate-spin" />
<span>Loading service...</span>
</div>
</div>
);
}
if (!isDemoMode && serviceQuery.isError) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
<div className="panel p-8 text-center max-w-md">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-lg font-medium text-[var(--text-primary)]">Failed to load service</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">{(serviceQuery.error as Error).message}</p>
<button
onClick={() => serviceQuery.refetch()}
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
Retry
</button>
</div>
</div>
);
}
if (!service || !project) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
<div className="panel p-8 text-center max-w-md">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Box size={24} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-lg font-medium text-[var(--text-primary)]">Service not found</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">This service may have been deleted.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen relative">
{/* Breadcrumb - self.html exact match */}
<div
className="flex items-center"
style={{
gap: '6px',
padding: '14px 24px 10px',
color: '#6b6e7d',
fontSize: '13px'
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
<button
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
style={{ color: '#6b6e7d', textDecoration: 'none' }}
className="hover:text-[#9295a4] transition-colors"
>
Servers
</button>
<span style={{ opacity: 0.4 }}>/</span>
<span style={{ color: '#9295a4' }}>{service.name}</span>
</div>
{/* Project Header - self.html exact match */}
<div className="flex items-center" style={{ padding: '0 24px 18px' }}>
<div
className="rounded-[13px] flex items-center justify-center flex-shrink-0"
style={{
width: '46px',
height: '46px',
background: '#e8316a',
marginRight: '14px'
}}
>
<svg width="22" height="22" 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>
<div>
<div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>{service.name}</span>
<span className={`badge-${service.status === 'running' ? 'active' : 'stopped'}`}>
{service.status === 'running' && <span className="live-dot" />}
{service.status === 'running' ? 'Active' : 'Stopped'}
</span>
</div>
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
{service.status === 'running' && (
<a
href={`https://${service.name}.containr.dev`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
>
https://{service.name}.containr.dev
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
)}
<button
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
>
Project Information
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
</div>
</div>
<div className="ml-auto flex" style={{ gap: '10px' }}>
{!isDemoMode && (
<>
<button
onClick={() => deployMutation.mutate('restart')}
disabled={deployMutation.isPending || service.status !== 'running'}
className={`btn-stop ${service.status !== 'running' ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
</svg>
STOP
</button>
<button
onClick={() => deployMutation.mutate('manual')}
disabled={deployMutation.isPending}
className={`btn-restart ${deployMutation.isPending ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
</svg>
RESTART
</button>
</>
)}
</div>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active — using sample data for preview</span>
</div>
</div>
</div>
)}
{/* Metrics Overview */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="CPU"
value={`${metricSet?.cpu ?? 0}%`}
hint="Current utilization"
icon={Cpu}
trend={metricSet && metricSet.cpu > 60 ? 'up' : metricSet && metricSet.cpu < 30 ? 'down' : 'stable'}
/>
<MetricCard
label="Memory"
value={`${metricSet?.memory ?? 0}%`}
hint="Container footprint"
icon={MemoryStick}
trend={metricSet && metricSet.memory > 70 ? 'up' : 'stable'}
/>
<MetricCard
label="Requests"
value={`${metricSet?.req ?? 0}`}
hint="Last 60 minutes"
icon={Zap}
/>
<MetricCard
label="Latency"
value={`${metricSet?.latency ?? 0}ms`}
hint="P95 estimate"
icon={Timer}
trend={metricSet && metricSet.latency > 150 ? 'up' : 'stable'}
/>
</div>
{/* Deployment Summary */}
{!isDemoMode && (
<div className="mt-4 flex items-center gap-6 text-sm">
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
<span className="w-2 h-2 rounded-full bg-[var(--accent-primary)]" />
<span>{deploymentSummary.total} deployments</span>
</div>
{deploymentSummary.active > 0 && (
<div className="flex items-center gap-2 text-[var(--warning)]">
<span className="w-2 h-2 rounded-full bg-[var(--warning)] animate-pulse" />
<span>{deploymentSummary.active} active</span>
</div>
)}
{deploymentSummary.failed > 0 && (
<div className="flex items-center gap-2 text-[var(--error)]">
<span className="w-2 h-2 rounded-full bg-[var(--error)]" />
<span>{deploymentSummary.failed} failed</span>
</div>
)}
{latestDeployment?.createdAt && (
<div className="flex items-center gap-1.5 text-[var(--text-tertiary)]">
<Clock size={12} />
<span>Latest {formatRelative(latestDeployment.createdAt)}</span>
</div>
)}
</div>
)}
</div>
{/* Tabs - self.html exact match */}
<div
className="flex"
style={{
borderBottom: '1px solid rgba(255,255,255,0.07)',
marginBottom: '18px',
padding: '0 24px'
}}
>
{sectionItems.map((item) => {
const active = activeSection === item.key;
const Icon = item.icon;
return (
<button
key={item.key}
onClick={() => setActiveSection(item.key)}
className={`tab ${active ? 'active' : ''}`}
>
<Icon size={14} />
{item.label}
</button>
);
})}
</div>
{/* Content - self.html exact match: padding 0 24px 28px */}
<div style={{ padding: '0 24px 28px' }}>
{activeSection === 'metrics' && (
<div className="space-y-6">
{/* Metrics Grid with enhanced cards */}
<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">
<div className="flex items-center gap-3">
<div className="card-icon">
<Cpu size={18} />
</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>
</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}%`}
/>
);
})}
</div>
</div>
<div className="panel p-5 group hover:border-[var(--success)]/30 transition-all duration-300">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="card-icon">
<Zap 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>
</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}%`}
/>
);
})}
</div>
</div>
</div>
{/* Domain & Networking Panel */}
{service.status === 'running' && (
<div className="panel p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Zap size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Networking</p>
<p className="text-xs text-[var(--text-tertiary)]">Public endpoints and ports</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div 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-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
<span className="text-xs text-[var(--text-secondary)]">HTTPS</span>
</div>
<a
href={`https://${service.name}.containr.dev`}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs text-[var(--accent-primary)] hover:underline"
>
{service.name}.containr.dev
</a>
</div>
<div 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-2">
<div className="w-2 h-2 rounded-full bg-[var(--accent-secondary)]" />
<span className="text-xs text-[var(--text-secondary)]">Port</span>
</div>
<span className="mono text-xs text-[var(--text-primary)]">8080 → 443</span>
</div>
</div>
</div>
)}
</div>
)}
{activeSection === 'logs' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-6">
<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">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Service Logs</h2>
<p className="text-sm text-[var(--text-secondary)]">Container stdout/stderr output</p>
</div>
</div>
{!isDemoMode && (
<div className="flex items-center gap-3">
<select
value={logTail}
onChange={(e) => setLogTail(e.target.value)}
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm"
>
<option value="50">50 lines</option>
<option value="100">100 lines</option>
<option value="250">250 lines</option>
</select>
<button
onClick={() => {
serviceLogsQuery.refetch();
if (latestDeployment?.id) deploymentLogsQuery.refetch();
}}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
</div>
)}
</div>
<div className="mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] max-h-[500px] overflow-auto">
{isDemoMode ? (
<div className="space-y-1">
{Array.from({ length: 15 }).map((_, i) => (
<p key={i}>
<span className="text-[var(--text-muted)]">
[{service.updatedAt ? new Date(new Date(service.updatedAt).getTime() - i * 45000).toLocaleTimeString() : '--:--:--'}]
</span>
{' '}
<span className="text-[var(--accent-primary)]">{service.name}</span>
{' '}
<span className="text-[var(--text-tertiary)]">{i % 3 === 0 ? 'health_check=ok' : 'request=200'}</span>
</p>
))}
</div>
) : serviceLogsQuery.isLoading ? (
<p className="text-[var(--text-muted)]">Loading logs...</p>
) : serviceLogsQuery.isError ? (
<p className="text-[var(--error)]">Failed to load logs.</p>
) : (serviceLogsQuery.data?.length ?? 0) === 0 ? (
<p className="text-[var(--text-muted)]">No logs available.</p>
) : (
<div className="space-y-1">
{serviceLogsQuery.data!.map((entry, i) => (
<p key={`${entry.timestamp}-${i}`}>
<span className="text-[var(--text-muted)]">
[{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '--:--:--'}]
</span>
{' '}
<span className="text-[var(--accent-primary)]">{entry.stream}</span>
{' '}
{entry.message}
</p>
))}
</div>
)}
</div>
{!isDemoMode && latestDeployment && (
<div className="mt-6 panel-soft p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Latest Deployment</p>
<StatusBadge status={latestDeployment.status} />
</div>
<p className="text-xs text-[var(--text-tertiary)] mono">{latestDeployment.id}</p>
<pre className="mono mt-3 max-h-40 overflow-auto whitespace-pre-wrap break-all text-xs text-[var(--text-secondary)]">
{deploymentLogsQuery.isLoading
? 'Loading...'
: deploymentLogsQuery.isError
? 'Failed to load.'
: deploymentLogsQuery.data?.buildLog || deploymentLogsQuery.data?.runtimeLog || '(no output)'}
</pre>
</div>
)}
</div>
)}
{activeSection === 'config' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Sliders size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Configuration</h2>
<p className="text-sm text-[var(--text-secondary)]">Runtime and deployment settings</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Environment</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{service.environment ?? 'production'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Image</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{service.image ?? 'not set'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Command</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{service.command ?? 'default'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Git Branch</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)]">{service.gitBranch ?? 'not configured'}</p>
</div>
</div>
</div>
)}
{activeSection === 'settings' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Settings size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<p className="text-sm text-[var(--text-secondary)]">Service metadata and history</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Service ID</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{service.id}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(service.createdAt)}</p>
</div>
<div className="panel-soft p-4 md:col-span-2">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Last Update</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatRelative(service.updatedAt)}</p>
</div>
</div>
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Deployment History</h3>
<button
onClick={() => deploymentsQuery.refetch()}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={12} />
Refresh
</button>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden">
<table className="min-w-full text-xs">
<thead className="bg-[var(--surface-muted)]">
<tr>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">Status</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">ID</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">Image</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">Created</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-subtle)]">
{deploymentsQuery.isLoading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-[var(--text-muted)]">
Loading...
</td>
</tr>
) : deploymentsQuery.isError ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-[var(--error)]">
Failed to load
</td>
</tr>
) : (deploymentsQuery.data?.length ?? 0) === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-[var(--text-muted)]">
No deployments yet
</td>
</tr>
) : (
deploymentsQuery.data!.map((deployment) => (
<tr key={deployment.id} className="hover:bg-[var(--surface-muted)]/50 transition-colors">
<td className="px-4 py-3">
<StatusBadge status={deployment.status} />
</td>
<td className="px-4 py-3 mono text-[var(--text-secondary)]">{deployment.id}</td>
<td className="px-4 py-3 mono text-[var(--text-secondary)]">
{deployment.imageName || '—'}:{deployment.imageTag || '—'}
</td>
<td className="px-4 py-3 text-[var(--text-tertiary)]">
{deployment.createdAt ? formatRelative(deployment.createdAt) : '—'}
</td>
<td className="px-4 py-3">
<button
onClick={() => rollbackMutation.mutate(deployment.id)}
disabled={!canRollback(deployment.status) || rollbackMutation.isPending}
className="px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] disabled:opacity-50 transition-colors"
>
{rollbackMutation.isPending && rollbackMutation.variables === deployment.id ? '...' : 'Rollback'}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
{/* Error Toasts */}
{!isDemoMode && (deleteServiceMutation.error || deployMutation.error || rollbackMutation.error) && (
<div className="fixed bottom-4 right-4 space-y-2">
{deleteServiceMutation.error && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
{(deleteServiceMutation.error as Error).message}
</div>
)}
{deployMutation.error && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
{(deployMutation.error as Error).message}
</div>
)}
{rollbackMutation.error && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
{(rollbackMutation.error as Error).message}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,494 @@
import { useMemo, useState, useEffect } from 'react';
import { useParams, useSearchParams, 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,
EnhancedMetricCard,
CacheMetricCard,
PerformanceMetricCard
} from '@/shared/components';
// Generate seeded random data for demo
function seededRandom(seed: string, min: number, max: number): number {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash) + seed.charCodeAt(i);
hash = hash & hash;
}
const normalized = Math.abs(Math.sin(hash));
return Math.floor(normalized * (max - min + 1)) + min;
}
type TabKey = 'metrics' | 'requests' | 'apis' | 'config';
const tabs: Array<{ key: TabKey; label: string; icon: typeof Activity }> = [
{ key: 'metrics', label: 'Metrics', icon: Activity },
{ key: 'requests', label: 'Requests', icon: Zap },
{ key: 'apis', label: 'APIs', icon: Globe },
{ key: 'config', label: 'Config', icon: Sliders },
];
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');
const [timePeriod, setTimePeriod] = useState('Day');
const [isSystemRunning, setIsSystemRunning] = useState(true);
// Generate realistic metric data
const metrics = useMemo(() => {
const cpuData = Array.from({ length: 24 }, (_, i) =>
seededRandom(`${serviceId}:cpu:${i}`, 10, 45)
);
const ramPercent = seededRandom(`${serviceId}:ram`, 50, 85);
const userCount = seededRandom(`${serviceId}:users`, 400, 500);
const userData = Array.from({ length: 15 }, (_, i) =>
seededRandom(`${serviceId}:users:${i}`, 40, 110)
);
const perfPercent = seededRandom(`${serviceId}:perf`, 82, 99);
const perfData1 = Array.from({ length: 12 }, (_, i) =>
seededRandom(`${serviceId}:perf1:${i}`, 70, 95)
);
const perfData2 = Array.from({ length: 12 }, (_, i) =>
seededRandom(`${serviceId}:perf2:${i}`, 60, 90)
);
return {
cpu: cpuData[cpuData.length - 1],
cpuData,
ram: ramPercent,
ramUsedGB: ((8 * ramPercent) / 100).toFixed(1),
users: userCount,
userData,
perf: perfPercent,
perfData: [
{ 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),
};
}, [serviceId, timePeriod]);
// Auto-update metrics every 2 seconds
useEffect(() => {
if (!isSystemRunning) return;
const interval = setInterval(() => {
// Trigger re-render by updating time period
setTimePeriod(prev => prev);
}, 2000);
return () => clearInterval(interval);
}, [isSystemRunning]);
const handleStop = () => {
setIsSystemRunning(false);
};
const handleRestart = () => {
setIsSystemRunning(true);
};
return (
<div className="min-h-screen" style={{ background: '#16171c' }}>
{/* Ambient glow */}
<div className="ambient-glow" />
<div className="flex min-h-screen relative">
{/* Sidebar - self.html exact match */}
<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'
}}
>
{/* Logo */}
<div
className="rounded-full flex items-center justify-center"
style={{
width: '38px',
height: '38px',
background: '#e8316a',
marginBottom: '14px'
}}
>
<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 */}
<div className="nav-item active">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.5"/>
<rect x="14" y="3" width="7" height="7" rx="1.5"/>
<rect x="3" y="14" width="7" height="7" rx="1.5"/>
<rect x="14" y="14" width="7" height="7" rx="1.5"/>
</svg>
</div>
<div className="nav-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<path d="M8 21h8M12 17v4"/>
</svg>
</div>
<div className="nav-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v6c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/>
<path d="M3 11v6c0 1.66 4.03 3 9 3s9-1.34 9-3v-6"/>
</svg>
</div>
<div className="flex-1" />
{/* User Avatar */}
<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'
}}
>
w.
</div>
</aside>
{/* Main Content */}
<div className="flex-1 flex flex-col min-h-screen overflow-hidden">
{/* Topbar - self.html exact match */}
<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="search-box">
<Search size={14} />
<input type="text" placeholder="Search logs..." />
</div>
<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'
}}
>
Support
</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',
gap: '6px'
}}
>
<ChevronUp size={13} />
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)'
}}
>
<Bell size={15} color="#9295a4" />
</button>
</div>
</header>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto" style={{ padding: '0 24px 28px' }}>
{/* Breadcrumb */}
<div
className="flex items-center"
style={{
gap: '6px',
padding: '14px 0 10px',
color: '#6b6e7d',
fontSize: '13px'
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
<button
onClick={() => navigate(-1)}
style={{ color: '#6b6e7d' }}
className="hover:text-[#9295a4] transition-colors"
>
Servers
</button>
<span style={{ opacity: 0.4 }}>/</span>
<span style={{ color: '#9295a4' }}>[NuFest] - App Project</span>
</div>
{/* Project Header */}
<div className="flex items-center" style={{ paddingBottom: '18px' }}>
<div
className="rounded-[13px] flex items-center justify-center flex-shrink-0"
style={{
width: '46px',
height: '46px',
background: '#e8316a',
marginRight: '14px'
}}
>
<svg width="22" height="22" 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>
<div>
<div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>
[NuFest] - App Project
</span>
<span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}>
{isSystemRunning && <span className="live-dot" />}
{isSystemRunning ? 'Active' : 'Stopped'}
</span>
</div>
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
<a
href="https://nufest-dth.app"
target="_blank"
rel="noopener noreferrer"
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
>
https://nufest-dth.app
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
<button
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', gap: '4px' }}
>
Project Information
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
</div>
</div>
<div className="ml-auto flex" style={{ gap: '10px' }}>
<button
onClick={handleStop}
disabled={!isSystemRunning}
className={`btn-stop ${!isSystemRunning ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
</svg>
STOP
</button>
<button
onClick={handleRestart}
disabled={isSystemRunning}
className={`btn-restart ${isSystemRunning ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
</svg>
RESTART
</button>
</div>
</div>
{/* Tabs */}
<div
className="flex"
style={{
borderBottom: '1px solid rgba(255,255,255,0.07)',
marginBottom: '18px'
}}
>
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`tab ${activeTab === tab.key ? 'active' : ''}`}
>
<Icon size={14} />
{tab.label}
</button>
);
})}
</div>
{/* Metrics Header */}
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span>
<div className="flex items-center" style={{ gap: '8px' }}>
<button
className="rounded-[9px] border flex items-center font-medium cursor-pointer"
style={{
height: '32px',
padding: '0 12px',
border: '1px solid rgba(255,255,255,0.09)',
background: 'rgba(255,255,255,0.04)',
color: '#9295a4',
fontSize: '12.5px',
gap: '6px'
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
Filter
</button>
<div className="pill-group">
{timePeriods.map((period) => (
<div
key={period}
onClick={() => setTimePeriod(period)}
className={`pill ${timePeriod === period ? 'active' : ''}`}
>
{period}
</div>
))}
</div>
</div>
</div>
{/* Row 1: CPU, RAM, Cache */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.95fr', gap: '13px', marginBottom: '13px' }}>
{/* CPU Card */}
<EnhancedMetricCard
title="CPU Usage"
value={`${metrics.cpu}%`}
subtitle="Daily usage"
status="good"
statusText="Good"
icon={<Cpu size={16} />}
chart={<LineChart data={metrics.cpuData} color="#ff7043" height={76} />}
animationDelay={0.04}
/>
{/* RAM Card */}
<div className="card" style={{ animationDelay: '0.09s' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">
<MemoryStick size={16} />
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span>
</div>
<div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>
{metrics.ram}%
</div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<span style={{ color: '#f0a040', fontWeight: 700 }}>Average</span> Daily usage
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
<DonutChart percentage={metrics.ram} color="#9c7ef0" />
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
<div style={{ fontSize: 10.5, color: '#6b6e7d', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: 12.5, fontWeight: 700, color: '#e8e9f0' }}>{metrics.ramUsedGB} GB / 8GB</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4 }}>
<span style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div>
{/* Cache Card */}
<CacheMetricCard
totalMB={352}
cacheMB={212}
nonCacheMB={85}
animationDelay={0.14}
/>
</div>
{/* Row 2: Active Users, Performance */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '13px' }}>
{/* Active Users Card */}
<EnhancedMetricCard
title="Active User"
value={`${metrics.users} K`}
subtitle="User active right now"
icon={<Users size={16} />}
chart={<LineAreaChart data={metrics.userData} color="#e8316a" height={130} />}
details={
<div className="flex items-center gap-1.5 flex-wrap">
{['🇨🇳', '🇮🇩', '🇲🇲', '🇲🇾', '🇯🇵', '🇮🇳', '🇰🇷', '🇵🇭'].map((flag, i) => (
<span key={i} className="flag">{flag}</span>
))}
</div>
}
horizontal
animationDelay={0.19}
/>
{/* Performance Card */}
<PerformanceMetricCard
percentage={metrics.perf}
upSpeed={parseFloat(metrics.upSpeed)}
downSpeed={parseFloat(metrics.downSpeed)}
datasets={metrics.perfData}
animationDelay={0.24}
/>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,962 @@
import { useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
deployTemplate,
getTemplateById,
listProjects,
listTemplates,
type TemplateDetailEntity,
type TemplateEntity,
} from '@/lib/api-client';
import {
Search,
Filter,
Check,
Loader2,
Sparkles,
Box,
Code,
Database,
Globe,
Layers,
Terminal,
Play,
ArrowRight,
Star,
} from 'lucide-react';
const demoTemplates: TemplateEntity[] = [
{
id: 'tpl-react',
name: 'React Application',
description: 'Single-page frontend with Vite and static serving runtime.',
category: 'frontend',
logo: 'https://cdn.simpleicons.org/react',
configRaw: '{"runtime":"node"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-go',
name: 'Go API Service',
description: 'API-ready Go runtime with direct binary startup.',
category: 'web',
logo: 'https://cdn.simpleicons.org/go',
configRaw: '{"runtime":"go"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-postgres',
name: 'PostgreSQL Database',
description: 'Managed PostgreSQL service with credential setup variables.',
category: 'database',
logo: 'https://cdn.simpleicons.org/postgresql',
configRaw: '{"runtime":"postgres"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-mysql',
name: 'MySQL Database',
description: 'Managed MySQL service for transactional workloads.',
category: 'database',
logo: 'https://cdn.simpleicons.org/mysql',
configRaw: '{"runtime":"mysql"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-mariadb',
name: 'MariaDB Database',
description: 'Managed MariaDB service with MySQL compatibility.',
category: 'database',
logo: 'https://cdn.simpleicons.org/mariadb',
configRaw: '{"runtime":"mariadb"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-clickhouse',
name: 'ClickHouse Database',
description: 'Columnar analytics database template for high-speed queries.',
category: 'database',
logo: 'https://cdn.simpleicons.org/clickhouse',
configRaw: '{"runtime":"clickhouse"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-dragonfly',
name: 'Dragonfly Database',
description: 'Redis-compatible in-memory store powered by Dragonfly.',
category: 'database',
logo: 'https://cdn.simpleicons.org/redis',
configRaw: '{"runtime":"dragonfly"}',
variablesRaw: '[]',
isOfficial: true,
},
];
const demoTemplateDetails: Record<string, TemplateDetailEntity> = {
'tpl-react': {
template: demoTemplates[0],
config: {
type: 'web',
runtime: 'node',
buildCommand: 'npm install && npm run build',
startCommand: 'npx serve -s dist',
port: 3000,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'VITE_API_URL',
label: 'API URL',
defaultValue: 'https://api.example.com',
required: true,
secret: false,
description: 'Public API endpoint for frontend calls',
},
],
},
'tpl-go': {
template: demoTemplates[1],
config: {
type: 'web',
runtime: 'go',
buildCommand: 'go build -o app .',
startCommand: './app',
port: 8080,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'GO_ENV',
label: 'Go Environment',
defaultValue: 'production',
required: false,
secret: false,
description: 'Runtime environment value',
},
],
},
'tpl-postgres': {
template: demoTemplates[2],
config: {
type: 'database',
runtime: 'postgres',
buildCommand: '',
startCommand: '',
port: 5432,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'POSTGRES_USER',
label: 'Username',
defaultValue: 'postgres',
required: true,
secret: false,
description: 'Database user',
},
{
key: 'POSTGRES_PASSWORD',
label: 'Password',
defaultValue: '',
required: true,
secret: true,
description: 'Database password',
},
],
},
'tpl-mysql': {
template: demoTemplates[3],
config: {
type: 'database',
runtime: 'mysql',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MYSQL_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MYSQL_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MYSQL_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MYSQL_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-mariadb': {
template: demoTemplates[4],
config: {
type: 'database',
runtime: 'mariadb',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MARIADB_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MARIADB_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MARIADB_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MARIADB_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-clickhouse': {
template: demoTemplates[5],
config: {
type: 'database',
runtime: 'clickhouse',
buildCommand: '',
startCommand: '',
port: 8123,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'CLICKHOUSE_DB',
label: 'Database Name',
defaultValue: 'app',
required: false,
secret: false,
description: 'Default database name',
},
{
key: 'CLICKHOUSE_USER',
label: 'Username',
defaultValue: 'default',
required: false,
secret: false,
description: 'ClickHouse user',
},
{
key: 'CLICKHOUSE_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'ClickHouse password',
},
],
},
'tpl-dragonfly': {
template: demoTemplates[6],
config: {
type: 'database',
runtime: 'dragonfly',
buildCommand: '',
startCommand: '',
port: 6379,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'DRAGONFLY_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'Optional Redis-compatible password',
},
],
},
};
const demoProjects = [
{ id: 'project-demo', name: 'Demo Project' },
{ id: 'project-internal', name: 'Internal Tooling' },
];
function toServiceName(value: string): string {
const normalized = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'service';
}
function categoryLabel(category: string): string {
return category ? category[0].toUpperCase() + category.slice(1) : 'Uncategorized';
}
function categoryIcon(category: string): typeof Box {
switch (category) {
case 'frontend':
return Globe;
case 'web':
return Code;
case 'database':
return Database;
case 'backend':
return Terminal;
default:
return Box;
}
}
export function TemplatesPage() {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1';
const [categoryFilter, setCategoryFilter] = useState('');
const [searchFilter, setSearchFilter] = useState('');
const [selectedTemplateIdState, setSelectedTemplateId] = useState<string | null>(null);
const [deployProjectIdState, setDeployProjectId] = useState('');
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
Record<string, Record<string, string>>
>({});
const [lastDeployment, setLastDeployment] = useState<{
projectId: string;
serviceId: string;
serviceName: string;
} | null>(null);
const templatesQuery = useQuery({
queryKey: ['templates-page', categoryFilter],
enabled: !isDemoMode,
queryFn: () => listTemplates({ category: categoryFilter || undefined }),
});
const projectsQuery = useQuery({
queryKey: ['template-projects'],
enabled: !isDemoMode,
queryFn: listProjects,
});
const templates = useMemo(
() => (isDemoMode ? demoTemplates : templatesQuery.data ?? []),
[isDemoMode, templatesQuery.data],
);
const filteredTemplates = useMemo(() => {
const query = searchFilter.trim().toLowerCase();
if (!query) {
return templates;
}
return templates.filter(
(template) =>
template.name.toLowerCase().includes(query) ||
template.description.toLowerCase().includes(query) ||
template.category.toLowerCase().includes(query),
);
}, [searchFilter, templates]);
const categoryOptions = useMemo(() => {
const values = new Set<string>();
for (const template of templates) {
if (template.category) {
values.add(template.category);
}
}
return Array.from(values).sort((left, right) => left.localeCompare(right));
}, [templates]);
const selectedTemplateId = useMemo(() => {
if (filteredTemplates.length === 0) {
return null;
}
if (selectedTemplateIdState && filteredTemplates.some((template) => template.id === selectedTemplateIdState)) {
return selectedTemplateIdState;
}
return filteredTemplates[0].id;
}, [filteredTemplates, selectedTemplateIdState]);
const templateDetailQuery = useQuery({
queryKey: ['template-detail-page', selectedTemplateId],
enabled: !isDemoMode && Boolean(selectedTemplateId),
queryFn: () => getTemplateById(selectedTemplateId!),
});
const selectedDetail = isDemoMode
? selectedTemplateId
? demoTemplateDetails[selectedTemplateId] ?? null
: null
: templateDetailQuery.data ?? null;
const projectOptions = useMemo(
() =>
isDemoMode
? demoProjects
: (projectsQuery.data ?? []).map((project) => ({
id: project.id,
name: project.name,
})),
[isDemoMode, projectsQuery.data],
);
const deployProjectId = useMemo(() => {
if (projectOptions.length === 0) {
return '';
}
if (projectOptions.some((project) => project.id === deployProjectIdState)) {
return deployProjectIdState;
}
return projectOptions[0].id;
}, [deployProjectIdState, projectOptions]);
const variableDefaults = useMemo(() => {
if (!selectedDetail) {
return {} as Record<string, string>;
}
const defaults: Record<string, string> = {};
for (const variable of selectedDetail.variables) {
defaults[variable.key] = variable.defaultValue;
}
return defaults;
}, [selectedDetail]);
const deployName = useMemo(() => {
if (!selectedTemplateId || !selectedDetail) {
return '';
}
return deployNameByTemplate[selectedTemplateId] ?? toServiceName(selectedDetail.template.name);
}, [deployNameByTemplate, selectedDetail, selectedTemplateId]);
const variableValues = useMemo(() => {
if (!selectedTemplateId) {
return variableDefaults;
}
return {
...variableDefaults,
...(variableValuesByTemplate[selectedTemplateId] ?? {}),
};
}, [selectedTemplateId, variableDefaults, variableValuesByTemplate]);
const missingRequiredVariables = useMemo(() => {
if (!selectedDetail) {
return [];
}
return selectedDetail.variables.filter((variable) => {
if (!variable.required) {
return false;
}
return !(variableValues[variable.key] ?? '').trim();
});
}, [selectedDetail, variableValues]);
const deployMutation = useMutation({
mutationFn: async () => {
if (!selectedTemplateId) {
throw new Error('No template selected');
}
const variables: Record<string, string> = {};
for (const [key, value] of Object.entries(variableValues)) {
if (value.trim()) {
variables[key] = value.trim();
}
}
return deployTemplate(selectedTemplateId, {
projectId: deployProjectId,
name: deployName.trim(),
variables,
});
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
queryClient.invalidateQueries({ queryKey: ['project-services'] });
setLastDeployment({
projectId: deployProjectId,
serviceId: result.serviceId,
serviceName: deployName.trim(),
});
},
});
const isDeployDisabled =
!selectedTemplateId ||
!deployProjectId ||
deployName.trim().length === 0 ||
missingRequiredVariables.length > 0 ||
deployMutation.isPending;
return (
<div className="min-h-screen">
{/* Header */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-[var(--text-primary)]">Template Catalog</h1>
<p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p>
</div>
<div className="flex items-center gap-2 text-sm text-[var(--text-tertiary)]">
<Layers size={16} />
<span>{filteredTemplates.length} templates</span>
</div>
</div>
</div>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active — using sample data</span>
</div>
</div>
</div>
)}
{/* Main Content */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6">
{/* Template List */}
<section className="panel overflow-hidden">
<div className="p-4 border-b border-[var(--border-subtle)]">
<div className="flex items-center gap-2 mb-4">
<Filter size={16} className="text-[var(--text-tertiary)]" />
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Category
</label>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(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"
>
<option value="">All categories</option>
{categoryOptions.map((option) => (
<option key={option} value={option}>{categoryLabel(option)}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Search
</label>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
<input
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
className="w-full h-10 pl-10 pr-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="Search templates..."
/>
</div>
</div>
</div>
</div>
{!isDemoMode && templatesQuery.isLoading ? (
<div className="p-12 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading templates...</p>
</div>
) : null}
{!isDemoMode && templatesQuery.isError ? (
<div className="p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<Box size={24} className="text-[var(--error)]" />
</div>
<p className="text-sm text-[var(--error)]">Failed to load templates</p>
</div>
) : null}
{filteredTemplates.length === 0 && !templatesQuery.isLoading ? (
<div className="p-12 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Search size={24} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-muted)]">No templates match filters</p>
</div>
) : null}
{filteredTemplates.length > 0 ? (
<div className="max-h-[600px] overflow-auto p-3">
<div className="grid grid-cols-1 gap-2">
{filteredTemplates.map((template) => {
const selected = template.id === selectedTemplateId;
const Icon = categoryIcon(template.category);
const categoryColor = template.category === 'database' ? '#9c7ef0' : template.category === 'frontend' ? '#6c8ef0' : template.category === 'web' ? '#e8316a' : '#9295a4';
return (
<button
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
: 'border-[var(--border-subtle)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)]/50 card-lift'
}`}
>
<div className="flex items-start gap-3">
<div
className={`w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-300 ${
selected ? 'ring-2 ring-white/20' : ''
}`}
style={{
background: selected ? categoryColor : `${categoryColor}20`,
color: selected ? 'white' : categoryColor
}}
>
<Icon size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className={`text-sm font-semibold tracking-tight ${selected ? 'text-[var(--accent-primary)]' : 'text-[var(--text-primary)]'}`}>
{template.name}
</p>
{template.isOfficial && (
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--warning-soft)] text-[var(--warning)] text-[10px] font-semibold">
<Star size={8} className="fill-[var(--warning)]" />
Official
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span
className="text-[10px] font-semibold uppercase tracking-wider"
style={{ color: categoryColor }}
>
{categoryLabel(template.category)}
</span>
<span className="text-[10px] text-[var(--text-tertiary)]">•</span>
<span className="text-[10px] text-[var(--text-tertiary)]">{template.configRaw ? JSON.parse(template.configRaw).runtime : 'n/a'}</span>
</div>
<p className="text-xs text-[var(--text-tertiary)] mt-2 line-clamp-2 group-hover:text-[var(--text-secondary)] transition-colors">{template.description}</p>
</div>
<ArrowRight
size={14}
className={`text-[var(--text-tertiary)] transition-all duration-300 ${
selected ? 'opacity-100 text-[var(--accent-primary)]' : 'opacity-0 group-hover:opacity-100'
}`}
/>
</div>
</button>
);
})}
</div>
</div>
) : null}
</section>
{/* Template Detail */}
<section className="panel p-6">
{!selectedTemplateId ? (
<div className="py-16 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Layers size={28} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-muted)]">Select a template to view details and deploy</p>
</div>
) : null}
{selectedTemplateId && !isDemoMode && templateDetailQuery.isLoading ? (
<div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading template details...</p>
</div>
) : null}
{selectedTemplateId && !isDemoMode && templateDetailQuery.isError ? (
<div className="py-16 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<Box size={24} className="text-[var(--error)]" />
</div>
<p className="text-sm text-[var(--error)]">Failed to load template details</p>
</div>
) : null}
{selectedDetail ? (
<>
{/* Header */}
<div className="flex items-start justify-between gap-4 mb-6">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
{(() => {
const Icon = categoryIcon(selectedDetail.template.category);
return <Icon size={24} className="text-[var(--accent-primary)]" />;
})()}
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">{selectedDetail.template.name}</h2>
{selectedDetail.template.isOfficial && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-[var(--warning-soft)] text-[var(--warning)] text-xs font-medium">
<Star size={10} className="fill-[var(--warning)]" />
Official
</span>
)}
</div>
<p className="text-sm text-[var(--text-secondary)] mt-1">{selectedDetail.template.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
{categoryLabel(selectedDetail.template.category)}
</span>
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
{selectedDetail.config.runtime || 'n/a'}
</span>
</div>
</div>
{/* Config Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="panel-soft p-4">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</p>
<p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</p>
<p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Build</p>
<p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Start</p>
<p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p>
</div>
</div>
<div className="panel-soft p-4 mb-6">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">How Templates Work</p>
<p className="text-sm text-[var(--text-secondary)]">
Template defaults are merged with your variable inputs. Creating from template saves a stopped service in the selected project, then you deploy it from the service detail page.
</p>
{selectedDetail.config.type === 'database' && (
<p className="text-sm text-[var(--text-secondary)] mt-2">
Database templates create preconfigured database services with credentials and runtime settings, so you only need to provide required secrets.
</p>
)}
</div>
{/* Deploy Section */}
<div className="border-t border-[var(--border-subtle)] pt-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--success-soft)] flex items-center justify-center">
<Play size={18} className="text-[var(--success)]" />
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
<p className="text-sm text-[var(--text-secondary)]">Configure and create a new service</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Project
</label>
<select
value={deployProjectId}
onChange={(e) => setDeployProjectId(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"
>
{projectOptions.length === 0 && <option value="">No projects available</option>}
{projectOptions.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Service Name
</label>
<input
value={deployName}
onChange={(e) => {
if (!selectedTemplateId) return;
setDeployNameByTemplate((current) => ({
...current,
[selectedTemplateId]: 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="service-name"
/>
</div>
</div>
{selectedDetail.variables.length > 0 && (
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedDetail.variables.map((variable) => {
const hasError = variable.required && !(variableValues[variable.key] ?? '').trim();
return (
<div key={variable.key}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
{variable.label}
{variable.required && <span className="text-[var(--error)] ml-1">*</span>}
</label>
<input
type={variable.secret ? 'password' : 'text'}
value={variableValues[variable.key] ?? ''}
onChange={(e) => {
if (!selectedTemplateId) return;
setVariableValuesByTemplate((current) => ({
...current,
[selectedTemplateId]: {
...(current[selectedTemplateId] ?? {}),
[variable.key]: e.target.value,
},
}));
}}
className={`w-full h-10 px-3 rounded-[var(--radius-md)] border bg-[var(--surface-muted)] text-sm transition-colors ${
hasError ? 'border-[var(--error)]' : 'border-[var(--border-subtle)] focus:border-[var(--accent-primary)]'
}`}
placeholder={variable.defaultValue}
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{variable.description}
</p>
</div>
);
})}
</div>
)}
{missingRequiredVariables.length > 0 && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
Fill required variables: {missingRequiredVariables.map((v) => v.key).join(', ')}
</div>
)}
{!isDemoMode && projectsQuery.isError && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
Failed to load projects
</div>
)}
{deployMutation.isError && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{deployMutation.error instanceof Error ? deployMutation.error.message : 'Failed to create service'}
</div>
)}
{lastDeployment && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--success-soft)] text-sm text-[var(--success)]">
<div className="flex items-center gap-2">
<Check size={16} />
<span>
Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created
</span>
<Link
to={`/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`}
className="flex items-center gap-1 ml-2 underline underline-offset-2 hover:no-underline"
>
View <ArrowRight size={12} />
</Link>
</div>
</div>
)}
<div className="mt-6">
<button
onClick={() => deployMutation.mutate()}
disabled={isDeployDisabled}
className="flex items-center gap-2 h-11 px-6 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: '#e8316a' }}
>
{deployMutation.isPending ? (
<>
<Loader2 size={16} className="animate-spin" />
Creating...
</>
) : (
<>
<Play size={16} />
Create Service
</>
)}
</button>
</div>
</div>
</>
) : null}
</section>
</div>
</div>
</div>
);
}
@@ -0,0 +1,202 @@
import type { ServiceEntity } from '@/lib/api-client';
import type { CanvasEdge } from './model';
export type ServiceVariable = {
key: string;
value: string;
isSecret: boolean;
};
type LinkRecord = {
edge: CanvasEdge;
reasons: string[];
};
function normalizeToken(raw: string): string {
return raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
function slugifyServiceName(name: string): string {
return normalizeToken(name);
}
function tokenizePlaceholders(value: string): string[] {
if (!value || typeof value !== 'string') {
return [];
}
const matches = value.match(/\{\{\s*([^{}]+?)\s*\}\}/g);
if (!matches) {
return [];
}
return matches
.map((match) => match.replace(/\{\{|\}\}/g, '').trim())
.map(normalizeToken)
.filter(Boolean);
}
function candidateTokensForService(service: ServiceEntity): Set<string> {
const slug = slugifyServiceName(service.name);
const tokens = new Set<string>([
slug,
`${slug}_url`,
`${slug}_uri`,
`${slug}_host`,
`${slug}_port`,
`${slug}_database_url`,
]);
if (service.type === 'database') {
tokens.add('database_url');
tokens.add('database_host');
tokens.add('database_port');
if (slug.includes('postgres') || slug.includes('pg')) {
tokens.add('postgres');
tokens.add('postgres_url');
tokens.add('postgres_host');
tokens.add('postgres_port');
tokens.add('db_url');
tokens.add('db_host');
tokens.add('db_port');
}
if (slug.includes('redis')) {
tokens.add('redis');
tokens.add('redis_url');
tokens.add('redis_host');
tokens.add('redis_port');
tokens.add('cache_url');
}
if (slug.includes('mysql')) {
tokens.add('mysql');
tokens.add('mysql_url');
tokens.add('mysql_host');
tokens.add('mysql_port');
}
if (slug.includes('mongo')) {
tokens.add('mongo');
tokens.add('mongodb');
tokens.add('mongodb_url');
tokens.add('mongo_url');
}
}
return tokens;
}
function buildTokenIndex(services: ServiceEntity[]): Map<string, string[]> {
const index = new Map<string, string[]>();
const push = (token: string, serviceId: string) => {
const values = index.get(token);
if (values) {
if (!values.includes(serviceId)) {
values.push(serviceId);
}
return;
}
index.set(token, [serviceId]);
};
const databaseServices = services.filter((service) => service.type === 'database');
const primaryDatabase = databaseServices[0];
for (const service of services) {
for (const token of candidateTokensForService(service)) {
push(token, service.id);
}
}
if (primaryDatabase) {
for (const genericToken of ['db', 'db_url', 'db_host', 'db_port']) {
push(genericToken, primaryDatabase.id);
}
}
return index;
}
function resolveTokenTarget(token: string, sourceService: ServiceEntity, services: ServiceEntity[], index: Map<string, string[]>): string[] {
const exact = index.get(token) ?? [];
const resolvedExact = exact.filter((candidate) => candidate !== sourceService.id);
if (resolvedExact.length > 0) {
return resolvedExact;
}
const potential = services
.filter((service) => service.id !== sourceService.id)
.filter((service) => {
const slug = slugifyServiceName(service.name);
return token.includes(slug) && (token.endsWith('url') || token.endsWith('uri') || token.endsWith('host') || token.endsWith('port'));
})
.map((service) => service.id);
if (potential.length > 0) {
return potential;
}
if (token.startsWith('db_') || token === 'database_url') {
const dbServices = services.filter((service) => service.id !== sourceService.id && service.type === 'database');
if (dbServices.length === 1) {
return [dbServices[0].id];
}
}
return [];
}
export function inferAutoConnections(
services: ServiceEntity[],
variablesByService: Record<string, ServiceVariable[]>,
): LinkRecord[] {
if (services.length === 0) {
return [];
}
const serviceMap = new Map(services.map((service) => [service.id, service]));
const tokenIndex = buildTokenIndex(services);
const linkMap = new Map<string, LinkRecord>();
for (const service of services) {
const variables = variablesByService[service.id] ?? [];
for (const variable of variables) {
const tokens = tokenizePlaceholders(variable.value);
for (const token of tokens) {
const targetIds = resolveTokenTarget(token, service, services, tokenIndex);
for (const targetId of targetIds) {
const targetService = serviceMap.get(targetId);
if (!targetService || targetService.id === service.id) {
continue;
}
const key = `${service.id}->${targetService.id}`;
const existing = linkMap.get(key);
const reason = `${variable.key}:{{${token}}}`;
if (existing) {
if (!existing.reasons.includes(reason)) {
existing.reasons.push(reason);
}
continue;
}
linkMap.set(key, {
edge: {
id: `auto-${service.id}-${targetService.id}`,
sourceServiceId: service.id,
targetServiceId: targetService.id,
},
reasons: [reason],
});
}
}
}
}
return Array.from(linkMap.values());
}
@@ -0,0 +1,467 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
import {
Background,
MarkerType,
ReactFlow,
ReactFlowProvider,
useNodesState,
useReactFlow,
type Edge,
type Node,
type NodeTypes,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type { ServiceEntity } from '@/lib/api-client';
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
import {
type CanvasGroup,
type CanvasNodeLayout,
type ProjectCanvasMetadata,
} from '../model';
import { loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react';
type CanvasProps = {
projectId: string;
services: ServiceEntity[];
variablesByService: Record<string, ServiceVariable[]>;
onAddService: () => void;
onOpenService: (serviceId: string) => void;
};
type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>;
type GroupCanvasNode = Node<GroupNodeData, 'groupNode'>;
type CanvasNode = ServiceCanvasNode | GroupCanvasNode;
type CanvasEdge = Edge<{ reasons: string[] }>;
const SERVICE_NODE_WIDTH = 210;
const SERVICE_NODE_HEIGHT = 96;
const GROUP_DEFAULT_WIDTH = 340;
const GROUP_DEFAULT_HEIGHT = 230;
const nodeTypes: NodeTypes = {
serviceNode: ServiceNode,
groupNode: GroupNode,
};
function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], onOpenService: CanvasProps['onOpenService']): CanvasNode[] {
const groups = metadata.groups.map(
(group): GroupCanvasNode => ({
id: group.id,
type: 'groupNode',
data: { title: group.title },
position: group.position,
draggable: true,
selectable: true,
style: {
width: group.width,
height: group.height,
},
}),
);
const layoutMap = new Map(metadata.nodes.map((layout) => [layout.serviceId, layout]));
const groupIds = new Set(groups.map((group) => group.id));
const serviceNodes = services.map((service): ServiceCanvasNode => {
const layout = layoutMap.get(service.id);
const parentId = layout?.groupId && groupIds.has(layout.groupId) ? layout.groupId : undefined;
return {
id: service.id,
type: 'serviceNode',
position: layout?.position ?? { x: 0, y: 0 },
parentId,
extent: parentId ? 'parent' : undefined,
data: {
service,
selected: false,
onOpen: onOpenService,
},
draggable: true,
selectable: true,
style: {
width: SERVICE_NODE_WIDTH,
},
};
});
return [...groups, ...serviceNodes];
}
function toFlowEdges(links: ReturnType<typeof inferAutoConnections>): CanvasEdge[] {
return links.map((link) => ({
id: link.edge.id,
source: link.edge.sourceServiceId,
target: link.edge.targetServiceId,
animated: false,
data: {
reasons: link.reasons,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: 'var(--accent-secondary)',
width: 14,
height: 14,
},
style: {
stroke: '#e8316a',
strokeWidth: 2,
},
className: 'edge-premium',
}));
}
function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
const groups: CanvasGroup[] = [];
const layouts: CanvasNodeLayout[] = [];
for (const node of nodes) {
if (node.type === 'groupNode') {
const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH;
const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT;
groups.push({
id: node.id,
title: node.data.title,
position: node.position,
width,
height,
});
continue;
}
if (node.type === 'serviceNode') {
layouts.push({
serviceId: node.id,
position: node.position,
groupId: node.parentId,
});
}
}
return {
groups,
nodes: layouts,
edges: [],
viewport,
};
}
function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService }: CanvasProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const persistTimeout = useRef<number | null>(null);
const hydratedRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
const [viewportTick, setViewportTick] = useState(0);
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow<CanvasNode, CanvasEdge>();
const serviceFingerprint = useMemo(
() => services.map((service) => service.id).sort().join('|'),
[services],
);
const inferredLinks = useMemo(
() => inferAutoConnections(services, variablesByService),
[services, variablesByService],
);
const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]);
useEffect(() => {
const metadata = loadCanvasMetadata(projectId, services);
const nextNodes = toFlowNodes(metadata, services, onOpenService);
setNodes(nextNodes);
window.requestAnimationFrame(() => {
setViewport(metadata.viewport, { duration: 120 });
});
hydratedRef.current = true;
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, services]);
useEffect(() => {
if (!hydratedRef.current) {
return;
}
if (persistTimeout.current) {
window.clearTimeout(persistTimeout.current);
}
persistTimeout.current = window.setTimeout(() => {
const metadata = buildMetadataFromFlow(nodes, getViewport());
saveCanvasMetadata(projectId, metadata);
}, 150);
return () => {
if (persistTimeout.current) {
window.clearTimeout(persistTimeout.current);
}
};
}, [getViewport, nodes, projectId, viewportTick]);
useEffect(() => {
setNodes((prev) =>
prev.map((node) => {
if (node.type !== 'serviceNode') {
return node;
}
return {
...node,
data: {
...node.data,
selected: node.id === selectedServiceId,
},
};
}),
);
}, [selectedServiceId, setNodes]);
const getGroupUnderPoint = useCallback((x: number, y: number, ignoreGroupId?: string) => {
for (const node of nodes) {
if (node.type !== 'groupNode' || node.id === ignoreGroupId) {
continue;
}
const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH;
const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT;
const base = getInternalNode(node.id)?.internals.positionAbsolute ?? node.position;
if (x >= base.x && x <= base.x + width && y >= base.y && y <= base.y + height) {
return node;
}
}
return null;
}, [getInternalNode, nodes]);
const onNodeDragStop = useCallback(
(_event: MouseEvent, movedNode: CanvasNode) => {
if (movedNode.type !== 'serviceNode') {
return;
}
const basePosition = getInternalNode(movedNode.id)?.internals.positionAbsolute ?? movedNode.position;
const centerX = basePosition.x + SERVICE_NODE_WIDTH / 2;
const centerY = basePosition.y + SERVICE_NODE_HEIGHT / 2;
const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId);
if (targetGroup) {
const groupBase = getInternalNode(targetGroup.id)?.internals.positionAbsolute ?? targetGroup.position;
const targetWidth = typeof targetGroup.style?.width === 'number' ? targetGroup.style.width : GROUP_DEFAULT_WIDTH;
const targetHeight = typeof targetGroup.style?.height === 'number' ? targetGroup.style.height : GROUP_DEFAULT_HEIGHT;
const relativeX = Math.max(12, Math.min(targetWidth - SERVICE_NODE_WIDTH - 12, basePosition.x - groupBase.x));
const relativeY = Math.max(30, Math.min(targetHeight - SERVICE_NODE_HEIGHT - 12, basePosition.y - groupBase.y));
setNodes((current) =>
current.map((node) => {
if (node.id !== movedNode.id || node.type !== 'serviceNode') {
return node;
}
return {
...node,
parentId: targetGroup.id,
extent: 'parent',
position: { x: relativeX, y: relativeY },
};
}),
);
return;
}
if (!movedNode.parentId) {
return;
}
setNodes((current) =>
current.map((node) => {
if (node.id !== movedNode.id || node.type !== 'serviceNode') {
return node;
}
return {
...node,
parentId: undefined,
extent: undefined,
position: {
x: basePosition.x,
y: basePosition.y,
},
};
}),
);
},
[getGroupUnderPoint, getInternalNode, setNodes],
);
const addGroup = useCallback(() => {
const bounds = wrapperRef.current?.getBoundingClientRect();
const center = bounds
? screenToFlowPosition({
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
})
: { x: 180, y: 140 };
const id = `group-${Date.now()}`;
setNodes((current) => [
...current,
{
id,
type: 'groupNode',
position: {
x: center.x - GROUP_DEFAULT_WIDTH / 2,
y: center.y - GROUP_DEFAULT_HEIGHT / 2,
},
data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}` },
draggable: true,
selectable: true,
style: {
width: GROUP_DEFAULT_WIDTH,
height: GROUP_DEFAULT_HEIGHT,
},
} satisfies GroupCanvasNode,
]);
}, [screenToFlowPosition, setNodes]);
const selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
const selectedServiceLinkCount = selectedService
? inferredLinks.filter(
(link) =>
link.edge.sourceServiceId === selectedService.id || link.edge.targetServiceId === selectedService.id,
).length
: 0;
return (
<div className="panel overflow-hidden">
{/* Toolbar - Railway-inspired premium design */}
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60 backdrop-blur-xl px-4 py-3">
<button
type="button"
onClick={onAddService}
className="flex items-center gap-2 h-9 px-4 rounded-lg text-white text-sm font-medium shadow-lg hover:shadow-xl transition-all duration-200"
style={{ background: '#e8316a' }}
>
<Plus size={15} />
Add Service
</button>
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" />
<button
type="button"
onClick={addGroup}
className="flex items-center gap-2 h-9 px-3 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] hover:border-[var(--border-default)] transition-all"
>
<Layers size={14} />
Group
</button>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={() => fitView({ padding: 0.2, duration: 240 })}
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-white hover:bg-[var(--surface-card)] border border-transparent hover:border-[var(--border-subtle)] transition-all"
title="Fit View"
>
<Maximize2 size={16} />
</button>
<button
type="button"
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-white hover:bg-[var(--surface-card)] border border-transparent hover:border-[var(--border-subtle)] transition-all"
title="Reset View"
>
<RotateCcw size={16} />
</button>
</div>
<div className="flex items-center gap-4 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Box size={12} />
<span className="font-medium">{services.length}</span>
</div>
<div className="w-px h-3 bg-[var(--border-subtle)]" />
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Link2 size={12} />
<span className="font-medium">{edges.length}</span>
</div>
</div>
</div>
{/* Canvas */}
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)]">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onPaneClick={() => setSelectedServiceId(null)}
onNodeClick={(_event, node) => {
if (node.type === 'serviceNode') {
setSelectedServiceId(node.id);
}
}}
onNodeDoubleClick={(_event, node) => {
if (node.type === 'serviceNode') {
onOpenService(node.id);
}
}}
onNodeDragStop={onNodeDragStop}
onMoveEnd={() => setViewportTick((value) => value + 1)}
fitView
panOnDrag
zoomOnScroll
minZoom={0.25}
maxZoom={2.3}
deleteKeyCode={null}
proOptions={{ hideAttribution: true }}
>
{/* SVG Definitions for premium edge styling */}
<svg style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}>
<defs />
</svg>
<Background color="rgba(255,255,255,0.05)" gap={28} />
</ReactFlow>
</div>
{/* Footer */}
<div className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60 backdrop-blur-xl px-4 py-3">
{selectedService ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
<Box size={14} className="text-[var(--accent-primary)]" />
</div>
<div>
<span className="text-sm font-medium text-[var(--text-primary)]">{selectedService.name}</span>
<span className="ml-2 text-xs text-[var(--text-tertiary)]">({selectedService.type})</span>
</div>
</div>
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-[var(--surface-muted)] text-xs text-[var(--text-tertiary)]">
<Link2 size={10} />
<span>{selectedServiceLinkCount} connection{selectedServiceLinkCount !== 1 ? 's' : ''}</span>
</div>
</div>
) : (
<p className="text-xs text-[var(--text-tertiary)] text-center">
Click a service to select • Double-click to open • Drag to reposition • Connections auto-inferred from variables
</p>
)}
</div>
</div>
);
}
export function ProjectCanvas(props: CanvasProps) {
return (
<ReactFlowProvider>
<CanvasInner {...props} />
</ReactFlowProvider>
);
}
@@ -0,0 +1,191 @@
import type { Node, NodeProps } from '@xyflow/react';
import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import { Box, ArrowUpRight, Globe, Database, Terminal, MoreHorizontal, ExternalLink } from 'lucide-react';
import { useState } from 'react';
export type ServiceNodeData = {
service: ServiceEntity;
selected: boolean;
onOpen: (serviceId: string) => void;
};
export type GroupNodeData = {
title: string;
};
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
function serviceTypeIcon(type: string): typeof Box {
switch (type) {
case 'web':
return Globe;
case 'database':
return Database;
case 'worker':
return Terminal;
default:
return Box;
}
}
function serviceTypeColor(type: string): string {
switch (type) {
case 'web':
return '#6c8ef0'; // Blue
case 'database':
return '#9c7ef0'; // Purple
case 'worker':
return '#e8316a'; // Pink
default:
return '#9295a4'; // Gray
}
}
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
const { service, selected, onOpen } = data;
const iconType = serviceTypeIcon(service.type);
const typeColor = serviceTypeColor(service.type);
const isRunning = service.status === 'running';
// Generate a mock domain for display (in real app, this would come from service data)
const domain = `${service.name}.containr.local`;
return (
<div
className={`rounded-[var(--radius-lg)] border transition-all duration-200 group relative overflow-hidden ${
selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-lg'
}`}
style={{ minWidth: 220, maxWidth: 280 }}
>
{/* Ambient overlay */}
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{ background: `${typeColor}10` }}
/>
{/* Status indicator bar at top */}
<div
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${isRunning ? 'opacity-100' : 'opacity-40'}`}
style={{ background: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
/>
{/* Action menu on hover */}
<button className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all duration-200 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] p-1.5 rounded-lg hover:bg-[var(--surface-muted)]">
<MoreHorizontal size={14} />
</button>
<div className="relative p-4">
{/* Header with icon and name */}
<div className="flex items-start gap-3 mb-3">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
selected ? 'ring-2 ring-white/20' : ''
}`}
style={{
background: selected ? typeColor : `${typeColor}20`,
color: selected ? 'white' : typeColor
}}
>
{iconType === Globe && <Globe size={18} />}
{iconType === Database && <Database size={18} />}
{iconType === Terminal && <Terminal size={18} />}
{iconType === Box && <Box size={18} />}
</div>
<div className="min-w-0 flex-1 pt-0.5">
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">{service.type}</p>
</div>
</div>
{/* Domain display - Railway style */}
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50">
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="text-[10px] text-[var(--text-secondary)] truncate mono">{domain}</span>
</div>
{/* Status and action row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span
className="text-[11px] font-semibold tracking-wide uppercase"
style={{ color: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
>
{isRunning ? 'Online' : service.status}
</span>
</div>
<button
type="button"
onClick={() => onOpen(service.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all duration-200 ${
selected
? 'bg-[var(--accent-primary)] text-white shadow-lg shadow-[var(--accent-primary-glow)]'
: 'border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]'
}`}
>
Open
<ArrowUpRight size={10} />
</button>
</div>
</div>
</div>
);
}
export function GroupNode({ data }: NodeProps<GroupNodeType>) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
style={{
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30'
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Ambient glow on hover */}
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
style={{
background: 'rgba(232,49,106,0.03)',
opacity: isHovered ? 1 : 0
}}
/>
{/* Header */}
<div className="relative p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)'
}}
>
<Box size={14} />
</div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
</div>
{isHovered && (
<button className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--surface-muted)]">
<MoreHorizontal size={14} />
</button>
)}
</div>
{/* Helper text */}
<p className="text-[10px] text-[var(--text-tertiary)] mt-2 opacity-0 transition-opacity duration-200" style={{ opacity: isHovered ? 1 : 0 }}>
Drag services here to group them
</p>
</div>
</div>
);
}
@@ -0,0 +1,306 @@
import { useEffect, useState } from 'react';
import {
LineMetricChart,
DualLineChart,
DonutChart,
} from '@/shared/components';
interface MetricData {
cpu: number[];
ram: number;
ramUsed: string;
cache: number;
cacheBreakdown: { cache: number; nonCache: number; total: number };
users: number[];
performance: number[];
performanceAlt: number[];
upSpeed: number;
downSpeed: number;
}
interface MetricsDashboardProps {
projectId?: string;
isRunning?: boolean;
onStop?: () => void;
onRestart?: () => void;
}
function getRandom(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function generateInitialData(): MetricData {
return {
cpu: Array.from({ length: 24 }, () => getRandom(10, 45)),
ram: getRandom(50, 85),
ramUsed: '5.4 GB',
cache: 352,
cacheBreakdown: { cache: 212, nonCache: 85.5, total: 1750 },
users: Array.from({ length: 15 }, () => getRandom(40, 110)),
performance: Array.from({ length: 12 }, () => getRandom(70, 95)),
performanceAlt: Array.from({ length: 12 }, () => getRandom(60, 85)),
upSpeed: 10.4,
downSpeed: 5.2,
};
}
export function MetricsDashboard({
isRunning = true,
}: MetricsDashboardProps) {
const [data, setData] = useState<MetricData>(generateInitialData);
const [timeRange, setTimeRange] = useState<'day' | 'month' | 'year'>('day');
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(() => {
setData((prev) => {
const newCpu = [...prev.cpu.slice(1), getRandom(10, 45)];
const newUsers = [...prev.users.slice(1), getRandom(60, 110)];
const newPerf = [...prev.performance.slice(1), getRandom(82, 99)];
const newPerfAlt = [...prev.performanceAlt.slice(1), getRandom(60, 85)];
return {
...prev,
cpu: newCpu,
ram: getRandom(50, 85),
ramUsed: `${(8 * (prev.ram / 100)).toFixed(1)} GB`,
users: newUsers,
performance: newPerf,
performanceAlt: newPerfAlt,
upSpeed: parseFloat((Math.random() * 5 + 8).toFixed(1)),
downSpeed: parseFloat((Math.random() * 3 + 4).toFixed(1)),
};
});
}, 2000);
return () => clearInterval(interval);
}, [isRunning]);
const currentCpu = data.cpu[data.cpu.length - 1];
const currentUsers = data.users[data.users.length - 1];
const currentPerf = data.performance[data.performance.length - 1];
return (
<div>
{/* Metrics Header - self.html exact match */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button style={{
height: '32px',
padding: '0 12px',
borderRadius: '9px',
border: '1px solid rgba(255,255,255,0.09)',
background: 'rgba(255,255,255,0.04)',
color: '#9295a4',
fontSize: '12.5px',
fontWeight: 500,
fontFamily: 'inherit',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
Filter
</button>
<div className="pill-group">
{(['day', 'month', 'year'] as const).map((range) => (
<div
key={range}
className={`pill ${timeRange === range ? 'active' : ''}`}
onClick={() => setTimeRange(range)}
>
{range.charAt(0).toUpperCase() + range.slice(1)}
</div>
))}
</div>
</div>
</div>
{/* Row 1: CPU, RAM, Cache - self.html exact: 13px gap */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.95fr', gap: '13px', marginBottom: '13px' }}>
{/* CPU Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>CPU Usage</span>
</div>
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentCpu}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: currentCpu < 50 ? '#3dd68c' : currentCpu < 75 ? '#f0a040' : '#ff7043', fontWeight: 700 }}>
{currentCpu < 50 ? 'Good' : currentCpu < 75 ? 'Average' : 'High'}
</span>{' '}
Daily usage
</div>
<div style={{ height: 76, margin: '12px 0 6px' }}>
<LineMetricChart data={data.cpu} color="#ff7043" height={76} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
{/* RAM Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="2" y="8" width="20" height="8" rx="2"/><path d="M6 8V6M10 8V6M14 8V6M18 8V6M6 16v2M18 16v2"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span>
</div>
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.ram}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: data.ram < 60 ? '#3dd68c' : '#f0a040', fontWeight: 700 }}>
{data.ram < 60 ? 'Good' : 'Average'}
</span>{' '}
Daily usage
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
<DonutChart percent={data.ram} color="#9c7ef0" size={160} thickness={16} />
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
<div style={{ fontSize: '10.5px', color: '#6b6e7d', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: '12.5px', fontWeight: 700, color: '#e8e9f0' }}>{data.ramUsed} / 8GB</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
{/* Cache Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
</div>
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.cache} MB</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: '#f0a040', fontWeight: 700 }}>220MB Average</span>{' '}
cached images and files
</div>
{/* Segmented Bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', margin: '16px 0 15px', height: '32px' }}>
<div className="cache-seg" style={{ width: '43%', background: '#ff6b5b', borderRadius: '10px 4px 4px 10px' }} />
<div className="cache-seg" style={{ width: '13%', background: '#8c6ef0', borderRadius: '5px' }} />
<div className="cache-seg" style={{ flex: 1, background: 'rgba(255,255,255,0.07)', borderRadius: '4px 10px 10px 4px' }} />
</div>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr auto 1fr', gap: 0, alignItems: 'stretch' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
<div className="stat-dot" style={{ background: '#ff6b5b' }} />
Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>212 MB</span>
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>12%</span>
</div>
</div>
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
<div className="stat-dot" style={{ background: '#8c6ef0' }} />
Non-Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>85.5 MB</span>
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>4%</span>
</div>
</div>
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>Total</div>
<div style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>1.75 GB</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '16px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
</div>
{/* Row 2: Active Users, Performance - self.html exact: 13px gap */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '13px' }}>
{/* Active Users - horizontal layout */}
<div className="card" style={{ flexDirection: 'row', padding: 0, overflow: 'hidden' }}>
<div style={{ flex: 1, padding: '20px 18px 18px 20px', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Active User</span>
</div>
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentUsers} K</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>User active right now</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', marginTop: '12px', flexWrap: 'wrap' }}>
{['🇨🇳', '🇮🇩', '🇲🇲', '🇲🇾', '🇯🇵', '🇮🇳', '🇰🇷', '🇵🇭'].map((flag, i) => (
<span key={i} className="flag">{flag}</span>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '14px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
<div style={{ width: '50%', padding: '16px 14px 46px 0', display: 'flex', alignItems: 'flex-end' }}>
<LineMetricChart data={data.users} color="#e8316a" fillOpacity={0.15} showArea height={130} />
</div>
</div>
{/* Performance Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '16px', flex: 1 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentPerf}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: currentPerf > 85 ? '#3dd68c' : '#f0a040', fontWeight: 700 }}>
{currentPerf > 85 ? 'Good' : 'Average'}
</span>{' '}
Last scan
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px' }}>
<div style={{ width: 134, height: 58 }}>
<DualLineChart data1={data.performance} data2={data.performanceAlt} height={58} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end' }}>
<div className="speed-row" style={{ color: '#6c8ef0' }}>
<svg viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 19 19 12"/></svg>
<span>{data.upSpeed}</span> Mbps
</div>
<div className="speed-row" style={{ color: '#e8316a' }}>
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 5 5 12"/></svg>
<span>{data.downSpeed}</span> Mbps
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '14px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Check Speed</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import type { ServiceEntity } from '@/lib/api-client';
export type CanvasPoint = {
x: number;
y: number;
};
export type CanvasGroup = {
id: string;
title: string;
position: CanvasPoint;
width: number;
height: number;
};
export type CanvasNodeLayout = {
serviceId: string;
position: CanvasPoint;
groupId?: string;
};
export type CanvasEdge = {
id: string;
sourceServiceId: string;
targetServiceId: string;
};
export type CanvasViewport = {
x: number;
y: number;
zoom: number;
};
export type ProjectCanvasMetadata = {
groups: CanvasGroup[];
nodes: CanvasNodeLayout[];
edges: CanvasEdge[];
viewport: CanvasViewport;
};
export const DEFAULT_VIEWPORT: CanvasViewport = {
x: 0,
y: 0,
zoom: 1,
};
export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata {
const nodes = services.map((service, index) => {
const col = index % 3;
const row = Math.floor(index / 3);
return {
serviceId: service.id,
position: {
x: 70 + col * 260,
y: 80 + row * 170,
},
};
});
return {
groups: [],
nodes,
edges: [],
viewport: DEFAULT_VIEWPORT,
};
}
@@ -0,0 +1,667 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
createService,
getProjectById,
listServiceLogs,
listServiceVariables,
listServicesByProject,
type CreateServiceInput,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
import { formatDate, formatRelative } from '@/lib/time';
import type { ServiceVariable } from '../auto-connections';
import { ProjectCanvas } from '../canvas/ProjectCanvas';
import { canvasStorageKey, clearCanvasMetadata } from '../storage';
import { MetricsDashboard } from '../components/MetricsDashboard';
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
import {
ArrowLeft,
LayoutGrid,
Activity,
FileText,
Settings,
Plus,
X,
Loader2,
Layers,
Clock,
Trash2,
Sparkles,
Box,
Search,
} from 'lucide-react';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid }> = [
{ key: 'canvas', label: 'Canvas', icon: LayoutGrid },
{ key: 'observability', label: 'Observability', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText },
{ key: 'settings', label: 'Settings', icon: Settings },
];
const serviceTypes: Array<CreateServiceInput['type']> = ['web', 'worker', 'database', 'cron'];
const serviceEnvironments = ['production', 'preview', 'development'] as const;
function ServiceCreateDialog(props: {
open: boolean;
onClose: () => void;
onSubmit: (payload: CreateServiceInput) => void;
loading: boolean;
errorMessage?: string;
}) {
const [form, setForm] = useState<CreateServiceInput>({
name: '',
type: 'web',
environment: 'production',
image: '',
command: '',
});
if (!props.open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={props.onClose} />
<div className="relative w-full max-w-lg panel p-6">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Plus size={20} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Add Service</h3>
</div>
<p className="text-sm text-[var(--text-secondary)]">
Deploy a new service to this project. Configure runtime settings after creation.
</p>
<div className="mt-6 space-y-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Service Name
</label>
<input
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: 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"
placeholder="api-gateway"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Type
</label>
<select
value={form.type}
onChange={(e) => setForm((p) => ({ ...p, type: e.target.value as CreateServiceInput['type'] }))}
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"
>
{serviceTypes.map((type) => (
<option key={type} value={type}>{type.charAt(0).toUpperCase() + type.slice(1)}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Environment
</label>
<select
value={form.environment}
onChange={(e) => setForm((p) => ({ ...p, environment: e.target.value as typeof serviceEnvironments[number] }))}
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"
>
{serviceEnvironments.map((env) => (
<option key={env} value={env}>{env.charAt(0).toUpperCase() + env.slice(1)}</option>
))}
</select>
</div>
</div>
<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>
<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>
</label>
<input
value={form.command ?? ''}
onChange={(e) => setForm((p) => ({ ...p, command: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
placeholder="npm run start"
/>
</div>
</div>
{props.errorMessage && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{props.errorMessage}
</div>
)}
<div className="mt-6 flex justify-end gap-3">
<button
onClick={props.onClose}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)] text-sm font-medium hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors"
>
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() })}
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' }}
>
{props.loading ? 'Creating...' : 'Create Service'}
</button>
</div>
</div>
</div>
);
}
export function ProjectWorkspacePage() {
const { projectId = '' } = useParams<{ projectId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const toast = useToast();
const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas';
const isDemoMode = searchParams.get('demo') === '1';
const [createOpen, setCreateOpen] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setCommandPaletteOpen((open) => !open);
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, []);
const projectQuery = useQuery({
queryKey: ['project', projectId],
queryFn: () => getProjectById(projectId),
enabled: Boolean(projectId) && !isDemoMode,
});
const servicesQuery = useQuery({
queryKey: ['project-services', projectId],
queryFn: () => listServicesByProject(projectId),
enabled: Boolean(projectId) && !isDemoMode,
});
const createServiceMutation = useMutation({
mutationFn: (payload: CreateServiceInput) => createService(projectId, payload),
onSuccess: () => {
setCreateOpen(false);
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
},
});
const project = useMemo(
() => (isDemoMode ? getDemoProjectById(projectId) : projectQuery.data),
[isDemoMode, projectId, projectQuery.data],
);
const services = useMemo(
() => (isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? []),
[isDemoMode, projectId, servicesQuery.data],
);
const serviceIdFingerprint = services.map((service) => service.id).sort().join('|');
const variablesQuery = useQuery({
queryKey: ['project-service-vars', projectId, serviceIdFingerprint],
queryFn: async () => {
const entries = await Promise.all(
services.map(async (service) => {
const variables = await listServiceVariables(service.id);
return [service.id, variables.map((variable) => ({
key: variable.key,
value: variable.value,
isSecret: variable.isSecret,
}))] as const;
}),
);
return Object.fromEntries(entries) as Record<string, ServiceVariable[]>;
},
enabled: !isDemoMode && services.length > 0,
});
const variablesByService = isDemoMode ? getDemoVariablesByProject(projectId) : variablesQuery.data ?? {};
const workspaceLogsQuery = useQuery({
queryKey: ['workspace-service-logs', projectId, serviceIdFingerprint],
queryFn: async () => {
const rows = await Promise.all(
services.map(async (service) => {
const logs = await listServiceLogs(service.id, { tail: '20' });
return logs.map((entry) => ({
serviceId: service.id,
serviceName: service.name,
stream: entry.stream,
message: entry.message,
timestamp: entry.timestamp,
}));
}),
);
return rows
.flat()
.sort((left, right) => {
const leftValue = left.timestamp ? new Date(left.timestamp).getTime() : 0;
const rightValue = right.timestamp ? new Date(right.timestamp).getTime() : 0;
return rightValue - leftValue;
})
.slice(0, 200);
},
enabled: !isDemoMode && activeView === 'logs' && services.length > 0,
});
const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]);
const serviceHref = (serviceId: string) =>
isDemoMode
? `/projects/${projectId}/services/${serviceId}?demo=1`
: `/projects/${projectId}/services/${serviceId}`;
if (!isDemoMode && projectQuery.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3 text-[var(--text-secondary)]">
<Loader2 size={20} className="animate-spin" />
<span>Loading workspace...</span>
</div>
</div>
);
}
if ((!isDemoMode && projectQuery.isError) || !project) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
<div className="panel p-8 text-center max-w-md">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-lg font-medium text-[var(--text-primary)]">Project not found</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
This project may have been deleted or you don't have access.
</p>
<button
onClick={() => navigate('/projects')}
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
Back to Projects
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen">
{/* Header */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/projects')}
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
>
<ArrowLeft size={16} />
<span>Projects</span>
</button>
<div className="w-px h-5 bg-[var(--border-subtle)]" />
<div>
<h1 className="text-xl font-semibold text-[var(--text-primary)]">{project.name}</h1>
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
</div>
</div>
<div className="flex items-center gap-6">
{/* Stats */}
<div className="hidden md:flex items-center gap-6">
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--text-primary)]">{services.length}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Services</p>
</div>
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--success)]">{runningServices}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Running</p>
</div>
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<LiveIndicator isLive={runningServices > 0} />
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Clock size={12} />
<span>{formatRelative(project.updatedAt)}</span>
</div>
</div>
{/* Search / Command Palette */}
<button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-sm hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
>
<Search size={14} />
<span>Search...</span>
<kbd className="ml-2 px-1.5 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button>
{/* Add Service Button */}
{!isDemoMode && (
<button
onClick={() => setCreateOpen(true)}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg hover:shadow-xl transition-all"
style={{ background: '#e8316a' }}
>
<Plus size={16} />
<span className="hidden sm:inline">Add Service</span>
</button>
)}
</div>
</div>
</div>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active — using sample data for preview</span>
</div>
</div>
</div>
)}
{/* Main Content */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="grid grid-cols-1 lg:grid-cols-[200px_1fr] gap-6">
{/* Sidebar Navigation */}
<aside className="lg:sticky lg:top-6 lg:h-fit">
<nav className="flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
{viewItems.map((item) => {
const active = activeView === item.key;
const Icon = item.icon;
return (
<button
key={item.key}
onClick={() => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('view', item.key);
return next;
})}
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] transition-all whitespace-nowrap ${
active
? '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={18} />
<span className="text-sm font-medium">{item.label}</span>
{active && (
<div className="hidden lg:block absolute left-0 w-0.5 h-5 bg-[var(--accent-primary)] rounded-full" />
)}
</button>
);
})}
</nav>
</aside>
{/* Content Area */}
<section className="min-w-0">
{activeView === 'canvas' && (
<ProjectCanvas
projectId={project.id}
services={services}
variablesByService={variablesByService}
onAddService={() => setCreateOpen(true)}
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
/>
)}
{activeView === 'observability' && (
<div className="space-y-6">
{/* Health Summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)] live-pulse" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Healthy</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--success)]">{runningServices}</p>
</div>
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--error)]" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--error)]">
{services.filter((s) => s.status === 'failed').length}
</p>
</div>
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--warning)] animate-pulse" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Building</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--warning)]">
{services.filter((s) => s.status === 'building').length}
</p>
</div>
</div>
{/* Metrics Dashboard */}
<MetricsDashboard
isRunning={runningServices > 0}
onStop={() => toast.showToast('Services stopped', 'warning')}
onRestart={() => toast.showToast('Services restarted', 'success')}
/>
{/* Service List */}
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Services</h3>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]">
{services.length === 0 ? (
<div className="p-8 text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Layers size={20} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-secondary)]">No services deployed yet</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Add services from the Canvas view</p>
</div>
) : (
services.map((service) => (
<button
key={service.id}
onClick={() => navigate(serviceHref(service.id))}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-[var(--surface-muted)]/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
<Box size={14} className="text-[var(--text-tertiary)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">{service.name}</p>
<p className="text-xs text-[var(--text-tertiary)]">{service.type}</p>
</div>
</div>
<StatusBadge status={service.status} />
</button>
))
)}
</div>
</div>
</div>
)}
{activeView === 'logs' && (
<div 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">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Logs</h2>
<p className="text-sm text-[var(--text-secondary)]">Aggregated service output</p>
</div>
</div>
{!isDemoMode && (
<button
onClick={() => workspaceLogsQuery.refetch()}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors"
>
Refresh
</button>
)}
</div>
<div className="mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] overflow-x-auto max-h-[600px] overflow-y-auto">
{services.length === 0 ? (
<p className="text-[var(--text-muted)]">No services available.</p>
) : isDemoMode ? (
<div className="space-y-1">
{services.slice(0, 10).map((service, i) => (
<p key={service.id}>
<span className="text-[var(--text-muted)]">
[{service.updatedAt ? new Date(new Date(service.updatedAt).getTime() - i * 60000).toLocaleTimeString() : '--:--:--'}]
</span>
{' '}
<span className="text-[var(--accent-primary)]">{service.name}</span>
{' '}
<span className="text-[var(--text-tertiary)]">status={service.status}</span>
</p>
))}
</div>
) : workspaceLogsQuery.isLoading ? (
<p className="text-[var(--text-muted)]">Loading logs...</p>
) : workspaceLogsQuery.isError ? (
<p className="text-[var(--error)]">Failed to load logs.</p>
) : (workspaceLogsQuery.data?.length ?? 0) === 0 ? (
<p className="text-[var(--text-muted)]">No recent logs.</p>
) : (
<div className="space-y-1">
{workspaceLogsQuery.data!.map((entry, i) => (
<p key={`${entry.serviceId}-${entry.timestamp}-${i}`}>
<span className="text-[var(--text-muted)]">
[{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '--:--:--'}]
</span>
{' '}
<span className="text-[var(--accent-primary)]">{entry.serviceName}</span>
{' '}
<span className="text-[var(--text-tertiary)]">{entry.stream}</span>
{' '}
{entry.message}
</p>
))}
</div>
)}
</div>
</div>
)}
{activeView === 'settings' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Settings size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<p className="text-sm text-[var(--text-secondary)]">Project configuration</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Project ID</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(project.createdAt)}</p>
</div>
<div className="panel-soft p-4 md:col-span-2">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Canvas Storage Key</p>
<p className="mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p>
</div>
</div>
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
<button
onClick={() => {
clearCanvasMetadata(project.id);
window.location.reload();
}}
className="flex items-center gap-2 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] transition-colors"
>
<Trash2 size={16} />
Reset Canvas Layout
</button>
</div>
</div>
)}
</section>
</div>
</div>
{/* Create Service Dialog */}
{!isDemoMode && (
<ServiceCreateDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
loading={createServiceMutation.isPending}
errorMessage={createServiceMutation.error ? (createServiceMutation.error as Error).message : undefined}
onSubmit={(payload) => createServiceMutation.mutate(payload)}
/>
)}
{/* Command Palette */}
<CommandPalette
open={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
onAddService={(type) => {
setCreateOpen(true);
toast.showToast(`Creating ${type} service...`, 'info');
}}
onNavigate={(path) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('view', path);
return next;
});
}}
/>
{/* Error Banner */}
{!isDemoMode && (servicesQuery.isError || variablesQuery.isError) && (
<div className="fixed bottom-4 right-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
Unable to load services for this project.
</div>
)}
</div>
);
}
@@ -0,0 +1,236 @@
import type { ServiceEntity } from '@/lib/api-client';
import {
createDefaultCanvasMetadata,
DEFAULT_VIEWPORT,
type CanvasEdge,
type CanvasGroup,
type CanvasNodeLayout,
type ProjectCanvasMetadata,
} from './model';
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function parsePoint(value: unknown): { x: number; y: number } | null {
if (!value || typeof value !== 'object') {
return null;
}
const candidate = value as Record<string, unknown>;
if (!isFiniteNumber(candidate.x) || !isFiniteNumber(candidate.y)) {
return null;
}
return { x: candidate.x, y: candidate.y };
}
function parseGroups(raw: unknown): CanvasGroup[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => {
if (!entry || typeof entry !== 'object') {
return null;
}
const group = entry as Record<string, unknown>;
const point = parsePoint(group.position);
if (!point || typeof group.id !== 'string' || typeof group.title !== 'string') {
return null;
}
const width = isFiniteNumber(group.width) ? group.width : 320;
const height = isFiniteNumber(group.height) ? group.height : 220;
return {
id: group.id,
title: group.title,
width,
height,
position: point,
} satisfies CanvasGroup;
})
.filter((entry): entry is CanvasGroup => entry !== null);
}
function parseNodes(raw: unknown): CanvasNodeLayout[] {
if (!Array.isArray(raw)) {
return [];
}
const parsed: CanvasNodeLayout[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') {
continue;
}
const node = entry as Record<string, unknown>;
const point = parsePoint(node.position);
if (!point || typeof node.serviceId !== 'string') {
continue;
}
const normalized: CanvasNodeLayout = {
serviceId: node.serviceId,
position: point,
};
if (typeof node.groupId === 'string') {
normalized.groupId = node.groupId;
}
parsed.push(normalized);
}
return parsed;
}
function parseEdges(raw: unknown): CanvasEdge[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => {
if (!entry || typeof entry !== 'object') {
return null;
}
const edge = entry as Record<string, unknown>;
if (
typeof edge.id !== 'string' ||
typeof edge.sourceServiceId !== 'string' ||
typeof edge.targetServiceId !== 'string'
) {
return null;
}
return {
id: edge.id,
sourceServiceId: edge.sourceServiceId,
targetServiceId: edge.targetServiceId,
} satisfies CanvasEdge;
})
.filter((entry): entry is CanvasEdge => entry !== null);
}
function parseViewport(raw: unknown): ProjectCanvasMetadata['viewport'] {
if (!raw || typeof raw !== 'object') {
return DEFAULT_VIEWPORT;
}
const viewport = raw as Record<string, unknown>;
if (!isFiniteNumber(viewport.x) || !isFiniteNumber(viewport.y) || !isFiniteNumber(viewport.zoom)) {
return DEFAULT_VIEWPORT;
}
return {
x: viewport.x,
y: viewport.y,
zoom: viewport.zoom,
};
}
function parseRawCanvasMetadata(raw: unknown): ProjectCanvasMetadata | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const source = raw as Record<string, unknown>;
return {
groups: parseGroups(source.groups),
nodes: parseNodes(source.nodes),
edges: parseEdges(source.edges),
viewport: parseViewport(source.viewport),
};
}
export function canvasStorageKey(projectId: string): string {
return `containr.canvas.v1.${projectId}`;
}
export function clearCanvasMetadata(projectId: string): void {
localStorage.removeItem(canvasStorageKey(projectId));
}
export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMetadata): void {
localStorage.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
}
export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata {
const key = canvasStorageKey(projectId);
const fallback = createDefaultCanvasMetadata(services);
const serviceIds = new Set(services.map((service) => service.id));
const raw = localStorage.getItem(key);
if (!raw) {
saveCanvasMetadata(projectId, fallback);
return fallback;
}
try {
const parsed = parseRawCanvasMetadata(JSON.parse(raw));
if (!parsed) {
saveCanvasMetadata(projectId, fallback);
return fallback;
}
const validGroups = parsed.groups;
const groupIds = new Set(validGroups.map((group) => group.id));
const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node]));
const normalizedNodes: CanvasNodeLayout[] = [];
for (const [index, service] of services.entries()) {
const existing = nodeMap.get(service.id);
if (existing) {
normalizedNodes.push({
serviceId: existing.serviceId,
position: existing.position,
groupId: existing.groupId && groupIds.has(existing.groupId) ? existing.groupId : undefined,
});
continue;
}
const col = index % 3;
const row = Math.floor(index / 3);
normalizedNodes.push({
serviceId: service.id,
position: {
x: 70 + col * 260,
y: 80 + row * 170,
},
});
}
const seenEdgeIds = new Set<string>();
const normalizedEdges = parsed.edges.filter((edge) => {
if (
!serviceIds.has(edge.sourceServiceId) ||
!serviceIds.has(edge.targetServiceId) ||
edge.sourceServiceId === edge.targetServiceId ||
seenEdgeIds.has(edge.id)
) {
return false;
}
seenEdgeIds.add(edge.id);
return true;
});
const normalized: ProjectCanvasMetadata = {
groups: validGroups,
nodes: normalizedNodes,
edges: normalizedEdges,
viewport: parseViewport(parsed.viewport),
};
saveCanvasMetadata(projectId, normalized);
return normalized;
} catch {
saveCanvasMetadata(projectId, fallback);
return fallback;
}
}