feat: filter nodes list/status
This commit is contained in:
@@ -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 <requestId>
|
||||
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 <duration>` to
|
||||
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
|
||||
|
||||
## Invoke / run
|
||||
|
||||
|
||||
@@ -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 <duration>", "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<string, unknown>)
|
||||
: {};
|
||||
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 <duration>", "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(
|
||||
|
||||
@@ -8,6 +8,8 @@ export type NodesRpcOpts = {
|
||||
params?: string;
|
||||
invokeTimeout?: string;
|
||||
idempotencyKey?: string;
|
||||
connected?: boolean;
|
||||
lastConnected?: string;
|
||||
target?: string;
|
||||
x?: string;
|
||||
y?: string;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user