diff --git a/ROADMAP2.md b/ROADMAP2.md index da51153..53037de 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -263,8 +263,11 @@ and wired refs, one click or one CLI call. - [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/ zt0 IPs at register; backend stores + displays. Optional `TAILSCALE_AUTH_KEY` enroll-at-provision later. -- [ ] **Node detail page** — placed services, telemetry history, agent - version + self-upgrade, **drain/cordon**, remove. +- [x] **Node detail page** — `/nodes/:id` (admin): heartbeat telemetry + (cpu/mem sparklines over 1h/24h/7d via `GET /agents/:id/metrics`), + containers, command history, rename, auto-prune toggle, prune-now, + remove. Drain/cordon and self-upgrade deferred — they need + `services.node_id` routing which lands with multi-node scheduling. - [x] **Node housekeeping** — agent `prune` + `system_df` commands (`docker system prune -af`, volumes excluded unless requested, optional `--until`). `node_agents.auto_prune` + the health sweep enqueue a prune diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index 125b04b..b2fff03 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -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 */} }> } /> + } /> } /> diff --git a/app/frontend/src/features/nodes/NodeDetailPage.tsx b/app/frontend/src/features/nodes/NodeDetailPage.tsx new file mode 100644 index 0000000..8a94939 --- /dev/null +++ b/app/frontend/src/features/nodes/NodeDetailPage.tsx @@ -0,0 +1,381 @@ +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(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 ; + + if (agentQuery.isLoading) { + return
Loading node…
; + } + if (!agent) { + return ( +
+

Node not found.

+ ← back to usage +
+ ); + } + + const online = agent.status === 'online' || agent.status === 'connecting'; + + return ( +
+
+
+
+ + usage + + + {agent.name} +
+
+
+

+ {rename === null ? agent.name : rename} + {isAdmin && rename === null && ( + + )} + _ +

+ {rename !== null && ( +
+ 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)]" + /> + + +
+ )} +

+ {agent.hostname} · {agent.ipAddress}:{agent.port} + {agent.version ? ` · v${agent.version}` : ''} + {agent.lastHeartbeat ? ` · heartbeat ${formatRelative(agent.lastHeartbeat)}` : ''} +

+
+
+ + {agent.status} + + {isAdmin && ( + <> + + + + + )} +
+
+
+
+ +
+
+ + + + +
+ +
+
+

telemetry

+
+ {TIME_RANGES.map((r) => ( + + ))} +
+
+ {metricsQuery.isLoading ? ( +

Loading metrics…

+ ) : metrics.length < 2 ? ( +

Not enough heartbeat history yet — the agent reports every few seconds once connected.

+ ) : ( +
+ m.cpu.usage_percent} /> + m.memory.usage_percent} /> +
+ )} +
+ +
+
+

containers

+
+ {containers.length === 0 && ( +

No containers reported on this node.

+ )} + {containers.map((c) => ( +
+
+

{c.name ?? c.id}

+

{c.image}

+
+ {c.status ?? '—'} +
+ ))} +
+
+ +
+

command history

+
+ {commands.length === 0 && ( +

No commands sent to this node yet.

+ )} + {commands.slice(0, 15).map((cmd) => ( +
+
+ {cmd.status === 'completed' + ? + : cmd.status === 'failed' + ? + : } + {cmd.type} + {cmd.error && {cmd.error}} +
+ + {cmd.created_at ? formatRelative(cmd.created_at) : ''} + +
+ ))} +
+
+
+
+ + {confirmVolumes && ( + + + also prune volumes (can delete database data) + + } + onCancel={() => setConfirmVolumes(false)} + onConfirm={() => { + const volumes = (document.getElementById('prune-volumes') as HTMLInputElement | null)?.checked ?? false; + pruneMutation.mutate(volumes); + }} + pending={pruneMutation.isPending} + /> + )} + {confirmDelete && ( + setConfirmDelete(false)} + onConfirm={() => deleteMutation.mutate()} + pending={deleteMutation.isPending} + /> + )} +
+ ); +} + +function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +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 ( +
+

+ {label} — now {vals[vals.length - 1].toFixed(0)}% / peak {Math.max(...vals).toFixed(0)}% +

+ + + +
+ ); +} + +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 ( +
+
e.stopPropagation()}> +

{title}

+

{body}

+ {extra &&
{extra}
} +
+ + +
+
+
+ ); +} diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index ef94363..95a8fc7 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -613,7 +613,7 @@ export function UsagePage() { agents.slice(0, 5).map((agent) => (
-

{agent.name}

+ {agent.name}

{agent.hostname} · {agent.ipAddress}:{agent.port}

diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 433de84..c61d420 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1131,6 +1131,59 @@ export async function pruneAgent(id: string, body?: { until?: string; volumes?: }); } +export async function getAgent(id: string): Promise { + const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`); + return payload.agent ? normalizeAgent(payload.agent) : null; +} + +export async function deleteAgent(id: string): Promise { + 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 { + 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 { + 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 { + const payload = await requestJson<{ commands?: AgentCommand[] }>(`/agents/${id}/commands`); + return payload.commands ?? []; +} + export type AgentAuthToken = components['schemas']['AgentAuthToken']; export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated'];