OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / 856f55e

0.0.59: /status — where every dead-end warning now leads
"1 record could not be read — the log arrived, the bytes did not" is accurate and useless alone: it names a state without naming WHOSE feed or what fixes it, so the only way to find out was to ask someone with a shell. That warning now links here. ⛔ THREE FEED STATES THAT LOOK IDENTICAL FROM A TIMELINE and are fixed by completely different things, so the page names them in the words the warnings use: followed-not-held (the feed has not replicated), held-but-unread (the log arrived and some bytes have not — this is what an unreadable record IS), and held. Plus the node's own identity and link state, the namespace and its claim count (a zero there means you are probably in the wrong namespace — an invite token and a namespace key are both 64 hex characters), and the update state including the daemon's last OTA line VERBATIM. ⛔ IT REPORTS STATE, NEVER A VERDICT. A page that prints a conclusion cannot be used to discover the conclusion is wrong — which is precisely how a staged update sat invisible behind "Up to date" for hours today, and how three OTA failures with three different causes all read as "nothing happened". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
856f55e929f487b1169946817bf15a1b56d56c09
by plankatron <fleeky@gmail.com> · 2026-09-10T17:51:43.000Z

Changed files (4) +131 -4

lib/pages.js +81 -2
……@@ -336,6 +336,10 @@ input[type=file]::file-selector-button:hover{border-color:var(--dim);background:
336336/* The update notice. Deliberately loud — it is the one thing on the page that expires. */
337337.update-bar{background:var(--warn-bg,#8a5a00);text-align:center;padding:.5rem}
338338.update-bar a{color:#fff;text-decoration:none;font-weight:600}
339/* Inline state words in the status table — loud enough to find, quiet enough to read past. */
340.warn-inline{color:var(--warn,#c88)}
341table{border-collapse:collapse;width:100%}
342td,th{text-align:left;padding:.25rem .5rem;vertical-align:top}
339343.ns-list{list-style:none;padding:0}
340344.ns-list li{margin:.35rem 0}
341345form.inline{display:inline}
……@@ -589,7 +593,7 @@ function oneLine (text, max) {
589593const NAV_ITEMS = [['/', 'timeline'], ['/rooms', 'rooms'], ['/search', 'search'], ['/discover', 'discover'],
590594 ['/following', 'following'],
591595 ['/notifications', 'notifications'], ['/mentions', 'mentions'], ['/knocks', 'knocks'],
592 ['/graph', 'graph'], ['/explore', 'explore'],
596 ['/graph', 'graph'], ['/explore', 'explore'], ['/status', 'status'],
593597 ['/settings', 'settings'], ['/logout', 'sign out']]
594598const NAV = (title) => '<nav>' + NAV_ITEMS.map(([href, label]) =>
595599 '<a href="' + href + '"' + (label === title || (title === 'favela' && href === '/') ? ' aria-current="page"' : '') +
……@@ -733,7 +737,8 @@ export function timeline (rows, { unreadable = 0, profiles = null, files = null,
733737 const warn = unreadable
734738 // ⛔ Never silent. "nothing here" and "nothing here, and N records I hold pointers for but
735739 // cannot read" are different answers, and the daemon distinguishes them — so the page must too.
736 ? '<p class="warn">' + unreadable + ' record(s) could not be read — the log arrived, the bytes did not.</p>'
740 ? '<p class="warn">' + unreadable + ' record(s) could not be read — the log arrived, the bytes did not. ' +
741 '<a href="/status">what this means</a></p>'
737742 : ''
738743 /**
739744 * ⛔ TWO KINDS OF EMPTY, and they are not the same problem.
……@@ -1768,3 +1773,77 @@ export function discover (handles, following = [], profiles = {}, ns = null) {
17681773 'their posts in your timeline — joining a namespace on its own shows you nothing.</p>' +
17691774 '<ul class="ns-list">' + rows.map(row).join('') + '</ul>')
17701775}
1776
1777// ── status ───────────────────────────────────────────────────────────────────────────────────────
1778//
1779// ⛔ THE PAGE EVERY DEAD-END WARNING SHOULD POINT AT. "1 record could not be read — the log arrived,
1780// the bytes did not" is accurate and useless on its own: it does not say WHOSE feed, or what would
1781// fix it, so the only way to find out was to ask someone with a shell. The same was true of every
1782// OTA failure until settings started printing the daemon's own last line — and that instrument
1783// found a real bug on its first run, after hours of guessing at it from the outside.
1784//
1785// ⚠ It reports STATE, never a verdict. A page that prints a conclusion cannot be used to discover
1786// the conclusion is wrong, which is exactly how a staged update sat invisible behind "Up to date".
1787export function status ({ me = null, ns = null, following = [], missing = [], profiles = {}, update = null } = {}) {
1788 const short = (k) => esc(String(k || '').slice(0, 12))
1789 const row = (k, v, why) => '<tr><td class="who">' + esc(k) + '</td><td>' + v +
1790 (why ? '<br><span class="who">' + esc(why) + '</span>' : '') + '</td></tr>'
1791
1792 const held = (following || []).map((f) => (typeof f === 'string' ? f : f && f.feed)).filter(Boolean)
1793 const gone = new Set((missing || []).map((f) => (typeof f === 'string' ? f : f && f.feed)).filter(Boolean))
1794
1795 const feedRows = held.length
1796 ? held.map((f) => {
1797 const p = (profiles || {})[f] || {}
1798 const name = p.name ? esc(p.name) : '<span class="who">no profile yet</span>'
1799 // ⛔ THE TWO STATES A PERSON ACTUALLY HITS, named in the terms the warnings use.
1800 const state = gone.has(f)
1801 ? '<span class="warn-inline">followed, not held</span>'
1802 : (p.name ? 'held' : '<span class="warn-inline">held, profile not read</span>')
1803 return '<tr><td><a href="/u/' + esc(f) + '"><code>' + short(f) + '</code></a></td>' +
1804 '<td>' + name + '</td><td>' + state + '</td></tr>'
1805 }).join('')
1806 : '<tr><td colspan="3" class="who">You follow nobody yet — try <a href="/discover">discover</a>.</td></tr>'
1807
1808 return layout('status',
1809 '<h1>Status</h1>' +
1810 '<p class="who">What this node holds, and what it is still waiting for. Every warning elsewhere ' +
1811 'in favela means one of the states below.</p>' +
1812
1813 '<h2>This node</h2><table>' +
1814 row('feed', me && me.feed ? '<code>' + short(me.feed) + '</code>' : '<span class="who">unknown</span>') +
1815 row('identity', me && me.linked ? 'linked' : '<span class="warn-inline">not linked</span>',
1816 me && me.linked ? '' : 'This node cannot claim a name or sign until an identity is minted.') +
1817 '</table>' +
1818
1819 '<h2>Namespace</h2><table>' +
1820 row('key', ns && ns.key ? '<code>' + short(ns.key) + '</code>' : '<span class="who">none</span>',
1821 'Handles resolve only within the namespace you are in.') +
1822 row('claims', ns ? String(ns.claims == null ? '?' : ns.claims) : '?',
1823 ns && ns.claims === 0 ? 'Nobody has claimed a handle here — you may be in the wrong namespace.' : '') +
1824 row('carrying', ns && ns.carrying ? 'yes' : 'no',
1825 ns && ns.carrying ? '' : 'This node reads the namespace but does not carry its log.') +
1826 '</table>' +
1827
1828 '<h2>Feeds</h2>' +
1829 // ⛔ THE ANSWER TO "the log arrived, the bytes did not". A feed can be followed without being
1830 // held, and held without its records being readable — three states that look identical from a
1831 // timeline, and are fixed by completely different things.
1832 '<p class="who"><b>followed, not held</b> — the feed is in your follow list but has not ' +
1833 'replicated. It arrives when a peer holding it is reachable.<br>' +
1834 '<b>held, profile not read</b> — the log arrived and some bytes have not. That is what an ' +
1835 'unreadable record means; it resolves as the content replicates.</p>' +
1836 '<table><tr><th>feed</th><th>who</th><th>state</th></tr>' + feedRows + '</table>' +
1837
1838 (update
1839 ? '<h2>Updates</h2><table>' +
1840 row('version', esc(update.version || 'unstamped')) +
1841 row('running', short(update.running) || '<span class="who">the app\'s own bundle</span>') +
1842 row('staged', short(update.staged) || '<span class="who">nothing staged</span>') +
1843 row('last checked', esc(update.checkedAt || 'never')) +
1844 // ⛔ The daemon's own words, verbatim. It is the only line that distinguishes "cannot reach
1845 // the swarm" from "nothing published" from "downloading" — three different problems.
1846 row('last message', update.note ? '<code>' + esc(update.note) + '</code>' : '<span class="who">none</span>') +
1847 '</table>'
1848 : ''))
1849}
lib/router.js +15 -0
……@@ -623,6 +623,21 @@ export async function route (req, res, ctx) {
623623 // which is right there — an empty query is not a request for the whole world. A namespace is the
624624 // one scope where "show me everyone" IS the question, so this asks with none and gets the full
625625 // claim list. Without it, joining a namespace and then finding nobody was the reported experience.
626 // ⛔ WHERE EVERY DEAD-END WARNING SHOULD LEAD. "the log arrived, the bytes did not" says nothing
627 // about whose feed or what fixes it, and until now the only way to find out was a shell.
628 if (req.method === 'GET' && path === '/status') {
629 const [me, ns, following, missing, profiles] = await Promise.all([
630 client.call('whoami').catch(() => null),
631 client.call('namespace').catch(() => null),
632 client.call('following').catch(() => []),
633 client.call('missing').catch(() => []),
634 client.call('profiles').catch(() => ({}))
635 ])
636 let update = null
637 try { update = ctx.appUpdate ? await ctx.appUpdate() : null } catch { /* no shell */ }
638 return send(res, pages.status({ me, ns, following, missing, profiles, update }))
639 }
640
626641 if (req.method === 'GET' && path === '/discover') {
627642 const named = await client.call('search-names', {}).catch(() => ({ handles: [] }))
628643 const ns = await client.call('namespace').catch(() => null)
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.58",
3 "version": "0.0.59",
44 "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.",
55 "type": "module",
66 "main": "index.js",
test/pages.test.js +34 -1
……@@ -2,7 +2,7 @@
22
33import test from 'brittle'
44import fs from '#node:fs'
5import { timeline, search, following, knocks, post, esc, layout, who, person, settings, handleSettings, namespaceSettings, composer, mentions, notifications, thread as pages_thread, withUpdateBanner, updateSettings, discover } from '../lib/pages.js'
5import { timeline, search, following, knocks, post, esc, layout, who, person, settings, handleSettings, namespaceSettings, composer, mentions, notifications, thread as pages_thread, withUpdateBanner, updateSettings, discover, status } from '../lib/pages.js'
66
77const row = (over = {}) => ({
88 author: 'ab'.repeat(32), collection: 'post', name: 'p1',
……@@ -659,3 +659,36 @@ test('the empty state names the namespace, so a wrong one is diagnosable', (t) =
659659 t.ok(html.includes('Nobody here yet'))
660660 t.ok(html.includes('abababab'), 'the key is shown so you can tell which namespace you are in')
661661})
662
663// ── status ───────────────────────────────────────────────────────────────────────────────────────
664//
665// ⛔ The page exists because "the log arrived, the bytes did not" named a state without naming whose
666// feed or what fixes it. These pin that the three feed states stay distinguishable — they look
667// identical from a timeline and are fixed by completely different things.
668test('★ status distinguishes followed-not-held from held-without-bytes', (t) => {
669 const A = 'a'.repeat(64)
670 const B = 'b'.repeat(64)
671 const html = status({
672 me: { feed: 'f'.repeat(64), linked: true },
673 ns: { key: '2c'.repeat(32), claims: 4, carrying: false },
674 following: [{ feed: A }, { feed: B }],
675 missing: [{ feed: A }],
676 profiles: { [B]: { name: 'stortron' } }
677 })
678 t.ok(html.includes('followed, not held'), 'a feed that has not replicated')
679 t.ok(html.includes('stortron'), 'and one that has')
680 t.ok(html.includes('4'), 'the namespace claim count, so a wrong namespace is visible')
681})
682
683test('⛔ status reports the daemon\'s own last OTA line, not a verdict about it', (t) => {
684 const html = status({
685 update: { version: '0.0.12', running: null, staged: 'abcd', checkedAt: 'now', note: 'ota: downloading 0.0.12' }
686 })
687 t.ok(html.includes('ota: downloading 0.0.12'), 'verbatim — it is the only line that says WHICH failure')
688 // ⚠ `running` null is the normal fresh-install state, and must read as that rather than as blank.
689 t.ok(html.includes("the app's own bundle"), 'a null running bundle is explained, not left empty')
690})
691
692test('an unlinked node says so, because nothing else it does will work', (t) => {
693 t.ok(status({ me: { feed: 'a'.repeat(64), linked: false } }).includes('not linked'))
694})