mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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:
1 parent
7547375cc2
commit
3f066b3b1e
23 files changed
+1410
-40
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
Reference in new issue
Block a user