CLI: align nodes list with node status
This commit is contained in:
parent
4c60956d8e
commit
70e491d062
@ -8,7 +8,7 @@ import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
import { formatPermissions, parseNodeList, parsePairingList } from "./format.js";
|
||||
import { renderPendingPairingRequestsTable } from "./pairing-render.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
import type { NodeListNode, NodesRpcOpts, PairedNode } from "./types.js";
|
||||
|
||||
function formatVersionLabel(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
@ -97,6 +97,118 @@ function parseSinceMs(raw: unknown, label: string): number | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function messageFromError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"message" in error &&
|
||||
typeof (error as { message?: unknown }).message === "string"
|
||||
) {
|
||||
return (error as { message: string }).message;
|
||||
}
|
||||
if (typeof error === "object" && error !== null) {
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function shouldFallbackToPairList(error: unknown): boolean {
|
||||
const message = messageFromError(error).toLowerCase();
|
||||
if (!message.includes("node.list")) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
message.includes("unknown method") ||
|
||||
message.includes("method not found") ||
|
||||
message.includes("not implemented") ||
|
||||
message.includes("unsupported")
|
||||
);
|
||||
}
|
||||
|
||||
type NodesListEntry = NodeListNode & {
|
||||
lastConnectedAtMs?: number;
|
||||
};
|
||||
|
||||
function mergePairedNodeSources(params: {
|
||||
liveNodes: NodeListNode[] | null;
|
||||
pairedNodes: PairedNode[];
|
||||
}): NodesListEntry[] {
|
||||
const merged = new Map<string, NodesListEntry>();
|
||||
|
||||
for (const paired of params.pairedNodes) {
|
||||
merged.set(paired.nodeId, {
|
||||
nodeId: paired.nodeId,
|
||||
displayName: paired.displayName,
|
||||
platform: paired.platform,
|
||||
version: paired.version,
|
||||
coreVersion: paired.coreVersion,
|
||||
uiVersion: paired.uiVersion,
|
||||
remoteIp: paired.remoteIp,
|
||||
permissions: paired.permissions,
|
||||
paired: true,
|
||||
connected: false,
|
||||
lastConnectedAtMs: paired.lastConnectedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
for (const live of params.liveNodes ?? []) {
|
||||
const previous = merged.get(live.nodeId);
|
||||
if (!live.paired && !previous?.paired) {
|
||||
continue;
|
||||
}
|
||||
merged.set(live.nodeId, {
|
||||
...previous,
|
||||
...live,
|
||||
displayName: live.displayName ?? previous?.displayName,
|
||||
platform: live.platform ?? previous?.platform,
|
||||
version: live.version ?? previous?.version,
|
||||
coreVersion: live.coreVersion ?? previous?.coreVersion,
|
||||
uiVersion: live.uiVersion ?? previous?.uiVersion,
|
||||
remoteIp: live.remoteIp ?? previous?.remoteIp,
|
||||
permissions: live.permissions ?? previous?.permissions,
|
||||
paired: true,
|
||||
connected: live.connected ?? previous?.connected,
|
||||
connectedAtMs: live.connectedAtMs ?? previous?.connectedAtMs,
|
||||
lastConnectedAtMs: previous?.lastConnectedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
async function loadPairedNodesForList(opts: NodesRpcOpts): Promise<{
|
||||
pending: ReturnType<typeof parsePairingList>["pending"];
|
||||
paired: NodesListEntry[];
|
||||
}> {
|
||||
const pairingResult = await callGatewayCli("node.pair.list", opts, {});
|
||||
const { pending, paired } = parsePairingList(pairingResult);
|
||||
try {
|
||||
const liveNodes = parseNodeList(await callGatewayCli("node.list", opts, {}));
|
||||
return {
|
||||
pending,
|
||||
paired: mergePairedNodeSources({ liveNodes, pairedNodes: paired }),
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldFallbackToPairList(error)) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
pending,
|
||||
paired: mergePairedNodeSources({ liveNodes: null, pairedNodes: paired }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function registerNodesStatusCommands(nodes: Command) {
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
@ -304,35 +416,24 @@ export function registerNodesStatusCommands(nodes: Command) {
|
||||
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, {});
|
||||
const { pending, paired } = parsePairingList(result);
|
||||
const { pending, paired } = await loadPairedNodesForList(opts);
|
||||
const { heading, muted, warn } = getNodesTheme();
|
||||
const tableWidth = getTerminalTableWidth();
|
||||
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) {
|
||||
if (!node.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
|
||||
typeof node.connectedAtMs === "number"
|
||||
? node.connectedAtMs
|
||||
: typeof node.lastConnectedAtMs === "number"
|
||||
? node.lastConnectedAtMs
|
||||
: undefined;
|
||||
if (typeof lastConnectedAtMs !== "number") {
|
||||
return false;
|
||||
@ -370,12 +471,11 @@ export function registerNodesStatusCommands(nodes: Command) {
|
||||
|
||||
if (filteredPaired.length > 0) {
|
||||
const pairedRows = filteredPaired.map((n) => {
|
||||
const live = connectedById?.get(n.nodeId);
|
||||
const lastConnectedAtMs =
|
||||
typeof n.lastConnectedAtMs === "number"
|
||||
? n.lastConnectedAtMs
|
||||
: typeof live?.connectedAtMs === "number"
|
||||
? live.connectedAtMs
|
||||
typeof n.connectedAtMs === "number"
|
||||
? n.connectedAtMs
|
||||
: typeof n.lastConnectedAtMs === "number"
|
||||
? n.lastConnectedAtMs
|
||||
: undefined;
|
||||
return {
|
||||
Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId,
|
||||
|
||||
@ -100,6 +100,82 @@ describe("cli program (nodes basics)", () => {
|
||||
expect(output).not.toContain("Two");
|
||||
});
|
||||
|
||||
it("runs nodes list and includes paired nodes from node.list when node.pair.list is empty", async () => {
|
||||
const now = Date.now();
|
||||
callGateway.mockImplementation(async (...args: unknown[]) => {
|
||||
const opts = (args[0] ?? {}) as { method?: string };
|
||||
if (opts.method === "node.pair.list") {
|
||||
return {
|
||||
pending: [],
|
||||
paired: [],
|
||||
};
|
||||
}
|
||||
if (opts.method === "node.list") {
|
||||
return {
|
||||
ts: now,
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "n1",
|
||||
displayName: "One",
|
||||
remoteIp: "10.0.0.1",
|
||||
paired: true,
|
||||
connected: true,
|
||||
connectedAtMs: now - 1_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await runProgram(["nodes", "list"]);
|
||||
|
||||
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
|
||||
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" }));
|
||||
|
||||
const output = getRuntimeOutput();
|
||||
expect(output).toContain("Pending: 0 · Paired: 1");
|
||||
expect(output).toContain("One");
|
||||
});
|
||||
|
||||
it("runs nodes list and falls back to node.pair.list when node.list is unavailable", async () => {
|
||||
callGateway.mockImplementation(async (...args: unknown[]) => {
|
||||
const opts = (args[0] ?? {}) as { method?: string };
|
||||
if (opts.method === "node.pair.list") {
|
||||
return {
|
||||
pending: [],
|
||||
paired: [
|
||||
{
|
||||
nodeId: "n1",
|
||||
displayName: "One",
|
||||
remoteIp: "10.0.0.1",
|
||||
lastConnectedAtMs: Date.now() - 1_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (opts.method === "node.list") {
|
||||
throw new Error("unknown method: node.list");
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await runProgram(["nodes", "list"]);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ method: "node.pair.list" }),
|
||||
);
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ method: "node.list" }),
|
||||
);
|
||||
|
||||
const output = getRuntimeOutput();
|
||||
expect(output).toContain("Pending: 0 · Paired: 1");
|
||||
expect(output).toContain("One");
|
||||
});
|
||||
|
||||
it("runs nodes status --last-connected and filters by age", async () => {
|
||||
const now = Date.now();
|
||||
callGateway.mockImplementation(async (...args: unknown[]) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user