From c353d9c7a2e0b595d8f6ba6e71ba2a25bc3cc300 Mon Sep 17 00:00:00 2001 From: Paul Valladares Date: Tue, 21 Jul 2026 16:58:38 -0600 Subject: [PATCH] feat: add fid watch live board --- src/cli.ts | 7 +- src/commands/watch.ts | 200 +++++++++++++++++++++++++++++++ src/tui/board.ts | 267 ++++++++++++++++++++++++++++++++++++++++++ tests/watch.test.ts | 83 +++++++++++++ 4 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 src/commands/watch.ts create mode 100644 src/tui/board.ts create mode 100644 tests/watch.test.ts diff --git a/src/cli.ts b/src/cli.ts index fdc9cda..f5ba466 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ const usage = `usage: --as who you are (or {"handle":"..."} in ~/.config/fid/config.json) fid stats network aggregates: versions, hosting, top knots fid repos what repos live on a knot + fid watch live network board (q to quit) fid --version print the version `; @@ -31,7 +32,8 @@ if (args.version) { } const [command] = args._; -if (!args.json) await animateBanner(); // keep --json pipe-clean +// keep --json pipe-clean; watch takes the whole screen immediately +if (!args.json && command !== "watch") await animateBanner(); if (args.help || !command) { console.log(usage); @@ -57,6 +59,9 @@ switch (command) { args._[1] === undefined ? undefined : String(args._[1]), ); break; + case "watch": + await (await import("./commands/watch.js")).run(args); + break; default: console.error(`unknown command: ${command}\n`); console.log(usage); diff --git a/src/commands/watch.ts b/src/commands/watch.ts new file mode 100644 index 0000000..224b289 --- /dev/null +++ b/src/commands/watch.ts @@ -0,0 +1,200 @@ +import { alternateBuffer, createInput, createTerm, cursor } from "@bomb.sh/tty"; +import { fetchMotd, probeKnot } from "../api/knot.js"; +import { loadNetwork } from "../core/network.js"; +import { pLimit } from "../util.js"; +import { + buildOps, + CHROME_ROWS, + clampWindow, + deriveRows, + FLASH_FADE_MS, + type WatchState, +} from "../tui/board.js"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export async function run(flags: { json?: boolean; fresh?: boolean }) { + if (flags.json) { + console.error("watch is interactive — use `fid list --json` for data"); + process.exit(1); + } + + process.stdout.write("assembling the network…"); + let network: Awaited>; + try { + network = await loadNetwork(!!flags.fresh, (msg) => + process.stdout.write(`\r\x1b[K${msg}`), + ); + } catch (err) { + process.stdout.write("\r\x1b[K"); + console.error(err instanceof Error ? err.message : err); + process.exit(2); + } + process.stdout.write("\r\x1b[K"); + + const state: WatchState = { + network, + probes: new Map(), + changed: new Map(), + selected: 0, + offset: 0, + detail: null, + sweeping: false, + sweptAt: null, + rows: deriveRows(network, new Map()), + }; + + const size = () => ({ + width: process.stdout.columns || 80, + height: process.stdout.rows || 24, + }); + let term = await createTerm(size()); + const input = await createInput({ escLatency: 25 }); + const alt = alternateBuffer({ clear: true }); + const cur = cursor(false); + + let quit = false; + let dirty = true; + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + process.stdout.write(cur.revert); + process.stdout.write(alt.revert); + if (process.stdin.isTTY) process.stdin.setRawMode(false); + process.stdin.pause(); + }; + process.on("SIGINT", () => { + cleanup(); + process.exit(0); + }); + + process.stdout.write(alt.apply); + process.stdout.write(cur.apply); + if (process.stdin.isTTY) process.stdin.setRawMode(true); + process.stdin.resume(); + + const motdCache = new Map(); + const viewH = () => Math.max(1, size().height - CHROME_ROWS); + const move = (delta: number) => { + state.selected = Math.min( + Math.max(state.selected + delta, 0), + state.rows.length - 1, + ); + state.offset = clampWindow( + state.selected, + state.offset, + viewH(), + state.rows.length, + ); + }; + + const openDetail = () => { + const row = state.rows[state.selected]; + if (!row) return; + state.detail = { host: row.host, motd: motdCache.get(row.host) ?? null }; + if (!motdCache.has(row.host)) { + fetchMotd(row.host).then((motd) => { + motdCache.set(row.host, motd); + if (state.detail?.host === row.host) { + state.detail = { host: row.host, motd }; + dirty = true; + } + }); + } + }; + + // biome-ignore lint/suspicious/noExplicitAny: input events are a closed union we narrow by type/code + const handle = (e: any) => { + if (e.type !== "keydown") return; + dirty = true; + if (e.code === "q" || (e.ctrl && e.code === "c")) quit = true; + else if (e.code === "Escape") state.detail = null; + else if (e.code === "Enter") { + if (state.detail) state.detail = null; + else openDetail(); + } else if (e.code === "ArrowUp") move(-1); + else if (e.code === "ArrowDown") move(1); + else if (e.code === "PageUp") move(-viewH()); + else if (e.code === "PageDown") move(viewH()); + else if (e.code === "g" && e.shift) move(state.rows.length); + else if (e.code === "g") move(-state.rows.length); + else if (e.code === "r") void sweep(); + else dirty = false; + }; + + let escTimer: ReturnType | undefined; + process.stdin.on("data", (buf: Buffer) => { + clearTimeout(escTimer); + const { events, pending } = input.scan(new Uint8Array(buf)); + for (const e of events) handle(e); + if (pending) { + escTimer = setTimeout(() => { + for (const e of input.scan().events) handle(e); + }, pending.delay); + } + }); + + async function sweep() { + if (state.sweeping) return; + state.sweeping = true; + dirty = true; + const limit = pLimit(16); + await Promise.all( + state.rows.map((row) => + limit(async () => { + const probe = await probeKnot(row.host); + const prev = state.probes.get(row.host) ?? row.probe; + const changed = + (prev?.xrpcOk ? "h" : prev?.reachable ? "s" : "d") !== + (probe.xrpcOk ? "h" : probe.reachable ? "s" : "d"); + state.probes.set(row.host, probe); + if (changed) state.changed.set(row.host, Date.now()); + state.rows = deriveRows(state.network, state.probes); + dirty = true; + }), + ), + ); + state.sweeping = false; + state.sweptAt = Date.now(); + dirty = true; + } + const sweepTimer = setInterval(() => void sweep(), 60_000); + void sweep(); + + process.stdout.on("resize", () => { + void createTerm(size()).then((t) => { + term = t; + dirty = true; + }); + }); + + // frame loop — render when dirty, keep rendering while transitions animate + let animating = false; + let last = performance.now(); + try { + while (!quit) { + // live flash windows need frames to fire and fade their bg transition + const now = Date.now(); + for (const [host, at] of state.changed) { + if (now - at < FLASH_FADE_MS + 200) dirty = true; + else state.changed.delete(host); + } + if (dirty || animating) { + dirty = false; + const nowMs = performance.now(); + const { width, height } = size(); + const result = term.render(buildOps(state, width, height, now), { + deltaTime: nowMs - last, + }); + last = nowMs; + process.stdout.write(result.output); + animating = result.animating; + } + await sleep(33); + } + } finally { + clearInterval(sweepTimer); + cleanup(); + } +} diff --git a/src/tui/board.ts b/src/tui/board.ts new file mode 100644 index 0000000..c54ccb1 --- /dev/null +++ b/src/tui/board.ts @@ -0,0 +1,267 @@ +import { close, fixed, grow, open, rgba, text, type Op } from "@bomb.sh/tty"; +import type { KnotProbe } from "../api/knot.js"; +import type { Network } from "../core/network.js"; + +export type KnotRow = { + host: string; + repoCount: number; + managed: boolean; + owners: { did: string; handle: string }[]; + probe: KnotProbe | null; + status: "healthy" | "stale" | "down"; +}; + +export type WatchState = { + network: Network; + probes: Map; + changed: Map; // host → when its status flipped (ms epoch) + selected: number; + offset: number; + detail: { host: string; motd: string | null } | null; + sweeping: boolean; + sweptAt: number | null; + rows: KnotRow[]; +}; + +// header + its border + footer + its border +export const CHROME_ROWS = 4; + +// status flash: solid this long, then the bg transition fades it out +export const FLASH_MS = 500; +export const FLASH_FADE_MS = 1000; + +const GREEN = rgba(74, 222, 128); +const YELLOW = rgba(250, 204, 21); +const RED = rgba(248, 113, 113); +const DIM = rgba(120, 125, 135); +const FG = rgba(225, 228, 235); +const BORDER = rgba(70, 75, 90); +const SELECTED_BG = rgba(40, 52, 74); +const FLASH_BG = rgba(96, 84, 28); +const CARD_BG = rgba(24, 28, 38); + +const statusOf = (probe: KnotProbe | null): KnotRow["status"] => + probe?.xrpcOk ? "healthy" : probe?.reachable ? "stale" : "down"; + +export const STATUS_COLOR = { healthy: GREEN, stale: YELLOW, down: RED }; + +// public knots with live probe overrides applied, snapshot order (repos desc) +export function deriveRows( + network: Network, + probes: Map, +): KnotRow[] { + return network.knots + .filter((k) => !k.local) + .map((k) => { + const probe = probes.get(k.host) ?? k.probe; + return { + host: k.host, + repoCount: k.repoCount, + managed: k.managed, + owners: k.owners, + probe, + status: statusOf(probe), + }; + }); +} + +// keep the selection inside the window +export function clampWindow( + selected: number, + offset: number, + viewH: number, + total: number, +): number { + if (total <= viewH) return 0; + if (selected < offset) return selected; + if (selected >= offset + viewH) return selected - viewH + 1; + return Math.min(offset, total - viewH); +} + +const age = (then: number | null, now: number): string => { + if (then === null) return "never"; + const s = Math.max(0, Math.round((now - then) / 1000)); + return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`; +}; + +export function buildOps( + state: WatchState, + width: number, + height: number, + now: number, +): Op[] { + const { rows } = state; + const viewH = Math.max(1, height - CHROME_ROWS); + const counts = { + healthy: rows.filter((r) => r.status === "healthy").length, + stale: rows.filter((r) => r.status === "stale").length, + down: rows.filter((r) => r.status === "down").length, + }; + + const ops: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + ]; + + // header — bordered bottom only + ops.push( + open("header", { + layout: { + width: grow(), + height: fixed(2), + direction: "ltr", + padding: { left: 1, right: 1 }, + }, + border: { color: BORDER, bottom: 1 }, + }), + text(`fid · ${rows.length} knots · ${state.network.totalRepos} repos `, { + color: FG, + }), + text(`● ${counts.healthy} `, { color: GREEN }), + text(`● ${counts.stale} `, { color: YELLOW }), + text(`● ${counts.down}`, { color: RED }), + open("header-gap", { layout: { width: grow() } }), + close(), + text(`swept ${age(state.sweptAt, now)}`, { color: DIM }), + close(), + ); + + // table window + const hostW = Math.min( + Math.max(...rows.map((r) => r.host.length), 4) + 2, + Math.max(20, width - 30), + ); + ops.push( + open("table", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + ); + for (const [i, row] of rows + .slice(state.offset, state.offset + viewH) + .entries()) { + const index = state.offset + i; + const selected = index === state.selected; + const flashedAgo = now - (state.changed.get(row.host) ?? Number.NEGATIVE_INFINITY); + const flashing = flashedAgo < FLASH_MS; + ops.push( + open(`row-${row.host}`, { + layout: { + width: grow(), + height: fixed(1), + direction: "ltr", + padding: { left: 1 }, + }, + bg: flashing ? FLASH_BG : selected ? SELECTED_BG : undefined, + // transition only while a flash is live/fading — selection must snap, + // or the moving highlight leaves a fading trail behind it + transition: + flashedAgo < FLASH_FADE_MS + ? { duration: 400, easing: "easeOut", properties: ["bg"] } + : undefined, + }), + text(row.host.padEnd(hostW), { color: selected ? FG : undefined }), + text(String(row.repoCount).padStart(6), { color: DIM }), + text( + ` ${row.status === "healthy" ? "v1.15+" : row.status === "stale" ? "pre-v1.15" : "down"}`.padEnd( + 12, + ), + { + color: STATUS_COLOR[row.status], + }, + ), + text( + row.probe?.latencyMs !== undefined ? `${row.probe.latencyMs}ms` : "", + { + color: DIM, + }, + ), + close(), + ); + } + ops.push(close()); + + // footer — bordered top only + ops.push( + open("footer", { + layout: { + width: grow(), + height: fixed(2), + direction: "ltr", + padding: { left: 1, right: 1 }, + alignY: "bottom", + }, + border: { color: BORDER, top: 1 }, + }), + text("↑↓ scroll · enter detail · r probe · q quit", { color: DIM }), + open("footer-gap", { layout: { width: grow() } }), + close(), + ...(state.sweeping ? [text("sweeping…", { color: YELLOW })] : []), + close(), + ); + + // floating detail card + if (state.detail) { + const row = rows.find((r) => r.host === state.detail?.host); + if (row) { + ops.push( + open("detail", { + layout: { + direction: "ttb", + padding: { left: 2, right: 2, top: 1, bottom: 1 }, + gap: 1, + }, + bg: CARD_BG, + border: { + color: STATUS_COLOR[row.status], + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, + floating: { + attachTo: "root", + attachPoints: { element: "center-center", parent: "center-center" }, + zIndex: 10, + }, + }), + text(row.host, { color: FG }), + open("detail-meta", { layout: { direction: "ttb" } }), + text( + `${row.status} · ${row.probe?.latencyMs !== undefined ? `${row.probe.latencyMs}ms · ` : ""}${row.managed ? "managed" : "self-hosted"} · ${row.repoCount} repos`, + { color: STATUS_COLOR[row.status] }, + ), + ...(row.owners.length + ? [ + text( + `owned by ${row.owners + .slice(0, 5) + .map((o) => `@${o.handle}`) + .join( + ", ", + )}${row.owners.length > 5 ? ` +${row.owners.length - 5}` : ""}`, + { color: DIM }, + ), + ] + : []), + close(), + ...(state.detail.motd + ? [ + open("detail-motd", { layout: { direction: "ttb" } }), + ...state.detail.motd + .split("\n") + .slice(0, 10) + .map((l) => text(l || " ", { color: DIM })), + close(), + ] + : []), + text("esc to close", { color: DIM }), + close(), + ); + } + } + + ops.push(close()); + return ops; +} diff --git a/tests/watch.test.ts b/tests/watch.test.ts new file mode 100644 index 0000000..500c08d --- /dev/null +++ b/tests/watch.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "vitest"; +import type { Network } from "../src/core/network.js"; +import { + buildOps, + clampWindow, + deriveRows, + type WatchState, +} from "../src/tui/board.js"; + +test("clampWindow keeps the selection visible", () => { + expect(clampWindow(0, 5, 10, 100)).toBe(0); // selection above window + expect(clampWindow(20, 5, 10, 100)).toBe(11); // selection below window + expect(clampWindow(99, 80, 10, 100)).toBe(90); // last row + expect(clampWindow(3, 0, 10, 5)).toBe(0); // list shorter than viewport + expect(clampWindow(7, 5, 10, 100)).toBe(5); // already visible — unchanged +}); + +const network: Network = { + knots: [ + { + host: "knot1.tangled.sh", + owners: [{ did: "did:plc:a", handle: "oppi.li" }], + repoCount: 42, + managed: true, + local: false, + probe: { reachable: true, xrpcOk: true, latencyMs: 40 }, + version: "v1.15+", + }, + { + host: "localhost:5555", + owners: [], + repoCount: 1, + managed: false, + local: true, + probe: null, + version: "unknown", + }, + ], + repos: [], + totalRepos: 43, + repoOwners: 2, + knotOwners: 2, + generatedAt: "2026-07-21T00:00:00.000Z", +}; + +const state: WatchState = { + network, + probes: new Map(), + changed: new Map(), + selected: 0, + offset: 0, + detail: null, + sweeping: false, + sweptAt: null, + rows: deriveRows(network, new Map()), +}; + +test("buildOps renders header, selected row, and footer; local knots excluded", () => { + const ops = buildOps(state, 80, 24, Date.now()); + const texts = ops + .filter((o) => "content" in o) + .map((o) => (o as { content: string }).content); + expect(texts.some((t) => t.includes("1 knots"))).toBe(true); + expect(texts.some((t) => t.includes("knot1.tangled.sh"))).toBe(true); + expect(texts.some((t) => t.includes("q quit"))).toBe(true); + expect(texts.some((t) => t.includes("localhost"))).toBe(false); +}); + +test("buildOps adds a floating detail card when detail is open", () => { + const ops = buildOps( + { ...state, detail: { host: "knot1.tangled.sh", motd: "ahoy" } }, + 80, + 24, + Date.now(), + ); + const floating = ops.find((o) => "floating" in o && o.floating); + expect(floating).toBeDefined(); + const texts = ops + .filter((o) => "content" in o) + .map((o) => (o as { content: string }).content); + expect(texts.some((t) => t.includes("ahoy"))).toBe(true); + expect(texts.some((t) => t.includes("@oppi.li"))).toBe(true); +}); -- 2.51.2