import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
getApiBaseUrl,
getAgentPublicBaseUrl,
getHostMonitoring,
createUser,
createGitProvider,
deleteGitProvider,
getCurrentUserProfile,
listAuditLogs,
listAgents,
listBuilds,
listGitProviders,
listProjects,
listTemplates,
createAgentToken,
listAgentTokens,
revokeAgentToken,
listDatabases,
databaseAction,
updateDatabaseBackupSchedule,
createDatabaseBackup,
restoreDatabaseBackup,
listServicesByProject,
listServiceVariables,
updateServiceVariables,
getGitHubAppInstallUrl,
connectGitHubApp,
updateCurrentUserProfile,
getPlatformSettings,
updatePlatformSettings,
getHAStatus,
setHAEnabled,
triggerFailover,
listFailoverPolicies,
setFailoverPolicy,
deleteFailoverPolicy,
listActiveAlerts,
resolveAlert,
listHealthResults,
startSecurityScan,
getSecurityHistory,
listVulnerabilities,
updateVulnerability,
getSecurityMetrics,
type AgentAuthTokenCreated,
type DatabaseEntity,
type FailoverPolicy,
} from '@/lib/api-client';
import { demoDatabases } from '@/lib/demo-data';
import { formatRelative } from '@/lib/time';
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
import { useBuildUpdates } from '@/lib/use-build-updates';
import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart } from '@/shared/components';
import {
Clock,
Activity,
Gauge,
Users,
Shield,
FileText,
Settings,
User,
UserPlus,
Key,
Database,
RefreshCw,
Trash2,
LogOut,
Check,
AlertCircle,
Loader2,
BookOpen,
Folder,
Box,
Terminal,
Radio,
Server,
HardDrive,
GitBranch,
Link2,
Unlink,
Cpu,
MemoryStick,
ScrollText,
ChevronRight,
ChevronDown,
Copy,
Play,
Square,
Archive,
ShieldCheck,
Zap,
HeartPulse,
Cloud,
ExternalLink,
} from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
return (
);
}
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 (
{value}
{title}
{description}
);
}
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)]' };
}
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 agentTokensQuery = useQuery({
queryKey: ['agent-tokens'],
queryFn: listAgentTokens,
});
const [tokenLabel, setTokenLabel] = useState('');
const [issuedToken, setIssuedToken] = useState(null);
const createTokenMutation = useMutation({
mutationFn: () => createAgentToken(tokenLabel.trim()),
onSuccess: (data) => {
setIssuedToken(data);
setTokenLabel('');
queryClient.invalidateQueries({ queryKey: ['agent-tokens'] });
},
});
const revokeTokenMutation = useMutation({
mutationFn: (id: string) => revokeAgentToken(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
});
const builds = buildsQuery.data?.builds ?? [];
const liveStatus = 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;
const host = hostQuery.data;
const agents = agentsQuery.data ?? [];
// Accumulate host samples for the load sparkline (polled every 15s).
const [loadHistory, setLoadHistory] = useState([]);
useEffect(() => {
const sample = hostQuery.data;
if (!sample || sample.cpu.cores <= 0) {
return;
}
const loadPercent = Math.min(100, (sample.load.load1m / sample.cpu.cores) * 100);
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
setLoadHistory((prev) => [...prev, loadPercent].slice(-24));
}, [hostQuery.data]);
const hostLoadPercent = host && host.cpu.cores > 0
? Math.min(100, (host.load.load1m / host.cpu.cores) * 100)
: 0;
const hostStatus = (percent: number): 'good' | 'average' | 'warning' =>
percent < 50 ? 'good' : percent < 80 ? 'average' : 'warning';
// Builds per day over the last 14 days, normalized to bar heights.
// Anchored on the newest build timestamp so the chart is render-pure.
const buildsPerDay = useMemo(() => {
const days = 14;
const counts = new Array(days).fill(0);
const timestamps = (buildsQuery.data?.builds ?? [])
.map((build) => new Date((build.startedAt ?? build.completedAt) as string).getTime())
.filter((t) => Number.isFinite(t));
if (timestamps.length === 0) {
return counts;
}
const anchor = Math.max(...timestamps);
(buildsQuery.data?.builds ?? []).forEach((build) => {
const timestamp = build.startedAt ?? build.completedAt;
if (!timestamp) {
return;
}
const created = new Date(timestamp).getTime();
const dayIndex = days - 1 - Math.floor((anchor - created) / 86_400_000);
if (dayIndex >= 0 && dayIndex < days) {
counts[dayIndex] += 1;
}
});
const peak = Math.max(...counts, 1);
return counts.map((count) => (count / peak) * 100);
}, [buildsQuery.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=${issuedToken?.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.';
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 (
{liveStatus === 'live' ? 'Live sync active' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling for updates'}
{buildsQuery.isLoading ? (
) : buildsQuery.isError ? (
Failed to load usage data
) : (
0 ? `${totalBuildHours.toFixed(1)}h` : '—'}
statusText={completedDurationsMs.length > 0 ? 'Active' : ''}
status="good"
subtitle={runtimeBody}
icon={ }
/>
0 ? 'average' : 'good'}
statusText={failedBuilds > 0 ? `${failedBuilds} failed` : 'Healthy'}
subtitle={buildActivityBody}
icon={ }
chart={ }
/>
0 ? 'average' : 'good'}
statusText={activeBuildPressure > 0 ? 'Busy' : 'Idle'}
subtitle={capacityBody}
icon={ }
/>
Host Monitoring
{host
? `${host.hostname} · ${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}${host.dockerAvailable ? '' : ' · Docker unavailable'}`
: 'Autoscaling base signal from this Containr host.'}
{
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"
>
Refresh
{hostQuery.isError ? (
Host monitoring unavailable.
) : (
}
value={host ? host.load.load1m.toFixed(2) : '—'}
status={hostStatus(hostLoadPercent)}
statusText={host ? { good: 'Good', average: 'Average', warning: 'High' }[hostStatus(hostLoadPercent)] : ''}
subtitle={host ? `${host.cpu.cores} cores · 5m ${host.load.load5m.toFixed(2)} · 15m ${host.load.load15m.toFixed(2)}` : 'Loading CPU telemetry.'}
chart={
0 ? loadHistory : [0]} color="#b4e34a" height={72} />}
/>
}
value={host ? `${host.memory.usagePercent.toFixed(0)}%` : '—'}
status={hostStatus(host?.memory.usagePercent ?? 0)}
statusText={host ? { good: 'Good', average: 'Average', warning: 'High' }[hostStatus(host.memory.usagePercent)] : ''}
subtitle={host ? `${formatBytes(host.memory.available)} free` : 'Loading memory telemetry.'}
chart={
Used
{host ? `${formatBytes(host.memory.used)} / ${formatBytes(host.memory.total)}` : '—'}
}
/>
}
value={host ? `${host.storage.usagePercent.toFixed(0)}%` : '—'}
status={hostStatus(host?.storage.usagePercent ?? 0)}
statusText={host ? { good: 'Good', average: 'Average', warning: 'High' }[hostStatus(host.storage.usagePercent)] : ''}
subtitle={host ? `Mounted at ${host.storage.path}` : 'Loading disk telemetry.'}
chart={
{host ? `${formatBytes(host.storage.used)} used` : '—'}
{host ? `${formatBytes(host.storage.available)} free` : '—'}
}
/>
)}
Node Agents
Remote VPS, VM, and LXC capacity for placement and autoscaling.
0 ? 'success' : 'default'}
/>
0 ? 'success' : 'default'}
/>
0 ? 'success' : 'default'}
/>
{agents.length > 0 ? (
agents.slice(0, 5).map((agent) => (
{agent.name}
{agent.hostname} · {agent.ipAddress}:{agent.port}
{agent.status}
{formatBytes(agent.resources.memory.available)} free
))
) : (
No remote agents registered yet.
)}
Connect Node
setTokenLabel(e.target.value)}
placeholder="Token label (e.g. node name)"
className="h-8 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
createTokenMutation.mutate()}
disabled={createTokenMutation.isPending}
className="h-8 px-3 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] transition-all disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
>
{createTokenMutation.isPending ? 'Issuing…' : 'Issue token'}
{issuedToken && (
{issuedToken.token}
void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
>
Copy
Shown once — store it now. Only the hash is kept server-side.
)}
{createTokenMutation.isError && (
{createTokenMutation.error instanceof Error ? createTokenMutation.error.message : 'Failed to issue token'}
)}
{connectCommand}
Agent endpoint: {agentEndpoint}
Use an issued token above, or set CONTAINR_AGENT_AUTH_TOKENS on the backend.
Agent sends host resources, polls pending Docker commands, and reports command results.
{(agentTokensQuery.data ?? []).length > 0 && (
Issued tokens
{(agentTokensQuery.data ?? []).map((t) => (
{t.label || t.id?.slice(0, 8)}
{t.revoked ? 'revoked' : t.last_used_at ? `used ${formatRelative(t.last_used_at)}` : 'unused'}
{!t.revoked && (
revokeTokenMutation.mutate(t.id ?? '')}
disabled={revokeTokenMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
>
Revoke
)}
))}
)}
)}
);
}
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'],
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 (
{profileQuery.isLoading ? (
) : (
Manual User Creation
Public registration is closed after bootstrap.
{createUserMutation.isError ? (
{createUserMutation.error instanceof Error ? createUserMutation.error.message : 'Failed to create user'}
) : null}
{createUserMutation.isSuccess ? (
User created. Share credentials through secure channel.
) : null}
)}
);
}
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 [searchParams, setSearchParams] = useSearchParams();
const providersQuery = useQuery({
queryKey: ['git-providers'],
queryFn: listGitProviders,
});
// GitHub redirects back here with ?installation_id= after app install.
const installIdParam = searchParams.get('installation_id');
const connectAppMutation = useMutation({
mutationFn: (installationId: number) => connectGitHubApp(installationId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
const next = new URLSearchParams(searchParams);
next.delete('installation_id');
next.delete('setup_action');
setSearchParams(next, { replace: true });
},
});
const connectFiredFor = useRef(null);
useEffect(() => {
const id = Number(installIdParam);
if (id > 0 && connectFiredFor.current !== id) {
connectFiredFor.current = id;
connectAppMutation.mutate(id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fire once per installation id
}, [installIdParam]);
const [installUrlError, setInstallUrlError] = useState(null);
const installApp = async () => {
setInstallUrlError(null);
try {
window.open(await getGitHubAppInstallUrl(), '_blank', 'noopener');
} catch (err) {
setInstallUrlError(err instanceof Error ? err.message : 'GitHub App is not configured');
}
};
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 (
Git Providers
Connect accounts to deploy services from repositories
void installApp()}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
Install GitHub App
setFormOpen((open) => !open)}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{formOpen ? 'Close' : 'Connect'}
{installUrlError && (
{installUrlError}
)}
{connectAppMutation.isPending && (
Completing GitHub App installation…
)}
{connectAppMutation.isError && (
{connectAppMutation.error instanceof Error ? connectAppMutation.error.message : 'Failed to connect GitHub App'}
)}
{providersQuery.isLoading ? (
) : providersQuery.isError ? (
Failed to load providers
) : providers.length === 0 ? (
No Git providers connected
Connect GitHub, GitLab, Gitea, or Bitbucket to deploy services from source
) : (
{providers.map((provider) => (
{provider.display_name}
{provider.name.replace('_', ' ')}
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"
>
Disconnect
))}
)}
{formOpen && (
{form.name === 'gitea' && (
Instance URL
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"
/>
)}
{createMutation.isError && (
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to connect provider'}
)}
createMutation.mutate()}
disabled={!form.accessToken.trim() || createMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{createMutation.isPending ? : }
Connect Provider
)}
);
}
function PlatformSettingsSection() {
const queryClient = useQueryClient();
const settingsQuery = useQuery({
queryKey: ['platform-settings'],
queryFn: getPlatformSettings,
});
const [signupDraft, setSignupDraft] = useState(null);
const [tokenDraft, setTokenDraft] = useState('');
const [feedback, setFeedback] = useState(null);
const [error, setError] = useState(null);
const signupEnabled = signupDraft ?? settingsQuery.data?.signupEnabled ?? false;
const tunnel = settingsQuery.data?.cloudflareTunnel;
const saveMutation = useMutation({
mutationFn: updatePlatformSettings,
onSuccess: (settings) => {
queryClient.setQueryData(['platform-settings'], settings);
setSignupDraft(null);
setTokenDraft('');
setFeedback('Settings saved.');
setError(null);
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Failed to save settings');
setFeedback(null);
},
});
const saveSignup = (enabled: boolean) => {
setSignupDraft(enabled);
saveMutation.mutate({ signupEnabled: enabled });
};
const saveToken = () => {
if (!tokenDraft.trim()) return;
saveMutation.mutate({ cloudflareTunnelToken: tokenDraft.trim() });
};
const clearToken = () => {
saveMutation.mutate({ cloudflareTunnelToken: '' });
};
return (
Platform
Owner only - values set here override environment variables
{settingsQuery.isLoading ? (
) : (
saveSignup(e.target.checked)}
disabled={saveMutation.isPending}
className="mt-0.5 accent-[var(--accent-primary)]"
/>
Open registration
Let anyone with the URL create an account. Off by default after the owner account is created.
Cloudflare Tunnel
{tunnel?.tokenSet
? `token saved (${tunnel.source}) - ${tunnel.container}`
: tunnel?.container === 'unavailable'
? 'Docker socket unavailable'
: 'not configured'}
Expose this instance through a Cloudflare Tunnel. Create a tunnel in the dashboard,
copy the token, paste it here - the cloudflared sidecar is managed for you.
setTokenDraft(e.target.value)}
className="flex-1 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder={tunnel?.tokenSet ? 'Token saved - paste a new one to rotate' : 'eyJhIjo... tunnel token'}
/>
Save
{tunnel?.tokenSet && tunnel.source === 'app' ? (
Remove
) : null}
Open Cloudflare Zero Trust dashboard - Networks → Tunnels → Create
{feedback ?
{feedback}
: null}
{error ?
{error}
: null}
)}
);
}
export function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const profileQuery = useQuery({
queryKey: ['settings-profile'],
queryFn: getCurrentUserProfile,
});
const [nameDraft, setNameDraft] = useState(null);
const [avatarDraft, setAvatarDraft] = useState(null);
const [storageSummary, setStorageSummary] = useState(() =>
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 (
{/* Profile Section */}
Profile
Backed by GET/PUT /user/profile
{profileQuery.isLoading ? (
) : profileQuery.isError ? (
Failed to load profile
) : (
Name
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"
/>
Avatar URL
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"
/>
updateProfileMutation.mutate()}
disabled={!hasProfileChanges || updateProfileMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{updateProfileMutation.isPending ? (
<>
Saving...
>
) : (
<>
Save Profile
>
)}
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"
>
Refresh
{updateProfileMutation.isError && (
{updateProfileMutation.error instanceof Error ? updateProfileMutation.error.message : 'Failed to update profile'}
)}
{updateProfileMutation.isSuccess && (
Profile updated successfully
)}
)}
{/* Platform Settings - owner only */}
{profileQuery.data?.isAdmin ?
: null}
{/* Git Providers Section */}
{/* Audit Logs Section */}
Audit Logs
Every authenticated action recorded by the platform
Filter the full audit trail by resource, action, actor, and time range.
navigate('/settings/audit-logs')}
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"
>
Open audit logs
{/* Runtime & Local State Section */}
Runtime & Local State
API endpoints, cookie-session mode, and storage diagnostics
API Base
{getApiBaseUrl()}
Auth Base
{getAuthBaseUrl()}
Session Mode
HttpOnly Better Auth cookie (no local auth token)
Canvas Cache
{storageSummary.canvasKeys.length} cached canvas entries ({storageSummary.totalKeys} total keys)
Refresh
Clear Cache
{
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"
>
Sign Out
{storageSummary.canvasKeys.length > 0 && (
Cached Keys
{storageSummary.canvasKeys.slice(0, 5).map((key) => (
{key}
))}
{storageSummary.canvasKeys.length > 5 && (
+{storageSummary.canvasKeys.length - 5} more...
)}
)}
);
}
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 (
{/* API Status Grid */}
Profile
{profileStatus.label}
{profileQuery.data ? `${profileQuery.data.name} authenticated` : 'GET /user/profile'}
Projects
{projectStatus.label}
{projectsQuery.data ? `${projectsQuery.data.length} projects` : 'GET /projects'}
Templates
{templateStatus.label}
{templatesQuery.data ? `${templatesQuery.data.length} templates` : 'GET /templates'}
{buildStatus.label}
{buildsQuery.data ? `${buildsQuery.data.total} builds` : 'GET /builds'}
{/* Repository Paths */}
Repository Paths
Source files and documentation
API Contract
docs/api/openapi.yaml
Generated Types
app/frontend/src/generated/api-types.ts
Primary Guides
README.md • DOCKER_SETUP.md • docs/guides/
UI References
docs/references/dashboard.png • projects.png
{/* Common Commands */}
Common Commands
Development and build scripts
{`# Regenerate frontend API types
npm --prefix app/frontend run generate:api
# Frontend type-check + build
npm --prefix app/frontend run build:check
# Backend API tests
cd app/backend && go test ./internal/api/...
# Build remote node agent
cd app/backend && go build -o bin/containr-agent ./cmd/agent`}
Active API Base
{getApiBaseUrl()}
);
}
export { ComponentShowcase } from './pages/ComponentShowcase';
const dbStatusClass: Record = {
running: 'bg-[var(--success-soft)] text-[var(--success)]',
stopped: 'bg-[var(--surface-muted)] text-[var(--text-muted)]',
building: 'bg-[var(--warning-soft)] text-[var(--warning)]',
error: 'bg-[var(--error-soft)] text-[var(--error)]',
};
export function DatabasesPage() {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1';
const [expandedId, setExpandedId] = useState(null);
const [bindDb, setBindDb] = useState(null);
const databasesQuery = useQuery({
queryKey: ['databases'],
queryFn: listDatabases,
refetchInterval: 15_000,
enabled: !isDemoMode,
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] });
const actionMutation = useMutation({
mutationFn: ({ id, action }: { id: string; action: 'start' | 'stop' | 'restart' }) =>
databaseAction(id, action),
onSuccess: invalidate,
});
const backupMutation = useMutation({
mutationFn: (id: string) => createDatabaseBackup(id),
onSuccess: () => window.setTimeout(invalidate, 3000),
});
const restoreMutation = useMutation({
mutationFn: ({ id, backupId }: { id: string; backupId: string }) =>
restoreDatabaseBackup(id, backupId),
onSuccess: () => window.setTimeout(invalidate, 3000),
});
const databases = isDemoMode ? demoDatabases : databasesQuery.data ?? [];
return (
{databasesQuery.isLoading ? (
) : databases.length === 0 ? (
No managed databases yet.
Create one via Add Service → Database in a project workspace.
) : (
{databases.map((db: DatabaseEntity) => {
const expanded = expandedId === db.id;
const running = db.status === 'running';
const backups = db.backups?.backups ?? [];
return (
setExpandedId(expanded ? null : (db.id ?? null))}
className="flex w-full items-center gap-3 p-4 text-left"
>
{db.name}
{db.type}{db.version ? ` ${db.version}` : ''}
{db.status}
{db.plan} · {db.region}
{expanded && (
Connection URL
{db.connection_url || '—'}
{db.connection_url && (
void navigator.clipboard.writeText(db.connection_url ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
>
Copy
)}
Actions
actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
disabled={running || actionMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
Start
actionMutation.mutate({ id: db.id ?? '', action: 'stop' })}
disabled={!running || actionMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
Stop
actionMutation.mutate({ id: db.id ?? '', action: 'restart' })}
disabled={!running || actionMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
Restart
setBindDb(bindDb?.id === db.id ? null : db)}
disabled={!db.connection_url}
title={db.connection_url ? 'Inject connection URL into a service' : 'No connection URL available'}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
Bind
{actionMutation.isError && (
{actionMutation.error instanceof Error ? actionMutation.error.message : 'Action failed'}
)}
{bindDb?.id === db.id && db.connection_url && (
setBindDb(null)}
/>
)}
{db.metrics && (
{([
['CPU', `${db.metrics.cpu ?? 0}%`],
['Memory', `${db.metrics.memory ?? 0}%`],
['Storage', `${db.metrics.storage ?? 0}%`],
['Connections', `${db.metrics.connections ?? 0}`],
] as const).map(([label, value]) => (
))}
)}
Backups
backupMutation.mutate(db.id ?? '')}
disabled={!running || backupMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-40 transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{backupMutation.isPending ? 'Starting…' : 'New backup'}
{backups.length === 0 ? (
No backups yet.
) : (
{backups.map((b) => (
{formatRelative(b.created_at)}
{b.size}
{b.status}
{b.status === 'completed' && (
Download
{ if (window.confirm(`Restore ${db.name} from this backup? Current data will be overwritten.`)) restoreMutation.mutate({ id: db.id ?? '', backupId: b.id ?? '' }); }}
disabled={restoreMutation.isPending}
className="text-[var(--accent-primary)] hover:underline disabled:opacity-40"
>
Restore
)}
))}
)}
{restoreMutation.isError && (
{restoreMutation.error instanceof Error ? restoreMutation.error.message : 'Restore failed'}
)}
)}
);
})}
)}
);
}
function BindDatabasePanel({
connectionUrl,
onClose,
}: {
connectionUrl: string;
onClose: () => void;
}) {
const [projectId, setProjectId] = useState('');
const [serviceId, setServiceId] = useState('');
const [varKey, setVarKey] = useState('DATABASE_URL');
const [bound, setBound] = useState(false);
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: listProjects });
const servicesQuery = useQuery({
queryKey: ['services', projectId],
queryFn: () => listServicesByProject(projectId),
enabled: projectId !== '',
});
const bindMutation = useMutation({
mutationFn: async () => {
const existing = await listServiceVariables(serviceId);
const merged = existing
.filter((v) => v.key !== varKey)
.map((v) => ({ key: v.key, value: v.value, is_secret: v.isSecret }));
merged.push({ key: varKey, value: connectionUrl, is_secret: true });
await updateServiceVariables(serviceId, merged);
},
onSuccess: () => setBound(true),
});
const services = servicesQuery.data ?? [];
return (
Injects this database's connection URL as an environment variable on the selected service.
Project
{ setProjectId(e.target.value); setServiceId(''); }}
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
>
Select…
{(projectsQuery.data ?? []).map((p) => (
{p.name}
))}
Service
setServiceId(e.target.value)}
disabled={!projectId}
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs text-[var(--text-primary)] disabled:opacity-40"
>
Select…
{services.map((s) => (
{s.name}
))}
Variable key
setVarKey(e.target.value)}
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)] w-40"
/>
bindMutation.mutate()}
disabled={!serviceId || !varKey.trim() || bindMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-xs font-medium disabled:opacity-40"
>
{bindMutation.isPending ? : } Bind
{bound && (
Bound — {varKey} is set on the service. Redeploy the service for it to take effect.
)}
{bindMutation.isError && (
{bindMutation.error instanceof Error ? bindMutation.error.message : 'Bind failed'}
)}
);
}
function BackupScheduleRow({
databaseId,
schedule,
nextBackupAt,
}: {
databaseId: string;
schedule: string;
nextBackupAt?: string;
}) {
const queryClient = useQueryClient();
const [value, setValue] = useState(schedule);
const mutation = useMutation({
mutationFn: (expr: string) => updateDatabaseBackupSchedule(databaseId, expr),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['databases'] }),
});
return (
setValue(e.target.value)}
placeholder="cron schedule, e.g. 0 3 * * *"
className="w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)]"
/>
mutation.mutate(value.trim())}
disabled={mutation.isPending || value.trim() === schedule}
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
>
{mutation.isPending ? 'Saving…' : 'Save schedule'}
{schedule && (
{ setValue(''); mutation.mutate(''); }}
disabled={mutation.isPending}
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
>
Disable
)}
{schedule ? `auto-backup ${schedule}${nextBackupAt ? ` — next ${formatRelative(nextBackupAt)}` : ''}` : 'manual only'}
{mutation.isError && (
{mutation.error instanceof Error ? mutation.error.message : 'Failed to save schedule'}
)}
);
}
const failoverStrategies = [
{ value: 'active_passive', label: 'Active / passive' },
{ value: 'active_active', label: 'Active / active' },
{ value: 'graceful', label: 'Graceful' },
] as const;
export function HighAvailabilityPage() {
const queryClient = useQueryClient();
const [failoverReason, setFailoverReason] = useState('');
const [policyForm, setPolicyForm] = useState<{
serviceId: string;
strategy: string;
minHealthyNodes: string;
maxFailures: string;
enabled: boolean;
editing: boolean;
} | null>(null);
const [policyProjectId, setPolicyProjectId] = useState('');
const statusQuery = useQuery({ queryKey: ['ha-status'], queryFn: getHAStatus, refetchInterval: 15_000 });
const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies });
const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000 });
const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000 });
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: listProjects });
const servicesQuery = useQuery({
queryKey: ['services', policyProjectId],
queryFn: () => listServicesByProject(policyProjectId),
enabled: Boolean(policyProjectId),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['ha-status'] });
queryClient.invalidateQueries({ queryKey: ['ha-policies'] });
queryClient.invalidateQueries({ queryKey: ['ha-alerts'] });
};
const toggleMutation = useMutation({
mutationFn: (enabled: boolean) => setHAEnabled(enabled),
onSuccess: invalidate,
});
const failoverMutation = useMutation({
mutationFn: () => triggerFailover(failoverReason.trim()),
onSuccess: () => { setFailoverReason(''); invalidate(); },
});
const savePolicyMutation = useMutation({
mutationFn: async () => {
if (!policyForm) return;
await setFailoverPolicy({
service_id: policyForm.serviceId,
enabled: policyForm.enabled,
failover_strategy: policyForm.strategy,
min_healthy_nodes: Number(policyForm.minHealthyNodes) || 1,
max_failures: Number(policyForm.maxFailures) || 3,
failover_timeout: 30e9,
recovery_timeout: 60e9,
});
},
onSuccess: () => { setPolicyForm(null); invalidate(); },
});
const deletePolicyMutation = useMutation({
mutationFn: (serviceId: string) => deleteFailoverPolicy(serviceId),
onSuccess: invalidate,
});
const resolveMutation = useMutation({
mutationFn: (alertId: string) => resolveAlert(alertId),
onSuccess: invalidate,
});
const status = statusQuery.data;
const pageError =
statusQuery.error ?? policiesQuery.error ?? alertsQuery.error ?? healthQuery.error ??
toggleMutation.error ?? failoverMutation.error ?? savePolicyMutation.error ??
deletePolicyMutation.error ?? resolveMutation.error;
return (
{pageError && (
{pageError instanceof Error ? pageError.message : 'Request failed'}
)}
Manager controls
toggleMutation.mutate(!status?.enabled)}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
>
{status?.enabled ? 'Disable HA manager' : 'Enable HA manager'}
{alertsQuery.isLoading ? (
Loading…
) : (alertsQuery.data ?? []).length === 0 ? (
No active alerts.
) : (
{(alertsQuery.data ?? []).map((alert) => (
{alert.message ?? alert.id}
{alert.severity ?? 'info'} · since {alert.starts_at ? formatRelative(alert.starts_at) : '—'}
alert.id && resolveMutation.mutate(alert.id)}
className="ml-4 shrink-0 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-xs text-[var(--text-primary)] disabled:opacity-50"
>
Resolve
))}
)}
Failover policies
{policyForm === null && (
setPolicyForm({ serviceId: '', strategy: 'active_passive', minHealthyNodes: '1', maxFailures: '3', enabled: true, editing: false })}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-xs font-medium"
>
New policy
)}
{policyForm !== null && (
{policyForm.editing ? (
Service
{policyForm.serviceId}
) : (
<>
Project
{ setPolicyProjectId(e.target.value); setPolicyForm({ ...policyForm, serviceId: '' }); }}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
Select project…
{(projectsQuery.data ?? []).map((p) => (
{p.name}
))}
Service
setPolicyForm({ ...policyForm, serviceId: e.target.value })}
disabled={!policyProjectId}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)] disabled:opacity-50"
>
Select service…
{(servicesQuery.data ?? []).map((s) => (
{s.name}
))}
>
)}
Strategy
setPolicyForm({ ...policyForm, strategy: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
{failoverStrategies.map((s) => (
{s.label}
))}
setPolicyForm({ ...policyForm, enabled: e.target.checked })}
/>
Enabled
savePolicyMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
>
{savePolicyMutation.isPending ? 'Saving…' : 'Save policy'}
setPolicyForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
Cancel
)}
{policiesQuery.isLoading ? (
Loading…
) : (policiesQuery.data ?? []).length === 0 ? (
No failover policies configured.
) : (
{(policiesQuery.data ?? []).map((policy: FailoverPolicy) => (
{policy.service_id}
{policy.enabled ? 'enabled' : 'disabled'} · {policy.failover_strategy ?? 'default'} ·
min nodes {policy.min_healthy_nodes ?? '—'} · max failures {policy.max_failures ?? '—'}
setPolicyForm({
serviceId: policy.service_id ?? '',
strategy: policy.failover_strategy ?? 'active_passive',
minHealthyNodes: String(policy.min_healthy_nodes ?? 1),
maxFailures: String(policy.max_failures ?? 3),
enabled: policy.enabled ?? true,
editing: true,
})}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-xs text-[var(--text-primary)]"
>
Edit
{policy.enabled && (
{
if (window.confirm(`Disable failover for ${policy.service_id}?`)) {
deletePolicyMutation.mutate(policy.service_id ?? '');
}
}}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--error)] text-xs text-[var(--error)] disabled:opacity-50"
>
Disable
)}
))}
)}
Health check results
{healthQuery.isLoading ? (
Loading…
) : (healthQuery.data ?? []).length === 0 ? (
No health checks configured.
) : (
{(healthQuery.data ?? []).slice(0, 20).map((result, i) => (
{result.check_id}
{result.message || '—'}
{result.status ?? 'unknown'}
{result.timestamp ? formatRelative(result.timestamp) : ''}
))}
)}
);
}
const scanTypes = [
{ value: 'dependency', label: 'Dependency' },
{ value: 'configuration', label: 'Configuration' },
{ value: 'comprehensive', label: 'Comprehensive' },
] as const;
const severityColor: Record = {
critical: 'text-[var(--error)]',
high: 'text-[var(--error)]',
medium: 'text-[var(--warning)]',
low: 'text-[var(--text-tertiary)]',
};
export function SecurityPage() {
const queryClient = useQueryClient();
const [projectId, setProjectId] = useState('');
const [scanType, setScanType] = useState('comprehensive');
const [scanServiceId, setScanServiceId] = useState('');
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: listProjects });
const servicesQuery = useQuery({
queryKey: ['services', projectId],
queryFn: () => listServicesByProject(projectId),
enabled: Boolean(projectId),
});
const metricsQuery = useQuery({
queryKey: ['security-metrics', projectId],
queryFn: () => getSecurityMetrics(projectId),
enabled: Boolean(projectId),
refetchInterval: 15_000,
});
const historyQuery = useQuery({
queryKey: ['security-history', projectId],
queryFn: () => getSecurityHistory(projectId),
enabled: Boolean(projectId),
refetchInterval: 15_000,
});
const vulnsQuery = useQuery({
queryKey: ['security-vulns', projectId],
queryFn: () => listVulnerabilities(projectId),
enabled: Boolean(projectId),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['security-metrics', projectId] });
queryClient.invalidateQueries({ queryKey: ['security-history', projectId] });
queryClient.invalidateQueries({ queryKey: ['security-vulns', projectId] });
};
const scanMutation = useMutation({
mutationFn: () => startSecurityScan({
project_id: projectId,
service_id: scanServiceId || undefined,
scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive',
}),
onSuccess: invalidate,
});
const vulnMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: 'open' | 'resolved' | 'ignored' }) =>
updateVulnerability(id, status),
onSuccess: invalidate,
});
const metrics = metricsQuery.data;
const vulns = (vulnsQuery.data ?? []).filter((v) => v.status !== 'resolved');
const pageError =
projectsQuery.error ?? metricsQuery.error ?? historyQuery.error ?? vulnsQuery.error ??
scanMutation.error ?? vulnMutation.error;
return (
{pageError && (
{pageError instanceof Error ? pageError.message : 'Request failed'}
)}
Project
{ setProjectId(e.target.value); setScanServiceId(''); }}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)] w-56"
>
Select project…
{(projectsQuery.data ?? []).map((p) => (
{p.name}
))}
{projectId && (
<>
Scan type
setScanType(e.target.value)}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
{scanTypes.map((t) => (
{t.label}
))}
Service (optional)
setScanServiceId(e.target.value)}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
All services
{(servicesQuery.data ?? []).map((s) => (
{s.name}
))}
scanMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
>
{scanMutation.isPending ? 'Starting…' : 'Run scan'}
>
)}
{projectId && metrics && (
)}
{projectId && (
Findings
{vulnsQuery.isLoading ? (
Loading…
) : vulns.length === 0 ? (
No open findings.
) : (
{vulns.map((v) => (
{v.description}
{v.type} · {v.status} · found {v.found_at ? formatRelative(v.found_at) : '—'}
{v.status === 'open' && v.id && (
vulnMutation.mutate({ id: v.id!, status: 'resolved' })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--success)] text-xs text-[var(--success)] disabled:opacity-50"
>
Resolve
vulnMutation.mutate({ id: v.id!, status: 'ignored' })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-xs text-[var(--text-secondary)] disabled:opacity-50"
>
Ignore
)}
))}
)}
)}
{projectId && (
Scan history
{historyQuery.isLoading ? (
Loading…
) : (historyQuery.data ?? []).length === 0 ? (
No scans yet.
) : (
{(historyQuery.data ?? []).map((scan) => (
{scan.scan_type} · {scan.findings_count ?? 0} findings
{scan.started_at ? formatRelative(scan.started_at) : '—'}
{scan.status}
))}
)}
)}
);
}