// The gateway status page — `metrics` as one admin page, alerts first (hyper-social #80; operator
// 2026-10-13: "metrics / errors for both ap / atproto bridges via webui").
//
// Served on ap-bridge's authenticated admin surface (:3401) as an added page (ap-bridge 0.10.17
// adminPages), so it sits behind the same login. Server-rendered HTML, no client JS, in that surface's
// own chrome. Everything that came from outside — handles, error strings, alert details — is escaped.
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
const ago = (ts, now) => {
if (!ts) return 'never'
const s = Math.max(0, Math.round((now - ts) / 1000))
if (s < 90) return s + 's ago'
if (s < 90 * 60) return Math.round(s / 60) + 'm ago'
if (s < 36 * 3600) return Math.round(s / 3600) + 'h ago'
return Math.round(s / 86400) + 'd ago'
}
const mb = (n) => (n == null ? '?' : Math.round(n / 1e6) + ' MB')
const dur = (s) => (s >= 86400 ? Math.floor(s / 86400) + 'd ' : '') + Math.floor((s % 86400) / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'
const ok = (b) => (b ? '<span>ok</span>' : '<span class="warn">NO</span>')
const table = (head, rows) => '<table><tr>' + head.map((h) => '<th>' + esc(h) + '</th>').join('') + '</tr>' + rows.join('') + '</table>'
const row = (cells) => '<tr>' + cells.map((c) => '<td>' + c + '</td>').join('') + '</tr>'
function alertsSection (a, now) {
if (!a) return '<h2>Alerts</h2><p class="warn">The alert watcher is not running — problems are not being watched.</p>'
const open = a.open.length
? table(['', 'problem', 'since', 'fix'], a.open.map((x) => row([
x.severity === 'critical' ? '<strong class="warn">CRITICAL</strong>' : 'warning',
'<strong>' + esc(x.title) + '</strong><br><span class="who">' + esc(x.detail) + '</span>',
'open ' + esc(ago(x.since, now).replace(' ago', '')),
'<code>' + esc(x.fix) + '</code>'
])))
: '<p>No open alerts.</p>'
const resolved = (a.recentlyResolved || []).slice(0, 5)
const res = resolved.length
? '<p class="who">Recently resolved: ' + resolved.map((x) => esc(x.title) + ' (resolved ' + esc(ago(x.resolvedAt, now)) + ')').join(' · ') + '</p>'
: ''
return '<h2>Alerts</h2><p class="who">Last check ' + esc(ago(a.lastCheckAt, now)) + ' (every 15 min; <code>favela-gateway alerts refresh=true</code> checks now).</p>' + open + res
}
export function renderStatusPage (m, { now = Date.now() } = {}) {
const parts = []
parts.push('<p class="who">favela-gateway ' + esc(m.version || '?') + ' · up ' + esc(dur(m.process.uptimeS)) + ' · RSS ' + esc(mb(m.process.rss)) + ' · heap ' + esc(mb(m.process.heapUsed)) + '/' + esc(mb(m.process.heapTotal)) + '</p>')
parts.push(alertsSection(m.alerts, now))
parts.push('<h2>Roles</h2>' + table(['role', 'state'], Object.entries(m.roles).map(([n, r]) => row([esc(n), r.up ? 'up' : '<strong class="warn">DOWN</strong> ' + esc(r.error || '')]))))
if (m.membrane) {
const g = m.membrane.guard || {}
const b = m.bsky
parts.push('<h2>bsky</h2>' + (b
? '<p>Relay sees <code>' + esc(b.hostname) + '</code> as <strong' + (b.relay.status === 'active' ? '' : ' class="warn"') + '>' + esc(b.relay.error ? 'UNREACHABLE ' + b.relay.error : b.relay.status) + '</strong> · relay seq ' + esc(b.relay.seq) + ' · ours ' + esc(m.membrane.relay.emitted) + ' · probed ' + esc(ago(b.at, now)) + '</p>'
: '<p class="warn">No bsky probe yet.</p>') +
'<p class="who">Relay guard: ' + (g.configured ? 'last pass ' + esc(ago(g.lastAt, now)) + (g.last && g.last.relayStatus ? ' (saw ' + esc(g.last.relayStatus) + ')' : '') + ' · self-heals ' + esc(g.heals || 0) + (g.lastHealAt ? ' (last ' + esc(ago(g.lastHealAt, now)) + ')' : '') : '<span class="warn">off</span>') + '</p>')
const byDid = new Map(((b && b.dids) || []).map((d) => [d.did, d]))
parts.push('<h2>Served DIDs</h2>' + table(['account', 'our head', 'bsky relay', 'peers', 'last append', 'PLC', 'AppView'], m.membrane.dids.map((d) => {
const p = byDid.get(d.did) || {}
const relay = p.inSync === true ? 'in sync' : p.relayRev ? '<span class="warn">' + esc(p.relayRev) + '</span>' : '?'
const plc = p.plcPds ? ok(p.plcPds === 'https://' + (b && b.hostname)) : '?'
const av = p.appview ? (p.appview.resolves ? esc(p.appview.postsCount) + ' posts, newest ' + esc((p.appview.newestIndexedAt || '?').slice(0, 10)) : '<span class="warn">does not resolve</span>') : '?'
return row([
'<strong>' + esc(d.handle || p.handle || '') + '</strong><br><code>' + esc(d.did) + '</code>',
'<code>' + esc(d.rev || '?') + '</code>',
relay,
(d.live ? '' : '<span class="warn">') + esc(d.live) + '/' + esc(d.fronts) + ' live' + (d.live ? '' : '</span>') + (d.forkRejected ? '<br>' + esc(d.forkRejected) + ' rival-line rejections' : ''),
esc(ago(d.lastAppendAt, now)),
plc,
av
])
})))
}
if (m.lens) {
parts.push('<h2>Lens (bsky custom feeds)</h2>' + table(['feed', 'posts', 'members', 'newest', 'notes'], m.lens.feeds.map((f) => row([
'<strong>' + esc(f.id) + '</strong>' + (f.feedUri ? '<br><code>' + esc(f.feedUri) + '</code>' : ''),
f.communityDid ? esc(f.posts) + ' posts' : '<span class="warn">waiting for the room</span>',
esc(f.members),
esc(f.newest ? f.newest.slice(0, 10) : '—'),
[f.curation !== 'roster' ? '<span class="warn">curation ' + esc(f.curation) + ' not implemented — serving roster</span>' : '',
f.unfronted.length ? '<span class="warn">not fronted: ' + f.unfronted.map(esc).join(', ') + '</span>' : '',
...f.perMember.filter((p) => p.error).map((p) => '<span class="warn">' + esc(p.did) + ': ' + esc(p.error) + '</span>')].filter(Boolean).join('<br>') || '—'
]))))
}
if (m.fediverse) {
const f = m.fediverse
parts.push('<h2>Fediverse</h2>' + table(['actor', 'followers', 'delivered', ''], f.users.map((u) => row(['<code>@' + esc(u.username) + '</code>', esc(u.followers), esc(u.delivered) + ' delivered', u.primary ? 'primary' : u.held ? 'held' : '<span class="warn">not held</span>']))) +
'<p>Queue: ' + esc(f.queue.pending) + ' pending, ' + (f.queue.dead ? '<strong class="warn">' + esc(f.queue.dead) + ' dead</strong>' : '0 dead') + (f.queue.oldestPendingAt ? ', oldest ' + esc(ago(f.queue.oldestPendingAt, now)) : '') + ' · inbound ' + esc(f.inbound.count) + ', last ' + esc(ago(f.inbound.lastReceivedAt, now)) + ' · registered ' + esc(f.registrations) + '</p>' +
(f.queue.failing.length ? table(['server', 'attempts', 'last error'], f.queue.failing.map((e) => row([esc(e.host), esc(e.attempts), esc(e.lastError || '?')]))) : ''))
}
return { title: 'Gateway status', body: parts.join('') }
}
export default renderStatusPage