feat(nodes): SSH bootstrap + housekeeping — install script, bundled binaries, auto-prune (#50)

* feat(nodes): SSH bootstrap + housekeeping — install script, bundled binaries, auto-prune

- `containr nodes add <user@host>` mints an enroll token, SSHes once,
  runs the install script remotely; --print emits the one-liner
- Public /api/agents/install.sh + /api/agents/download/{platform};
  agent binaries built into the image under /agents
- Agent-side `prune` (bounded docker system prune, volumes opt-in)
  and `system_df` commands
- node_agents.auto_prune + health sweep enqueues daily prune;
  offline/online transitions notify admins
- CLI nodes update/prune/commands + MCP tools (prune confirm-gated)
- Usage page: install one-liner, per-node Prune + auto toggle

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(nodes): node detail page — telemetry, containers, commands, controls

/nodes/:id (admin): cpu/mem sparklines from heartbeat history
(1h/24h/7d), containers on node, command history, inline rename,
auto-prune toggle, prune-now with volume opt-in, remove. Agent rows
on the Usage page link into it.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(nodes): mesh awareness + node detail page

- agent reports tailscale/netbird/zerotier/wireguard interface IPs at
  register (authoritative — absent interfaces clear stored state)
- backend stores under node_agents.metadata.mesh, merged on re-register
- node detail page shows mesh addresses under the primary IP

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-09 12:41:26 +02:00
1 parent 7547375cc2
commit 3f066b3b1e
23 files changed
+1410 -40

No files matched your search

+2
View File
@@ -26,6 +26,7 @@ import {
ComponentShowcase,
GitHubAppCallbackPage,
} from '@/features/secondary/pages';
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
@@ -168,6 +169,7 @@ export default function App() {
{/* Platform owner only */}
<Route element={<AdminRequired />}>
<Route path="/admin" element={<AdminPage />} />
<Route path="/nodes/:id" element={<NodeDetailPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
@@ -0,0 +1,393 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
deleteAgent,
getAgent,
getAgentMetrics,
listAgentCommands,
listAgentContainers,
pruneAgent,
updateAgent,
type AgentMetricPoint,
} from '@/lib/api-client';
import { getCurrentUserProfile } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { formatBytes, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import {
ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle,
} from 'lucide-react';
const TIME_RANGES = [
{ label: '1h', value: '1h' },
{ label: '24h', value: '24h' },
{ label: '7d', value: '168h' },
];
export function NodeDetailPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
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 [timeRange, setTimeRange] = useState('24h');
const [rename, setRename] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmVolumes, setConfirmVolumes] = useState(false);
const agentQuery = useQuery({
queryKey: ['node-agent', id],
queryFn: () => getAgent(id),
enabled: !isDemoMode && id !== '',
refetchInterval: 15000,
});
const metricsQuery = useQuery({
queryKey: ['node-metrics', id, timeRange],
queryFn: () => getAgentMetrics(id, timeRange),
enabled: !isDemoMode && id !== '',
refetchInterval: 30000,
});
const containersQuery = useQuery({
queryKey: ['node-containers', id],
queryFn: () => listAgentContainers(id),
enabled: !isDemoMode && id !== '',
refetchInterval: 15000,
});
const commandsQuery = useQuery({
queryKey: ['node-commands', id],
queryFn: () => listAgentCommands(id),
enabled: !isDemoMode && id !== '',
refetchInterval: 15000,
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['node-agent', id] });
queryClient.invalidateQueries({ queryKey: ['node-commands', id] });
queryClient.invalidateQueries({ queryKey: ['usage-agents'] });
};
const updateMutation = useMutation({
mutationFn: (body: { name?: string; auto_prune?: boolean }) => updateAgent(id, body),
onSuccess: () => { setRename(null); invalidate(); },
});
const pruneMutation = useMutation({
mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }),
onSuccess: () => { setConfirmVolumes(false); invalidate(); },
});
const deleteMutation = useMutation({
mutationFn: () => deleteAgent(id),
onSuccess: () => navigate('/usage'),
});
const agent = agentQuery.data;
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data]);
const latest = metrics.length > 0 ? metrics[metrics.length - 1] : null;
const containers = containersQuery.data ?? [];
const commands = commandsQuery.data ?? [];
if (isDemoMode) return <DemoRestricted feature="Nodes" />;
if (agentQuery.isLoading) {
return <div className="px-8 py-10 text-xs text-[var(--text-muted)]">Loading node…</div>;
}
if (!agent) {
return (
<div className="px-8 py-10">
<p className="text-sm text-[var(--text-secondary)]">Node not found.</p>
<Link to="/usage" className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">← back to usage</Link>
</div>
);
}
const online = agent.status === 'online' || agent.status === 'connecting';
return (
<div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2">
<Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1">
<ArrowLeft size={12} /> usage
</Link>
<ChevronRight size={10} />
<span className="text-[var(--text-primary)]">{agent.name}</span>
</div>
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="v-title">
{rename === null ? agent.name : rename}
{isAdmin && rename === null && (
<button
onClick={() => setRename(agent.name)}
className="ml-2 inline-block align-middle text-[var(--text-muted)] hover:text-[var(--text-primary)]"
title="Rename node"
>
<Pencil size={14} />
</button>
)}
<span className="v-cursor">_</span>
</h1>
{rename !== null && (
<div className="mt-2 flex items-center gap-2">
<input
value={rename}
onChange={(e) => setRename(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') updateMutation.mutate({ name: rename });
if (e.key === 'Escape') setRename(null);
}}
autoFocus
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-1 text-sm text-[var(--text-primary)]"
/>
<button onClick={() => updateMutation.mutate({ name: rename })} className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">save</button>
<button onClick={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button>
</div>
)}
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
{agent.hostname} · {agent.ipAddress}:{agent.port}
{agent.version ? ` · v${agent.version}` : ''}
{agent.lastHeartbeat ? ` · heartbeat ${formatRelative(agent.lastHeartbeat)}` : ''}
</p>
{meshAddresses(agent).length > 0 && (
<p className="v-mono mt-1 text-[11px] text-[var(--text-tertiary)]">
mesh: {meshAddresses(agent).map(([iface, ip]) => `${iface}=${ip}`).join(' · ')}
</p>
)}
</div>
<div className="flex items-center gap-3">
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
? 'border-[var(--success)]/40 text-[var(--success)]'
: 'border-[var(--error)]/40 text-[var(--error)]'}`}>
{agent.status}
</span>
{isAdmin && (
<>
<label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
<input
type="checkbox"
checked={agent.autoPrune}
onChange={(e) => updateMutation.mutate({ auto_prune: e.target.checked })}
className="accent-[var(--accent-primary)]"
/>
auto-prune
</label>
<button
onClick={() => setConfirmVolumes(true)}
disabled={pruneMutation.isPending}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
>
prune now
</button>
<button
onClick={() => setConfirmDelete(true)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--error)]/40 text-[var(--error)] hover:bg-[var(--error)]/10"
>
remove
</button>
</>
)}
</div>
</div>
</div>
</div>
<div className="px-8 py-6 space-y-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="cpu" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
<Stat label="memory" value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
<Stat label="load 1m" value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} />
<Stat label="containers" value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
</div>
<section className="panel p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">telemetry</h2>
<div className="flex gap-1">
{TIME_RANGES.map((r) => (
<button
key={r.value}
onClick={() => setTimeRange(r.value)}
className={`v-mono text-[11px] px-2.5 py-1 rounded-md border ${timeRange === r.value
? 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}
>
{r.label}
</button>
))}
</div>
</div>
{metricsQuery.isLoading ? (
<p className="text-xs text-[var(--text-muted)]">Loading metrics…</p>
) : metrics.length < 2 ? (
<p className="text-xs text-[var(--text-muted)]">Not enough heartbeat history yet — the agent reports every few seconds once connected.</p>
) : (
<div className="grid gap-6 md:grid-cols-2">
<Sparkline label="cpu %" points={metrics} value={(m) => m.cpu.usage_percent} />
<Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} />
</div>
)}
</section>
<div className="grid gap-6 lg:grid-cols-2">
<section className="panel p-0">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">containers</h2>
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
{containers.length === 0 && (
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p>
)}
{containers.map((c) => (
<div key={c.id} className="flex items-center justify-between gap-3 px-5 py-3">
<div className="min-w-0">
<p className="truncate text-sm text-[var(--text-primary)]">{c.name ?? c.id}</p>
<p className="truncate v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p>
</div>
<span className="v-mono text-[11px] text-[var(--text-secondary)]">{c.status ?? '—'}</span>
</div>
))}
</div>
</section>
<section className="panel p-0">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">command history</h2>
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
{commands.length === 0 && (
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p>
)}
{commands.slice(0, 15).map((cmd) => (
<div key={cmd.id} className="flex items-center justify-between gap-3 px-5 py-3">
<div className="flex items-center gap-2 min-w-0">
{cmd.status === 'completed'
? <CheckCircle2 size={13} className="text-[var(--success)] shrink-0" />
: cmd.status === 'failed'
? <XCircle size={13} className="text-[var(--error)] shrink-0" />
: <Loader2 size={13} className="text-[var(--text-muted)] animate-spin shrink-0" />}
<span className="v-mono text-[11px] text-[var(--text-primary)]">{cmd.type}</span>
{cmd.error && <span className="truncate text-[11px] text-[var(--error)]">{cmd.error}</span>}
</div>
<span className="v-mono text-[11px] text-[var(--text-muted)] whitespace-nowrap">
{cmd.created_at ? formatRelative(cmd.created_at) : ''}
</span>
</div>
))}
</div>
</section>
</div>
</div>
{confirmVolumes && (
<ConfirmModal
title="Prune this node?"
body={`Runs a bounded docker system prune on ${agent.name}. Unused images, stopped containers, and networks are removed.`}
confirmLabel="prune"
danger={false}
extra={
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)] cursor-pointer">
<input type="checkbox" id="prune-volumes" className="accent-[var(--error)]" />
also prune volumes (can delete database data)
</label>
}
onCancel={() => setConfirmVolumes(false)}
onConfirm={() => {
const volumes = (document.getElementById('prune-volumes') as HTMLInputElement | null)?.checked ?? false;
pruneMutation.mutate(volumes);
}}
pending={pruneMutation.isPending}
/>
)}
{confirmDelete && (
<ConfirmModal
title={`Remove ${agent.name}?`}
body="The agent record and its history are deleted. Containers running on the host are not stopped — uninstall the agent there separately."
confirmLabel="remove node"
danger
onCancel={() => setConfirmDelete(false)}
onConfirm={() => deleteMutation.mutate()}
pending={deleteMutation.isPending}
/>
)}
</div>
);
}
function meshAddresses(agent: { metadata: Record<string, unknown> }): [string, string][] {
const mesh = agent.metadata?.mesh;
if (!mesh || typeof mesh !== 'object' || Array.isArray(mesh)) return [];
return Object.entries(mesh as Record<string, unknown>)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '');
}
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="panel p-4">
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
<p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{value}</p>
{sub && <p className="mt-0.5 v-mono text-[11px] text-[var(--text-muted)]">{sub}</p>}
</div>
);
}
function Sparkline({ label, points, value }: {
label: string;
points: AgentMetricPoint[];
value: (m: AgentMetricPoint) => number;
}) {
const w = 560;
const h = 90;
const vals = points.map(value);
const max = Math.max(100, ...vals);
const step = w / Math.max(points.length - 1, 1);
const polyline = vals.map((v, i) => `${(i * step).toFixed(1)},${(h - (v / max) * (h - 6)).toFixed(1)}`).join(' ');
return (
<div>
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
{label} — now {vals[vals.length - 1].toFixed(0)}% / peak {Math.max(...vals).toFixed(0)}%
</p>
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-24 rounded-md border border-[var(--border-subtle)] bg-[var(--bg-secondary)]">
<polyline points={polyline} fill="none" stroke="var(--accent-primary)" strokeWidth="1.5" />
</svg>
</div>
);
}
function ConfirmModal({ title, body, confirmLabel, danger, extra, onCancel, onConfirm, pending }: {
title: string;
body: string;
confirmLabel: string;
danger: boolean;
extra?: React.ReactNode;
onCancel: () => void;
onConfirm: () => void;
pending: boolean;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div className="panel w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
<p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p>
{extra && <div className="mt-3">{extra}</div>}
<div className="mt-5 flex justify-end gap-2">
<button onClick={onCancel} className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)]">
cancel
</button>
<button
onClick={onConfirm}
disabled={pending}
className={`v-mono text-[11px] px-3 py-1.5 rounded-md border disabled:opacity-50 ${danger
? 'border-[var(--error)]/50 text-[var(--error)] hover:bg-[var(--error)]/10'
: 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)] hover:bg-[var(--accent-primary)]/10'}`}
>
{pending ? 'working…' : confirmLabel}
</button>
</div>
</div>
</div>
);
}
+42 -8
View File
@@ -11,6 +11,8 @@ import {
getCurrentUserProfile,
listAuditLogs,
listAgents,
pruneAgent,
updateAgent,
listBuilds,
listGitProviders,
listProjects,
@@ -293,6 +295,14 @@ export function UsagePage() {
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 ?? []),
@@ -377,9 +387,9 @@ export function UsagePage() {
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`;
const apiBase = getApiBaseUrl().replace(/\/api\/v1$/, '');
const installCommand = `curl -fsSL ${apiBase}/api/agents/install.sh | bash -s -- --url ${apiBase} --token ${issuedToken?.token ?? '<token>'}`;
const sshCommand = `containr nodes add --ssh <user@host>${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.';
@@ -603,12 +613,35 @@ containr-agent`;
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>
<Link to={`/nodes/${agent.id}`} className="truncate text-sm font-medium text-[var(--text-primary)] hover:text-[var(--accent-primary)]">{agent.name}</Link>
<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 className="flex items-center gap-3">
{isAdmin && (
<>
<button
onClick={() => 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
</button>
<label className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
<input
type="checkbox"
checked={agent.autoPrune}
onChange={(e) => autoPruneMutation.mutate({ id: agent.id, on: e.target.checked })}
className="accent-[var(--accent-primary)]"
/>
auto
</label>
</>
)}
<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>
))
@@ -666,7 +699,8 @@ containr-agent`;
</p>
)}
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{connectCommand}</pre>
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre>
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</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>
+157
View File
@@ -8363,6 +8363,157 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/agents/install.sh": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Agent install script
* @description Public bootstrap script — `curl | bash -s -- --url <api> --token <enroll-token>`.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Bourne shell installer */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/x-sh": string;
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/agents/download/{platform}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Download a bundled agent binary
* @description platform: linux-amd64 | linux-arm64. Bundled in release images; 404 when absent (install script falls back to GH releases).
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
platform: "linux-amd64" | "linux-arm64";
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Agent binary */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
/** @description No bundled binary for platform */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/agents/{id}/prune": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Enqueue a bounded docker prune on a node
* @description Queues a `prune` command — `docker system prune -af`, volumes excluded unless requested.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Node agent ID */
id: components["parameters"]["AgentIdParam"];
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
/** @description Only prune objects older than this duration (e.g. "168h"). */
until?: string;
/** @description Also prune volumes — destructive, off by default. */
volumes?: boolean;
};
};
};
responses: {
/** @description Prune command queued */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
command?: components["schemas"]["AgentCommand"];
};
};
};
/** @description Agent not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/agents/{id}/metrics": {
parameters: {
query?: never;
@@ -11749,6 +11900,8 @@ export interface components {
metadata?: {
[key: string]: unknown;
};
/** @description Enqueue a bounded docker prune on this node once per day. */
auto_prune?: boolean;
/** Format: date-time */
last_heartbeat?: string;
/** Format: date-time */
@@ -11873,6 +12026,10 @@ export interface components {
capabilities: components["schemas"]["AgentCapabilities"];
/** @description May also be sent via the X-Agent-Token header */
auth_token?: string;
/** @description Overlay-network interface IPs (tailscale/netbird/zerotier/wireguard). Stored under agent metadata.mesh; authoritative — send an empty object when no mesh interfaces exist. */
mesh?: {
[key: string]: string;
};
};
AgentHeartbeat: {
agent_id?: string;
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md",
"section": "guides",
"title": "Containr CLI",
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nactivity Line truncated
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nnodes add <user@host> SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n Line truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
+72
View File
@@ -341,6 +341,8 @@ export type NodeAgentEntity = {
version: string;
capabilities: AgentCapabilities;
resources: NodeResources;
autoPrune: boolean;
metadata: Record<string, unknown>;
lastHeartbeat?: string;
createdAt?: string;
updatedAt?: string;
@@ -1076,6 +1078,8 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
},
},
lastHeartbeat: agent.last_heartbeat,
autoPrune: agent.auto_prune ?? false,
metadata: (agent.metadata ?? {}) as Record<string, unknown>,
createdAt: agent.created_at,
updatedAt: agent.updated_at,
};
@@ -1114,6 +1118,74 @@ export async function listAgents(): Promise<NodeAgentEntity[]> {
return normalizeAgentArray(payload.agents);
}
export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean }): Promise<NodeAgentEntity | null> {
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
return payload.agent ? normalizeAgent(payload.agent) : null;
}
export async function pruneAgent(id: string, body?: { until?: string; volumes?: boolean }): Promise<void> {
await requestJson(`/agents/${id}/prune`, {
method: 'POST',
body: JSON.stringify(body ?? {}),
});
}
export async function getAgent(id: string): Promise<NodeAgentEntity | null> {
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`);
return payload.agent ? normalizeAgent(payload.agent) : null;
}
export async function deleteAgent(id: string): Promise<void> {
await requestJson(`/agents/${id}`, { method: 'DELETE' });
}
export type AgentMetricPoint = {
timestamp: string;
cpu: { usage: number; usage_percent: number; cores: number };
memory: { usage: number; usage_percent: number; limit: number; available: number };
system_load: { load_1m: number; load_5m: number; load_15m: number };
container_count: number;
};
export async function getAgentMetrics(id: string, timeRange = '24h'): Promise<AgentMetricPoint[]> {
const payload = await requestJson<{ metrics?: AgentMetricPoint[] }>(
`/agents/${id}/metrics?time_range=${encodeURIComponent(timeRange)}`,
);
return payload.metrics ?? [];
}
export type AgentContainer = {
id?: string;
name?: string;
image?: string;
status?: string;
service_id?: string;
created_at?: string;
};
export async function listAgentContainers(id: string): Promise<AgentContainer[]> {
const payload = await requestJson<{ containers?: AgentContainer[] }>(`/agents/${id}/containers`);
return payload.containers ?? [];
}
export type AgentCommand = {
id?: string;
type?: string;
status?: string;
result?: string;
error?: string;
created_at?: string;
completed_at?: string;
};
export async function listAgentCommands(id: string): Promise<AgentCommand[]> {
const payload = await requestJson<{ commands?: AgentCommand[] }>(`/agents/${id}/commands`);
return payload.commands ?? [];
}
export type AgentAuthToken = components['schemas']['AgentAuthToken'];
export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated'];