| |
| import { formatByteSize } from "@openclaw/normalization-core"; |
| import { |
| normalizeLowercaseStringOrEmpty, |
| normalizeOptionalLowercaseString, |
| normalizeOptionalString, |
| } from "@openclaw/normalization-core/string-coerce"; |
| import type { Command } from "commander"; |
| import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js"; |
| import { getTerminalTableWidth, renderTable } from "../../../packages/terminal-core/src/table.js"; |
| import { formatErrorMessage } from "../../infra/errors.js"; |
| import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; |
| import { defaultRuntime } from "../../runtime.js"; |
| import { isNodeHostStats } from "../../shared/node-host-stats.js"; |
| import { shortenHomeInString } from "../../utils.js"; |
| import { formatPairingApproveCommand } from "../pairing-command-format.js"; |
| import { parseDurationMs } from "../parse-duration.js"; |
| import { formatConnectionFlagReminder, getNodesTheme, runNodesCommand } from "./cli-utils.js"; |
| import { formatPermissions, parseNodeList, parsePairingList } from "./format.js"; |
| import { renderPendingPairingRequestsTable } from "./pairing-render.js"; |
| import { |
| callNodesGatewayCli, |
| callNodeDiagnosticsGatewayCli, |
| nodesCallOpts, |
| resolveNodeDiagnosticsId, |
| } from "./rpc.js"; |
| import type { NodeListNode, NodesRpcOpts, PairedNode } from "./types.js"; |
|
|
| type PairedNodeListRow = PairedNode & Partial<NodeListNode>; |
| type NodeApprovalState = NonNullable<NodeListNode["approvalState"]>; |
|
|
| function formatNodeStatsBytes(bytes: number): string { |
| return formatByteSize(bytes, { |
| style: "legacy-binary", |
| maxUnit: "tera", |
| separator: " ", |
| fractionDigits: (value, unit) => (value < 10 && unit !== "byte" ? 1 : 0), |
| }); |
| } |
|
|
| function formatNodeHostStats(stats: unknown, connected: boolean, now: number): string | null { |
| if (!isNodeHostStats(stats)) { |
| return null; |
| } |
| const totalMemory = formatNodeStatsBytes(stats.memoryTotalBytes); |
| const usedMemory = formatNodeStatsBytes(stats.memoryTotalBytes - stats.memoryFreeBytes); |
| const memoryUnit = totalMemory.slice(totalMemory.lastIndexOf(" ")); |
| const usedLabel = usedMemory.endsWith(memoryUnit) |
| ? usedMemory.slice(0, -memoryUnit.length) |
| : usedMemory; |
| const summary = [ |
| stats.loadAverage ? `load ${stats.loadAverage[0].toFixed(1)}/${stats.cpuCount}` : null, |
| `mem ${usedLabel}/${totalMemory}`, |
| stats.diskAvailableBytes !== undefined |
| ? `disk ${formatNodeStatsBytes(stats.diskAvailableBytes)} free` |
| : null, |
| ] |
| .filter(Boolean) |
| .join(" · "); |
| return connected |
| ? summary |
| : `${summary} (last known ${formatTimeAgo(Math.max(0, now - stats.updatedAtMs))})`; |
| } |
|
|
| function formatVersionLabel(raw: string) { |
| const trimmed = raw.trim(); |
| if (!trimmed) { |
| return raw; |
| } |
| if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("v")) { |
| return trimmed; |
| } |
| return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed; |
| } |
|
|
| function resolveNodeVersions(node: { |
| platform?: string; |
| version?: string; |
| coreVersion?: string; |
| uiVersion?: string; |
| }) { |
| const core = normalizeOptionalString(node.coreVersion); |
| const ui = normalizeOptionalString(node.uiVersion); |
| if (core || ui) { |
| return { core, ui }; |
| } |
| const legacy = node.version?.trim(); |
| if (!legacy) { |
| return { core: undefined, ui: undefined }; |
| } |
| const platform = normalizeOptionalLowercaseString(node.platform) ?? ""; |
| |
| const headless = |
| platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows"; |
| return headless ? { core: legacy, ui: undefined } : { core: undefined, ui: legacy }; |
| } |
|
|
| function formatNodeVersions(node: { |
| platform?: string; |
| version?: string; |
| coreVersion?: string; |
| uiVersion?: string; |
| }) { |
| const { core, ui } = resolveNodeVersions(node); |
| const parts: string[] = []; |
| if (core) { |
| parts.push(`core ${formatVersionLabel(core)}`); |
| } |
| if (ui) { |
| parts.push(`ui ${formatVersionLabel(ui)}`); |
| } |
| return parts.length > 0 ? parts.join(" · ") : null; |
| } |
|
|
| function isWindowsNodePlatform(platform?: string): boolean { |
| const normalized = normalizeOptionalLowercaseString(platform) ?? ""; |
| return normalized === "win32" || normalized === "windows"; |
| } |
|
|
| function formatPathEnv(raw?: string, platform?: string): string | null { |
| if (typeof raw !== "string") { |
| return null; |
| } |
| const trimmed = raw.trim(); |
| if (!trimmed) { |
| return null; |
| } |
| const delimiter = isWindowsNodePlatform(platform) ? ";" : ":"; |
| const parts = trimmed.split(delimiter).filter(Boolean); |
| const display = |
| parts.length <= 3 |
| ? trimmed |
| : `${parts.slice(0, 2).join(delimiter)}${delimiter}…${delimiter}${parts.slice(-1)[0]}`; |
| return shortenHomeInString(display); |
| } |
|
|
| function formatClientLabel(node: { clientId?: string; clientMode?: string }): string | null { |
| const clientId = node.clientId?.trim(); |
| const clientMode = node.clientMode?.trim(); |
| if (clientId && clientMode) { |
| return `${clientId}/${clientMode}`; |
| } |
| return clientId || clientMode || null; |
| } |
|
|
| function formatNodeTerminalLabel(node: { nodeId: string; displayName?: string }): string { |
| const label = node.displayName?.trim() ? node.displayName.trim() : node.nodeId; |
| return sanitizeTerminalText(label); |
| } |
|
|
| function formatNodeTimeAgo(now: number, timestamp: unknown): string | null { |
| return typeof timestamp === "number" && Number.isFinite(timestamp) |
| ? formatTimeAgo(Math.max(0, now - timestamp)) |
| : null; |
| } |
|
|
| function formatNodeApprovalState(raw: unknown): NodeApprovalState | null { |
| return raw === "approved" || |
| raw === "pending-approval" || |
| raw === "pending-reapproval" || |
| raw === "unapproved" |
| ? raw |
| : null; |
| } |
|
|
| function formatApprovalStateLabel(state: NodeApprovalState): string { |
| if (state === "pending-approval") { |
| return "approval pending"; |
| } |
| if (state === "pending-reapproval") { |
| return "reapproval pending"; |
| } |
| return state; |
| } |
|
|
| function isPendingApprovalState( |
| state: NodeApprovalState | null, |
| ): state is "pending-approval" | "pending-reapproval" { |
| return state === "pending-approval" || state === "pending-reapproval"; |
| } |
|
|
| function parseSinceMs(raw: string | undefined, label: string): number | undefined { |
| if (raw === undefined) { |
| return undefined; |
| } |
| try { |
| return parseDurationMs(raw); |
| } catch (err) { |
| throw new Error(`${label}: ${formatErrorMessage(err)}`, { cause: err }); |
| } |
| } |
|
|
| function matchesNodeConnectionFilter( |
| node: NodeListNode, |
| connectedOnly: boolean, |
| sinceMs: number | undefined, |
| now: number, |
| ): boolean { |
| if (connectedOnly && !node.connected) { |
| return false; |
| } |
| |
| const lastConnectedAtMs = node.lastConnectedAtMs ?? node.connectedAtMs; |
| return ( |
| sinceMs === undefined || |
| (typeof lastConnectedAtMs === "number" && now - lastConnectedAtMs <= sinceMs) |
| ); |
| } |
|
|
| function mergePairedNodeWithEffectiveNode( |
| paired: PairedNode | undefined, |
| effective: NodeListNode, |
| ): PairedNodeListRow { |
| return { |
| ...paired, |
| ...effective, |
| createdAtMs: paired?.createdAtMs, |
| |
| lastConnectedAtMs: |
| effective.lastConnectedAtMs ?? paired?.lastConnectedAtMs ?? effective.connectedAtMs, |
| displayName: effective.displayName ?? paired?.displayName, |
| platform: effective.platform ?? paired?.platform, |
| version: effective.version ?? paired?.version, |
| coreVersion: effective.coreVersion ?? paired?.coreVersion, |
| uiVersion: effective.uiVersion ?? paired?.uiVersion, |
| remoteIp: effective.remoteIp ?? paired?.remoteIp, |
| permissions: effective.permissions ?? paired?.permissions, |
| approvedAtMs: effective.approvedAtMs ?? paired?.approvedAtMs, |
| }; |
| } |
|
|
| function mergePairedNodesWithEffectiveNodes( |
| paired: PairedNode[], |
| effectiveNodes: NodeListNode[] | null, |
| ): PairedNodeListRow[] { |
| if (effectiveNodes === null) { |
| return paired; |
| } |
| const pairedById = new Map(paired.map((node) => [node.nodeId, node])); |
| const seen = new Set<string>(); |
| const rows: PairedNodeListRow[] = []; |
| for (const effective of effectiveNodes) { |
| const pairedNode = pairedById.get(effective.nodeId); |
| if (!pairedNode && effective.paired !== true) { |
| continue; |
| } |
| seen.add(effective.nodeId); |
| rows.push(mergePairedNodeWithEffectiveNode(pairedNode, effective)); |
| } |
| for (const node of paired) { |
| if (!seen.has(node.nodeId)) { |
| rows.push(node); |
| } |
| } |
| return rows; |
| } |
|
|
| async function tryReadNodeList(opts: NodesRpcOpts): Promise<NodeListNode[] | null> { |
| try { |
| return parseNodeList(await callNodeDiagnosticsGatewayCli("node.list", opts, {})); |
| } catch (error) { |
| |
| |
| |
| defaultRuntime.error( |
| getNodesTheme().muted( |
| `live node view unavailable (${formatErrorMessage(error)}); showing paired-only data`, |
| ), |
| ); |
| return null; |
| } |
| } |
|
|
| |
| 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 callNodeDiagnosticsGatewayCli("node.list", opts, {}); |
| const obj: Record<string, unknown> = |
| typeof result === "object" && result !== null ? result : {}; |
| const { ok, warn, muted } = getNodesTheme(); |
| const tableWidth = getTerminalTableWidth(); |
| const now = Date.now(); |
| const nodesLocal = parseNodeList(result); |
| const filtered = nodesLocal.filter((node) => |
| matchesNodeConnectionFilter(node, connectedOnly, sinceMs, now), |
| ); |
|
|
| if (opts.json) { |
| const ts = typeof obj.ts === "number" ? obj.ts : Date.now(); |
| defaultRuntime.writeJson({ ...obj, ts, nodes: filtered }); |
| return; |
| } |
|
|
| const pairedCount = filtered.filter((n) => Boolean(n.paired)).length; |
| const connectedCount = filtered.filter((n) => Boolean(n.connected)).length; |
| const filteredLabel = |
| filtered.length !== nodesLocal.length ? ` (of ${nodesLocal.length})` : ""; |
| defaultRuntime.log( |
| `Known: ${filtered.length}${filteredLabel} · Paired: ${pairedCount} · Connected: ${connectedCount}`, |
| ); |
| if (filtered.length === 0) { |
| return; |
| } |
|
|
| const rows = filtered.map((n) => { |
| const perms = formatPermissions(n.permissions); |
| const versions = formatNodeVersions(n); |
| const pathEnv = formatPathEnv(n.pathEnv, n.platform); |
| const client = formatClientLabel(n); |
| const lastActive = formatNodeTimeAgo(now, n.lastActiveAtMs); |
| const detailParts = [ |
| client ? `client: ${client}` : null, |
| n.deviceFamily ? `device: ${n.deviceFamily}` : null, |
| n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null, |
| perms ? `perms: ${perms}` : null, |
| versions, |
| formatNodeHostStats(n.hostStats, Boolean(n.connected), now), |
| pathEnv ? `path: ${pathEnv}` : null, |
| lastActive ? `input: ${lastActive}${n.active ? " (active)" : ""}` : null, |
| ] |
| .filter(Boolean) |
| .map((part) => sanitizeTerminalText(String(part))); |
| const caps = Array.isArray(n.caps) |
| ? sanitizeTerminalText(n.caps.map(String).filter(Boolean).toSorted().join(", ")) |
| : "?"; |
| const paired = n.paired ? ok("paired") : warn("unpaired"); |
| const connected = n.connected ? ok("connected") : muted("disconnected"); |
| const approvalState = formatNodeApprovalState(n.approvalState); |
| const approval = |
| approvalState === "approved" |
| ? ok("approved") |
| : isPendingApprovalState(approvalState) |
| ? warn(formatApprovalStateLabel(approvalState)) |
| : approvalState === "unapproved" |
| ? warn("unapproved") |
| : null; |
| const since = |
| typeof n.connectedAtMs === "number" |
| ? ` (${formatTimeAgo(Math.max(0, now - n.connectedAtMs))})` |
| : ""; |
|
|
| return { |
| Node: formatNodeTerminalLabel(n), |
| ID: sanitizeTerminalText(n.nodeId), |
| IP: sanitizeTerminalText(n.remoteIp ?? ""), |
| Detail: detailParts.join(" · "), |
| Status: `${paired} · ${connected}${since}${approval ? ` · ${approval}` : ""}`, |
| Caps: caps, |
| }; |
| }); |
|
|
| 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: "Detail", header: "Detail", minWidth: 18, flex: true }, |
| { key: "Status", header: "Status", minWidth: 18 }, |
| { key: "Caps", header: "Caps", minWidth: 12, flex: true }, |
| ], |
| rows, |
| }).trimEnd(), |
| ); |
| for (const node of filtered) { |
| const approvalState = formatNodeApprovalState(node.approvalState); |
| const requestId = normalizeOptionalString(node.pendingRequestId); |
| if (isPendingApprovalState(approvalState) && requestId) { |
| const approveCommand = formatPairingApproveCommand("nodes", requestId, { |
| timeout: opts.timeout, |
| }); |
| const action = approvalState === "pending-reapproval" ? "Reapproval" : "Approval"; |
| defaultRuntime.log( |
| warn( |
| `${action} pending for ${formatNodeTerminalLabel(node)}. Run ${sanitizeTerminalText(approveCommand)}`, |
| ), |
| ); |
| const connectionReminder = formatConnectionFlagReminder(opts); |
| if (connectionReminder) { |
| defaultRuntime.log(warn(connectionReminder)); |
| } |
| } |
| } |
| }); |
| }), |
| ); |
|
|
| nodesCallOpts( |
| nodes |
| .command("describe") |
| .description("Describe a node (capabilities + supported invoke commands)") |
| .requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP") |
| .action(async (opts: NodesRpcOpts) => { |
| await runNodesCommand("describe", async () => { |
| const nodeId = await resolveNodeDiagnosticsId(opts, opts.node ?? ""); |
| const result = await callNodeDiagnosticsGatewayCli("node.describe", opts, { |
| nodeId, |
| }); |
| if (opts.json) { |
| defaultRuntime.writeJson(result); |
| return; |
| } |
|
|
| const obj: Record<string, unknown> = |
| typeof result === "object" && result !== null ? result : {}; |
| const displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId; |
| const connected = Boolean(obj.connected); |
| const paired = Boolean(obj.paired); |
| const caps = Array.isArray(obj.caps) |
| ? obj.caps.map(String).filter(Boolean).toSorted() |
| : null; |
| const commands = Array.isArray(obj.commands) |
| ? obj.commands.map(String).filter(Boolean).toSorted() |
| : []; |
| const perms = formatPermissions(obj.permissions); |
| const approvalState = formatNodeApprovalState(obj.approvalState); |
| const pendingRequestId = normalizeOptionalString(obj.pendingRequestId); |
| const pendingCaps = Array.isArray(obj.pendingDeclaredCaps) |
| ? obj.pendingDeclaredCaps.map(String).filter(Boolean).toSorted() |
| : null; |
| const pendingCommands = Array.isArray(obj.pendingDeclaredCommands) |
| ? obj.pendingDeclaredCommands.map(String).filter(Boolean).toSorted() |
| : []; |
| const pendingPerms = formatPermissions(obj.pendingDeclaredPermissions); |
| const approveCommand = |
| isPendingApprovalState(approvalState) && pendingRequestId |
| ? formatPairingApproveCommand("nodes", pendingRequestId, { timeout: opts.timeout }) |
| : null; |
| const connectionReminder = approveCommand ? formatConnectionFlagReminder(opts) : null; |
| const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null; |
| const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null; |
| const client = formatClientLabel(obj as { clientId?: string; clientMode?: string }); |
| const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null; |
| const pathEnv = typeof obj.pathEnv === "string" ? obj.pathEnv : null; |
| const versions = formatNodeVersions( |
| obj as { |
| platform?: string; |
| version?: string; |
| coreVersion?: string; |
| uiVersion?: string; |
| }, |
| ); |
| const lastActive = formatNodeTimeAgo(Date.now(), obj.lastActiveAtMs); |
| const stats = formatNodeHostStats(obj.hostStats, connected, Date.now()); |
|
|
| const { heading, ok, warn, muted } = getNodesTheme(); |
| const status = `${paired ? ok("paired") : warn("unpaired")} · ${ |
| connected ? ok("connected") : muted("disconnected") |
| }`; |
| const tableWidth = getTerminalTableWidth(); |
| const rows = [ |
| { Field: "ID", Value: sanitizeTerminalText(nodeId) }, |
| displayName ? { Field: "Name", Value: sanitizeTerminalText(displayName) } : null, |
| client ? { Field: "Client", Value: sanitizeTerminalText(client) } : null, |
| ip ? { Field: "IP", Value: sanitizeTerminalText(ip) } : null, |
| family ? { Field: "Device", Value: sanitizeTerminalText(family) } : null, |
| model ? { Field: "Model", Value: sanitizeTerminalText(model) } : null, |
| perms ? { Field: "Perms", Value: sanitizeTerminalText(perms) } : null, |
| versions ? { Field: "Version", Value: sanitizeTerminalText(versions) } : null, |
| stats ? { Field: "Stats", Value: stats } : null, |
| pathEnv ? { Field: "PATH", Value: sanitizeTerminalText(pathEnv) } : null, |
| lastActive |
| ? { |
| Field: "Last input", |
| Value: `${lastActive}${obj.active === true ? " (active node)" : ""}`, |
| } |
| : null, |
| { Field: "Status", Value: status }, |
| approvalState |
| ? { Field: "Approval", Value: formatApprovalStateLabel(approvalState) } |
| : null, |
| pendingRequestId |
| ? { Field: "Pending request", Value: sanitizeTerminalText(pendingRequestId) } |
| : null, |
| pendingCaps |
| ? { Field: "Pending caps", Value: sanitizeTerminalText(pendingCaps.join(", ")) } |
| : null, |
| pendingPerms |
| ? { Field: "Pending perms", Value: sanitizeTerminalText(pendingPerms) } |
| : null, |
| approveCommand |
| ? { |
| Field: approvalState === "pending-reapproval" ? "Reapprove" : "Approve", |
| Value: sanitizeTerminalText(approveCommand), |
| } |
| : null, |
| approveCommand && connectionReminder |
| ? { Field: "Connection reminder", Value: connectionReminder } |
| : null, |
| { Field: "Caps", Value: caps ? sanitizeTerminalText(caps.join(", ")) : "?" }, |
| ].filter(Boolean) as Array<{ Field: string; Value: string }>; |
|
|
| defaultRuntime.log(heading("Node")); |
| defaultRuntime.log( |
| renderTable({ |
| width: tableWidth, |
| columns: [ |
| { key: "Field", header: "Field", minWidth: 8 }, |
| { key: "Value", header: "Value", minWidth: 24, flex: true }, |
| ], |
| rows, |
| }).trimEnd(), |
| ); |
| defaultRuntime.log(""); |
| defaultRuntime.log(heading("Commands")); |
| if (commands.length === 0) { |
| defaultRuntime.log(muted("- (none effective)")); |
| } else { |
| for (const c of commands) { |
| defaultRuntime.log(`- ${sanitizeTerminalText(c)}`); |
| } |
| } |
| if (pendingCommands.length > 0) { |
| defaultRuntime.log(""); |
| defaultRuntime.log(heading("Pending commands")); |
| for (const command of pendingCommands) { |
| defaultRuntime.log(`- ${sanitizeTerminalText(command)}`); |
| } |
| } |
| }); |
| }), |
| ); |
|
|
| nodesCallOpts( |
| 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 callNodesGatewayCli("node.pair.list", opts, {}); |
| const { pending, paired } = parsePairingList(result); |
| const { heading, muted, warn } = getNodesTheme(); |
| const tableWidth = getTerminalTableWidth(); |
| const now = Date.now(); |
| const hasFilters = connectedOnly || sinceMs !== undefined; |
| |
| |
| const pendingRows = pending; |
| const effectiveNodes = hasFilters |
| ? parseNodeList(await callNodeDiagnosticsGatewayCli("node.list", opts, {})) |
| : await tryReadNodeList(opts); |
| const effectivePairedRows = mergePairedNodesWithEffectiveNodes(paired, effectiveNodes); |
| const filteredPaired = effectivePairedRows.filter((node) => |
| matchesNodeConnectionFilter(node, connectedOnly, sinceMs, now), |
| ); |
| const filteredLabel = |
| hasFilters && filteredPaired.length !== effectivePairedRows.length |
| ? ` (of ${effectivePairedRows.length})` |
| : ""; |
| if (opts.json) { |
| defaultRuntime.writeJson({ |
| pending: pendingRows, |
| |
| |
| |
| paired: filteredPaired.map((row) => { |
| const { token: _token, ...rest } = row as { token?: unknown }; |
| return rest; |
| }), |
| }); |
| return; |
| } |
|
|
| defaultRuntime.log( |
| `Pending: ${pendingRows.length} · Paired: ${filteredPaired.length}${filteredLabel}`, |
| ); |
|
|
| if (pendingRows.length > 0) { |
| const rendered = renderPendingPairingRequestsTable({ |
| pending: pendingRows, |
| now, |
| tableWidth, |
| theme: { heading, warn, muted }, |
| }); |
| defaultRuntime.log(""); |
| defaultRuntime.log(rendered.heading); |
| defaultRuntime.log(rendered.table); |
| } |
|
|
| if (filteredPaired.length > 0) { |
| const pairedTableRows = filteredPaired.map((n) => ({ |
| Node: formatNodeTerminalLabel(n), |
| Id: sanitizeTerminalText(n.nodeId), |
| IP: sanitizeTerminalText(n.remoteIp ?? ""), |
| LastConnect: |
| formatNodeTimeAgo(now, n.lastConnectedAtMs ?? n.connectedAtMs) ?? 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: pairedTableRows, |
| }).trimEnd(), |
| ); |
| } |
| }); |
| }), |
| ); |
| } |
|
|