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

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

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

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

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

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

No files matched your search

+40 -84
View File
@@ -1,16 +1,14 @@
import { useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { AlertCircle, ArrowRight, Github, Gitlab, KeyRound, Loader2, Mail, Shield, User2, Workflow } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, ArrowRight, Github, Loader2, Mail, User2 } from 'lucide-react';
import {
AuthError,
startBitbucketSignIn,
startGitLabSignIn,
requestMagicLinkInvite,
getAuthBootstrap,
startGoogleSignIn,
signInWithEmail,
signUpWithEmail,
startGiteaSignIn,
startGitHubSignIn,
} from '@/lib/auth-client';
import { useAuthSession } from '@/lib/use-auth-session';
@@ -48,7 +46,7 @@ function AuthCanvas() {
Better Auth
</h1>
<p className="mt-4 max-w-md text-sm leading-relaxed text-[var(--text-secondary)]">
Email/password, invite magic links, and OAuth providers (GitHub, GitLab, Bitbucket, Gitea) are handled by a dedicated Better Auth service with cookie sessions.
Email/password plus GitHub and Google sign-in run through secure cookie sessions. First account bootstraps platform ownership; every later user is created from inside Containr.
</p>
</div>
@@ -132,18 +130,24 @@ export function SignInPage() {
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
const sessionQuery = useAuthSession();
const bootstrapQuery = useQuery({
queryKey: ['auth-bootstrap'],
queryFn: getAuthBootstrap,
});
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [magicEmail, setMagicEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isMagicLoading, setIsMagicLoading] = useState(false);
if (sessionQuery.data) {
return <Navigate to={redirectPath} replace />;
}
if (bootstrapQuery.data?.mode === 'register') {
return <Navigate to={`/auth/sign-up?redirect=${encodeURIComponent(redirectPath)}`} replace />;
}
const submitEmailPassword = async (event: FormEvent) => {
event.preventDefault();
setError(null);
@@ -162,48 +166,32 @@ export function SignInPage() {
}
};
const submitMagicLink = async (event: FormEvent) => {
event.preventDefault();
const signInWithGitHubProvider = async () => {
setError(null);
setInfo(null);
setIsMagicLoading(true);
try {
await requestMagicLinkInvite(magicEmail.trim(), buildOAuthCallbackURL(redirectPath));
setInfo('Magic invite link sent. Check your email inbox.');
await startGitHubSignIn(buildOAuthCallbackURL(redirectPath));
} catch (exception) {
const message = exception instanceof AuthError ? exception.message : 'Failed to send magic link';
const message = exception instanceof AuthError ? exception.message : 'GitHub sign-in failed';
setError(message);
} finally {
setIsMagicLoading(false);
}
};
const signInWithGitHubProvider = async () => {
const signInWithGoogleProvider = async () => {
setError(null);
await startGitHubSignIn(buildOAuthCallbackURL(redirectPath));
};
const signInWithGiteaProvider = async () => {
setError(null);
await startGiteaSignIn(buildOAuthCallbackURL(redirectPath));
};
const signInWithGitLabProvider = async () => {
setError(null);
await startGitLabSignIn(buildOAuthCallbackURL(redirectPath));
};
const signInWithBitbucketProvider = async () => {
setError(null);
await startBitbucketSignIn(buildOAuthCallbackURL(redirectPath));
try {
await startGoogleSignIn(buildOAuthCallbackURL(redirectPath));
} catch (exception) {
const message = exception instanceof AuthError ? exception.message : 'Google sign-in failed';
setError(message);
}
};
return (
<AuthLayout>
<AuthCard title="Sign In" subtitle="Use your Containr account, invite magic link, or provider OAuth.">
<AuthCard title="Sign In" subtitle="Use email/password, GitHub, or Google.">
{error ? <AuthErrorNotice message={error} /> : null}
{info ? <AuthInfoNotice message={info} /> : null}
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null}
<form className="space-y-3" onSubmit={submitEmailPassword}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
@@ -256,58 +244,17 @@ export function SignInPage() {
</button>
<button
type="button"
onClick={() => void signInWithGitLabProvider()}
onClick={() => void signInWithGoogleProvider()}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
>
<Gitlab size={15} />
GitLab
</button>
<button
type="button"
onClick={() => void signInWithBitbucketProvider()}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
>
<Workflow size={15} />
Bitbucket
</button>
<button
type="button"
onClick={() => void signInWithGiteaProvider()}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
>
<Shield size={15} />
Gitea
<span className="text-sm font-semibold">G</span>
Google
</button>
</div>
<form className="mt-4 space-y-2" onSubmit={submitMagicLink}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Magic Link Invite
<input
type="email"
autoComplete="email"
value={magicEmail}
onChange={(event) => setMagicEmail(event.target.value)}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="[email protected]"
/>
</label>
<button
type="submit"
disabled={isMagicLoading}
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)] disabled:opacity-60"
>
{isMagicLoading ? <Loader2 size={15} className="animate-spin" /> : <KeyRound size={15} />}
Send Invite Link
</button>
</form>
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span>No account yet?</span>
<Link to={`/auth/sign-up?redirect=${encodeURIComponent(redirectPath)}`} className="inline-flex items-center gap-1 text-[var(--accent-primary)] hover:underline">
Create one <ArrowRight size={12} />
</Link>
<span>Need access?</span>
<span>Ask platform owner to create account.</span>
</div>
</AuthCard>
</AuthLayout>
@@ -321,6 +268,10 @@ export function SignUpPage() {
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
const sessionQuery = useAuthSession();
const bootstrapQuery = useQuery({
queryKey: ['auth-bootstrap'],
queryFn: getAuthBootstrap,
});
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -331,6 +282,10 @@ export function SignUpPage() {
return <Navigate to={redirectPath} replace />;
}
if (bootstrapQuery.data?.mode === 'login') {
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
}
const submitSignUp = async (event: FormEvent) => {
event.preventDefault();
setError(null);
@@ -350,8 +305,9 @@ export function SignUpPage() {
return (
<AuthLayout>
<AuthCard title="Create Account" subtitle="Provision your operator account with email/password auth.">
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap.">
{error ? <AuthErrorNotice message={error} /> : null}
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
<form className="space-y-3" onSubmit={submitSignUp}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
@@ -126,8 +126,11 @@ export function BuildsPage() {
[isDemoMode, buildsQuery.data?.builds],
);
const subscribedBuildIds = useMemo(() => builds.map((build) => build.id), [builds]);
const liveConnected = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
const subscribedBuildIds = useMemo(
() => (isDemoMode ? [] : builds.map((build) => build.id)),
[isDemoMode, builds],
);
const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
if (selectedBuild && channel === `build:${selectedBuild.id}`) {
@@ -149,9 +152,6 @@ export function BuildsPage() {
queryFn: () => getBuildLogs(selectedBuild!.id),
});
const isCancellingBuild = (buildId: string): boolean =>
cancelMutation.isPending && cancelMutation.variables === buildId;
// Stats
const stats = useMemo(() => {
const running = builds.filter(b => b.status === 'running' || b.status === 'pending').length;
@@ -173,9 +173,9 @@ export function BuildsPage() {
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm">
<div className={`w-2 h-2 rounded-full ${liveConnected ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={liveConnected ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
{liveConnected ? 'Live' : 'Offline'}
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
{liveStatus === 'live' ? 'Live' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling'}
</span>
</div>
<button
@@ -226,41 +226,36 @@ export function ProjectsPage() {
return (
<div className="min-h-screen relative">
{/* Hero Section - Premium ambient design */}
<div className="relative overflow-hidden border-b border-[var(--border-subtle)]">
{/* Solid ambient background */}
<div className="absolute inset-0 bg-[#e8316a]/5" />
<div className="absolute top-0 left-1/4 w-96 h-96 bg-[var(--accent-primary)]/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-[var(--accent-secondary)]/5 rounded-full blur-3xl" />
<div className="relative mx-auto w-full max-w-[1400px] px-6 py-12 md:py-16 lg:py-20">
<div className="max-w-2xl">
<div className="flex items-center gap-3 mb-4">
<div
className="flex items-center justify-center rounded-xl shadow-lg ring-1 ring-white/10"
style={{ width: '44px', height: '44px', background: '#e8316a' }}
>
<FolderOpen size={20} className="text-white" />
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="w-1.5 h-1.5 rounded-full bg-[var(--success)] live-pulse" />
<span className="text-[10px] font-semibold uppercase tracking-widest text-[var(--text-tertiary)]">
Workspace
</span>
</div>
{/* Header */}
<div className="border-b border-[var(--border-subtle)]">
<div className="mx-auto w-full max-w-[1400px] px-6 py-5 flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="flex items-center justify-center rounded-[var(--radius-md)]"
style={{ width: '36px', height: '36px', background: '#e8316a' }}
>
<FolderOpen size={17} className="text-white" />
</div>
<h1 className="font-headline text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-[var(--text-primary)]">
Projects
</h1>
<p className="mt-4 text-lg text-[var(--text-secondary)] max-w-xl leading-relaxed">
Deploy, manage, and monitor your containerized services with visual topology mapping and real-time observability.
</p>
<div>
<h1 className="font-headline text-xl font-semibold tracking-tight text-[var(--text-primary)]">
Projects
</h1>
<p className="text-xs text-[var(--text-tertiary)]">
Deploy, manage, and monitor containerized services
</p>
</div>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="w-1.5 h-1.5 rounded-full bg-[var(--success)] live-pulse" />
<span className="text-[10px] font-semibold uppercase tracking-widest text-[var(--text-tertiary)]">
Workspace
</span>
</div>
</div>
</div>
{/* Main Content */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-8">
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<div className="relative flex-1">
+531 -53
View File
@@ -3,9 +3,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {
getApiBaseUrl,
getAgentPublicBaseUrl,
getHostMonitoring,
createUser,
createGitProvider,
deleteGitProvider,
getCurrentUserProfile,
listAuditLogs,
listAgents,
listBuilds,
listGitProviders,
listProjects,
listTemplates,
updateCurrentUserProfile,
@@ -21,6 +28,7 @@ import {
FileText,
Settings,
User,
UserPlus,
Key,
Database,
RefreshCw,
@@ -34,6 +42,11 @@ import {
Box,
Terminal,
Radio,
Server,
HardDrive,
GitBranch,
Link2,
Unlink,
} from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
@@ -120,15 +133,51 @@ function endpointStateBadge(isLoading: boolean, isError: boolean): {
return { label: 'Available', toneClass: 'text-[var(--ok)]' };
}
function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
function formatUptime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) {
return '—';
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
if (days > 0) {
return `${days}d ${hours}h`;
}
return `${hours}h ${Math.floor((seconds % 3600) / 60)}m`;
}
export function UsagePage() {
const queryClient = useQueryClient();
const buildsQuery = useQuery({
queryKey: ['usage-builds'],
queryFn: () => listBuilds({ page: 1, limit: 100 }),
});
const hostQuery = useQuery({
queryKey: ['usage-host-monitoring'],
queryFn: getHostMonitoring,
refetchInterval: 15_000,
});
const agentsQuery = useQuery({
queryKey: ['usage-agents'],
queryFn: listAgents,
refetchInterval: 15_000,
});
const builds = buildsQuery.data?.builds ?? [];
const liveConnected = useBuildUpdates(
const liveStatus = useBuildUpdates(
builds.map((build) => build.id),
() => {
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
@@ -156,6 +205,15 @@ export function UsagePage() {
const avgBuildMinutes = averageBuildDurationMs / 60_000;
const activeBuildPressure = runningBuilds + pendingBuilds;
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
const host = hostQuery.data;
const agents = agentsQuery.data ?? [];
const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length;
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
const agentEndpoint = getAgentPublicBaseUrl();
const connectCommand = `CONTAINR_API_URL=${getApiBaseUrl().replace(/\/api\/v1$/, '')} \\
CONTAINR_AGENT_AUTH_TOKEN=<token> \\
containr-agent`;
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
@@ -197,9 +255,9 @@ export function UsagePage() {
/>
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="flex items-center gap-2 mb-6">
<div className={`w-2 h-2 rounded-full ${liveConnected ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={`text-sm ${liveConnected ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
{liveConnected ? 'Live sync active' : 'Offline'}
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
{liveStatus === 'live' ? 'Live sync active' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling for updates'}
</span>
</div>
@@ -216,28 +274,165 @@ export function UsagePage() {
<p className="text-sm text-[var(--error)]">Failed to load usage data</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Runtime Hours"
value={totalBuildHours > 0 ? `${totalBuildHours.toFixed(1)}h` : '—'}
description={runtimeBody}
icon={Clock}
color={completedDurationsMs.length > 0 ? 'success' : 'default'}
/>
<StatCard
title="Build Activity"
value={String(builds.length)}
description={buildActivityBody}
icon={Activity}
color={failedBuilds > 0 ? 'warning' : 'success'}
/>
<StatCard
title="Capacity"
value={String(activeBuildPressure)}
description={capacityBody}
icon={Gauge}
color={activeBuildPressure > 0 ? 'warning' : 'default'}
/>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Runtime Hours"
value={totalBuildHours > 0 ? `${totalBuildHours.toFixed(1)}h` : '—'}
description={runtimeBody}
icon={Clock}
color={completedDurationsMs.length > 0 ? 'success' : 'default'}
/>
<StatCard
title="Build Activity"
value={String(builds.length)}
description={buildActivityBody}
icon={Activity}
color={failedBuilds > 0 ? 'warning' : 'success'}
/>
<StatCard
title="Capacity"
value={String(activeBuildPressure)}
description={capacityBody}
icon={Gauge}
color={activeBuildPressure > 0 ? 'warning' : 'default'}
/>
</div>
<section className="panel p-6">
<div className="mb-5 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
<Server size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Host Monitoring</h2>
<p className="text-xs text-[var(--text-tertiary)]">Autoscaling base signal from this Containr host.</p>
</div>
</div>
<button
onClick={() => {
void hostQuery.refetch();
void agentsQuery.refetch();
}}
className="inline-flex h-9 items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 text-sm"
>
<RefreshCw size={14} />
Refresh
</button>
</div>
{hostQuery.isError ? (
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
Host monitoring unavailable.
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<StatCard
title="Host"
value={host?.hostname || '—'}
description={host ? `${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}` : 'Loading host identity.'}
icon={Server}
color={host?.dockerAvailable ? 'success' : 'warning'}
/>
<StatCard
title="CPU Load"
value={host ? host.load.load1m.toFixed(2) : '—'}
description={host ? `${host.cpu.cores} cores · 5m ${host.load.load5m.toFixed(2)} · 15m ${host.load.load15m.toFixed(2)}` : 'Loading CPU load.'}
icon={Gauge}
color={host && host.load.load1m > host.cpu.cores ? 'warning' : 'default'}
/>
<StatCard
title="Memory"
value={host ? `${host.memory.usagePercent.toFixed(0)}%` : '—'}
description={host ? `${formatBytes(host.memory.used)} used · ${formatBytes(host.memory.available)} free` : 'Loading memory telemetry.'}
icon={Activity}
color={host && host.memory.usagePercent > 80 ? 'warning' : 'success'}
/>
<StatCard
title="Disk"
value={host ? `${host.storage.usagePercent.toFixed(0)}%` : '—'}
description={host ? `${formatBytes(host.storage.used)} used · ${formatBytes(host.storage.available)} free on ${host.storage.path}` : 'Loading disk telemetry.'}
icon={HardDrive}
color={host && host.storage.usagePercent > 80 ? 'warning' : 'success'}
/>
</div>
)}
</section>
<section className="panel p-6">
<div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--surface-muted)]">
<Radio size={18} className="text-[var(--text-tertiary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Node Agents</h2>
<p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr]">
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
title="Registered"
value={String(agents.length)}
description={agentsQuery.isError ? 'Agent API unavailable.' : 'Nodes connected through token heartbeats.'}
icon={Radio}
color={agents.length > 0 ? 'success' : 'default'}
/>
<StatCard
title="Online"
value={String(onlineAgents)}
description="Online and connecting agents can receive queued commands."
icon={Activity}
color={onlineAgents > 0 ? 'success' : 'default'}
/>
<StatCard
title="Remote Memory"
value={formatBytes(totalAgentMemory)}
description={`${formatBytes(availableAgentMemory)} currently free across agents.`}
icon={Database}
color={totalAgentMemory > 0 ? 'success' : 'default'}
/>
</div>
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--border-subtle)]">
{agents.length > 0 ? (
agents.slice(0, 5).map((agent) => (
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{agent.name}</p>
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
</div>
<div className="text-right">
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
</div>
</div>
))
) : (
<div className="px-4 py-6 text-sm text-[var(--text-tertiary)]">
No remote agents registered yet.
</div>
)}
</div>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4">
<div className="mb-3 flex items-center gap-2">
<Terminal size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connect Node</span>
</div>
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{connectCommand}</pre>
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
<p>Set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKEN</span> to a value accepted by the backend.</p>
<p>Agent sends host resources, polls pending Docker commands, and reports command results.</p>
</div>
</div>
</div>
</section>
</div>
)}
</div>
@@ -246,10 +441,30 @@ export function UsagePage() {
}
export function PeoplePage() {
const queryClient = useQueryClient();
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
});
const [newUserName, setNewUserName] = useState('');
const [newUserEmail, setNewUserEmail] = useState('');
const [newUserPassword, setNewUserPassword] = useState('');
const createUserMutation = useMutation({
mutationFn: () =>
createUser({
name: newUserName.trim(),
email: newUserEmail.trim(),
password: newUserPassword,
}),
onSuccess: () => {
setNewUserName('');
setNewUserEmail('');
setNewUserPassword('');
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
queryClient.invalidateQueries({ queryKey: ['people-audit-logs'] });
},
});
const projectsQuery = useQuery({
queryKey: ['people-projects'],
@@ -312,28 +527,95 @@ export function PeoplePage() {
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading team data...</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Members"
value={profileQuery.data ? '1' : '—'}
description={membersBody}
icon={Users}
color={profileQuery.data ? 'success' : 'default'}
/>
<StatCard
title="Roles"
value={projectsQuery.data?.length ? String(projectsQuery.data.length) : '—'}
description={rolesBody}
icon={Shield}
color="default"
/>
<StatCard
title="Audit Trail"
value={auditLogsQuery.data ? String(auditLogsQuery.data.length) : '—'}
description={auditBody}
icon={FileText}
color={auditLogsQuery.data?.length ? 'success' : 'default'}
/>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Members"
value={profileQuery.data ? '1+' : '—'}
description={membersBody}
icon={Users}
color={profileQuery.data ? 'success' : 'default'}
/>
<StatCard
title="Roles"
value={projectsQuery.data?.length ? String(projectsQuery.data.length) : '—'}
description={rolesBody}
icon={Shield}
color="default"
/>
<StatCard
title="Audit Trail"
value={auditLogsQuery.data ? String(auditLogsQuery.data.length) : '—'}
description={auditBody}
icon={FileText}
color={auditLogsQuery.data?.length ? 'success' : 'default'}
/>
</div>
<section className="panel p-6">
<div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
<UserPlus size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Manual User Creation</h2>
<p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p>
</div>
</div>
<form
className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]"
onSubmit={(event) => {
event.preventDefault();
createUserMutation.mutate();
}}
>
<input
value={newUserName}
onChange={(event) => setNewUserName(event.target.value)}
required
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
placeholder="Name"
/>
<input
value={newUserEmail}
onChange={(event) => setNewUserEmail(event.target.value)}
type="email"
required
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
placeholder="[email protected]"
/>
<input
value={newUserPassword}
onChange={(event) => setNewUserPassword(event.target.value)}
type="password"
minLength={8}
required
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
placeholder="Temporary password"
/>
<button
type="submit"
disabled={createUserMutation.isPending}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 text-sm font-semibold text-white disabled:opacity-60"
style={{ background: '#e8316a' }}
>
{createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
Create
</button>
</form>
{createUserMutation.isError ? (
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
{createUserMutation.error instanceof Error ? createUserMutation.error.message : 'Failed to create user'}
</div>
) : null}
{createUserMutation.isSuccess ? (
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--success-soft)] px-4 py-3 text-sm text-[var(--success)]">
User created. Share credentials through secure channel.
</div>
) : null}
</section>
</div>
)}
</div>
@@ -341,6 +623,196 @@ export function PeoplePage() {
);
}
const gitProviderTypes = [
{ value: 'github', label: 'GitHub', tokenHint: 'Personal access token with repo scope' },
{ value: 'gitlab', label: 'GitLab', tokenHint: 'Personal access token with read_api scope' },
{ value: 'gitea', label: 'Gitea', tokenHint: 'Access token from your Gitea instance' },
{ value: 'bitbucket', label: 'Bitbucket', tokenHint: 'App password with repository read' },
] as const;
function GitProvidersSection() {
const queryClient = useQueryClient();
const providersQuery = useQuery({
queryKey: ['git-providers'],
queryFn: listGitProviders,
});
const [form, setForm] = useState({
name: 'github' as (typeof gitProviderTypes)[number]['value'],
displayName: '',
accessToken: '',
apiUrl: '',
});
const [formOpen, setFormOpen] = useState(false);
const createMutation = useMutation({
mutationFn: () =>
createGitProvider({
name: form.name,
display_name: form.displayName.trim() || gitProviderTypes.find((t) => t.value === form.name)!.label,
access_token: form.accessToken.trim(),
api_url: form.apiUrl.trim() || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
setForm({ name: 'github', displayName: '', accessToken: '', apiUrl: '' });
setFormOpen(false);
},
});
const deleteMutation = useMutation({
mutationFn: (providerId: string) => deleteGitProvider(providerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
},
});
const providers = providersQuery.data ?? [];
const selectedType = gitProviderTypes.find((t) => t.value === form.name)!;
return (
<section className="panel p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<GitBranch size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Git Providers</h2>
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
</div>
</div>
<button
onClick={() => setFormOpen((open) => !open)}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg transition-all"
style={{ background: '#e8316a' }}
>
<Link2 size={14} />
{formOpen ? 'Close' : 'Connect'}
</button>
</div>
{providersQuery.isLoading ? (
<div className="py-6 text-center">
<Loader2 size={20} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : providersQuery.isError ? (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
Failed to load providers
</div>
) : providers.length === 0 ? (
<div className="py-6 text-center">
<p className="text-sm text-[var(--text-secondary)]">No Git providers connected</p>
<p className="text-xs text-[var(--text-muted)] mt-1">
Connect GitHub, GitLab, Gitea, or Bitbucket to deploy services from source
</p>
</div>
) : (
<div className="space-y-2">
{providers.map((provider) => (
<div
key={provider.id}
className="flex items-center justify-between p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] flex items-center justify-center">
<GitBranch size={14} className="text-[var(--text-tertiary)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">{provider.display_name}</p>
<p className="text-xs text-[var(--text-muted)] capitalize">{provider.name.replace('_', ' ')}</p>
</div>
</div>
<button
onClick={() => deleteMutation.mutate(provider.id)}
disabled={deleteMutation.isPending}
className="flex items-center gap-2 h-8 px-3 rounded-[var(--radius-sm)] border border-[var(--error-soft)] text-[var(--error)] text-xs font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
>
<Unlink size={12} />
Disconnect
</button>
</div>
))}
</div>
)}
{formOpen && (
<div className="mt-4 pt-4 border-t border-[var(--border-subtle)] space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Provider
</label>
<select
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value as typeof form.name }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
>
{gitProviderTypes.map((type) => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Display Name
</label>
<input
value={form.displayName}
onChange={(e) => setForm((p) => ({ ...p, displayName: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder={selectedType.label}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Access Token
</label>
<input
type="password"
value={form.accessToken}
onChange={(e) => setForm((p) => ({ ...p, accessToken: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors"
placeholder="••••••••••••••••"
/>
<p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p>
</div>
{form.name === 'gitea' && (
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Instance URL
</label>
<input
value={form.apiUrl}
onChange={(e) => setForm((p) => ({ ...p, apiUrl: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors"
placeholder="https://gitea.example.com"
/>
</div>
)}
{createMutation.isError && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to connect provider'}
</div>
)}
<div className="flex justify-end pt-1">
<button
onClick={() => createMutation.mutate()}
disabled={!form.accessToken.trim() || createMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: '#e8316a' }}
>
{createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Connect Provider
</button>
</div>
</div>
)}
</section>
);
}
export function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -502,6 +974,9 @@ export function SettingsPage() {
)}
</section>
{/* Git Providers Section */}
<GitProvidersSection />
{/* Runtime & Local State Section */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
@@ -701,7 +1176,7 @@ export function DocsPage() {
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Generated Types</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">frontend/src/generated/api-types.ts</p>
<p className="mono text-xs text-[var(--text-primary)]">app/frontend/src/generated/api-types.ts</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
@@ -715,7 +1190,7 @@ export function DocsPage() {
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">UI References</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">docs/references/self.html</p>
<p className="mono text-xs text-[var(--text-primary)]">docs/references/dashboard.png • projects.png</p>
</div>
</div>
</section>
@@ -735,13 +1210,16 @@ export function DocsPage() {
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)]">
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">
{`# Regenerate frontend API types
npm --prefix frontend run generate:api
npm --prefix app/frontend run generate:api
# Frontend type-check + build
npm --prefix frontend run build:check
npm --prefix app/frontend run build:check
# Backend API tests
cd backend && go test ./internal/api/...`}
cd app/backend && go test ./internal/api/...
# Build remote node agent
cd app/backend && go build -o bin/containr-agent ./cmd/agent`}
</pre>
</div>
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
@@ -7,21 +7,18 @@ import {
getProjectById,
getDeploymentLogs,
getServiceById,
getServiceMetrics,
listDeployments,
listServiceLogs,
rollbackDeployment,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data';
import { formatDate, formatRelative, seededMetric } from '@/lib/time';
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
import {
ArrowLeft,
Activity,
FileText,
Settings,
Sliders,
Play,
RotateCw,
Trash2,
Check,
X,
Loader2,
@@ -171,23 +168,50 @@ export function ServiceDetailPage() {
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
const metricsQuery = useQuery({
queryKey: ['service-metrics', serviceId],
queryFn: () => getServiceMetrics(serviceId),
enabled: !isDemoMode && Boolean(serviceId),
refetchInterval: 5000,
});
// Accumulate live samples into a small history for the timeline charts.
const [metricHistory, setMetricHistory] = useState<{ cpu: number[]; memory: number[] }>({ cpu: [], memory: [] });
useEffect(() => {
const sample = metricsQuery.data;
if (!sample || sample.status !== 'ok') {
return;
}
const memPercent = sample.memory_limit_bytes > 0
? (sample.memory_usage_bytes / sample.memory_limit_bytes) * 100
: 0;
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
setMetricHistory((prev) => ({
cpu: [...prev.cpu, Math.min(100, sample.cpu_percent)].slice(-24),
memory: [...prev.memory, Math.min(100, memPercent)].slice(-24),
}));
}, [metricsQuery.data]);
const liveMetrics = isDemoMode ? null : metricsQuery.data ?? null;
const hasLiveTelemetry = Boolean(liveMetrics && liveMetrics.status === 'ok' && liveMetrics.instances.length > 0);
const runningInstances = liveMetrics?.instances.filter((instance) => instance.state === 'running').length ?? 0;
const memoryPercent = liveMetrics && liveMetrics.memory_limit_bytes > 0
? (liveMetrics.memory_usage_bytes / liveMetrics.memory_limit_bytes) * 100
: 0;
// Demo mode renders seeded values so the layout can be previewed without a backend.
const metricSet = useMemo(() => {
if (!service) {
if (!service || !isDemoMode) {
return null;
}
const cpu = seededMetric(`${service.id}:cpu`, 12, 78);
const memory = seededMetric(`${service.id}:mem`, 24, 91);
const req = seededMetric(`${service.id}:req`, 120, 5100);
const latency = seededMetric(`${service.id}:lat`, 17, 210);
return {
cpu,
memory,
req,
latency,
cpu: seededMetric(`${service.id}:cpu`, 12, 78),
memory: seededMetric(`${service.id}:mem`, 24, 91),
req: seededMetric(`${service.id}:req`, 120, 5100),
latency: seededMetric(`${service.id}:lat`, 17, 210),
};
}, [service]);
}, [service, isDemoMode]);
const deploymentSummary = useMemo(() => {
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
@@ -372,30 +396,29 @@ export function ServiceDetailPage() {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="CPU"
value={`${metricSet?.cpu ?? 0}%`}
hint="Current utilization"
value={isDemoMode ? `${metricSet?.cpu ?? 0}%` : hasLiveTelemetry ? `${liveMetrics!.cpu_percent.toFixed(1)}%` : '—'}
hint={isDemoMode ? 'Current utilization' : hasLiveTelemetry ? `${runningInstances} running container${runningInstances === 1 ? '' : 's'}` : 'No running containers'}
icon={Cpu}
trend={metricSet && metricSet.cpu > 60 ? 'up' : metricSet && metricSet.cpu < 30 ? 'down' : 'stable'}
trend={hasLiveTelemetry && liveMetrics!.cpu_percent > 60 ? 'up' : 'stable'}
/>
<MetricCard
label="Memory"
value={`${metricSet?.memory ?? 0}%`}
hint="Container footprint"
value={isDemoMode ? `${metricSet?.memory ?? 0}%` : hasLiveTelemetry ? formatBytes(liveMetrics!.memory_usage_bytes) : '—'}
hint={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${memoryPercent.toFixed(0)}% of ${formatBytes(liveMetrics!.memory_limit_bytes)}` : 'No running containers'}
icon={MemoryStick}
trend={metricSet && metricSet.memory > 70 ? 'up' : 'stable'}
trend={hasLiveTelemetry && memoryPercent > 70 ? 'up' : 'stable'}
/>
<MetricCard
label="Requests"
value={`${metricSet?.req ?? 0}`}
hint="Last 60 minutes"
label="Network"
value={isDemoMode ? `${metricSet?.req ?? 0}` : hasLiveTelemetry ? `${formatBytes(liveMetrics!.network_rx_bytes)}` : '—'}
hint={isDemoMode ? 'Requests, last 60 minutes' : hasLiveTelemetry ? `RX total · TX ${formatBytes(liveMetrics!.network_tx_bytes)}` : 'No traffic recorded'}
icon={Zap}
/>
<MetricCard
label="Latency"
value={`${metricSet?.latency ?? 0}ms`}
hint="P95 estimate"
label="Instances"
value={isDemoMode ? `${metricSet?.latency ?? 0}ms` : hasLiveTelemetry ? `${runningInstances}` : '0'}
hint={isDemoMode ? 'P95 latency estimate' : hasLiveTelemetry ? `${liveMetrics!.instances.length} container${liveMetrics!.instances.length === 1 ? '' : 's'} discovered` : 'Deploy to create containers'}
icon={Timer}
trend={metricSet && metricSet.latency > 150 ? 'up' : 'stable'}
/>
</div>
@@ -458,6 +481,20 @@ export function ServiceDetailPage() {
{activeSection === 'metrics' && (
<div className="space-y-6">
{/* Metrics Grid with enhanced cards */}
{!isDemoMode && !hasLiveTelemetry && (
<div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Activity size={22} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-base font-medium text-[var(--text-primary)]">No telemetry yet</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
{liveMetrics?.status === 'docker_unavailable'
? 'Docker is not reachable on this host, so container metrics cannot be collected.'
: 'Metrics appear once this service has running containers. Deploy it to start collecting.'}
</p>
</div>
)}
{(isDemoMode || hasLiveTelemetry) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="panel p-5 group hover:border-[var(--accent-primary)]/30 transition-all duration-300">
<div className="flex items-center justify-between mb-4">
@@ -467,28 +504,36 @@ export function ServiceDetailPage() {
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">CPU Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">Last 24 intervals</p>
<p className="text-xs text-[var(--text-tertiary)]">
{isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'}
</p>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-xs text-[var(--text-tertiary)]">Click to expand</span>
</div>
</div>
<div className="flex h-32 items-end gap-1">
{Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer group/bar"
style={{
height: `${value}%`,
background: '#ff7043',
}}
title={`${value}%`}
/>
);
})}
{isDemoMode
? Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: '#ff7043' }}
title={`${value}%`}
/>
);
})
: metricHistory.cpu.map((value, i) => (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: '#ff7043' }}
title={`${value.toFixed(1)}%`}
/>
))}
{!isDemoMode && metricHistory.cpu.length === 0 && (
<p className="self-center text-xs text-[var(--text-muted)]">Collecting first sample...</p>
)}
</div>
</div>
@@ -496,32 +541,77 @@ export function ServiceDetailPage() {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="card-icon">
<Zap size={18} />
<MemoryStick size={18} />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Request Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">Last 24 intervals</p>
<p className="text-sm font-medium text-[var(--text-primary)]">Memory Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">
{isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'}
</p>
</div>
</div>
</div>
<div className="flex h-32 items-end gap-1">
{Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:req:${i}`, 18, 96);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
style={{
height: `${value}%`,
background: '#3dd68c',
}}
title={`${value}%`}
/>
);
})}
{isDemoMode
? Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:req:${i}`, 18, 96);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: '#3dd68c' }}
title={`${value}%`}
/>
);
})
: metricHistory.memory.map((value, i) => (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: '#3dd68c' }}
title={`${value.toFixed(1)}%`}
/>
))}
{!isDemoMode && metricHistory.memory.length === 0 && (
<p className="self-center text-xs text-[var(--text-muted)]">Collecting first sample...</p>
)}
</div>
</div>
</div>
)}
{/* Instance list — real container states */}
{!isDemoMode && hasLiveTelemetry && (
<div className="panel p-5">
<div className="flex items-center gap-3 mb-4">
<div className="card-icon">
<Layers size={18} />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Instances</p>
<p className="text-xs text-[var(--text-tertiary)]">Containers backing this service</p>
</div>
</div>
<div className="space-y-2">
{liveMetrics!.instances.map((instance) => (
<div
key={instance.container_id}
className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]"
>
<div className="flex items-center gap-3 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${instance.state === 'running' ? 'bg-[var(--success)]' : 'bg-[var(--text-muted)]'}`} />
<span className="mono text-xs text-[var(--text-primary)] truncate">{instance.name}</span>
</div>
<div className="flex items-center gap-4 text-xs text-[var(--text-tertiary)] flex-shrink-0">
<span className="mono">{instance.cpu_percent.toFixed(1)}% CPU</span>
<span className="mono">{formatBytes(instance.memory_usage_bytes)}</span>
<span>{instance.started_at ? `up ${formatRelative(instance.started_at).replace(' ago', '')}` : instance.state}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Domain & Networking Panel */}
{service.status === 'running' && (
@@ -1,25 +1,21 @@
import { useMemo, useState, useEffect } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import {
Cpu,
MemoryStick,
Zap,
Users,
import { useParams, useNavigate } from 'react-router-dom';
import {
Cpu,
MemoryStick,
Zap,
Users,
Globe,
ArrowLeft,
Search,
Bell,
ChevronUp,
Activity,
FileText,
Sliders,
Settings as SettingsIcon
} from 'lucide-react';
import {
LineChart,
LineAreaChart,
DonutChart,
MultiLineChart,
import {
LineChart,
LineAreaChart,
DonutChart,
EnhancedMetricCard,
CacheMetricCard,
PerformanceMetricCard
@@ -49,7 +45,6 @@ const timePeriods = ['Day', 'Month', 'Year'];
export function ServiceMetricsDashboard() {
const { serviceId = 'demo-service' } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabKey>('metrics');
@@ -86,10 +81,10 @@ export function ServiceMetricsDashboard() {
{ data: perfData1, color: '#6c8ef0', fillOpacity: 0.15 },
{ data: perfData2, color: '#9c7ef0', fillOpacity: 0.15 }
],
upSpeed: (Math.random() * 5 + 8).toFixed(1),
downSpeed: (Math.random() * 3 + 4).toFixed(1),
upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1),
downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1),
};
}, [serviceId, timePeriod]);
}, [serviceId]);
// Auto-update metrics every 2 seconds
useEffect(() => {
@@ -14,11 +14,12 @@ import '@xyflow/react/dist/style.css';
import type { ServiceEntity } from '@/lib/api-client';
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
import {
DEFAULT_VIEWPORT,
type CanvasGroup,
type CanvasNodeLayout,
type ProjectCanvasMetadata,
} from '../model';
import { loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react';
@@ -152,6 +153,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const wrapperRef = useRef<HTMLDivElement | null>(null);
const persistTimeout = useRef<number | null>(null);
const hydratedRef = useRef(false);
const viewportRestoredRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
const [viewportTick, setViewportTick] = useState(0);
@@ -169,20 +171,34 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]);
useEffect(() => {
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
const metadata = loadCanvasMetadata(projectId, services);
const nextNodes = toFlowNodes(metadata, services, onOpenService);
viewportRestoredRef.current = false;
setNodes(nextNodes);
window.requestAnimationFrame(() => {
setViewport(metadata.viewport, { duration: 120 });
window.requestAnimationFrame(() => {
const isDefaultViewport =
metadata.viewport.x === DEFAULT_VIEWPORT.x &&
metadata.viewport.y === DEFAULT_VIEWPORT.y &&
metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom;
if (!hasStoredViewport || isDefaultViewport) {
void fitView({ padding: 0.15, duration: 120 });
} else {
void setViewport(metadata.viewport, { duration: 120 });
}
viewportRestoredRef.current = true;
});
});
hydratedRef.current = true;
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, services]);
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services]);
useEffect(() => {
if (!hydratedRef.current) {
if (!hydratedRef.current || !viewportRestoredRef.current) {
return;
}
@@ -414,7 +430,6 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
}}
onNodeDragStop={onNodeDragStop}
onMoveEnd={() => setViewportTick((value) => value + 1)}
fitView
panOnDrag
zoomOnScroll
minZoom={0.25}
@@ -4,6 +4,9 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
createService,
getProjectById,
listGitBranches,
listGitProviders,
listGitRepositories,
listServiceLogs,
listServiceVariables,
listServicesByProject,
@@ -31,6 +34,8 @@ import {
Sparkles,
Box,
Search,
GitBranch,
Container,
} from 'lucide-react';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
@@ -59,6 +64,44 @@ function ServiceCreateDialog(props: {
image: '',
command: '',
});
const [source, setSource] = useState<'image' | 'git'>('image');
const [providerId, setProviderId] = useState('');
const [repoFullName, setRepoFullName] = useState('');
const [repoSearch, setRepoSearch] = useState('');
const providersQuery = useQuery({
queryKey: ['git-providers'],
queryFn: listGitProviders,
enabled: props.open && source === 'git',
});
const repositoriesQuery = useQuery({
queryKey: ['git-repositories', providerId, repoSearch],
queryFn: () => listGitRepositories(providerId, repoSearch),
enabled: props.open && source === 'git' && Boolean(providerId),
});
const selectedRepo = useMemo(
() => (repositoriesQuery.data ?? []).find((repo) => repo.full_name === repoFullName) ?? null,
[repositoriesQuery.data, repoFullName],
);
const branchesQuery = useQuery({
queryKey: ['git-branches', providerId, repoFullName],
queryFn: () => {
const [owner, repo] = repoFullName.split('/');
return listGitBranches(providerId, owner, repo);
},
enabled: props.open && source === 'git' && Boolean(providerId) && Boolean(repoFullName),
});
const providers = providersQuery.data ?? [];
const repositories = repositoriesQuery.data ?? [];
const branches = branchesQuery.data ?? [];
const canSubmit =
Boolean(form.name.trim()) &&
(source === 'image' || Boolean(repoFullName));
if (!props.open) return null;
@@ -122,16 +165,169 @@ function ServiceCreateDialog(props: {
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
Source
</label>
<input
value={form.image ?? ''}
onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
placeholder="ghcr.io/org/app:latest"
/>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setSource('image')}
className={`flex items-center justify-center gap-2 h-10 rounded-[var(--radius-md)] border text-sm font-medium transition-colors ${
source === 'image'
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:border-[var(--border-default)]'
}`}
>
<Container size={14} />
Image
</button>
<button
type="button"
onClick={() => setSource('git')}
className={`flex items-center justify-center gap-2 h-10 rounded-[var(--radius-md)] border text-sm font-medium transition-colors ${
source === 'git'
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:border-[var(--border-default)]'
}`}
>
<GitBranch size={14} />
Git Repository
</button>
</div>
</div>
{source === 'image' ? (
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label>
<input
value={form.image ?? ''}
onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
placeholder="ghcr.io/org/app:latest"
/>
</div>
) : (
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Provider
</label>
<select
value={providerId}
onChange={(e) => {
setProviderId(e.target.value);
setRepoFullName('');
setForm((p) => ({ ...p, git_repo: '', git_branch: '' }));
}}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
>
<option value="">Select provider</option>
{providers.map((provider) => (
<option key={provider.id} value={provider.id}>{provider.display_name}</option>
))}
</select>
{!providersQuery.isLoading && providers.length === 0 && (
<p className="text-xs text-[var(--warn)] mt-1.5">
No providers connected — add one in Settings → Git Providers
</p>
)}
</div>
{providerId && (
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Repository
</label>
<input
value={repoSearch}
onChange={(e) => setRepoSearch(e.target.value)}
className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono mb-2"
placeholder="Search repositories..."
/>
{repositoriesQuery.isLoading ? (
<div className="py-3 text-center">
<Loader2 size={14} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : repositoriesQuery.isError ? (
<p className="text-xs text-[var(--error)] py-2">Failed to load repositories — check the provider token</p>
) : repositories.length === 0 ? (
<p className="text-xs text-[var(--text-muted)] py-2">No repositories found</p>
) : (
<div className="max-h-40 overflow-y-auto space-y-0.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-1">
{repositories.slice(0, 50).map((repo) => (
<button
key={repo.id}
type="button"
onClick={() => {
setRepoFullName(repo.full_name);
setForm((p) => ({
...p,
git_repo: repo.clone_url ?? repo.full_name,
git_branch: p.git_branch || repo.default_branch || '',
}));
}}
className={`w-full flex items-center justify-between px-3 py-2 rounded-[var(--radius-sm)] text-left text-sm transition-colors ${
repoFullName === repo.full_name
? 'bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-hover)]'
}`}
>
<span className="mono text-xs truncate">{repo.full_name}</span>
<span className="text-[10px] text-[var(--text-muted)] ml-2 shrink-0">{repo.default_branch}</span>
</button>
))}
</div>
)}
</div>
)}
{repoFullName && (
<>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Branch
</label>
{branchesQuery.isLoading ? (
<div className="py-2 text-center">
<Loader2 size={14} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : branches.length > 0 ? (
<select
value={form.git_branch ?? ''}
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
>
<option value="">Select branch</option>
{branches.map((branch) => (
<option key={branch.name} value={branch.name}>{branch.name}</option>
))}
</select>
) : (
<input
value={form.git_branch ?? ''}
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
placeholder={selectedRepo?.default_branch ?? 'main'}
/>
)}
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Build Path <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label>
<input
value={form.build_path ?? ''}
onChange={(e) => setForm((p) => ({ ...p, build_path: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
placeholder="/ (repo root)"
/>
</div>
</>
)}
</div>
)}
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Command <span className="normal-case text-[var(--text-muted)]">(optional)</span>
@@ -159,8 +355,18 @@ function ServiceCreateDialog(props: {
Cancel
</button>
<button
disabled={!form.name.trim() || props.loading}
onClick={() => props.onSubmit({ ...form, name: form.name.trim(), image: form.image?.trim(), command: form.command?.trim() })}
disabled={!canSubmit || props.loading}
onClick={() =>
props.onSubmit({
...form,
name: form.name.trim(),
image: source === 'image' ? form.image?.trim() : '',
command: form.command?.trim(),
git_repo: source === 'git' ? form.git_repo?.trim() : '',
git_branch: source === 'git' ? form.git_branch?.trim() : '',
build_path: source === 'git' ? form.build_path?.trim() : '',
})
}
className="px-5 py-2 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: '#e8316a' }}
>