the tangled network observatory ◈ every knot, its health, its version, in your terminal
tangled cli tui knot monitoring observability atproto federation
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: map spindle fleet with generic service records

+213 -31
+19 -8
src/api/relay.ts
··· 68 68 return values; 69 69 } 70 70 71 - // pre-~v1.x records carry a host field with a tid rkey; newer ones drop the 72 - // field entirely and the rkey IS the hostname (at://did/sh.tangled.knot/knot.example.com) 73 - const KnotRecord = z.object({ host: z.string().min(1).optional() }); 71 + // service records (knots, spindles) share a shape: pre-~v1.x ones carry a host 72 + // field with a tid rkey; newer ones drop the field and the rkey IS the hostname 73 + // (at://did/sh.tangled.knot/knot.example.com, at://did/sh.tangled.spindle/spindle.example.com) 74 + const ServiceRecord = z.object({ host: z.string().min(1).optional() }); 74 75 const RepoRecord = z.object({ 75 76 knot: z.string().min(1), 77 + spindle: z.string().optional(), 76 78 description: z.string().optional(), 77 79 createdAt: z.string().optional(), 78 80 }); ··· 80 82 export type RepoRef = { 81 83 name: string; 82 84 knot: string; 85 + spindle?: string; 83 86 description?: string; 84 87 createdAt?: string; 85 88 }; 86 89 87 90 const host = (h: string) => h.trim().toLowerCase(); 88 91 89 - // hosts this owner has verified as knots (malformed records skipped, not fatal) 90 - export async function fetchKnotHosts( 92 + // hosts this owner has verified for a service collection (malformed records skipped, not fatal) 93 + export async function fetchServiceHosts( 91 94 pds: string, 92 95 did: string, 96 + collection: "sh.tangled.knot" | "sh.tangled.spindle", 93 97 ): Promise<string[]> { 94 - const records = await listRecords(pds, did, "sh.tangled.knot"); 98 + const records = await listRecords(pds, did, collection); 95 99 return records.flatMap((r) => { 96 - const parsed = KnotRecord.safeParse(r.value); 100 + const parsed = ServiceRecord.safeParse(r.value); 97 101 if (!parsed.success) return []; 98 102 const h = parsed.data.host ?? r.uri.split("/").pop() ?? ""; 99 103 return h.includes(".") || h.includes(":") ? [host(h)] : []; // tid rkey without host field = junk ··· 107 111 const parsed = RepoRecord.safeParse(r.value); 108 112 if (!parsed.success) return []; 109 113 const name = r.uri.split("/").pop() ?? ""; 110 - return [{ ...parsed.data, knot: host(parsed.data.knot), name }]; 114 + return [ 115 + { 116 + ...parsed.data, 117 + knot: host(parsed.data.knot), 118 + spindle: parsed.data.spindle ? host(parsed.data.spindle) : undefined, 119 + name, 120 + }, 121 + ]; 111 122 }); 112 123 }
+4
src/cli.ts
··· 20 20 --as <handle> who you are (or {"handle":"..."} in ~/.config/fid/config.json) 21 21 fid stats network aggregates: versions, hosting, top knots 22 22 fid repos <host> what repos live on a knot 23 + fid spindles the ci runner fleet 23 24 fid watch live network board (q to quit) 24 25 fid --version print the version 25 26 `; ··· 79 80 args, 80 81 args._[1] === undefined ? undefined : String(args._[1]), 81 82 ); 83 + break; 84 + case "spindles": 85 + await (await import("./commands/spindles.js")).run(args); 82 86 break; 83 87 case "watch": 84 88 await (await import("./commands/watch.js")).run(args);
+4 -2
src/commands/check.ts
··· 5 5 import * as p from "@clack/prompts"; 6 6 import { resolveDid, resolveHandle } from "../api/identity.js"; 7 7 import { fetchMotd, isLocalHost, probeKnot } from "../api/knot.js"; 8 - import { fetchKnotHosts } from "../api/relay.js"; 8 + import { fetchServiceHosts } from "../api/relay.js"; 9 9 import { assessKnot, versionOf } from "../core/health.js"; 10 10 11 11 type Flags = { json?: boolean; mine?: boolean; as?: string }; ··· 35 35 const did = await resolveHandle(who); 36 36 const id = await resolveDid(did); 37 37 if (!id.pds) throw new Error(`no pds found for ${who}`); 38 - const hosts = [...new Set(await fetchKnotHosts(id.pds, did))]; 38 + const hosts = [ 39 + ...new Set(await fetchServiceHosts(id.pds, did, "sh.tangled.knot")), 40 + ]; 39 41 if (hosts.length === 0) 40 42 throw new Error(`${who} owns no knots (no sh.tangled.knot records)`); 41 43 return hosts;
+69
src/commands/spindles.ts
··· 1 + import { styleText } from "node:util"; 2 + import * as p from "@clack/prompts"; 3 + import { loadNetwork, type Network } from "../core/network.js"; 4 + 5 + const dim = (s: string) => styleText("dim", s); 6 + 7 + export async function run(flags: { 8 + json?: boolean; 9 + all?: boolean; 10 + fresh?: boolean; 11 + }) { 12 + if (!flags.json) p.intro(styleText("green", "fiddling with knots")); 13 + const spin = flags.json ? null : p.spinner(); 14 + spin?.start("assembling the network"); 15 + let network: Network; 16 + try { 17 + network = await loadNetwork(!!flags.fresh, (msg) => spin?.message(msg)); 18 + } catch (err) { 19 + spin?.stop("upstream failure", 1); 20 + console.error(err instanceof Error ? err.message : err); 21 + process.exit(2); 22 + } 23 + spin?.stop(`network assembled ${dim(`(${network.generatedAt})`)}`); 24 + 25 + if (flags.json) { 26 + console.log(JSON.stringify(network.spindles, null, 2)); 27 + return; 28 + } 29 + 30 + const shown = flags.all 31 + ? network.spindles 32 + : network.spindles.filter((s) => !s.local); 33 + const hidden = network.spindles.length - shown.length; 34 + 35 + const status = (s: (typeof shown)[number]) => { 36 + if (s.local) return dim("local"); 37 + if (s.probe?.reachable) 38 + return styleText("green", `up ${s.probe.latencyMs ?? "?"}ms`); 39 + return styleText("red", "down"); 40 + }; 41 + 42 + const rows = shown.map((s) => [ 43 + s.host, 44 + String(s.repoCount), 45 + status(s), 46 + s.owners[0] 47 + ? `@${s.owners[0].handle}${s.owners.length > 1 ? dim(` +${s.owners.length - 1}`) : ""}` 48 + : dim("—"), 49 + ]); 50 + 51 + const header = ["HOST", "REPOS", "STATUS", "OWNER"]; 52 + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping our own ansi colors for width math 53 + const visible = (s: string) => s.replace(/\x1b\[\d+m/g, "").length; 54 + const widths = header.map((h, i) => 55 + Math.max(h.length, ...rows.map((r) => visible(r[i]))), 56 + ); 57 + const line = (cells: string[]) => 58 + cells.map((c, i) => c + " ".repeat(widths[i] - visible(c))).join(" "); 59 + 60 + p.log.message([dim(line(header)), ...rows.map((r) => line(r))].join("\n")); 61 + 62 + const reachable = shown.filter((s) => s.probe?.reachable).length; 63 + p.outro( 64 + dim( 65 + `${shown.length} spindles · ${reachable} reachable` + 66 + (hidden ? ` · ${hidden} local/dev hidden (--all)` : ""), 67 + ), 68 + ); 69 + }
+1 -1
src/commands/stats.ts
··· 77 77 const { knots, types, repos } = stats; 78 78 const growth = growthLine(stats, previous); 79 79 p.log.message( 80 - `${knots.total} knots · ${repos.total} repos · ${repos.owners} owners` + 80 + `${knots.total} knots · ${repos.total} repos · ${repos.owners} owners · ${stats.spindles.total} spindles` + 81 81 (growth ? `\n${dim(growth)}` : ""), 82 82 ); 83 83
+58 -14
src/core/network.ts
··· 1 1 import { resolveDid, type Identity } from "../api/identity.js"; 2 2 import { 3 - fetchKnotHosts, 4 3 fetchRepos, 4 + fetchServiceHosts, 5 5 listDidsByCollection, 6 6 type RepoRef, 7 7 } from "../api/relay.js"; ··· 25 25 26 26 export type RepoInfo = RepoRef & { owner: { did: string; handle: string } }; 27 27 28 + export type Spindle = { 29 + host: string; 30 + owners: { did: string; handle: string }[]; 31 + repoCount: number; // repos configured to run ci on this spindle 32 + local: boolean; 33 + probe: KnotProbe | null; // only reachable/latencyMs are meaningful here 34 + }; 35 + 28 36 export type Network = { 29 37 knots: Knot[]; 38 + spindles: Spindle[]; 30 39 repos: RepoInfo[]; 31 40 totalRepos: number; 32 41 repoOwners: number; 33 42 knotOwners: number; 34 43 generatedAt: string; 35 44 }; 45 + 46 + type ServiceOwners = { identity: Identity; hosts: string[] }[]; 36 47 37 48 // pure assembly: data in, data out — the io lives in assembleNetwork below 38 49 export function groupNetwork(input: { 39 - knotOwners: { identity: Identity; hosts: string[] }[]; 50 + knotOwners: ServiceOwners; 51 + spindleOwners: ServiceOwners; 40 52 repos: RepoInfo[]; // every repo record on the network 41 53 repoOwners: number; 42 54 probes: Map<string, KnotProbe>; ··· 74 86 } 75 87 for (const repo of input.repos) knot(repo.knot).repoCount++; 76 88 77 - const knots = [...byHost.values()].sort( 78 - (a, b) => b.repoCount - a.repoCount || a.host.localeCompare(b.host), 79 - ); 89 + const bySpindle = new Map<string, Spindle>(); 90 + const spindle = (host: string): Spindle => { 91 + let s = bySpindle.get(host); 92 + if (!s) { 93 + s = { 94 + host, 95 + owners: [], 96 + repoCount: 0, 97 + local: isLocalHost(host), 98 + probe: input.probes.get(host) ?? null, 99 + }; 100 + bySpindle.set(host, s); 101 + } 102 + return s; 103 + }; 104 + for (const { identity, hosts } of input.spindleOwners) { 105 + for (const host of new Set(hosts)) { 106 + spindle(host).owners.push({ did: identity.did, handle: identity.handle }); 107 + } 108 + } 109 + for (const repo of input.repos) { 110 + if (repo.spindle) spindle(repo.spindle).repoCount++; 111 + } 112 + 113 + const byCount = (a: { repoCount: number; host: string }, b: typeof a) => 114 + b.repoCount - a.repoCount || a.host.localeCompare(b.host); 80 115 return { 81 - knots, 116 + knots: [...byHost.values()].sort(byCount), 117 + spindles: [...bySpindle.values()].sort(byCount), 82 118 repos: input.repos, 83 119 totalRepos: input.repos.length, 84 120 repoOwners: input.repoOwners, ··· 93 129 const progress = onProgress ?? (() => {}); 94 130 95 131 progress("enumerating the network via relay…"); 96 - const [knotDids, repoDids] = await Promise.all([ 132 + const [knotDids, spindleDids, repoDids] = await Promise.all([ 97 133 listDidsByCollection("sh.tangled.knot"), 134 + listDidsByCollection("sh.tangled.spindle"), 98 135 listDidsByCollection("sh.tangled.repo", (n) => 99 136 progress(`enumerating repo owners… ${n}`), 100 137 ), ··· 127 164 const knotOwnersRaw = await hydrate( 128 165 knotDids, 129 166 "reading knot records", 130 - fetchKnotHosts, 167 + (p, d) => fetchServiceHosts(p, d, "sh.tangled.knot"), 168 + ); 169 + const spindleOwnersRaw = await hydrate( 170 + spindleDids, 171 + "reading spindle records", 172 + (p, d) => fetchServiceHosts(p, d, "sh.tangled.spindle"), 131 173 ); 132 174 const repoOwnersRaw = await hydrate( 133 175 repoDids, ··· 135 177 fetchRepos, 136 178 ); 137 179 138 - const knotOwners = knotOwnersRaw.map(({ identity, result }) => ({ 139 - identity, 140 - hosts: result ?? [], 141 - })); 180 + const toOwners = (raw: typeof knotOwnersRaw) => 181 + raw.map(({ identity, result }) => ({ identity, hosts: result ?? [] })); 182 + const knotOwners = toOwners(knotOwnersRaw); 183 + const spindleOwners = toOwners(spindleOwnersRaw); 142 184 const repos: RepoInfo[] = repoOwnersRaw.flatMap(({ identity, result }) => 143 185 (result ?? []).map((r) => ({ 144 186 ...r, ··· 150 192 ...new Set( 151 193 [ 152 194 ...knotOwners.flatMap((k) => k.hosts), 195 + ...spindleOwners.flatMap((s) => s.hosts), 153 196 ...repos.map((r) => r.knot), 154 197 ].filter((h) => !isLocalHost(h)), 155 198 ), ··· 170 213 171 214 return groupNetwork({ 172 215 knotOwners, 216 + spindleOwners, 173 217 repos, 174 218 repoOwners: repoDids.length, 175 219 probes, ··· 180 224 fresh: boolean, 181 225 onProgress?: (msg: string) => void, 182 226 ): Promise<Network> { 183 - // key bumped when the snapshot shape changes — v1 lacked `repos` 184 - return cached("network2", fresh ? 0 : 5 * 60 * 1000, () => 227 + // key bumped when the snapshot shape changes — v2 lacked `spindles` 228 + return cached("network3", fresh ? 0 : 5 * 60 * 1000, () => 185 229 assembleNetwork(onProgress), 186 230 ); 187 231 }
+5
src/core/stats.ts
··· 10 10 }; 11 11 types: { managed: number; selfHosted: number }; 12 12 repos: { total: number; owners: number }; 13 + spindles: { total: number; reachable: number }; 13 14 topKnots: { host: string; repoCount: number; version: string }[]; 14 15 histogram: { label: string; count: number }[]; 15 16 }; ··· 45 46 selfHosted: pub.filter((k) => !k.managed).length, 46 47 }, 47 48 repos: { total: network.totalRepos, owners: network.repoOwners }, 49 + spindles: { 50 + total: network.spindles.filter((s) => !s.local).length, 51 + reachable: network.spindles.filter((s) => s.probe?.reachable).length, 52 + }, 48 53 // network.knots is already sorted by repoCount desc (groupNetwork) 49 54 topKnots: pub.slice(0, 10).map((k) => ({ 50 55 host: k.host,
+28 -6
tests/network.test.ts
··· 1 1 import { readFileSync } from "node:fs"; 2 2 import { afterEach, expect, test, vi } from "vitest"; 3 - import { fetchKnotHosts, fetchRepos } from "../src/api/relay.js"; 3 + import { fetchRepos, fetchServiceHosts } from "../src/api/relay.js"; 4 4 import { groupNetwork, type RepoInfo } from "../src/core/network.js"; 5 5 6 - const repo = (name: string, knot: string, handle = "someone"): RepoInfo => ({ 6 + const repo = ( 7 + name: string, 8 + knot: string, 9 + handle = "someone", 10 + spindle?: string, 11 + ): RepoInfo => ({ 7 12 name, 8 13 knot, 14 + spindle, 9 15 owner: { did: `did:plc:${handle}`, handle }, 10 16 }); 11 17 ··· 21 27 hosts: ["knot.krasovs.ky"], 22 28 }, 23 29 ], 30 + spindleOwners: [ 31 + { 32 + identity: { did: "did:plc:c", handle: "tangled.org" }, 33 + hosts: ["spindle.tangled.sh"], 34 + }, 35 + ], 24 36 repos: [ 25 - repo("alpha", "knot1.tangled.sh"), 37 + repo("alpha", "knot1.tangled.sh", "someone", "spindle.tangled.sh"), 26 38 repo("beta", "knot1.tangled.sh"), 27 - repo("gamma", "knot.krasovs.ky"), 39 + repo("gamma", "knot.krasovs.ky", "someone", "spindle.tangled.sh"), 28 40 repo("delta", "ghost.example.com"), 29 41 ], 30 42 repoOwners: 3, ··· 59 71 expect(local).toMatchObject({ local: true, probe: null }); 60 72 expect(network.totalRepos).toBe(4); 61 73 expect(network.repos.map((r) => r.name)).toContain("alpha"); 74 + expect(network.spindles).toHaveLength(1); 75 + expect(network.spindles[0]).toMatchObject({ 76 + host: "spindle.tangled.sh", 77 + repoCount: 2, 78 + owners: [{ did: "did:plc:c", handle: "tangled.org" }], 79 + }); 62 80 }); 63 81 64 82 afterEach(() => vi.unstubAllGlobals()); 65 83 66 - test("fetchKnotHosts handles both lexicon shapes: host field and rkey-as-host", async () => { 84 + test("fetchServiceHosts handles both lexicon shapes: host field and rkey-as-host", async () => { 67 85 const fixture = JSON.parse( 68 86 readFileSync( 69 87 new URL("./fixtures/listRecords.knot.json", import.meta.url), ··· 74 92 "fetch", 75 93 vi.fn(async () => new Response(JSON.stringify(fixture))), 76 94 ); 77 - const hosts = await fetchKnotHosts("https://pds.example", "did:plc:x"); 95 + const hosts = await fetchServiceHosts( 96 + "https://pds.example", 97 + "did:plc:x", 98 + "sh.tangled.knot", 99 + ); 78 100 // old shape uses value.host (normalized), new shape uses the rkey, tid-rkey junk is dropped 79 101 expect(hosts).toEqual(["knot.example.com", "localhost:5557"]); 80 102 });
+24
tests/stats.test.ts
··· 40 40 version: "unknown", 41 41 }), 42 42 ], 43 + spindles: [ 44 + { 45 + host: "spindle.tangled.sh", 46 + owners: [], 47 + repoCount: 9, 48 + local: false, 49 + probe: { reachable: true, xrpcOk: false, latencyMs: 80 }, 50 + }, 51 + { 52 + host: "spindle.dead.dev", 53 + owners: [], 54 + repoCount: 0, 55 + local: false, 56 + probe: { reachable: false, xrpcOk: false }, 57 + }, 58 + { 59 + host: "localhost:6555", 60 + owners: [], 61 + repoCount: 0, 62 + local: true, 63 + probe: null, 64 + }, 65 + ], 43 66 repos: [], 44 67 totalRepos: 169, 45 68 repoOwners: 42, ··· 58 81 }); 59 82 expect(s.types).toEqual({ managed: 1, selfHosted: 4 }); 60 83 expect(s.repos).toEqual({ total: 169, owners: 42 }); 84 + expect(s.spindles).toEqual({ total: 2, reachable: 1 }); 61 85 // local knot excluded everywhere; zero-repo knots excluded from the histogram 62 86 expect(s.histogram).toEqual([ 63 87 { label: "1", count: 1 },
+1
tests/watch.test.ts
··· 36 36 version: "unknown", 37 37 }, 38 38 ], 39 + spindles: [], 39 40 repos: [], 40 41 totalRepos: 43, 41 42 repoOwners: 2,