Files
Containr/app/frontend/src/features/secondary/pages.tsx
T
Tomas Dvorak 08d92fb10f feat(backups): scheduled database backups via cron tick
- database_services gains backup_schedule + next_backup_at (goose migration,
  sqlc schema, new queries).
- PUT /databases/:id accepts backup_schedule — validated cron expression,
  empty string disables; invalid returns 400.
- Cron scheduler tick now also folds due databases into the existing
  snapshot pipeline (createBackupProcess).
- Databases page backups section gets a schedule row showing current expr
  and next run.
- resolveBackupConfig reports real next_backup/enabled instead of a fake
  last+24h estimate.
- Verified e2e: '* * * * *' produced three consecutive completed archives
  containing the seeded payload; invalid schedule rejected.

Authored By: TDvorak <[email protected]>
2026-09-17 17:46:02 +02:00

1888 lines
87 KiB
TypeScript

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,
type AgentAuthTokenCreated,
type DatabaseEntity,
} from '@/lib/api-client';
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,
} from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
return (
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<h1 className="v-title">{title}<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">{description}</p>
</div>
</div>
);
}
function StatCard({
title,
value,
description,
icon: Icon,
color = 'default',
}: {
title: string;
value: string;
description: string;
icon: typeof Clock;
color?: 'default' | 'success' | 'warning' | 'error';
}) {
const colorClasses = {
default: 'text-[var(--text-primary)]',
success: 'text-[var(--success)]',
warning: 'text-[var(--warning)]',
error: 'text-[var(--error)]',
};
return (
<div className="panel p-5">
<div className="flex items-start justify-between mb-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<Icon size={18} className="text-[var(--text-tertiary)]" />
</div>
</div>
<p className={`text-2xl font-semibold ${colorClasses[color]}`}>{value}</p>
<p className="text-xs font-medium text-[var(--text-muted)] mt-1">{title}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-2">{description}</p>
</div>
);
}
type LocalStorageSummary = {
canvasKeys: string[];
totalKeys: number;
};
function getLocalStorageSummary(): LocalStorageSummary {
if (typeof window === 'undefined') {
return { canvasKeys: [], totalKeys: 0 };
}
const canvasKeys: string[] = [];
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key && key.startsWith('containr.canvas.v1.')) {
canvasKeys.push(key);
}
}
canvasKeys.sort((left, right) => left.localeCompare(right));
return {
canvasKeys,
totalKeys: localStorage.length,
};
}
function endpointStateBadge(isLoading: boolean, isError: boolean): {
label: string;
toneClass: string;
} {
if (isLoading) {
return { label: 'Loading', toneClass: 'text-[var(--warn)]' };
}
if (isError) {
return { label: 'Unavailable', toneClass: 'text-[var(--bad)]' };
}
return { label: 'Available', toneClass: 'text-[var(--ok)]' };
}
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<AgentAuthTokenCreated | null>(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<number[]>([]);
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<number>(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 ?? '<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 (
<div className="min-h-screen">
<SecondaryPageHeader
title="Usage"
description="Platform usage metrics and operational summaries"
/>
<div className="w-full px-8 py-6">
<div className="flex items-center gap-2 mb-6">
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
{liveStatus === 'live' ? 'Live sync active' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling for updates'}
</span>
</div>
{buildsQuery.isLoading ? (
<div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p>
</div>
) : buildsQuery.isError ? (
<div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<AlertCircle size={24} className="text-[var(--error)]" />
</div>
<p className="text-sm text-[var(--error)]">Failed to load usage data</p>
</div>
) : (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<EnhancedMetricCard
title="Runtime Hours"
value={totalBuildHours > 0 ? `${totalBuildHours.toFixed(1)}h` : '—'}
statusText={completedDurationsMs.length > 0 ? 'Active' : ''}
status="good"
subtitle={runtimeBody}
icon={<Clock size={18} />}
/>
<EnhancedMetricCard
title="Build Activity"
value={String(builds.length)}
status={failedBuilds > 0 ? 'average' : 'good'}
statusText={failedBuilds > 0 ? `${failedBuilds} failed` : 'Healthy'}
subtitle={buildActivityBody}
icon={<Activity size={18} />}
chart={<BarChart data={buildsPerDay} color="var(--accent-primary)" height={72} gap={3} />}
/>
<EnhancedMetricCard
title="Capacity"
value={String(activeBuildPressure)}
status={activeBuildPressure > 0 ? 'average' : 'good'}
statusText={activeBuildPressure > 0 ? 'Busy' : 'Idle'}
subtitle={capacityBody}
icon={<Gauge size={18} />}
/>
</div>
<section className="panel p-6">
<div className="mb-5 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
<Server size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Host Monitoring</h2>
<p className="text-xs text-[var(--text-tertiary)]">
{host
? `${host.hostname} · ${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}${host.dockerAvailable ? '' : ' · Docker unavailable'}`
: 'Autoscaling base signal from this Containr host.'}
</p>
</div>
</div>
<button
onClick={() => {
void hostQuery.refetch();
void agentsQuery.refetch();
}}
className="inline-flex h-9 items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 text-sm"
>
<RefreshCw size={14} />
Refresh
</button>
</div>
{hostQuery.isError ? (
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
Host monitoring unavailable.
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<EnhancedMetricCard
title="CPU Load"
icon={<Cpu size={18} />}
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={<LineAreaChart data={loadHistory.length > 0 ? loadHistory : [0]} color="#b4e34a" height={72} />}
/>
<EnhancedMetricCard
title="Memory"
icon={<MemoryStick size={18} />}
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={
<div className="relative mx-auto" style={{ width: 150 }}>
<DonutChart percentage={host?.memory.usagePercent ?? 0} color="#f2c94c" size={150} thickness={14} />
<div className="absolute inset-x-0 bottom-0 text-center">
<div className="text-[10px] uppercase tracking-wide text-[#6b6e7d]">Used</div>
<div className="text-sm font-bold text-[#e8e9f0]">
{host ? `${formatBytes(host.memory.used)} / ${formatBytes(host.memory.total)}` : '—'}
</div>
</div>
</div>
}
/>
<EnhancedMetricCard
title="Disk"
icon={<HardDrive size={18} />}
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={
<div>
<SegmentedBar
segments={[
{ width: host?.storage.usagePercent ?? 0, color: '#ff8a5c' },
{ width: Math.max(2, 100 - (host?.storage.usagePercent ?? 0)), color: 'rgba(255,255,255,0.07)' },
]}
height={18}
/>
<div className="mt-3 flex items-center gap-4 text-xs text-[#6b6e7d]">
<span><span className="mr-1.5 inline-block h-2 w-2 rounded-full" style={{ background: '#ff8a5c' }} />{host ? `${formatBytes(host.storage.used)} used` : '—'}</span>
<span><span className="mr-1.5 inline-block h-2 w-2 rounded-full bg-white/10" />{host ? `${formatBytes(host.storage.available)} free` : '—'}</span>
</div>
</div>
}
/>
</div>
)}
</section>
<section className="panel p-6">
<div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--surface-muted)]">
<Radio size={18} className="text-[var(--text-tertiary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Node Agents</h2>
<p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr]">
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
title="Registered"
value={String(agents.length)}
description={agentsQuery.isError ? 'Agent API unavailable.' : 'Nodes connected through token heartbeats.'}
icon={Radio}
color={agents.length > 0 ? 'success' : 'default'}
/>
<StatCard
title="Online"
value={String(onlineAgents)}
description="Online and connecting agents can receive queued commands."
icon={Activity}
color={onlineAgents > 0 ? 'success' : 'default'}
/>
<StatCard
title="Remote Memory"
value={formatBytes(totalAgentMemory)}
description={`${formatBytes(availableAgentMemory)} currently free across agents.`}
icon={Database}
color={totalAgentMemory > 0 ? 'success' : 'default'}
/>
</div>
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--border-subtle)]">
{agents.length > 0 ? (
agents.slice(0, 5).map((agent) => (
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{agent.name}</p>
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
</div>
<div className="text-right">
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
</div>
</div>
))
) : (
<div className="px-4 py-6 text-sm text-[var(--text-tertiary)]">
No remote agents registered yet.
</div>
)}
</div>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4">
<div className="mb-3 flex items-center gap-2">
<Terminal size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connect Node</span>
</div>
<div className="mb-3 flex gap-2">
<input
value={tokenLabel}
onChange={(e) => 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"
/>
<button
onClick={() => 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'}
</button>
</div>
{issuedToken && (
<div className="mb-3 rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
<div className="flex items-center justify-between gap-2">
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
<button
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
>
<Copy size={11} />
Copy
</button>
</div>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">Shown once — store it now. Only the hash is kept server-side.</p>
</div>
)}
{createTokenMutation.isError && (
<p className="mb-3 text-xs text-[var(--error)]">
{createTokenMutation.error instanceof Error ? createTokenMutation.error.message : 'Failed to issue token'}
</p>
)}
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{connectCommand}</pre>
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
<p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
<p>Agent sends host resources, polls pending Docker commands, and reports command results.</p>
</div>
{(agentTokensQuery.data ?? []).length > 0 && (
<div className="mt-4 border-t border-[var(--border-subtle)] pt-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Issued tokens</p>
<div className="space-y-1.5">
{(agentTokensQuery.data ?? []).map((t) => (
<div key={t.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<span className="text-[var(--text-primary)]">{t.label || t.id?.slice(0, 8)}</span>
<span className="ml-2 text-[var(--text-muted)]">
{t.revoked ? 'revoked' : t.last_used_at ? `used ${formatRelative(t.last_used_at)}` : 'unused'}
</span>
</div>
{!t.revoked && (
<button
onClick={() => revokeTokenMutation.mutate(t.id ?? '')}
disabled={revokeTokenMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
>
Revoke
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
</section>
</div>
)}
</div>
</div>
);
}
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 (
<div className="min-h-screen">
<SecondaryPageHeader
title="People"
description="Team management and access control"
/>
<div className="w-full px-8 py-6">
{profileQuery.isLoading ? (
<div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading team data...</p>
</div>
) : (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Members"
value={profileQuery.data ? '1+' : '—'}
description={membersBody}
icon={Users}
color={profileQuery.data ? 'success' : 'default'}
/>
<StatCard
title="Roles"
value={projectsQuery.data?.length ? String(projectsQuery.data.length) : '—'}
description={rolesBody}
icon={Shield}
color="default"
/>
<StatCard
title="Audit Trail"
value={auditLogsQuery.data ? String(auditLogsQuery.data.length) : '—'}
description={auditBody}
icon={FileText}
color={auditLogsQuery.data?.length ? 'success' : 'default'}
/>
</div>
<section className="panel p-6">
<div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
<UserPlus size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Manual User Creation</h2>
<p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p>
</div>
</div>
<form
className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]"
onSubmit={(event) => {
event.preventDefault();
createUserMutation.mutate();
}}
>
<input
value={newUserName}
onChange={(event) => setNewUserName(event.target.value)}
required
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
placeholder="Name"
/>
<input
value={newUserEmail}
onChange={(event) => setNewUserEmail(event.target.value)}
type="email"
required
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
placeholder="[email protected]"
/>
<input
value={newUserPassword}
onChange={(event) => setNewUserPassword(event.target.value)}
type="password"
minLength={8}
required
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
placeholder="Temporary password"
/>
<button
type="submit"
disabled={createUserMutation.isPending}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 text-sm font-semibold text-[var(--accent-on)] disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
{createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
Create
</button>
</form>
{createUserMutation.isError ? (
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
{createUserMutation.error instanceof Error ? createUserMutation.error.message : 'Failed to create user'}
</div>
) : null}
{createUserMutation.isSuccess ? (
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--success-soft)] px-4 py-3 text-sm text-[var(--success)]">
User created. Share credentials through secure channel.
</div>
) : null}
</section>
</div>
)}
</div>
</div>
);
}
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=<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<number | null>(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<string | null>(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 (
<section className="panel p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<GitBranch size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Git Providers</h2>
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => 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"
>
<GitBranch size={14} />
Install GitHub App
</button>
<button
onClick={() => 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)' }}
>
<Link2 size={14} />
{formOpen ? 'Close' : 'Connect'}
</button>
</div>
</div>
{installUrlError && (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{installUrlError}
</div>
)}
{connectAppMutation.isPending && (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--surface-muted)] text-sm text-[var(--text-secondary)]">
Completing GitHub App installation…
</div>
)}
{connectAppMutation.isError && (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{connectAppMutation.error instanceof Error ? connectAppMutation.error.message : 'Failed to connect GitHub App'}
</div>
)}
{providersQuery.isLoading ? (
<div className="py-6 text-center">
<Loader2 size={20} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : providersQuery.isError ? (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
Failed to load providers
</div>
) : providers.length === 0 ? (
<div className="py-6 text-center">
<p className="text-sm text-[var(--text-secondary)]">No Git providers connected</p>
<p className="text-xs text-[var(--text-muted)] mt-1">
Connect GitHub, GitLab, Gitea, or Bitbucket to deploy services from source
</p>
</div>
) : (
<div className="space-y-2">
{providers.map((provider) => (
<div
key={provider.id}
className="flex items-center justify-between p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] flex items-center justify-center">
<GitBranch size={14} className="text-[var(--text-tertiary)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">{provider.display_name}</p>
<p className="text-xs text-[var(--text-muted)] capitalize">{provider.name.replace('_', ' ')}</p>
</div>
</div>
<button
onClick={() => deleteMutation.mutate(provider.id)}
disabled={deleteMutation.isPending}
className="flex items-center gap-2 h-8 px-3 rounded-[var(--radius-sm)] border border-[var(--error-soft)] text-[var(--error)] text-xs font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
>
<Unlink size={12} />
Disconnect
</button>
</div>
))}
</div>
)}
{formOpen && (
<div className="mt-4 pt-4 border-t border-[var(--border-subtle)] space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Provider
</label>
<select
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value as typeof form.name }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
>
{gitProviderTypes.map((type) => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Display Name
</label>
<input
value={form.displayName}
onChange={(e) => setForm((p) => ({ ...p, displayName: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder={selectedType.label}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Access Token
</label>
<input
type="password"
value={form.accessToken}
onChange={(e) => setForm((p) => ({ ...p, accessToken: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors"
placeholder="••••••••••••••••"
/>
<p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p>
</div>
{form.name === 'gitea' && (
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Instance URL
</label>
<input
value={form.apiUrl}
onChange={(e) => setForm((p) => ({ ...p, apiUrl: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors"
placeholder="https://gitea.example.com"
/>
</div>
)}
{createMutation.isError && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to connect provider'}
</div>
)}
<div className="flex justify-end pt-1">
<button
onClick={() => createMutation.mutate()}
disabled={!form.accessToken.trim() || createMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[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 ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Connect Provider
</button>
</div>
</div>
)}
</section>
);
}
export function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const profileQuery = useQuery({
queryKey: ['settings-profile'],
queryFn: getCurrentUserProfile,
});
const [nameDraft, setNameDraft] = useState<string | null>(null);
const [avatarDraft, setAvatarDraft] = useState<string | null>(null);
const [storageSummary, setStorageSummary] = useState<LocalStorageSummary>(() =>
getLocalStorageSummary(),
);
const resolvedNameDraft = nameDraft ?? profileQuery.data?.name ?? '';
const resolvedAvatarDraft = avatarDraft ?? profileQuery.data?.avatarUrl ?? '';
const hasProfileChanges = useMemo(() => {
if (!profileQuery.data) {
return false;
}
return (
resolvedNameDraft.trim() !== profileQuery.data.name ||
resolvedAvatarDraft.trim() !== (profileQuery.data.avatarUrl ?? '')
);
}, [profileQuery.data, resolvedAvatarDraft, resolvedNameDraft]);
const updateProfileMutation = useMutation({
mutationFn: () =>
updateCurrentUserProfile({
name: resolvedNameDraft.trim(),
avatarUrl: resolvedAvatarDraft.trim() || undefined,
}),
onSuccess: (profile) => {
queryClient.setQueryData(['settings-profile'], profile);
queryClient.setQueryData(['user-profile'], profile);
setNameDraft(null);
setAvatarDraft(null);
},
});
const refreshStorage = () => {
setStorageSummary(getLocalStorageSummary());
};
const clearCanvasCache = () => {
for (const key of storageSummary.canvasKeys) {
localStorage.removeItem(key);
}
refreshStorage();
};
const signOutLocalSession = async () => {
try {
await signOutAuthSession();
} catch {
// Continue with local cleanup even if remote sign-out call fails.
}
queryClient.removeQueries();
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate('/auth/sign-in', { replace: true });
refreshStorage();
};
return (
<div className="min-h-screen">
<SecondaryPageHeader
title="Settings"
description="Manage account profile and local configuration"
/>
<div className="w-full px-8 py-6">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Profile Section */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<User size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Profile</h2>
<p className="text-xs text-[var(--text-tertiary)]">Backed by GET/PUT /user/profile</p>
</div>
</div>
{profileQuery.isLoading ? (
<div className="py-8 text-center">
<Loader2 size={20} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : profileQuery.isError ? (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
Failed to load profile
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Name
</label>
<input
value={resolvedNameDraft}
onChange={(e) => setNameDraft(e.target.value)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="Your name"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Avatar URL
</label>
<input
value={resolvedAvatarDraft}
onChange={(e) => setAvatarDraft(e.target.value)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="https://example.com/avatar.png"
/>
</div>
<div className="flex items-center gap-3 pt-2">
<button
onClick={() => updateProfileMutation.mutate()}
disabled={!hasProfileChanges || updateProfileMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[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 ? (
<>
<Loader2 size={14} className="animate-spin" />
Saving...
</>
) : (
<>
<Check size={14} />
Save Profile
</>
)}
</button>
<button
onClick={() => profileQuery.refetch()}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
</div>
{updateProfileMutation.isError && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{updateProfileMutation.error instanceof Error ? updateProfileMutation.error.message : 'Failed to update profile'}
</div>
)}
{updateProfileMutation.isSuccess && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--success-soft)] text-sm text-[var(--success)] flex items-center gap-2">
<Check size={16} />
Profile updated successfully
</div>
)}
</div>
)}
</section>
{/* Git Providers Section */}
<GitProvidersSection />
{/* Audit Logs Section */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<ScrollText size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Audit Logs</h2>
<p className="text-xs text-[var(--text-tertiary)]">Every authenticated action recorded by the platform</p>
</div>
</div>
<p className="text-sm text-[var(--text-secondary)] mb-4">
Filter the full audit trail by resource, action, actor, and time range.
</p>
<button
onClick={() => 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
<ChevronRight size={14} />
</button>
</section>
{/* Runtime & Local State Section */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<Settings size={18} className="text-[var(--text-tertiary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Runtime & Local State</h2>
<p className="text-xs text-[var(--text-tertiary)]">API endpoints, cookie-session mode, and storage diagnostics</p>
</div>
</div>
<div className="space-y-3">
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Base</span>
</div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Auth Base</span>
</div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<Shield size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Session Mode</span>
</div>
<p className="text-xs text-[var(--text-primary)]">HttpOnly Better Auth cookie (no local auth token)</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<Database size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Canvas Cache</span>
</div>
<p className="text-xs text-[var(--text-primary)]">
{storageSummary.canvasKeys.length} cached canvas entries ({storageSummary.totalKeys} total keys)
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 mt-4">
<button
onClick={refreshStorage}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
<button
onClick={clearCanvasCache}
disabled={storageSummary.canvasKeys.length === 0}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--warning-soft)] text-[var(--warning)] text-sm font-medium hover:bg-[var(--warning-soft)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Trash2 size={14} />
Clear Cache
</button>
<button
onClick={() => {
void signOutLocalSession();
}}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] transition-colors"
>
<LogOut size={14} />
Sign Out
</button>
</div>
{storageSummary.canvasKeys.length > 0 && (
<div className="mt-4 p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">Cached Keys</p>
<ul className="space-y-1">
{storageSummary.canvasKeys.slice(0, 5).map((key) => (
<li key={key} className="mono text-xs text-[var(--text-tertiary)] truncate">
{key}
</li>
))}
{storageSummary.canvasKeys.length > 5 && (
<li className="text-xs text-[var(--text-muted)]">
+{storageSummary.canvasKeys.length - 5} more...
</li>
)}
</ul>
</div>
)}
</section>
</div>
</div>
</div>
);
}
export function DocsPage() {
const profileQuery = useQuery({
queryKey: ['docs-profile'],
queryFn: getCurrentUserProfile,
});
const projectsQuery = useQuery({
queryKey: ['docs-projects'],
queryFn: listProjects,
});
const templatesQuery = useQuery({
queryKey: ['docs-templates'],
queryFn: () => listTemplates(),
});
const buildsQuery = useQuery({
queryKey: ['docs-builds'],
queryFn: () => listBuilds({ page: 1, limit: 1 }),
});
const profileStatus = endpointStateBadge(profileQuery.isLoading, profileQuery.isError);
const projectStatus = endpointStateBadge(projectsQuery.isLoading, projectsQuery.isError);
const templateStatus = endpointStateBadge(templatesQuery.isLoading, templatesQuery.isError);
const buildStatus = endpointStateBadge(buildsQuery.isLoading, buildsQuery.isError);
return (
<div className="min-h-screen">
<SecondaryPageHeader
title="Docs"
description="Operational references and API documentation"
/>
<div className="w-full px-8 py-6">
{/* API Status Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<User size={14} className={profileStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Profile</span>
</div>
<p className={`text-sm font-semibold ${profileStatus.toneClass}`}>{profileStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{profileQuery.data ? `${profileQuery.data.name} authenticated` : 'GET /user/profile'}
</p>
</div>
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<Folder size={14} className={projectStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Projects</span>
</div>
<p className={`text-sm font-semibold ${projectStatus.toneClass}`}>{projectStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{projectsQuery.data ? `${projectsQuery.data.length} projects` : 'GET /projects'}
</p>
</div>
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<Box size={14} className={templateStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Templates</span>
</div>
<p className={`text-sm font-semibold ${templateStatus.toneClass}`}>{templateStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{templatesQuery.data ? `${templatesQuery.data.length} templates` : 'GET /templates'}
</p>
</div>
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<Activity size={14} className={buildStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Builds</span>
</div>
<p className={`text-sm font-semibold ${buildStatus.toneClass}`}>{buildStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{buildsQuery.data ? `${buildsQuery.data.total} builds` : 'GET /builds'}
</p>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Repository Paths */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<BookOpen size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Repository Paths</h2>
<p className="text-xs text-[var(--text-tertiary)]">Source files and documentation</p>
</div>
</div>
<div className="space-y-3">
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Contract</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">docs/api/openapi.yaml</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Generated Types</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">app/frontend/src/generated/api-types.ts</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<BookOpen size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Primary Guides</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">README.md • DOCKER_SETUP.md • docs/guides/</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">UI References</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">docs/references/dashboard.png • projects.png</p>
</div>
</div>
</section>
{/* Common Commands */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<Terminal size={18} className="text-[var(--text-tertiary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Common Commands</h2>
<p className="text-xs text-[var(--text-tertiary)]">Development and build scripts</p>
</div>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)]">
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">
{`# Regenerate frontend API types
npm --prefix 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`}
</pre>
</div>
<div className="mt-4 p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<Radio size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Active API Base</span>
</div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p>
</div>
</section>
</div>
</div>
</div>
);
}
export { ComponentShowcase } from './pages/ComponentShowcase';
const dbStatusClass: Record<string, string> = {
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 [expandedId, setExpandedId] = useState<string | null>(null);
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
const databasesQuery = useQuery({
queryKey: ['databases'],
queryFn: listDatabases,
refetchInterval: 15_000,
});
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 = databasesQuery.data ?? [];
return (
<div>
<SecondaryPageHeader
title="Databases"
description="Managed database services — connection info, runtime actions and backups"
/>
<div className="w-full px-8 py-6">
{databasesQuery.isLoading ? (
<div className="flex items-center justify-center py-16 text-[var(--text-muted)]">
<Loader2 size={20} className="animate-spin" />
</div>
) : databases.length === 0 ? (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-10 text-center">
<Database size={28} className="mx-auto text-[var(--text-muted)]" />
<p className="mt-3 text-sm text-[var(--text-secondary)]">No managed databases yet.</p>
<p className="mt-1 text-xs text-[var(--text-muted)]">
Create one via Add Service &rarr; Database in a project workspace.
</p>
</div>
) : (
<div className="space-y-3">
{databases.map((db: DatabaseEntity) => {
const expanded = expandedId === db.id;
const running = db.status === 'running';
const backups = db.backups?.backups ?? [];
return (
<div key={db.id} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)]">
<button
onClick={() => setExpandedId(expanded ? null : (db.id ?? null))}
className="flex w-full items-center gap-3 p-4 text-left"
>
<div className="w-9 h-9 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center shrink-0">
<Database size={17} className="text-[var(--accent-primary)]" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${dbStatusClass[db.status ?? ''] ?? 'bg-[var(--surface-muted)] text-[var(--text-muted)]'}`}>{db.status}</span>
</div>
<p className="mt-0.5 text-xs text-[var(--text-muted)]">{db.plan} · {db.region}</p>
</div>
<ChevronDown size={15} className={`shrink-0 text-[var(--text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
{expanded && (
<div className="border-t border-[var(--border-subtle)] p-4 space-y-4">
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connection URL</p>
<div className="flex items-center gap-2">
<code className="mono flex-1 truncate rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs text-[var(--text-primary)]">
{db.connection_url || '—'}
</code>
{db.connection_url && (
<button
onClick={() => void navigator.clipboard.writeText(db.connection_url ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
>
<Copy size={11} /> Copy
</button>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mr-1">Actions</span>
<button
onClick={() => 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"
>
<Play size={11} /> Start
</button>
<button
onClick={() => 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"
>
<Square size={11} /> Stop
</button>
<button
onClick={() => 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"
>
<RefreshCw size={11} /> Restart
</button>
<button
onClick={() => 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"
>
<Link2 size={11} /> Bind
</button>
{actionMutation.isError && (
<span className="text-xs text-[var(--error)]">
{actionMutation.error instanceof Error ? actionMutation.error.message : 'Action failed'}
</span>
)}
</div>
{bindDb?.id === db.id && db.connection_url && (
<BindDatabasePanel
connectionUrl={db.connection_url}
onClose={() => setBindDb(null)}
/>
)}
{db.metrics && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{([
['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]) => (
<div key={label} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2">
<p className="text-xs text-[var(--text-muted)]">{label}</p>
<p className="mono text-sm text-[var(--text-primary)]">{value}</p>
</div>
))}
</div>
)}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
<button
onClick={() => 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)' }}
>
<Archive size={11} />
{backupMutation.isPending ? 'Starting…' : 'New backup'}
</button>
</div>
<BackupScheduleRow
databaseId={db.id ?? ''}
schedule={db.backup_schedule ?? ''}
nextBackupAt={db.next_backup_at}
/>
{backups.length === 0 ? (
<p className="text-xs text-[var(--text-muted)]">No backups yet.</p>
) : (
<div className="space-y-1.5">
{backups.map((b) => (
<div key={b.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs">
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
<span className="mono text-[var(--text-muted)]">{b.size}</span>
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
{b.status === 'completed' && (
<span className="ml-auto flex items-center gap-3">
<a
href={`${getApiBaseUrl()}/databases/${encodeURIComponent(db.id ?? '')}/backups/${encodeURIComponent(b.id ?? '')}/download`}
className="text-[var(--accent-primary)] hover:underline"
>
Download
</a>
<button
onClick={() => { 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
</button>
</span>
)}
</div>
))}
</div>
)}
{restoreMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">
{restoreMutation.error instanceof Error ? restoreMutation.error.message : 'Restore failed'}
</p>
)}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
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 (
<div className="rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-raised)] p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-[var(--text-primary)]">Bind to service</p>
<button onClick={onClose} className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]">Close</button>
</div>
<p className="text-xs text-[var(--text-muted)]">
Injects this database's connection URL as an environment variable on the selected service.
</p>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
Project
<select
value={projectId}
onChange={(e) => { 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)]"
>
<option value="">Select…</option>
{(projectsQuery.data ?? []).map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
Service
<select
value={serviceId}
onChange={(e) => 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"
>
<option value="">Select…</option>
{services.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
Variable key
<input
value={varKey}
onChange={(e) => 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"
/>
</label>
<button
onClick={() => 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 ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind
</button>
</div>
{bound && (
<p className="text-xs text-[var(--success)]">
Bound — {varKey} is set on the service. Redeploy the service for it to take effect.
</p>
)}
{bindMutation.isError && (
<p className="text-xs text-[var(--error)]">
{bindMutation.error instanceof Error ? bindMutation.error.message : 'Bind failed'}
</p>
)}
</div>
);
}
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 (
<div className="mb-3 flex flex-wrap items-center gap-2">
<input
value={value}
onChange={(e) => 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)]"
/>
<button
onClick={() => 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'}
</button>
{schedule && (
<button
onClick={() => { 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
</button>
)}
<span className="text-xs text-[var(--text-muted)]">
{schedule ? `auto-backup ${schedule}${nextBackupAt ? ` — next ${formatRelative(nextBackupAt)}` : ''}` : 'manual only'}
</span>
{mutation.isError && (
<span className="text-xs text-[var(--error)]">
{mutation.error instanceof Error ? mutation.error.message : 'Failed to save schedule'}
</span>
)}
</div>
);
}