mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
small fix, don't worry about it
This commit is contained in:
1 parent
08bd0c6e5c
commit
08cb5754f3
638 files changed
+57330
-34704
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user