From beec504ebd6ab3629ada8427fc9e89f0dbc86850 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 04:39:11 +0000 Subject: [PATCH] feat: filter nodes list/status --- docs/cli/nodes.md | 6 ++ src/cli/nodes-cli/register.status.ts | 154 ++++++++++++++++++++++----- src/cli/nodes-cli/types.ts | 2 + src/cli/program.nodes-basic.test.ts | 77 ++++++++++++++ 4 files changed, 211 insertions(+), 28 deletions(-) diff --git a/docs/cli/nodes.md b/docs/cli/nodes.md index 1bfecca7e..b76bf4d36 100644 --- a/docs/cli/nodes.md +++ b/docs/cli/nodes.md @@ -18,12 +18,18 @@ Related: ```bash clawdbot nodes list +clawdbot nodes list --connected +clawdbot nodes list --last-connected 24h clawdbot nodes pending clawdbot nodes approve clawdbot nodes status +clawdbot nodes status --connected +clawdbot nodes status --last-connected 24h ``` `nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect). +Use `--connected` to only show currently-connected nodes. Use `--last-connected ` to +filter to nodes that connected within a duration (e.g. `24h`, `7d`). ## Invoke / run diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index b77d3e305..4f8e2ae76 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -5,6 +5,7 @@ import { getNodesTheme, 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 { parseDurationMs } from "../parse-duration.js"; function formatVersionLabel(raw: string) { const trimmed = raw.trim(); @@ -43,30 +44,79 @@ function formatNodeVersions(node: { return parts.length > 0 ? parts.join(" · ") : null; } +function parseSinceMs(raw: unknown, label: string): number | undefined { + if (raw === undefined || raw === null) return undefined; + const value = String(raw).trim(); + if (!value) return undefined; + try { + return parseDurationMs(value); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + defaultRuntime.error(`${label}: ${message}`); + defaultRuntime.exit(1); + return undefined; + } +} + export function registerNodesStatusCommands(nodes: Command) { nodesCallOpts( nodes .command("status") .description("List known nodes with connection status and capabilities") + .option("--connected", "Only show connected nodes") + .option("--last-connected ", "Only show nodes connected within duration (e.g. 24h)") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("status", async () => { + const connectedOnly = Boolean(opts.connected); + const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected"); const result = (await callGatewayCli("node.list", opts, {})) as unknown; - if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); - return; - } + const obj = + typeof result === "object" && result !== null + ? (result as Record) + : {}; const { ok, warn, muted } = getNodesTheme(); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const now = Date.now(); const nodes = parseNodeList(result); - const pairedCount = nodes.filter((n) => Boolean(n.paired)).length; - const connectedCount = nodes.filter((n) => Boolean(n.connected)).length; - defaultRuntime.log( - `Known: ${nodes.length} · Paired: ${pairedCount} · Connected: ${connectedCount}`, - ); - if (nodes.length === 0) return; + const lastConnectedById = + sinceMs !== undefined + ? new Map( + parsePairingList(await callGatewayCli("node.pair.list", opts, {})).paired.map( + (entry) => [entry.nodeId, entry], + ), + ) + : null; + const filtered = nodes.filter((n) => { + if (connectedOnly && !n.connected) return false; + if (sinceMs !== undefined) { + const paired = lastConnectedById?.get(n.nodeId); + const lastConnectedAtMs = + typeof paired?.lastConnectedAtMs === "number" + ? paired.lastConnectedAtMs + : typeof n.connectedAtMs === "number" + ? n.connectedAtMs + : undefined; + if (typeof lastConnectedAtMs !== "number") return false; + if (now - lastConnectedAtMs > sinceMs) return false; + } + return true; + }); - const rows = nodes.map((n) => { + if (opts.json) { + const ts = typeof obj.ts === "number" ? obj.ts : Date.now(); + defaultRuntime.log(JSON.stringify({ ...obj, ts, nodes: filtered }, null, 2)); + return; + } + + const pairedCount = filtered.filter((n) => Boolean(n.paired)).length; + const connectedCount = filtered.filter((n) => Boolean(n.connected)).length; + const filteredLabel = filtered.length !== nodes.length ? ` (of ${nodes.length})` : ""; + defaultRuntime.log( + `Known: ${filtered.length}${filteredLabel} · Paired: ${pairedCount} · Connected: ${connectedCount}`, + ); + if (filtered.length === 0) return; + + const rows = filtered.map((n) => { const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId; const perms = formatPermissions(n.permissions); const versions = formatNodeVersions(n); @@ -197,21 +247,60 @@ export function registerNodesStatusCommands(nodes: Command) { nodes .command("list") .description("List pending and paired nodes") + .option("--connected", "Only show connected nodes") + .option("--last-connected ", "Only show nodes connected within duration (e.g. 24h)") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("list", async () => { + const connectedOnly = Boolean(opts.connected); + const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected"); const result = (await callGatewayCli("node.pair.list", opts, {})) as unknown; - if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); - return; - } const { pending, paired } = parsePairingList(result); - defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`); const { heading, muted, warn } = getNodesTheme(); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const now = Date.now(); + const hasFilters = connectedOnly || sinceMs !== undefined; + const pendingRows = hasFilters ? [] : pending; + const connectedById = hasFilters + ? new Map( + parseNodeList(await callGatewayCli("node.list", opts, {})).map((node) => [ + node.nodeId, + node, + ]), + ) + : null; + const filteredPaired = paired.filter((node) => { + if (connectedOnly) { + const live = connectedById?.get(node.nodeId); + if (!live?.connected) return false; + } + if (sinceMs !== undefined) { + const live = connectedById?.get(node.nodeId); + const lastConnectedAtMs = + typeof node.lastConnectedAtMs === "number" + ? node.lastConnectedAtMs + : typeof live?.connectedAtMs === "number" + ? live.connectedAtMs + : undefined; + if (typeof lastConnectedAtMs !== "number") return false; + if (now - lastConnectedAtMs > sinceMs) return false; + } + return true; + }); + const filteredLabel = + hasFilters && filteredPaired.length !== paired.length ? ` (of ${paired.length})` : ""; + defaultRuntime.log( + `Pending: ${pendingRows.length} · Paired: ${filteredPaired.length}${filteredLabel}`, + ); - if (pending.length > 0) { - const pendingRows = pending.map((r) => ({ + if (opts.json) { + defaultRuntime.log( + JSON.stringify({ pending: pendingRows, paired: filteredPaired }, null, 2), + ); + return; + } + + if (pendingRows.length > 0) { + const pendingRowsRendered = pendingRows.map((r) => ({ Request: r.requestId, Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, IP: r.remoteIp ?? "", @@ -233,21 +322,30 @@ export function registerNodesStatusCommands(nodes: Command) { { key: "Requested", header: "Requested", minWidth: 12 }, { key: "Repair", header: "Repair", minWidth: 6 }, ], - rows: pendingRows, + rows: pendingRowsRendered, }).trimEnd(), ); } - if (paired.length > 0) { - const pairedRows = paired.map((n) => ({ - Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId, - Id: n.nodeId, - IP: n.remoteIp ?? "", - LastConnect: + if (filteredPaired.length > 0) { + const pairedRows = filteredPaired.map((n) => { + const live = connectedById?.get(n.nodeId); + const lastConnectedAtMs = typeof n.lastConnectedAtMs === "number" - ? `${formatAge(Math.max(0, now - n.lastConnectedAtMs))} ago` - : muted("unknown"), - })); + ? n.lastConnectedAtMs + : typeof live?.connectedAtMs === "number" + ? live.connectedAtMs + : undefined; + return { + Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId, + Id: n.nodeId, + IP: n.remoteIp ?? "", + LastConnect: + typeof lastConnectedAtMs === "number" + ? `${formatAge(Math.max(0, now - lastConnectedAtMs))} ago` + : muted("unknown"), + }; + }); defaultRuntime.log(""); defaultRuntime.log(heading("Paired")); defaultRuntime.log( diff --git a/src/cli/nodes-cli/types.ts b/src/cli/nodes-cli/types.ts index fbcf128df..d2ecb6d03 100644 --- a/src/cli/nodes-cli/types.ts +++ b/src/cli/nodes-cli/types.ts @@ -8,6 +8,8 @@ export type NodesRpcOpts = { params?: string; invokeTimeout?: string; idempotencyKey?: string; + connected?: boolean; + lastConnected?: string; target?: string; x?: string; y?: string; diff --git a/src/cli/program.nodes-basic.test.ts b/src/cli/program.nodes-basic.test.ts index 9ab835d2b..12518b5aa 100644 --- a/src/cli/program.nodes-basic.test.ts +++ b/src/cli/program.nodes-basic.test.ts @@ -68,6 +68,83 @@ describe("cli program (nodes basics)", () => { expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0"); }); + it("runs nodes list --connected and filters to connected nodes", async () => { + const now = Date.now(); + callGateway.mockImplementation(async (opts: { method?: string }) => { + if (opts.method === "node.pair.list") { + return { + pending: [], + paired: [ + { + nodeId: "n1", + displayName: "One", + remoteIp: "10.0.0.1", + lastConnectedAtMs: now - 1_000, + }, + { + nodeId: "n2", + displayName: "Two", + remoteIp: "10.0.0.2", + lastConnectedAtMs: now - 1_000, + }, + ], + }; + } + if (opts.method === "node.list") { + return { + nodes: [ + { nodeId: "n1", connected: true }, + { nodeId: "n2", connected: false }, + ], + }; + } + return { ok: true }; + }); + const program = buildProgram(); + runtime.log.mockClear(); + await program.parseAsync(["nodes", "list", "--connected"], { from: "user" }); + + expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" })); + const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n"); + expect(output).toContain("One"); + expect(output).not.toContain("Two"); + }); + + it("runs nodes status --last-connected and filters by age", async () => { + const now = Date.now(); + callGateway.mockImplementation(async (opts: { method?: string }) => { + if (opts.method === "node.list") { + return { + ts: now, + nodes: [ + { nodeId: "n1", displayName: "One", connected: false }, + { nodeId: "n2", displayName: "Two", connected: false }, + ], + }; + } + if (opts.method === "node.pair.list") { + return { + pending: [], + paired: [ + { nodeId: "n1", lastConnectedAtMs: now - 1_000 }, + { nodeId: "n2", lastConnectedAtMs: now - 2 * 24 * 60 * 60 * 1000 }, + ], + }; + } + return { ok: true }; + }); + const program = buildProgram(); + runtime.log.mockClear(); + await program.parseAsync(["nodes", "status", "--last-connected", "24h"], { + from: "user", + }); + + expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" })); + const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n"); + expect(output).toContain("One"); + expect(output).not.toContain("Two"); + }); + it("runs nodes status and calls node.list", async () => { callGateway.mockResolvedValue({ ts: Date.now(),