| … | … | @@ -17,4 +17,17 @@ Planned: `lens` (the bsky custom feed, computed keylessly from the fronted repos |
| 17 | 17 | - A role that fails to start is reported down; the others keep serving. |
| 18 | 18 | - `favela-gateway admin …` is ap-bridge's operator CLI. |
| 19 | 19 | |
| 20 | ## Operating it | |
| 21 | ||
| 22 | ``` | |
| 23 | favela-gateway metrics # roles, peers, bsky's view of us, fediverse delivery, problems | |
| 24 | favela-gateway metrics refresh=true # probe bsky now (otherwise cached 5 min) | |
| 25 | favela-gateway help # the verbs the running gateway serves | |
| 26 | favela-gateway probe-front did=did:plc:… | |
| 27 | ``` | |
| 28 | ||
| 29 | Any argument talks to the RUNNING gateway's socket and never boots one; the bare command (what pgh runs) | |
| 30 | refuses to start beside a running gateway. When bsky's relay marks us offline while we are caught up, the | |
| 31 | membrane's guard sends one requestCrawl (at most every 15 min) and `metrics` shows it. | |
| 32 | ||
| 20 | 33 | `GATEWAY_ROLES` picks the roles (default `membrane,fediverse`). |
| … | … | @@ -21,7 +21,9 @@ import { loadConfig as loadMembraneConfig } from 'atproto-bridge/pds/lib/config. |
| 21 | 21 | import { createShutdown } from 'atproto-bridge/pds/lib/shutdown.js' |
| 22 | 22 | import { maintainSidecarRegistration } from 'atproto-bridge/pds/lib/sidecar.js' |
| 23 | 23 | import { loadConfig as loadApConfig, assertUsable } from 'ap-bridge/lib/config.js' |
| 24 | import net from 'bare-net' | |
| 24 | 25 | import { startGateway } from '../lib/gateway.js' |
| 26 | import { runCli } from '../lib/cli.js' | |
| 25 | 27 | |
| 26 | 28 | const log = (o) => console.log(JSON.stringify({ t: new Date().toISOString(), ...o })) |
| 27 | 29 | |
| … | … | @@ -38,8 +40,34 @@ if (process.argv[2] === 'admin') { |
| 38 | 40 | process.exit(await runAdminCli(process.argv.slice(3), { uds: path.join(dir, 'admin.sock') })) |
| 39 | 41 | } |
| 40 | 42 | |
| 43 | // ⛔ ANY OTHER ARGUMENT MEANS CLIENT, and a client never boots. pgh's supervisor starts the daemon with no | |
| 44 | // arguments; everything an operator types (`favela-gateway metrics`, `… probe-front did=…`, `… help`) goes | |
| 45 | // to the RUNNING gateway's control socket instead. An audit agent once ran `favela-peer --help` and booted | |
| 46 | // a second daemon on a live store — that cannot happen here. | |
| 47 | const sockPath = process.env.GATEWAY_SOCK || path.join(os.homedir(), '.config', 'favela-gateway', 'svc.sock') | |
| 48 | if (process.argv.length > 2) { | |
| 49 | process.exit(await runCli(process.argv.slice(2), { sock: sockPath })) | |
| 50 | } | |
| 51 | ||
| 52 | // ⛔ AND THE DAEMON REFUSES TO START BESIDE A RUNNING ONE. A bare `favela-gateway` typed by hand would | |
| 53 | // otherwise open the same stores and swarm keypairs as the live gateway — two writers on one identity. | |
| 54 | function socketIsLive (p) { | |
| 55 | return new Promise((resolve) => { | |
| 56 | if (!fs.existsSync(p)) return resolve(false) | |
| 57 | const probe = net.connect(p) | |
| 58 | const done = (v) => { try { probe.destroy() } catch {}; resolve(v) } | |
| 59 | probe.on('connect', () => done(true)) | |
| 60 | probe.on('error', () => done(false)) | |
| 61 | setTimeout(() => done(false), 500) | |
| 62 | }) | |
| 63 | } | |
| 64 | if (await socketIsLive(sockPath)) { | |
| 65 | console.error(`favela-gateway: a gateway is already running on ${sockPath} — not starting a second one. Try: favela-gateway metrics`) | |
| 66 | process.exit(1) | |
| 67 | } | |
| 68 | ||
| 41 | 69 | const roles = String(process.env.GATEWAY_ROLES || 'membrane,fediverse').split(',').map((s) => s.trim()).filter(Boolean) |
| 42 | const sock = process.env.GATEWAY_SOCK || path.join(os.homedir(), '.config', 'favela-gateway', 'svc.sock') | |
| 70 | const sock = sockPath | |
| 43 | 71 | const name = process.env.PGH_APP_NAME || 'favela-gateway' |
| 44 | 72 | const version = pkgVersion() |
| 45 | 73 |
| … | … | @@ -0,0 +1,140 @@ |
| 1 | // favela-gateway CLI — `favela-gateway <verb> [key=value …]` against the RUNNING gateway's control socket. | |
| 2 | // | |
| 3 | // No pgh in between: this is the gateway's own operator surface (hyper-social #80). It only ever talks to | |
| 4 | // a running gateway; it never opens a store, binds a port or boots anything (see bin/favela-gateway.js). | |
| 5 | ||
| 6 | export const USAGE = `favela-gateway — the favela gateway (bsky membrane + fediverse bridge) | |
| 7 | ||
| 8 | favela-gateway run the gateway (pgh does this; refuses if one is already running) | |
| 9 | favela-gateway metrics [--json] health: roles, peers, bsky's view of us, fediverse delivery | |
| 10 | favela-gateway metrics refresh=true probe bsky now instead of using the 5-minute cache | |
| 11 | favela-gateway status roles and ports | |
| 12 | favela-gateway help the verbs the running gateway serves | |
| 13 | favela-gateway <verb> [key=value …] any verb, e.g. probe-front did=did:plc:…, invite, notifications | |
| 14 | favela-gateway admin … the fediverse role's operator CLI (ap-bridge admin)` | |
| 15 | ||
| 16 | // key=value → typed params. Numbers, booleans, null and JSON objects/arrays are parsed; anything else is | |
| 17 | // a string. A bare word is refused rather than guessed at. | |
| 18 | export function parseParams (args) { | |
| 19 | const out = {} | |
| 20 | for (const a of args) { | |
| 21 | const i = a.indexOf('=') | |
| 22 | if (i <= 0) throw new Error(`expected key=value, got "${a}"`) | |
| 23 | const k = a.slice(0, i) | |
| 24 | const v = a.slice(i + 1) | |
| 25 | let val = v | |
| 26 | if (/^(true|false|null)$/.test(v) || /^-?\d+(\.\d+)?$/.test(v) || /^[[{]/.test(v)) { | |
| 27 | try { val = JSON.parse(v) } catch { val = v } | |
| 28 | } | |
| 29 | out[k] = val | |
| 30 | } | |
| 31 | return out | |
| 32 | } | |
| 33 | ||
| 34 | async function defaultConnect (sock) { | |
| 35 | const { Client } = await import('hyper-svc') | |
| 36 | return Client.connectUds(sock) | |
| 37 | } | |
| 38 | ||
| 39 | const ago = (ts, now) => { | |
| 40 | if (!ts) return 'never' | |
| 41 | const s = Math.max(0, Math.round((now - ts) / 1000)) | |
| 42 | if (s < 90) return s + 's ago' | |
| 43 | if (s < 90 * 60) return Math.round(s / 60) + 'm ago' | |
| 44 | if (s < 36 * 3600) return Math.round(s / 3600) + 'h ago' | |
| 45 | return Math.round(s / 86400) + 'd ago' | |
| 46 | } | |
| 47 | const mb = (n) => (n == null ? '?' : Math.round(n / 1e6) + ' MB') | |
| 48 | const dur = (s) => (s >= 3600 ? Math.floor(s / 3600) + 'h' + String(Math.floor((s % 3600) / 60)).padStart(2, '0') + 'm' : Math.floor(s / 60) + 'm' + (s % 60) + 's') | |
| 49 | ||
| 50 | /** The `metrics` answer as an operator reads it: what is up, what bsky sees, what is wrong. */ | |
| 51 | export function renderMetrics (m, { now = Date.now() } = {}) { | |
| 52 | const L = [] | |
| 53 | L.push(`favela-gateway ${m.version || '?'} up ${dur(m.process.uptimeS)} rss ${mb(m.process.rss)} heap ${mb(m.process.heapUsed)}/${mb(m.process.heapTotal)}`) | |
| 54 | L.push('') | |
| 55 | L.push('roles') | |
| 56 | for (const [name, r] of Object.entries(m.roles)) L.push(` ${name.padEnd(10)} ${r.up ? 'up' : 'DOWN ' + (r.error || '')}`) | |
| 57 | ||
| 58 | if (m.membrane) { | |
| 59 | const g = m.membrane.guard || {} | |
| 60 | L.push('') | |
| 61 | L.push(`bsky (host ${m.bsky ? m.bsky.hostname : '?'}, probed ${m.bsky ? ago(m.bsky.at, now) : 'never'})`) | |
| 62 | if (m.bsky) { | |
| 63 | const r = m.bsky.relay | |
| 64 | L.push(` relay ${r.error ? 'UNREACHABLE ' + r.error : (r.status || '?')} relay seq ${r.seq == null ? '?' : r.seq} ours ${m.membrane.relay.emitted}`) | |
| 65 | } | |
| 66 | L.push(` guard ${g.configured ? 'last pass ' + ago(g.lastAt, now) + (g.last && g.last.relayStatus ? ' (saw ' + g.last.relayStatus + ')' : '') + ', heals ' + (g.heals || 0) + (g.lastHealAt ? ' (last ' + ago(g.lastHealAt, now) + ')' : '') : 'OFF (no crawl relay configured)'}`) | |
| 67 | L.push('') | |
| 68 | L.push('served DIDs') | |
| 69 | const byDid = new Map(((m.bsky && m.bsky.dids) || []).map((d) => [d.did, d])) | |
| 70 | for (const d of m.membrane.dids) { | |
| 71 | const b = byDid.get(d.did) || {} | |
| 72 | const sync = b.inSync === true ? 'in sync' : b.inSync === false ? 'relay ' + b.relayRev : 'relay ?' | |
| 73 | const av = b.appview ? (b.appview.resolves ? `appview ok (${b.appview.postsCount == null ? '?' : b.appview.postsCount} posts, newest ${b.appview.newestIndexedAt ? b.appview.newestIndexedAt.slice(0, 10) : '?'})` : 'APPVIEW DOES NOT RESOLVE') : 'appview ?' | |
| 74 | L.push(` ${(d.handle || b.handle || d.did).padEnd(24)} head ${d.rev || '?'} ${sync}`) | |
| 75 | L.push(` ${''.padEnd(24)} peers ${d.live}/${d.fronts} live, last append ${ago(d.lastAppendAt, now)}${d.forkRejected ? ', ' + d.forkRejected + ' rival-line rejections' : ''}${d.denied ? ', ' + d.denied + ' denied' : ''}`) | |
| 76 | L.push(` ${''.padEnd(24)} ${av}`) | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | if (m.fediverse) { | |
| 81 | const f = m.fediverse | |
| 82 | L.push('') | |
| 83 | L.push('fediverse') | |
| 84 | for (const u of f.users) L.push(` ${('@' + u.username).padEnd(24)} ${u.followers} follower${u.followers === 1 ? '' : 's'}, ${u.delivered} delivered${u.primary ? ' (primary)' : u.held ? '' : ', NOT HELD'}`) | |
| 85 | L.push(` queue ${f.queue.pending} pending, ${f.queue.dead} dead${f.queue.oldestPendingAt ? ', oldest ' + ago(f.queue.oldestPendingAt, now) : ''}`) | |
| 86 | for (const e of f.queue.failing) L.push(` ${e.host}: ${e.attempts} attempts — ${e.lastError || '?'}`) | |
| 87 | L.push(` inbound ${f.inbound.count}, last ${ago(f.inbound.lastReceivedAt, now)}`) | |
| 88 | L.push(` registered ${f.registrations}`) | |
| 89 | } | |
| 90 | ||
| 91 | const problems = (m.bsky && m.bsky.problems) || [] | |
| 92 | L.push('') | |
| 93 | if (problems.length) { | |
| 94 | L.push('PROBLEMS') | |
| 95 | for (const p of problems) L.push(' - ' + p) | |
| 96 | } else { | |
| 97 | L.push('no problems reported') | |
| 98 | } | |
| 99 | return L.join('\n') | |
| 100 | } | |
| 101 | ||
| 102 | /** Returns the process exit code. */ | |
| 103 | export async function runCli (argv, { sock, out = (s) => console.log(s), err = (s) => console.error(s), connect = defaultConnect } = {}) { | |
| 104 | const [verb, ...rest] = argv | |
| 105 | if (!verb || verb === '--help' || verb === '-h') { out(USAGE); return 0 } | |
| 106 | ||
| 107 | const json = rest.includes('--json') | |
| 108 | let params | |
| 109 | try { params = parseParams(rest.filter((a) => a !== '--json')) } catch (e) { err(e.message); err(USAGE); return 2 } | |
| 110 | ||
| 111 | let c | |
| 112 | try { c = await connect(sock) } catch (e) { | |
| 113 | if (e && (e.code === 'ENOENT' || e.code === 'ECONNREFUSED' || /ENOENT|ECONNREFUSED/.test(e.message))) { | |
| 114 | err(`favela-gateway is not running (no control socket at ${sock}). Start it with: pgh app start favela-gateway`) | |
| 115 | } else { | |
| 116 | err('could not reach the gateway: ' + ((e && e.message) || e)) | |
| 117 | } | |
| 118 | return 1 | |
| 119 | } | |
| 120 | ||
| 121 | try { | |
| 122 | if (verb === 'help') { | |
| 123 | const r = await c.call('verbs', {}) | |
| 124 | out(USAGE) | |
| 125 | out('\nverbs served by this gateway: ' + r.verbs.join(', ')) | |
| 126 | return 0 | |
| 127 | } | |
| 128 | const r = await c.call(verb, params) | |
| 129 | if (verb === 'metrics' && !json) out(renderMetrics(r)) | |
| 130 | else out(JSON.stringify(r, null, 2)) | |
| 131 | return r && r.ok === false ? 1 : 0 | |
| 132 | } catch (e) { | |
| 133 | err((e && e.message) || String(e)) | |
| 134 | return 1 | |
| 135 | } finally { | |
| 136 | try { await c.close() } catch {} | |
| 137 | } | |
| 138 | } | |
| 139 | ||
| 140 | export default runCli |
| … | … | @@ -123,6 +123,9 @@ export async function startGateway ({ roles, log = defaultLog, bootstrap = null, |
| 123 | 123 | } |
| 124 | 124 | } |
| 125 | 125 | |
| 126 | // The verbs this gateway serves, for `favela-gateway help`. release-ready is the process owner's. | |
| 127 | handlers.verbs = async () => ({ verbs: [...Object.keys(handlers), 'release-ready'].sort() }) | |
| 128 | ||
| 126 | 129 | return { roles: running, down, handlers, status, metrics, steps } |
| 127 | 130 | } |
| 128 | 131 |
| … | … | @@ -1,16 +1,17 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-gateway", |
| 3 | "version": "0.1.0", | |
| 3 | "version": "0.2.0", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "favela-gateway", |
| 9 | "version": "0.1.0", | |
| 9 | "version": "0.2.0", | |
| 10 | 10 | "dependencies": { |
| 11 | 11 | "ap-bridge": "file:../ap-bridge", |
| 12 | 12 | "atproto-bridge": "file:../atproto-bridge", |
| 13 | 13 | "bare-fs": "^4.8.1", |
| 14 | "bare-net": "^2.3.3", | |
| 14 | 15 | "bare-os": "^3.9.3", |
| 15 | 16 | "bare-path": "^3.1.2", |
| 16 | 17 | "bare-process": "^4.5.1", |
| … | … | @@ -26,7 +27,7 @@ |
| 26 | 27 | } |
| 27 | 28 | }, |
| 28 | 29 | "../ap-bridge": { |
| 29 | "version": "0.10.15", | |
| 30 | "version": "0.10.16", | |
| 30 | 31 | "license": "MIT", |
| 31 | 32 | "dependencies": { |
| 32 | 33 | "atproto-did-peer": "file:../atproto-did-peer", |
| … | … | @@ -61,7 +62,7 @@ |
| 61 | 62 | } |
| 62 | 63 | }, |
| 63 | 64 | "../atproto-bridge": { |
| 64 | "version": "0.9.52", | |
| 65 | "version": "0.9.54", | |
| 65 | 66 | "hasInstallScript": true, |
| 66 | 67 | "dependencies": { |
| 67 | 68 | "@ipld/car": "^5.4.0", |
| … | … | @@ -318,7 +319,6 @@ |
| 318 | 319 | "version": "2.2.0", |
| 319 | 320 | "resolved": "https://registry.npmjs.org/bare-dns/-/bare-dns-2.2.0.tgz", |
| 320 | 321 | "integrity": "sha512-iEqjj122eXdbDP5gXwk/NG/XVI5xoGjQA24UlGBPafUeMIGesxUZ5rxDvjNXPBHDyTkO+H3IsV5izZ8T5IGAXA==", |
| 321 | "dev": true, | |
| 322 | 322 | "license": "Apache-2.0", |
| 323 | 323 | "engines": { |
| 324 | 324 | "bare": ">=1.7.0" |
| … | … | @@ -564,7 +564,6 @@ |
| 564 | 564 | "version": "2.3.3", |
| 565 | 565 | "resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.3.tgz", |
| 566 | 566 | "integrity": "sha512-q1noXFJKn+eNi6vXYv2Y+5FTnn+o7qpli4m2lUZTdK8cBjVs2zGaykuzTUIa29v70eOsQI9OAIyYwN3ODHtFaQ==", |
| 567 | "dev": true, | |
| 568 | 567 | "license": "Apache-2.0", |
| 569 | 568 | "dependencies": { |
| 570 | 569 | "bare-events": "^2.2.2", |
| … | … | @@ -729,7 +728,6 @@ |
| 729 | 728 | "version": "2.6.1", |
| 730 | 729 | "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.6.1.tgz", |
| 731 | 730 | "integrity": "sha512-8f8UFP7O27gIGaVWJPysQ2am8LSD6YCuVSbnTbOrQjUD0RkWEZexvi2Q66P99MLxSephlyjoU0mLxWmozRJMnA==", |
| 732 | "dev": true, | |
| 733 | 731 | "license": "Apache-2.0", |
| 734 | 732 | "dependencies": { |
| 735 | 733 | "bare-dns": "^2.0.4", |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-gateway", |
| 3 | "version": "0.1.0", | |
| 3 | "version": "0.2.0", | |
| 4 | 4 | "description": "The favela gateway: the bsky membrane (atproto-bridge) and the fediverse bridge (ap-bridge) in one process, keyless towards atproto. Later roles: the bsky lens and a blind seeder (hyper-social #80).", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "lib/gateway.js", |
| … | … | @@ -14,6 +14,7 @@ |
| 14 | 14 | "ap-bridge": "file:../ap-bridge", |
| 15 | 15 | "atproto-bridge": "file:../atproto-bridge", |
| 16 | 16 | "bare-fs": "^4.8.1", |
| 17 | "bare-net": "^2.3.3", | |
| 17 | 18 | "bare-os": "^3.9.3", |
| 18 | 19 | "bare-path": "^3.1.2", |
| 19 | 20 | "bare-process": "^4.5.1", |
| … | … | @@ -0,0 +1,91 @@ |
| 1 | // The gateway's own CLI: `favela-gateway <verb> [key=value …]` talks to the RUNNING gateway's control | |
| 2 | // socket — no pgh in between (operator request, 2026-10-13). | |
| 3 | // | |
| 4 | // ⛔ A CLIENT NEVER BOOTS. An earlier audit agent ran `favela-peer --help` and booted a second daemon on a | |
| 5 | // live store. Here any argument means "client"; only the bare command (what pgh's supervisor runs) is | |
| 6 | // the daemon — and that refuses when a gateway already answers (bin/favela-gateway.js). | |
| 7 | ||
| 8 | import test from 'brittle' | |
| 9 | import { runCli, parseParams, renderMetrics } from '../lib/cli.js' | |
| 10 | ||
| 11 | function capture () { | |
| 12 | const lines = [] | |
| 13 | return { lines, write: (s) => lines.push(String(s)), text: () => lines.join('\n') } | |
| 14 | } | |
| 15 | ||
| 16 | // A fake connection answering verbs from a table, recording what was asked. | |
| 17 | function fakeConnect (answers, calls = []) { | |
| 18 | return async () => ({ | |
| 19 | call: async (verb, params) => { | |
| 20 | calls.push({ verb, params }) | |
| 21 | if (!(verb in answers)) throw new Error('unknown method: ' + verb) | |
| 22 | const a = answers[verb] | |
| 23 | return typeof a === 'function' ? a(params) : a | |
| 24 | }, | |
| 25 | close: async () => {} | |
| 26 | }) | |
| 27 | } | |
| 28 | ||
| 29 | test('parseParams: key=value pairs, with numbers, booleans and JSON values typed', (t) => { | |
| 30 | t.alike(parseParams(['did=did:plc:x', 'limit=5', 'force=true', 'raw={"a":1}']), { did: 'did:plc:x', limit: 5, force: true, raw: { a: 1 } }) | |
| 31 | t.alike(parseParams([]), {}) | |
| 32 | t.exception(() => parseParams(['nonsense']), /key=value/, 'a bare word is refused, not guessed at') | |
| 33 | }) | |
| 34 | ||
| 35 | test('★ a verb is called on the socket and its reply printed', async (t) => { | |
| 36 | const calls = [] | |
| 37 | const out = capture() | |
| 38 | const code = await runCli(['probe-front', 'did=did:plc:x'], { sock: '/x', out: out.write, err: out.write, connect: fakeConnect({ 'probe-front': { ok: true, did: 'did:plc:x' } }, calls) }) | |
| 39 | t.is(code, 0) | |
| 40 | t.alike(calls, [{ verb: 'probe-front', params: { did: 'did:plc:x' } }]) | |
| 41 | t.ok(out.text().includes('"did": "did:plc:x"'), 'the reply, as readable JSON') | |
| 42 | }) | |
| 43 | ||
| 44 | test('help lists the verbs the RUNNING gateway serves', async (t) => { | |
| 45 | const out = capture() | |
| 46 | await runCli(['help'], { sock: '/x', out: out.write, err: out.write, connect: fakeConnect({ verbs: { verbs: ['status', 'metrics', 'invite'] } }) }) | |
| 47 | t.ok(out.text().includes('metrics') && out.text().includes('invite')) | |
| 48 | }) | |
| 49 | ||
| 50 | test('⛔ a gateway that is not running says so, and how to start it — exit 1, nothing booted', async (t) => { | |
| 51 | const out = capture() | |
| 52 | const code = await runCli(['status'], { sock: '/x', out: out.write, err: out.write, connect: async () => { const e = new Error('connect ENOENT /x'); e.code = 'ENOENT'; throw e } }) | |
| 53 | t.is(code, 1) | |
| 54 | t.ok(/not running/.test(out.text()) && /pgh app start favela-gateway/.test(out.text())) | |
| 55 | }) | |
| 56 | ||
| 57 | test('a verb the gateway does not serve fails with the gateway\'s own error', async (t) => { | |
| 58 | const out = capture() | |
| 59 | const code = await runCli(['nope'], { sock: '/x', out: out.write, err: out.write, connect: fakeConnect({}) }) | |
| 60 | t.is(code, 1) | |
| 61 | t.ok(/unknown method: nope/.test(out.text())) | |
| 62 | }) | |
| 63 | ||
| 64 | const sampleMetrics = { | |
| 65 | version: '0.2.0', | |
| 66 | process: { uptimeS: 3725, rss: 196e6, heapUsed: 60e6, heapTotal: 80e6 }, | |
| 67 | roles: { membrane: { up: true }, fediverse: { up: false, error: 'fediverse config: AP_HOST is required' } }, | |
| 68 | membrane: { | |
| 69 | dids: [{ did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', handle: 'p2p.7rnx.net', rev: '3mrhxnkbhv622', fronts: 2, live: 1, lastAppendAt: 1e12, forkRejected: 0, denied: 0 }], | |
| 70 | relay: { emitted: 40267, seqBase: 35200 }, | |
| 71 | guard: { configured: true, lastAt: 1e12, last: { relayStatus: 'active' }, heals: 1, lastHealAt: 1e12 } | |
| 72 | }, | |
| 73 | fediverse: null, | |
| 74 | bsky: { at: 1e12, hostname: 'pds.7rnx.net', relay: { status: 'active', seq: 40267 }, dids: [{ did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', relayRev: '3mrhxnkbhv622', inSync: true, plcPds: 'https://pds.7rnx.net', handle: 'p2p.7rnx.net', appview: { resolves: true, postsCount: 201, newestIndexedAt: '2026-09-15T14:05:39Z' } }], problems: ['did:plc:x: the relay is behind'] } | |
| 75 | } | |
| 76 | ||
| 77 | test('★ metrics renders as a readable summary: roles, bsky, peers, problems', (t) => { | |
| 78 | const s = renderMetrics(sampleMetrics, { now: 1e12 + 120e3 }) | |
| 79 | t.ok(/fediverse +DOWN.*AP_HOST is required/.test(s), 'a down role, with why') | |
| 80 | t.ok(/relay +active/.test(s) && s.includes('40267'), 'bsky relay status and seq') | |
| 81 | t.ok(s.includes('p2p.7rnx.net') && s.includes('in sync'), 'per DID: handle and relay sync') | |
| 82 | t.ok(/1\/2 live/.test(s), 'peers: live of cached fronts') | |
| 83 | t.ok(/2m ago/.test(s), 'last append as an age, not a timestamp') | |
| 84 | t.ok(/problems/i.test(s) && s.includes('the relay is behind'), 'the named problems, last') | |
| 85 | }) | |
| 86 | ||
| 87 | test('metrics --json prints the raw answer', async (t) => { | |
| 88 | const out = capture() | |
| 89 | await runCli(['metrics', '--json'], { sock: '/x', out: out.write, err: out.write, connect: fakeConnect({ metrics: sampleMetrics }) }) | |
| 90 | t.is(JSON.parse(out.text()).version, '0.2.0') | |
| 91 | }) |