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.

feat: add fid repos backed by full repo records

+140 -35
+17 -7
src/api/relay.ts
··· 71 71 // pre-~v1.x records carry a host field with a tid rkey; newer ones drop the 72 72 // field entirely and the rkey IS the hostname (at://did/sh.tangled.knot/knot.example.com) 73 73 const KnotRecord = z.object({ host: z.string().min(1).optional() }); 74 - const RepoRecord = z.object({ knot: z.string().min(1) }); 74 + const RepoRecord = z.object({ 75 + knot: z.string().min(1), 76 + description: z.string().optional(), 77 + createdAt: z.string().optional(), 78 + }); 79 + 80 + export type RepoRef = { 81 + name: string; 82 + knot: string; 83 + description?: string; 84 + createdAt?: string; 85 + }; 75 86 76 87 const host = (h: string) => h.trim().toLowerCase(); 77 88 ··· 89 100 }); 90 101 } 91 102 92 - // knot host of each repo record this owner holds — one entry per repo 93 - export async function fetchRepoKnots( 94 - pds: string, 95 - did: string, 96 - ): Promise<string[]> { 103 + // this owner's repos — rkey is the repo name on tangled, display rkeys 104 + export async function fetchRepos(pds: string, did: string): Promise<RepoRef[]> { 97 105 const records = await listRecords(pds, did, "sh.tangled.repo"); 98 106 return records.flatMap((r) => { 99 107 const parsed = RepoRecord.safeParse(r.value); 100 - return parsed.success ? [host(parsed.data.knot)] : []; 108 + if (!parsed.success) return []; 109 + const name = r.uri.split("/").pop() ?? ""; 110 + return [{ ...parsed.data, knot: host(parsed.data.knot), name }]; 101 111 }); 102 112 }
+7
src/cli.ts
··· 18 18 --mine check every knot you own instead 19 19 --as <handle> who you are (or {"handle":"..."} in ~/.config/fid/config.json) 20 20 fid stats network aggregates: versions, hosting, top knots 21 + fid repos <host> what repos live on a knot 21 22 fid --version print the version 22 23 `; 23 24 ··· 49 50 break; 50 51 case "stats": 51 52 await (await import("./commands/stats.js")).run(args); 53 + break; 54 + case "repos": 55 + await (await import("./commands/repos.js")).run( 56 + args, 57 + args._[1] === undefined ? undefined : String(args._[1]), 58 + ); 52 59 break; 53 60 default: 54 61 console.error(`unknown command: ${command}\n`);
+66
src/commands/repos.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( 8 + flags: { json?: boolean; fresh?: boolean }, 9 + hostArg?: string, 10 + ) { 11 + if (!hostArg) { 12 + console.error("usage: fid repos <host>"); 13 + process.exit(1); 14 + } 15 + const host = hostArg.trim().toLowerCase(); 16 + 17 + if (!flags.json) p.intro(styleText("green", "fiddling with knots")); 18 + const spin = flags.json ? null : p.spinner(); 19 + spin?.start("assembling the network"); 20 + let network: Network; 21 + try { 22 + network = await loadNetwork(!!flags.fresh, (msg) => spin?.message(msg)); 23 + } catch (err) { 24 + spin?.stop("upstream failure", 1); 25 + console.error(err instanceof Error ? err.message : err); 26 + process.exit(2); 27 + } 28 + spin?.stop(`network assembled ${dim(`(${network.generatedAt})`)}`); 29 + 30 + const repos = network.repos 31 + .filter((r) => r.knot === host) 32 + .sort((a, b) => a.name.localeCompare(b.name)); 33 + 34 + if (flags.json) { 35 + console.log(JSON.stringify(repos, null, 2)); 36 + return; 37 + } 38 + 39 + if (repos.length === 0) { 40 + const known = network.knots.some((k) => k.host === host); 41 + if (!known) { 42 + p.outro( 43 + styleText("red", `${host} is not a knot this network knows about`), 44 + ); 45 + process.exit(1); 46 + } 47 + p.outro(dim(`no repos on ${host}`)); 48 + return; 49 + } 50 + 51 + const clip = (s: string, max = 60) => 52 + s.length > max ? `${s.slice(0, max - 1)}…` : s; 53 + const nameW = Math.max(...repos.map((r) => r.name.length), 4); 54 + const ownerW = Math.max(...repos.map((r) => r.owner.handle.length + 1), 5); 55 + p.log.message( 56 + [ 57 + dim(`${"NAME".padEnd(nameW)} ${"OWNER".padEnd(ownerW)} DESCRIPTION`), 58 + ...repos.map( 59 + (r) => 60 + `${r.name.padEnd(nameW)} ${`@${r.owner.handle}`.padEnd(ownerW)} ${dim(clip(r.description ?? ""))}`, 61 + ), 62 + ].join("\n"), 63 + ); 64 + 65 + p.outro(dim(`${repos.length} repos on ${host}`)); 66 + }
+23 -11
src/core/network.ts
··· 1 1 import { resolveDid, type Identity } from "../api/identity.js"; 2 2 import { 3 3 fetchKnotHosts, 4 - fetchRepoKnots, 4 + fetchRepos, 5 5 listDidsByCollection, 6 + type RepoRef, 6 7 } from "../api/relay.js"; 7 8 import { 8 9 isLocalHost, ··· 21 22 probe: KnotProbe | null; // null = not probed (local) 22 23 version: "v1.15+" | "pre-v1.15" | "unknown"; 23 24 }; 25 + 26 + export type RepoInfo = RepoRef & { owner: { did: string; handle: string } }; 24 27 25 28 export type Network = { 26 29 knots: Knot[]; 30 + repos: RepoInfo[]; 27 31 totalRepos: number; 28 32 repoOwners: number; 29 33 knotOwners: number; ··· 33 37 // pure assembly: data in, data out — the io lives in assembleNetwork below 34 38 export function groupNetwork(input: { 35 39 knotOwners: { identity: Identity; hosts: string[] }[]; 36 - repoKnots: string[]; // one host entry per repo record on the network 40 + repos: RepoInfo[]; // every repo record on the network 37 41 repoOwners: number; 38 42 probes: Map<string, KnotProbe>; 39 43 }): Network { ··· 68 72 knot(host).owners.push({ did: identity.did, handle: identity.handle }); 69 73 } 70 74 } 71 - for (const host of input.repoKnots) knot(host).repoCount++; 75 + for (const repo of input.repos) knot(repo.knot).repoCount++; 72 76 73 77 const knots = [...byHost.values()].sort( 74 78 (a, b) => b.repoCount - a.repoCount || a.host.localeCompare(b.host), 75 79 ); 76 80 return { 77 81 knots, 78 - totalRepos: input.repoKnots.length, 82 + repos: input.repos, 83 + totalRepos: input.repos.length, 79 84 repoOwners: input.repoOwners, 80 85 knotOwners: input.knotOwners.length, 81 86 generatedAt: new Date().toISOString(), ··· 127 132 const repoOwnersRaw = await hydrate( 128 133 repoDids, 129 134 "reading repo records", 130 - fetchRepoKnots, 135 + fetchRepos, 131 136 ); 132 137 133 138 const knotOwners = knotOwnersRaw.map(({ identity, result }) => ({ 134 139 identity, 135 140 hosts: result ?? [], 136 141 })); 137 - const repoKnots = repoOwnersRaw.flatMap(({ result }) => result ?? []); 142 + const repos: RepoInfo[] = repoOwnersRaw.flatMap(({ identity, result }) => 143 + (result ?? []).map((r) => ({ 144 + ...r, 145 + owner: { did: identity.did, handle: identity.handle }, 146 + })), 147 + ); 138 148 139 149 const publicHosts = [ 140 150 ...new Set( 141 - [...knotOwners.flatMap((k) => k.hosts), ...repoKnots].filter( 142 - (h) => !isLocalHost(h), 143 - ), 151 + [ 152 + ...knotOwners.flatMap((k) => k.hosts), 153 + ...repos.map((r) => r.knot), 154 + ].filter((h) => !isLocalHost(h)), 144 155 ), 145 156 ]; 146 157 const probeLimit = pLimit(16); ··· 159 170 160 171 return groupNetwork({ 161 172 knotOwners, 162 - repoKnots, 173 + repos, 163 174 repoOwners: repoDids.length, 164 175 probes, 165 176 }); ··· 169 180 fresh: boolean, 170 181 onProgress?: (msg: string) => void, 171 182 ): Promise<Network> { 172 - return cached("network", fresh ? 0 : 5 * 60 * 1000, () => 183 + // key bumped when the snapshot shape changes — v1 lacked `repos` 184 + return cached("network2", fresh ? 0 : 5 * 60 * 1000, () => 173 185 assembleNetwork(onProgress), 174 186 ); 175 187 }
+5 -7
src/core/stats.ts
··· 46 46 }, 47 47 repos: { total: network.totalRepos, owners: network.repoOwners }, 48 48 // network.knots is already sorted by repoCount desc (groupNetwork) 49 - topKnots: pub 50 - .slice(0, 10) 51 - .map((k) => ({ 52 - host: k.host, 53 - repoCount: k.repoCount, 54 - version: k.version, 55 - })), 49 + topKnots: pub.slice(0, 10).map((k) => ({ 50 + host: k.host, 51 + repoCount: k.repoCount, 52 + version: k.version, 53 + })), 56 54 histogram, 57 55 }; 58 56 }
+21 -10
tests/network.test.ts
··· 1 1 import { readFileSync } from "node:fs"; 2 2 import { afterEach, expect, test, vi } from "vitest"; 3 - import { fetchKnotHosts, fetchRepoKnots } from "../src/api/relay.js"; 4 - import { groupNetwork } from "../src/core/network.js"; 3 + import { fetchKnotHosts, fetchRepos } from "../src/api/relay.js"; 4 + import { groupNetwork, type RepoInfo } from "../src/core/network.js"; 5 + 6 + const repo = (name: string, knot: string, handle = "someone"): RepoInfo => ({ 7 + name, 8 + knot, 9 + owner: { did: `did:plc:${handle}`, handle }, 10 + }); 5 11 6 12 test("groupNetwork groups repos by knot host and classifies versions", () => { 7 13 const network = groupNetwork({ ··· 15 21 hosts: ["knot.krasovs.ky"], 16 22 }, 17 23 ], 18 - repoKnots: [ 19 - "knot1.tangled.sh", 20 - "knot1.tangled.sh", 21 - "knot.krasovs.ky", 22 - "ghost.example.com", 24 + repos: [ 25 + repo("alpha", "knot1.tangled.sh"), 26 + repo("beta", "knot1.tangled.sh"), 27 + repo("gamma", "knot.krasovs.ky"), 28 + repo("delta", "ghost.example.com"), 23 29 ], 24 30 repoOwners: 3, 25 31 probes: new Map([ ··· 52 58 expect(ghost).toMatchObject({ owners: [], version: "unknown" }); 53 59 expect(local).toMatchObject({ local: true, probe: null }); 54 60 expect(network.totalRepos).toBe(4); 61 + expect(network.repos.map((r) => r.name)).toContain("alpha"); 55 62 }); 56 63 57 64 afterEach(() => vi.unstubAllGlobals()); ··· 72 79 expect(hosts).toEqual(["knot.example.com", "localhost:5557"]); 73 80 }); 74 81 75 - test("fetchRepoKnots parses real pds output, skips malformed records", async () => { 82 + test("fetchRepos parses real pds output, skips malformed records", async () => { 76 83 const fixture = JSON.parse( 77 84 readFileSync( 78 85 new URL("./fixtures/listRecords.repo.json", import.meta.url), ··· 83 90 "fetch", 84 91 vi.fn(async () => new Response(JSON.stringify(fixture))), 85 92 ); 86 - const knots = await fetchRepoKnots( 93 + const repos = await fetchRepos( 87 94 "https://pds.example", 88 95 "did:plc:qfpnj4og54vl56wngdriaxug", 89 96 ); 90 - expect(knots).toEqual(["knot1.tangled.sh", "localhost:6444"]); 97 + expect(repos.map((r) => [r.name, r.knot])).toEqual([ 98 + ["valley-sans", "knot1.tangled.sh"], 99 + ["test-microvms", "localhost:6444"], 100 + ]); 101 + expect(repos[0].description).toMatch(/valley-sans/); 91 102 });
+1
tests/stats.test.ts
··· 40 40 version: "unknown", 41 41 }), 42 42 ], 43 + repos: [], 43 44 totalRepos: 169, 44 45 repoOwners: 42, 45 46 knotOwners: 5,