From caf9dec89cf8b5f50d381c6467a3de17456fde13 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 03:02:50 +0000 Subject: [PATCH] feat: add nodes list table with last connect --- docs/cli/nodes.md | 3 +- src/cli/nodes-cli/register.status.ts | 72 +++++++++++++++---- src/cli/nodes-cli/types.ts | 1 + .../server/ws-connection/message-handler.ts | 8 +++ src/infra/node-pairing.ts | 2 + 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/docs/cli/nodes.md b/docs/cli/nodes.md index b7ed4e8ea..1bfecca7e 100644 --- a/docs/cli/nodes.md +++ b/docs/cli/nodes.md @@ -23,10 +23,11 @@ clawdbot nodes approve clawdbot nodes status ``` +`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect). + ## Invoke / run ```bash clawdbot nodes invoke --node --command --params clawdbot nodes run --node ``` - diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 3371a09bc..c092c121f 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -4,6 +4,8 @@ import { formatAge, formatPermissions, parseNodeList, parsePairingList } from ". import { runNodesCommand } from "./cli-utils.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; +import { renderTable } from "../../terminal/table.js"; +import { isRich, theme } from "../../terminal/theme.js"; function formatVersionLabel(raw: string) { const trimmed = raw.trim(); @@ -155,23 +157,65 @@ export function registerNodesStatusCommands(nodes: Command) { } const { pending, paired } = parsePairingList(result); defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`); + const rich = isRich(); + const heading = (text: string) => (rich ? theme.heading(text) : text); + const muted = (text: string) => (rich ? theme.muted(text) : text); + const warn = (text: string) => (rich ? theme.warn(text) : text); + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const now = Date.now(); + if (pending.length > 0) { - defaultRuntime.log("\nPending:"); - for (const r of pending) { - const name = r.displayName || r.nodeId; - const repair = r.isRepair ? " (repair)" : ""; - const ip = r.remoteIp ? ` · ${r.remoteIp}` : ""; - const age = typeof r.ts === "number" ? ` · ${formatAge(Date.now() - r.ts)} ago` : ""; - defaultRuntime.log(`- ${r.requestId}: ${name}${repair}${ip}${age}`); - } + const pendingRows = pending.map((r) => ({ + Request: r.requestId, + Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, + IP: r.remoteIp ?? "", + Requested: + typeof r.ts === "number" + ? `${formatAge(Math.max(0, now - r.ts))} ago` + : muted("unknown"), + Repair: r.isRepair ? warn("yes") : "", + })); + defaultRuntime.log(""); + defaultRuntime.log(heading("Pending")); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Request", header: "Request", minWidth: 8 }, + { key: "Node", header: "Node", minWidth: 14, flex: true }, + { key: "IP", header: "IP", minWidth: 10 }, + { key: "Requested", header: "Requested", minWidth: 12 }, + { key: "Repair", header: "Repair", minWidth: 6 }, + ], + rows: pendingRows, + }).trimEnd(), + ); } + if (paired.length > 0) { - defaultRuntime.log("\nPaired:"); - for (const n of paired) { - const name = n.displayName || n.nodeId; - const ip = n.remoteIp ? ` · ${n.remoteIp}` : ""; - defaultRuntime.log(`- ${n.nodeId}: ${name}${ip}`); - } + const pairedRows = paired.map((n) => ({ + Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId, + Id: n.nodeId, + IP: n.remoteIp ?? "", + LastConnect: + typeof n.lastConnectedAtMs === "number" + ? `${formatAge(Math.max(0, now - n.lastConnectedAtMs))} ago` + : muted("unknown"), + })); + defaultRuntime.log(""); + defaultRuntime.log(heading("Paired")); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Node", header: "Node", minWidth: 14, flex: true }, + { key: "Id", header: "ID", minWidth: 10 }, + { key: "IP", header: "IP", minWidth: 10 }, + { key: "LastConnect", header: "Last Connect", minWidth: 14 }, + ], + rows: pairedRows, + }).trimEnd(), + ); } }); }), diff --git a/src/cli/nodes-cli/types.ts b/src/cli/nodes-cli/types.ts index 9c0cf67b4..d7d247aa7 100644 --- a/src/cli/nodes-cli/types.ts +++ b/src/cli/nodes-cli/types.ts @@ -83,6 +83,7 @@ export type PairedNode = { permissions?: Record; createdAtMs?: number; approvedAtMs?: number; + lastConnectedAtMs?: number; }; export type PairingList = { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 8ed224eeb..91b675ffe 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -15,6 +15,7 @@ import { updatePairedDeviceMetadata, verifyDeviceToken, } from "../../../infra/device-pairing.js"; +import { updatePairedNodeMetadata } from "../../../infra/node-pairing.js"; import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js"; import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { upsertPresence } from "../../../infra/system-presence.js"; @@ -718,6 +719,13 @@ export function attachGatewayWsMessageHandler(params: { if (role === "node") { const context = buildRequestContext(); const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr }); + void updatePairedNodeMetadata(nodeSession.nodeId, { + lastConnectedAtMs: nodeSession.connectedAtMs, + }).catch((err) => + logGateway.warn( + `failed to record last connect for ${nodeSession.nodeId}: ${formatForLog(err)}`, + ), + ); recordRemoteNodeInfo({ nodeId: nodeSession.nodeId, displayName: nodeSession.displayName, diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index de03f6817..f852ff420 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -39,6 +39,7 @@ export type NodePairingPairedNode = { remoteIp?: string; createdAtMs: number; approvedAtMs: number; + lastConnectedAtMs?: number; }; export type NodePairingList = { @@ -295,6 +296,7 @@ export async function updatePairedNodeMetadata( commands: patch.commands ?? existing.commands, bins: patch.bins ?? existing.bins, permissions: patch.permissions ?? existing.permissions, + lastConnectedAtMs: patch.lastConnectedAtMs ?? existing.lastConnectedAtMs, }; state.pairedByNodeId[normalized] = next;