import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import {
getApiBaseUrl,
getAgentPublicBaseUrl,
getHostMonitoring,
createUser,
createGitProvider,
deleteGitProvider,
getCurrentUserProfile,
listAuditLogs,
listAgents,
pruneAgent,
updateAgent,
listBuilds,
listGitProviders,
listProjects,
createAgentToken,
listAgentTokens,
revokeAgentToken,
createUserToken,
listUserTokens,
revokeUserToken,
listRegistries,
createRegistry,
deleteRegistry,
type Registry,
listDatabases,
databaseAction,
updateDatabaseBackupSchedule,
createDatabaseBackup,
restoreDatabaseBackup,
createManagedDatabase,
registerExternalDatabase,
importDatabaseBackup,
updateDatabase,
listBackupTargets,
createBackupTarget,
deleteBackupTarget,
testBackupTarget,
testDatabaseConnection,
testDatabaseConnectionByID,
type ExternalDatabaseInput,
listServicesByProject,
listServiceVariables,
updateServiceVariables,
getGitHubAppInstallUrl,
getGitHubAppStatus,
createGitHubAppManifest,
convertGitHubAppCode,
connectGitHubApp,
updateCurrentUserProfile,
getPlatformSettings,
updatePlatformSettings,
type Branding,
type BrandingInput,
getHAStatus,
setHAEnabled,
triggerFailover,
listFailoverPolicies,
setFailoverPolicy,
deleteFailoverPolicy,
listActiveAlerts,
resolveAlert,
listHealthResults,
startSecurityScan,
getSecurityHistory,
listVulnerabilities,
updateVulnerability,
getSecurityMetrics,
type AgentAuthTokenCreated,
type UserTokenCreated,
type UserTokenScope,
type CreateDatabaseInput,
type DatabaseEntity,
type FailoverPolicy,
listNotificationChannels,
createNotificationChannel,
deleteNotificationChannel,
testNotificationChannel,
type NotificationChannel,
} from '@/lib/api-client';
import {
demoBuilds,
demoDatabases,
demoHAAlerts,
demoHAHealthResults,
demoHAPolicies,
demoHAStatus,
demoHostMonitoring,
demoProjects,
demoSecurityMetrics,
demoSecurityScans,
demoVulnerabilities,
getDemoServicesByProject,
} from '@/lib/demo-data';
import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon';
import { serviceAccent } from '@/features/workspace/canvas/service-visuals';
import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
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, DemoRestricted } from '@/shared/components';
import {
Clock,
Activity,
Gauge,
Users,
Shield,
FileText,
Settings,
User,
UserPlus,
Key,
Database,
RefreshCw,
Trash2,
LogOut,
Check,
AlertCircle,
Loader2,
Terminal,
Radio,
Server,
HardDrive,
GitBranch,
Link2,
Unlink,
Cpu,
MemoryStick,
ScrollText,
ChevronRight,
ChevronDown,
Copy,
Play,
Square,
Archive,
ShieldCheck,
Zap,
HeartPulse,
Cloud,
ExternalLink,
Upload,
Bell,
Send,
} 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 {
let storage: Storage | null = null;
try {
storage = typeof window === 'undefined' ? null : window.localStorage;
} catch {
storage = null;
}
if (!storage) {
return { canvasKeys: [], totalKeys: 0 };
}
const canvasKeys: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key && key.startsWith('containr.canvas.v1.')) {
canvasKeys.push(key);
}
}
canvasKeys.sort((left, right) => left.localeCompare(right));
return {
canvasKeys,
totalKeys: storage.length,
};
}
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 isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false,
});
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const buildsQuery = useQuery({
queryKey: ['usage-builds'],
queryFn: () => listBuilds({ page: 1, limit: 100 }),
enabled: signedIn && !isDemoMode,
retry: false,
});
const hostQuery = useQuery({
queryKey: ['usage-host-monitoring'],
queryFn: getHostMonitoring,
refetchInterval: 15_000,
enabled: !isDemoMode,
});
const agentsQuery = useQuery({
queryKey: ['usage-agents'],
queryFn: listAgents,
refetchInterval: 15_000,
enabled: isAdmin,
retry: false,
});
const agentTokensQuery = useQuery({
queryKey: ['agent-tokens'],
queryFn: listAgentTokens,
enabled: isAdmin,
retry: false,
});
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 pruneMutation = useMutation({
mutationFn: (id: string) => pruneAgent(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['usage-agents'] }),
});
const autoPruneMutation = useMutation({
mutationFn: ({ id, on }: { id: string; on: boolean }) => updateAgent(id, { auto_prune: on }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['usage-agents'] }),
});
const builds = useMemo(
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
[isDemoMode, buildsQuery.data],
);
const liveStatus = useBuildUpdates(
isDemoMode ? [] : 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 = isDemoMode ? demoHostMonitoring : hostQuery.data;
const agents = agentsQuery.data ?? [];
// Accumulate host samples for the load sparkline (polled every 15s).
const [loadHistory, setLoadHistory] = useState([]);
useEffect(() => {
if (!host || host.cpu.cores <= 0) {
return;
}
const loadPercent = Math.min(100, (host.load.load1m / host.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));
}, [host]);
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 = 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);
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);
}, [builds]);
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 apiBase = getApiBaseUrl().replace(/\/api\/v1$/, '');
const installCommand = `curl -fsSL ${apiBase}/api/agents/install.sh | bash -s -- --url ${apiBase} --token ${issuedToken?.token ?? ''}`;
const sshCommand = `containr nodes add --ssh ${issuedToken?.token ? '' : ' # run on a host with the CLI'}`;
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'}
{!isDemoMode && buildsQuery.isLoading ? (
) : !isDemoMode && buildsQuery.isError ? (
Failed to load usage data
) : !signedIn ? (
Sign in to view build activity and capacity.
) : (
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
{!isDemoMode && 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="var(--accent-primary)" 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}
{isAdmin && (
<>
pruneMutation.mutate(agent.id)}
disabled={pruneMutation.isPending}
title="Enqueue a bounded docker prune on this node"
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:underline disabled:opacity-50"
>
Prune
autoPruneMutation.mutate({ id: agent.id, on: e.target.checked })}
className="accent-[var(--accent-primary)]"
/>
auto
>
)}
{agent.status}
{formatBytes(agent.resources.memory.available)} free
))
) : (
No remote agents registered yet.
)}
Connect Node
{isAdmin ? (
<>
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'}
)}
{installCommand}
{sshCommand}
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
)}
))}
)}
>
) : (
Node registration and token issuance are admin operations. {signedIn ? 'Your account does not have admin rights.' : 'Sign in as an admin to manage nodes.'}
)}
)}
);
}
export function PeoplePage() {
const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode,
});
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(),
enabled: !isDemoMode,
});
const auditLogsQuery = useQuery({
queryKey: ['people-audit-logs'],
queryFn: () => listAuditLogs({ page: 1, limit: 20 }),
enabled: !isDemoMode,
});
if (isDemoMode) {
return ;
}
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');
}
};
// Admins can self-provision the instance's GitHub App via the manifest
// flow when GITHUB_APP_* env vars were never configured.
const profileQuery = useQuery({ queryKey: ['user-profile'], queryFn: getCurrentUserProfile, retry: false });
const isAdmin = Boolean(profileQuery.data?.isAdmin);
const ghAppQuery = useQuery({
queryKey: ['github-app-status'],
queryFn: getGitHubAppStatus,
enabled: isAdmin,
retry: false,
});
const provisionMutation = useMutation({
mutationFn: async () => {
const { url, manifest } = await createGitHubAppManifest({ base_url: window.location.origin });
// GitHub requires a form POST carrying the manifest JSON.
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'manifest';
input.value = manifest;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
},
});
const ghAppStatus = ghAppQuery.data;
const showProvision = isAdmin && ghAppStatus && !ghAppStatus.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
{showProvision ? (
provisionMutation.mutate()}
disabled={provisionMutation.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 transition-all disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
>
{provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'}
) : (
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}
)}
{provisionMutation.isError && (
{provisionMutation.error instanceof Error ? provisionMutation.error.message : 'Failed to start GitHub App setup'}
)}
{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
)}
);
}
// GitHub redirects here after the admin completes the manifest form —
// /git/github-app/callback?code=…&state=…
export function GitHubAppCallbackPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const code = searchParams.get('code');
const convertMutation = useMutation({
mutationFn: (appCode: string) => convertGitHubAppCode(appCode),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['github-app-status'] });
// The setup wizard stashes a return marker before submitting the
// manifest form — resume there instead of the settings page.
const inSetup = sessionStorage.getItem('containr.setup_return') === '1';
sessionStorage.removeItem('containr.setup_return');
navigate(inSetup ? '/setup' : '/settings', { replace: true });
},
});
const firedRef = useRef(false);
useEffect(() => {
if (code && !firedRef.current) {
firedRef.current = true;
convertMutation.mutate(code);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fire once per code
}, [code]);
return (
{!code ? (
<>
Missing code
GitHub did not return an app code. Start the setup again from Git providers.
>
) : convertMutation.isError ? (
<>
Setup failed
{convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'}
navigate('/settings', { replace: true })}
className="mt-6 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-sm font-semibold text-[var(--accent-on)]"
>
Back to settings
>
) : (
<>
Setting up GitHub App
Exchanging the app credentials…
>
)}
);
}
function PlatformSettingsSection() {
const queryClient = useQueryClient();
const settingsQuery = useQuery({
queryKey: ['platform-settings'],
queryFn: getPlatformSettings,
});
const [signupDraft, setSignupDraft] = useState(null);
const [tokenDraft, setTokenDraft] = useState('');
const [brandDraft, setBrandDraft] = 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);
queryClient.invalidateQueries({ queryKey: ['branding'] });
setSignupDraft(null);
setTokenDraft('');
setBrandDraft({});
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
Branding
leave blank to keep Containr defaults
White-label this instance — product name, logo, accent color and links apply
to the shell, login page and document title.
{(
[
['productName', 'Product name'],
['logoUrl', 'Logo URL'],
['faviconUrl', 'Favicon URL'],
['accentColor', 'Accent color (#hex)'],
['docsUrl', 'Docs URL'],
['supportUrl', 'Support URL'],
] as const
).map(([field, label]) => (
{label}
setBrandDraft((d) => ({ ...d, [field]: e.target.value }))}
className="h-9 w-full px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] text-sm focus:border-[var(--accent-primary)] transition-colors"
/>
))}
{
const b = { ...(settingsQuery.data?.branding ?? {}), ...brandDraft } as Branding;
saveMutation.mutate({ branding: b });
}}
disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ background: 'var(--accent-primary)' }}
>
Save branding
{Object.keys(brandDraft).length > 0 ? (
setBrandDraft({})}
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)]"
>
Reset draft
) : null}
{feedback ?
{feedback}
: null}
{error ?
{error}
: null}
)}
);
}
const PAT_EXPIRY_OPTIONS: { label: string; days: number }[] = [
{ label: 'Never', days: 0 },
{ label: '30 days', days: 30 },
{ label: '90 days', days: 90 },
{ label: '1 year', days: 365 },
];
function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
const queryClient = useQueryClient();
const tokensQuery = useQuery({
queryKey: ['user-tokens'],
queryFn: listUserTokens,
});
const [tokenName, setTokenName] = useState('');
const [tokenScope, setTokenScope] = useState('write');
const [expiryDays, setExpiryDays] = useState(0);
const [issuedToken, setIssuedToken] = useState(null);
const createMutation = useMutation({
mutationFn: () =>
createUserToken({
name: tokenName.trim(),
scope: tokenScope,
expiresInDays: expiryDays,
}),
onSuccess: (data) => {
setIssuedToken(data);
setTokenName('');
queryClient.invalidateQueries({ queryKey: ['user-tokens'] });
},
});
const revokeMutation = useMutation({
mutationFn: (id: string) => revokeUserToken(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user-tokens'] }),
});
const tokens = tokensQuery.data ?? [];
const scopeOptions: { value: UserTokenScope; label: string }[] = isAdmin
? [
{ value: 'read', label: 'Read - GET requests only' },
{ value: 'write', label: 'Write - read and manage resources' },
{ value: 'admin', label: 'Admin - everything, incl. platform settings' },
]
: [
{ value: 'read', label: 'Read - GET requests only' },
{ value: 'write', label: 'Write - read and manage resources' },
];
return (
Personal Access Tokens
Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope
setTokenName(e.target.value)}
placeholder="Token name (e.g. laptop cli)"
className="h-9 flex-1 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
setTokenScope(e.target.value as UserTokenScope)}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
>
{scopeOptions.map((option) => (
{option.label}
))}
setExpiryDays(Number(e.target.value))}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
>
{PAT_EXPIRY_OPTIONS.map((option) => (
{option.label}
))}
createMutation.mutate()}
disabled={createMutation.isPending || !tokenName.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{createMutation.isPending ? 'Creating…' : 'Create 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. Use with containr auth login --token or as a Bearer token.
) : null}
{createMutation.isError ? (
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create token'}
) : null}
{tokensQuery.isLoading ? (
) : tokens.length === 0 ? (
No tokens yet. Create one to connect the CLI or an agent.
) : (
{tokens.map((token) => (
{token.name}
{token.key_prefix}…
{token.scope}
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
{token.expires_at ? ` · expires ${formatRelative(token.expires_at)}` : ''}
{
if (window.confirm(`Revoke token "${token.name}"? Clients using it lose access immediately.`)) {
revokeMutation.mutate(token.id ?? '');
}
}}
disabled={revokeMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
>
Revoke
))}
)}
);
}
function RegistriesSection() {
const queryClient = useQueryClient();
const registriesQuery = useQuery({
queryKey: ['registries'],
queryFn: listRegistries,
});
const [name, setName] = useState('');
const [host, setHost] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const createMutation = useMutation({
mutationFn: () =>
createRegistry({
name: name.trim(),
host: host.trim(),
username: username.trim() || undefined,
password: password || undefined,
}),
onSuccess: () => {
setName('');
setHost('');
setUsername('');
setPassword('');
queryClient.invalidateQueries({ queryKey: ['registries'] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteRegistry(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['registries'] }),
});
const registries = registriesQuery.data ?? [];
return (
Registry credentials
Pull private images on deploy - matched by image host, stored encrypted
setName(e.target.value)}
placeholder="Name (e.g. ghcr)"
className="h-9 flex-1 min-w-[120px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
setHost(e.target.value)}
placeholder="Host (ghcr.io, docker.io)"
className="h-9 flex-1 min-w-[160px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
setUsername(e.target.value)}
placeholder="Username"
className="h-9 w-40 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
setPassword(e.target.value)}
placeholder="Password / token"
className="h-9 w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
createMutation.mutate()}
disabled={createMutation.isPending || !name.trim() || !host.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{createMutation.isPending ? 'Adding…' : 'Add registry'}
{createMutation.isError ? (
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add registry'}
) : null}
{registriesQuery.isLoading ? (
) : registries.length === 0 ? (
No credentials. Image pulls are anonymous unless a registry matching the image host exists here.
) : (
{registries.map((r: Registry) => (
{r.name}
{r.host}
{r.username ? {r.username} : null}
{r.has_password ? 'password set' : 'anonymous'}
{
if (window.confirm(`Remove credentials for ${r.host}?`)) {
deleteMutation.mutate(r.id);
}
}}
disabled={deleteMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
>
Remove
))}
)}
);
}
function BackupTargetsSection() {
const queryClient = useQueryClient();
const targetsQuery = useQuery({
queryKey: ['backup-targets'],
queryFn: listBackupTargets,
});
const [form, setForm] = useState({ name: '', endpoint: '', bucket: '', region: '', prefix: '', accessKey: '', secretKey: '', noTls: false });
const [testResult, setTestResult] = useState>({});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['backup-targets'] });
const createMutation = useMutation({
mutationFn: () =>
createBackupTarget({
name: form.name.trim(),
endpoint: form.endpoint.trim(),
bucket: form.bucket.trim(),
region: form.region.trim() || undefined,
prefix: form.prefix.trim() || undefined,
access_key: form.accessKey || undefined,
secret_key: form.secretKey || undefined,
use_tls: !form.noTls,
}),
onSuccess: () => {
setForm({ name: '', endpoint: '', bucket: '', region: '', prefix: '', accessKey: '', secretKey: '', noTls: false });
invalidate();
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteBackupTarget(id),
onSuccess: invalidate,
});
const testMutation = useMutation({
mutationFn: (id: string) => testBackupTarget(id),
onSuccess: (result, id) => setTestResult((prev) => ({ ...prev, [id]: result })),
onError: (error, id) =>
setTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })),
});
const targets = targetsQuery.data ?? [];
const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all';
return (
Backup targets
S3-compatible destinations for database archives — keys stored encrypted
);
}
function NotificationChannelsSection() {
const queryClient = useQueryClient();
const channelsQuery = useQuery({
queryKey: ['notification-channels'],
queryFn: listNotificationChannels,
});
const [kind, setKind] = useState<'ntfy' | 'gotify'>('ntfy');
const [endpoint, setEndpoint] = useState('');
const [token, setToken] = useState('');
const [testResult, setTestResult] = useState>({});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['notification-channels'] });
const createMutation = useMutation({
mutationFn: () =>
createNotificationChannel({
kind,
endpoint: endpoint.trim(),
token: token.trim() || undefined,
}),
onSuccess: () => {
setEndpoint('');
setToken('');
invalidate();
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteNotificationChannel(id),
onSuccess: invalidate,
});
const testMutation = useMutation({
mutationFn: (id: string) => testNotificationChannel(id),
onSuccess: (_d, id) => setTestResult((prev) => ({ ...prev, [id]: 'ok' })),
onError: (err, id) =>
setTestResult((prev) => ({
...prev,
[id]: err instanceof Error ? err.message : 'delivery failed',
})),
});
const channels = channelsQuery.data ?? [];
return (
Push notifications
Relay notifications to a self-hosted ntfy or Gotify server
setKind(e.target.value as 'ntfy' | 'gotify')}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
>
ntfy
Gotify
setEndpoint(e.target.value)}
placeholder={kind === 'ntfy' ? 'https://ntfy.example.com/my-alerts' : 'https://gotify.example.com'}
className="h-9 flex-1 min-w-[220px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
setToken(e.target.value)}
placeholder={kind === 'ntfy' ? 'Access token (optional)' : 'App token'}
className="h-9 w-48 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
createMutation.mutate()}
disabled={createMutation.isPending || !endpoint.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{createMutation.isPending ? 'Adding…' : 'Add channel'}
{createMutation.isError ? (
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add channel'}
) : null}
{channelsQuery.isLoading ? (
) : channels.length === 0 ? (
No push channels. Notifications stay in-app until you add one.
) : (
{channels.map((ch: NotificationChannel) => (
{ch.kind}
{ch.endpoint}
{testResult[ch.id] === 'ok' ? (
delivered
) : testResult[ch.id] ? (
{testResult[ch.id]}
) : null}
testMutation.mutate(ch.id)}
disabled={testMutation.isPending}
className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] disabled:opacity-50"
>
Test
{
if (window.confirm(`Remove ${ch.kind} channel ${ch.endpoint}?`)) {
deleteMutation.mutate(ch.id);
}
}}
disabled={deleteMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50"
>
Remove
))}
)}
);
}
export function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const profileQuery = useQuery({
queryKey: ['settings-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode,
});
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 = () => {
try {
for (const key of storageSummary.canvasKeys) {
window.localStorage.removeItem(key);
}
} catch {
// Storage unavailable — nothing to clear.
}
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();
};
if (isDemoMode) {
return ;
}
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
)}
)}
{/* Personal Access Tokens */}
{/* 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() {
return (
);
}
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)]',
};
const DATABASE_TYPES = ['postgresql', 'mysql', 'mariadb', 'mongodb', 'redis', 'dragonfly', 'clickhouse'] as const;
const DATABASE_PLANS = ['hobby', 'starter', 'standard', 'business'] as const;
export function DatabasesPage() {
const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const [expandedId, setExpandedId] = useState(null);
const [bindDb, setBindDb] = useState(null);
const [createOpen, setCreateOpen] = useState(false);
const [createMode, setCreateMode] = useState<'managed' | 'external'>('managed');
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local', public: false });
const [externalForm, setExternalForm] = useState({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
const [externalProbe, setExternalProbe] = useState<{ ok: boolean; latency_ms?: number; error?: string } | null>(null);
const [externalTestResult, setExternalTestResult] = useState>({});
const [createError, setCreateError] = useState(null);
const databasesQuery = useQuery({
queryKey: ['databases'],
queryFn: listDatabases,
refetchInterval: 15_000,
enabled: !isDemoMode,
});
const backupTargetsQuery = useQuery({
queryKey: ['backup-targets'],
queryFn: listBackupTargets,
enabled: !isDemoMode,
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] });
const createMutation = useMutation({
mutationFn: () =>
createManagedDatabase({
name: createForm.name.trim(),
type: createForm.type as CreateDatabaseInput['type'],
plan: createForm.plan as CreateDatabaseInput['plan'],
region: createForm.region.trim() || 'local',
public_port: createForm.public,
}),
onSuccess: () => {
setCreateOpen(false);
setCreateForm({ name: '', type: 'postgresql', plan: 'hobby', region: 'local', public: false });
setCreateError(null);
invalidate();
},
onError: (error) => {
setCreateError(error instanceof Error ? error.message : 'Failed to create database');
},
});
const externalPayload = (): ExternalDatabaseInput => ({
name: externalForm.name.trim(),
type: externalForm.type,
host: externalForm.host.trim(),
port: Number(externalForm.port) || undefined,
database: externalForm.database.trim() || undefined,
username: externalForm.username.trim() || undefined,
password: externalForm.password || undefined,
ssl: externalForm.ssl,
});
const registerExternalMutation = useMutation({
mutationFn: () => registerExternalDatabase(externalPayload()),
onSuccess: () => {
setCreateOpen(false);
setExternalForm({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
setExternalProbe(null);
setCreateError(null);
invalidate();
},
onError: (error) => {
setCreateError(error instanceof Error ? error.message : 'Failed to register database');
},
});
const probeMutation = useMutation({
mutationFn: () => testDatabaseConnection(externalPayload()),
onSuccess: (result) => setExternalProbe(result),
onError: (error) => setExternalProbe({ ok: false, error: error instanceof Error ? error.message : 'probe failed' }),
});
const publicPortMutation = useMutation({
mutationFn: ({ id, publicPort }: { id: string; publicPort: boolean }) =>
updateDatabase(id, { public_port: publicPort }),
onSuccess: () => window.setTimeout(invalidate, 1500),
});
const backupTargetMutation = useMutation({
mutationFn: ({ id, targetId }: { id: string; targetId: string }) =>
updateDatabase(id, { backup_target_id: targetId }),
onSuccess: invalidate,
});
const importBackupMutation = useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) => importDatabaseBackup(id, file),
onSuccess: invalidate,
});
const testByIdMutation = useMutation({
mutationFn: (id: string) => testDatabaseConnectionByID(id),
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
onError: (error, id) =>
setExternalTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })),
});
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 (
{databases.length} {databases.length === 1 ? 'database' : 'databases'}
{signedIn && !isDemoMode ? (
setCreateOpen(true)}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
New database
) : null}
{databasesQuery.isLoading ? (
) : databases.length === 0 ? (
No managed databases yet.
{signedIn ? (
setCreateOpen(true)}
className="mt-4 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
Create your first database
) : (
Sign in to provision a managed database.
)}
) : (
{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.provider === 'external' && (
external
)}
{db.status}
{db.provider === 'external' && db.external
? `${db.external.host}:${db.external.port}${db.external.database ? ` · ${db.external.database}` : ''}`
: `${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
)}
{signedIn ? (
<>
Actions
{db.provider === 'external' ? (
<>
testByIdMutation.mutate(db.id ?? '')}
disabled={testByIdMutation.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"
>
Test connection
{externalTestResult[db.id ?? ''] && (
{externalTestResult[db.id ?? '']?.ok
? `reachable (${externalTestResult[db.id ?? '']?.latency_ms ?? 0}ms)`
: `unreachable — ${externalTestResult[db.id ?? '']?.error ?? 'probe failed'}`}
)}
>
) : (
<>
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
{ if (window.confirm(`${db.public_port ? 'Stop exposing' : 'Expose'} ${db.name} on all interfaces? The container will be recreated; data persists.`)) publicPortMutation.mutate({ id: db.id ?? '', publicPort: !db.public_port }) }}
disabled={publicPortMutation.isPending}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border text-xs font-medium transition-colors disabled:opacity-40 ${db.public_port ? 'border-[var(--warning)] text-[var(--warning)]' : 'border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--border-default)]'}`}
>
{db.public_port ? 'Public ✓' : 'Local only'}
>
)}
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'}
)}
>
) : (
Sign in to manage this database.
)}
{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]) => (
))}
)}
{db.provider !== 'external' && (
Backups
{signedIn ? (
) : null}
{backups.length === 0 ? (
No backups yet.
) : (
{backups.map((b) => (
{formatRelative(b.created_at)}
{b.size}
{b.status}
{b.remote &&
offsite }
{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'}
)}
)}
)}
);
})}
)}
{createOpen ? (
New database
{(['managed', 'external'] as const).map((mode) => (
{ setCreateMode(mode); setCreateError(null); }}
className={`flex-1 rounded-[calc(var(--radius-md)-2px)] px-3 py-1.5 text-xs font-medium capitalize transition-colors ${createMode === mode ? 'bg-[var(--bg-base)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
>
{mode}
))}
{createMode === 'managed' ? 'Provision a managed database service.' : 'Register a database hosted outside Containr.'}
setCreateOpen(false)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
(createMode === 'managed' ? createMutation.mutate() : registerExternalMutation.mutate())}
disabled={
(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ||
(createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0)
}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? : null}
{createMode === 'managed' ? 'Create database' : 'Register database'}
) : null}
);
}
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-on)] 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 isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false,
});
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
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, enabled: !isDemoMode });
const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies, enabled: !isDemoMode });
const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000, enabled: !isDemoMode });
const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
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 = isDemoMode ? demoHAStatus : statusQuery.data;
const alerts = isDemoMode ? demoHAAlerts : alertsQuery.data ?? [];
const policies = isDemoMode ? demoHAPolicies : policiesQuery.data ?? [];
const healthResults = isDemoMode ? demoHAHealthResults : healthQuery.data ?? [];
const pageError =
statusQuery.error ?? policiesQuery.error ?? alertsQuery.error ?? healthQuery.error ??
toggleMutation.error ?? failoverMutation.error ?? savePolicyMutation.error ??
deletePolicyMutation.error ?? resolveMutation.error;
return (
HA state feeds the platform security posture.
Open Security →
{pageError && (
{pageError instanceof Error ? pageError.message : 'Request failed'}
)}
{isAdmin ? (
Manager controls
toggleMutation.mutate(!status?.enabled)}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{status?.enabled ? 'Disable HA manager' : 'Enable HA manager'}
) : null}
{!isDemoMode && alertsQuery.isLoading ? (
Loading…
) : alerts.length === 0 ? (
No active alerts.
) : (
{alerts.map((alert) => (
{alert.message ?? alert.id}
{alert.severity ?? 'info'} · since {alert.starts_at ? formatRelative(alert.starts_at) : '—'}
{isAdmin ? (
alert.id && resolveMutation.mutate(alert.id)}
className="ml-4 shrink-0 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)] disabled:opacity-50"
>
Resolve
) : null}
))}
)}
Failover policies
{policyForm === null && isAdmin && (
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-on)] 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(--surface-muted)] border border-[var(--border-subtle)] 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(--surface-muted)] border border-[var(--border-subtle)] 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(--surface-muted)] border border-[var(--border-subtle)] 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-on)] 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-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
)}
{!isDemoMode && policiesQuery.isLoading ? (
Loading…
) : policies.length === 0 ? (
No failover policies configured.
) : (
{policies.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 ?? '—'}
{isAdmin ? (
<>
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-subtle)] 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
)}
>
) : null}
))}
)}
Health check results
{!isDemoMode && healthQuery.isLoading ? (
Loading…
) : healthResults.length === 0 ? (
No health checks configured.
) : (
{healthResults.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 isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
const [projectId, setProjectId] = useState(isDemoMode ? 'demo-project-core' : '');
const [scanType, setScanType] = useState('comprehensive');
const [scanServiceId, setScanServiceId] = useState('');
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
const servicesQuery = useQuery({
queryKey: ['services', projectId],
queryFn: () => listServicesByProject(projectId),
enabled: !isDemoMode && Boolean(projectId),
});
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? [];
const metricsQuery = useQuery({
queryKey: ['security-metrics', projectId],
queryFn: () => getSecurityMetrics(projectId),
enabled: !isDemoMode && Boolean(projectId),
refetchInterval: 15_000,
});
const historyQuery = useQuery({
queryKey: ['security-history', projectId],
queryFn: () => getSecurityHistory(projectId),
enabled: !isDemoMode && Boolean(projectId),
refetchInterval: 15_000,
});
const vulnsQuery = useQuery({
queryKey: ['security-vulns', projectId],
queryFn: () => listVulnerabilities(projectId),
enabled: !isDemoMode && 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 = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
const scanHistory = isDemoMode
? demoSecurityScans.filter((s) => s.project_id === projectId)
: historyQuery.data ?? [];
const vulns = (isDemoMode
? demoVulnerabilities.filter((v) => v.project_id === projectId)
: vulnsQuery.data ?? []
).filter((v) => v.status !== 'resolved');
const pageError =
projectsQuery.error ?? metricsQuery.error ?? historyQuery.error ?? vulnsQuery.error ??
scanMutation.error ?? vulnMutation.error;
return (
Platform availability and failover live under High availability.
Open HA →
{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(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56"
>
Select project…
{projects.map((p) => (
{p.name}
))}
{projectId && (
<>
Scan type
setScanType(e.target.value)}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] 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(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
All services
{services.map((s) => (
{s.name}
))}
{signedIn ? (
scanMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{scanMutation.isPending ? 'Starting…' : 'Run scan'}
) : (
{isDemoMode ? 'Read-only in demo.' : 'Sign in to run scans.'}
)}
>
)}
{projectId && metrics && (
)}
{projectId && (
Findings
{!isDemoMode && 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 && signedIn && (
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-subtle)] text-xs text-[var(--text-secondary)] disabled:opacity-50"
>
Ignore
)}
))}
)}
)}
{projectId && (
Scan history
{!isDemoMode && historyQuery.isLoading ? (
Loading…
) : scanHistory.length === 0 ? (
No scans yet.
) : (
{scanHistory.map((scan) => (
{scan.scan_type} · {scan.findings_count ?? 0} findings
{scan.started_at ? formatRelative(scan.started_at) : '—'}
{scan.status}
))}
)}
)}
);
}