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

+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'];