From c5fc5d747e150db66d5462f06e2e9bdbe5c5d5ec Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sun, 6 Sep 2026 03:09:08 -0500 Subject: [PATCH 1/2] feat(dashboard): #1854 Backup is its own view, not a card under the config editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing a fresh appliance install, the operator could not find a way to back the machine up. The card was not missing — it sat below the config editor in the Configuration view, so finding it meant scrolling past a form they had no reason to open. Backup becomes a fourth entry in the view toggle, beside Simple, Advanced and Configuration, and the card moves there rather than being duplicated. The view is persisted like the other three. Two things the card now says that it did not: - Both halves of what a restore needs. The archive and the kit that carries its passphrase are useless apart, and a machine set up later asks for that pair. - Nothing an appliance operator cannot act on. The disabled explainer told the reader to edit config.json and run ./pithead apply; the appliance has no shell and keeps the control channel on, so there the card says backup returns with the channel instead of naming a file they cannot open. The non-appliance wording is unchanged, and a test holds that branch narrow. Docs: docs/ is in _shared.paths. The prose naming "Configuration -> Backup" is falsified by this change; a whole-tree phrase sweep found two sites in docs/appliance.md (the second one is not where I first noticed the problem), both corrected, and docs/dashboard.md gains a Backup view section. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KZuzdtxNX8ae4rnDtBqEqR --- .../web/static/backupview.mjs | 13 +++++ .../web/static/components.mjs | 16 +++++-- .../mining_dashboard/web/static/dashboard.js | 2 +- dashboard/tests/frontend/backupnav.test.mjs | 47 +++++++++++++++++++ dashboard/tests/frontend/backupview.test.mjs | 20 ++++++++ dashboard/tests/frontend/dashboard.test.mjs | 10 ++++ docs/appliance.md | 4 +- docs/dashboard.md | 18 +++++++ 8 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 dashboard/tests/frontend/backupnav.test.mjs diff --git a/dashboard/mining_dashboard/web/static/backupview.mjs b/dashboard/mining_dashboard/web/static/backupview.mjs index 971535fa0..068ce3798 100644 --- a/dashboard/mining_dashboard/web/static/backupview.mjs +++ b/dashboard/mining_dashboard/web/static/backupview.mjs @@ -128,6 +128,16 @@ export class BackupPanel extends Component { render() { if (!this.props.enabled) { + // The appliance has no shell and keeps the control channel on, so the host-CLI remedy below + // is advice its operator cannot act on (#1854). There, a quiet channel is a fault to wait + // out, not a setting to change — say that instead of naming a file they cannot open. + if (this.props.appliance) { + return html`
+

Backup

+

Backup is unavailable while the control channel is not answering. It returns with + the channel; nothing on this machine has to be changed by hand.

+
`; + } return html`

Backup

Backup export is off with the rest of the control channel. To enable it, set @@ -146,6 +156,9 @@ export class BackupPanel extends Component {

Export an encrypted archive of config.json, .env, the Tor onion-service keys, and the dashboard database — the state a dead box takes with it. Blockchains are excluded; they re-sync.

+

Keep both halves: the archive, and the kit that carries the + passphrase opening it. Neither is any use without the other, and setting a machine up + later asks for this same pair.

${modal}`; diff --git a/dashboard/mining_dashboard/web/static/components.mjs b/dashboard/mining_dashboard/web/static/components.mjs index eab21ded3..ef93548cc 100644 --- a/dashboard/mining_dashboard/web/static/components.mjs +++ b/dashboard/mining_dashboard/web/static/components.mjs @@ -1015,6 +1015,9 @@ function DashboardView({ }) { const advanced = ui.view === "advanced"; const configView = ui.view === "config"; + // Backup is its own view, not a card below the config editor (#1854): an operator handed a + // working machine has to be able to find "take a backup" without reading the editor first. + const backupView = ui.view === "backup"; // Layout by operator relevance (#159): the at-a-glance chart and the rigs themselves lead (this // stack may drive many machines), then this stack's own detail cards, then pool-wide and network // context as reference at the bottom — "mine" first, "the world" last. @@ -1030,22 +1033,29 @@ function DashboardView({
- +
<${AdvancedHint} ui=${ui} onView=${onView} onDismissHint=${onDismissHint} /> ${ configView - ? html`
<${ConfigView} appliance=${!!state.os_update} /><${BackupPanel} enabled=${state.control_enabled} /><${DiagnosticsPanel} enabled=${state.control_enabled} /><${SecurityPanel} />
` + ? html`
<${ConfigView} appliance=${!!state.os_update} /><${DiagnosticsPanel} enabled=${state.control_enabled} /><${SecurityPanel} />
` : null } ${ - configView + backupView + ? html`
<${BackupPanel} enabled=${state.control_enabled} appliance=${!!state.os_update} />
` + : null + } + ${ + configView || backupView ? null : html`
diff --git a/dashboard/mining_dashboard/web/static/dashboard.js b/dashboard/mining_dashboard/web/static/dashboard.js index 6a04658b9..60c33ccf3 100644 --- a/dashboard/mining_dashboard/web/static/dashboard.js +++ b/dashboard/mining_dashboard/web/static/dashboard.js @@ -71,7 +71,7 @@ export function initDashboard({ avg: normalizeAvgWindow(storage.getItem("dashboardAvgWindow")), // Workers-table sort (#658); persisted like the other view preferences. ...normalizeSort(storage.getItem("dashboardSort"), WORKER_COLUMNS.length), - view: ["advanced", "config"].includes(storage.getItem("dashboardView")) + view: ["advanced", "config", "backup"].includes(storage.getItem("dashboardView")) ? storage.getItem("dashboardView") : "simple", // Theme is persisted in localStorage so it survives reloads and stack restarts (Issue #43). diff --git a/dashboard/tests/frontend/backupnav.test.mjs b/dashboard/tests/frontend/backupnav.test.mjs new file mode 100644 index 000000000..f5be1ca8c --- /dev/null +++ b/dashboard/tests/frontend/backupnav.test.mjs @@ -0,0 +1,47 @@ +// Can an operator find the backup without reading the config editor first (#1854)? +// +// Run with Node's built-in test runner (CI runs exactly this): +// node --test dashboard/tests/frontend/ +// +// The card's own render states live in backupview.test.mjs; these cover the WIRING — that Backup +// is its own view rather than a card under the editor, that choosing it puts the card on screen +// instead of the dashboard grid, and that the appliance flag reaches the card. Only a render +// through App can prove any of that. New file: components.test.mjs is at its file-budget ceiling. +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { clone, renderApp, UI } from './harness.mjs'; + +// The base fixture has the control channel off, so the card's disabled explainer is the cheapest +// proof that BackupPanel rendered at all — it does not depend on the backup flow being reachable. +// The leading word is load-bearing: the Diagnostics card beside it says the same sentence about +// itself, and a needle without "Backup export" matches that one instead. +const CARD = /Backup export is off with the rest of the control channel/; + +test('the view controls offer Backup as an entry of its own (#1854)', () => { + assert.match(renderApp(), /class="btn-toggle[^"]*"[^>]*>Backup { + const backup = renderApp({ ui: { ...UI, view: 'backup' } }); + assert.match(backup, /class="btn-toggle active"[^>]*>Backup { + const config = renderApp({ ui: { ...UI, view: 'config' } }); + assert.doesNotMatch(config, CARD); + assert.doesNotMatch(config, /class="btn-toggle active"[^>]*>Backup { + const state = clone(); + state.os_update = { status: 'idle' }; // what makes App call this machine an appliance + const out = renderApp({ state, ui: { ...UI, view: 'backup' } }); + assert.match(out, /Backup is unavailable while the control channel is not answering/); + assert.doesNotMatch(out, /pithead apply/); +}); diff --git a/dashboard/tests/frontend/backupview.test.mjs b/dashboard/tests/frontend/backupview.test.mjs index aed1f053e..bd1ea2fca 100644 --- a/dashboard/tests/frontend/backupview.test.mjs +++ b/dashboard/tests/frontend/backupview.test.mjs @@ -139,3 +139,23 @@ test("BackupPanel failed phase surfaces the host's error", () => { c.state = { phase: "failed", id: null, result: { status: "failed", error: "boom" } }; assert.match(renderToString(c.render()), /boom/); }); + +// #1854: the appliance has no shell, so the host-CLI remedy in the explainer above is advice its +// operator cannot act on. The card has to say something true for a box you cannot log into. +test("BackupPanel on the appliance drops the remedy it has no shell to run (#1854)", () => { + const out = renderToString(inst({ enabled: false, appliance: true }).render()); + assert.match(out, /Backup is unavailable while the control channel is not answering/); + assert.doesNotMatch(out, /pithead apply/); + assert.doesNotMatch(out, /config\.json/); +}); + +test("BackupPanel off a non-appliance host keeps the remedy — the appliance branch is narrow (#1854)", () => { + const out = renderToString(inst({ enabled: false, appliance: false }).render()); + assert.match(out, /pithead apply/); +}); + +test("BackupPanel names both halves the operator has to keep — archive and kit (#1854)", () => { + const out = renderToString(inst({ enabled: true }).render()); + assert.match(out, /Keep both halves/); + assert.match(out, /passphrase/); +}); diff --git a/dashboard/tests/frontend/dashboard.test.mjs b/dashboard/tests/frontend/dashboard.test.mjs index 27ac34684..e757751ab 100644 --- a/dashboard/tests/frontend/dashboard.test.mjs +++ b/dashboard/tests/frontend/dashboard.test.mjs @@ -362,3 +362,13 @@ test('onInspect/onCloseInspect: transient worker-panel state — no fetch, nothi assert.equal(t.fetches.length, polls); assert.deepEqual(Object.keys(t.stored), []); // transient: nothing written to storage }); + +test('a persisted Backup view is restored, not quietly dropped to Simple (#1854)', async () => { + const t = makeEnv({ stored: { dashboardView: 'backup' }, responses: [ok({})] }); + initDashboard(t.env); + assert.equal(t.paints[0].ui.view, 'backup'); + // The control: the whitelist really does reject a view it does not know. + const u = makeEnv({ stored: { dashboardView: 'nonsense' }, responses: [ok({})] }); + initDashboard(u.env); + assert.equal(u.paints[0].ui.view, 'simple'); +}); diff --git a/docs/appliance.md b/docs/appliance.md index 647458c04..2d3c7012d 100644 --- a/docs/appliance.md +++ b/docs/appliance.md @@ -431,7 +431,7 @@ another computer instead: The machine holds state a resync cannot rebuild: your wallet settings, the Tor onion keys that give it its address, and the dashboard's history. There is no filesystem to -copy from a shell-less box, so the dashboard's **Configuration → Backup** card exports it +copy from a shell-less box, so the dashboard's **Backup** view exports it for you as one encrypted file. Click **Back up now** and the machine stops the stack, archives `config.json`, `.env`, @@ -492,7 +492,7 @@ factory reset you asked for never shows this notice. Fresh flash, restore, done — if the machine is gone (dead disk, stolen, dropped), a backup taken beforehand provisions a replacement in one page, with nothing retyped. -**Take a backup before you need it.** The dashboard's **Configuration → Backup** card is +**Take a backup before you need it.** The dashboard's **Backup** view is the machine's own way to do that ([Backing up your data](#backing-up-your-data) above): the archive it downloads and the passphrase from its kit are exactly what restore asks for. The console works too — log in as `root` with the dashboard password and run: diff --git a/docs/dashboard.md b/docs/dashboard.md index 0b2a7f6f1..74d92a682 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -1211,6 +1211,24 @@ daemon, use the support bundle or the console. If you pick a service the host will not read logs for, the host refuses and the panel shows its reason as-is rather than guessing at one. +## Backup view + +**Backup** is its own entry in the toggle above the chart, beside Simple, Advanced and +Configuration ([#1854](https://github.com/p2pool-starter-stack/pithead/issues/1854)). It used to +sit below the config editor, where an operator handed a working machine had to scroll past a form +they had no reason to open before finding it. The card itself is unchanged — one button, one +archive, one passphrase shown once — and +[Backing up your data](appliance.md#backing-up-your-data) covers what it produces. + +The card names both halves a restore needs: the encrypted archive, and the kit that carries the +passphrase opening it. Neither half is any use without the other, and setting a machine up later +asks for that same pair. + +On the appliance the card drops the host-side remedy the other builds print. Turning the control +channel back on means editing `config.json` and running `./pithead apply`, and an appliance +operator has no shell for either, so there the card says backup returns with the control channel +rather than naming a file they cannot open. + ## Upgrading from the dashboard With `dashboard.control.enabled: true` (the same flag as the Configuration view) and a newer From 6fcc6ad195f3008f6fdbaea566b167eca3d27e22 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sun, 6 Sep 2026 04:53:49 -0500 Subject: [PATCH 2/2] fix(dashboard): #1854 the appliance's Backup card names the login, not a channel that is coming back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review's RETURN, taken. The card told an appliance operator to wait for something that never arrives, and the comment beside it argued for the same wrong reason. WHY IT WAS WRONG, re-derived at source rather than taken from the review: - `enabled` is `state.control_enabled` = `config.DASHBOARD_CONTROL_ENABLED` (`views.py:353`), a config constant. Its own comment says "display gating only" — there is no liveness in it, so "not answering" and "it returns with the channel" are both false. - The only way an APPLIANCE reaches this branch is the "No login" setup. `apply_appliance_defaults` (`27-defaults-and-config-validation.sh:26-31`) turns the channel on when `dashboard.control.enabled` is null AND the dashboard password is non-empty, so an empty password leaves it off — and that file's own comment calls leaving it off "the honest resolution", not a fault. Nothing is coming back. So the card now names the cause the operator can actually act on: no dashboard login was set, the control channel sits behind one, and Set up again in the boot menu is where a password gets set. The narrow-branch guarantees are unchanged — no `pithead apply`, no `config.json`, neither of which an appliance operator can reach. The comment above the branch is corrected too. It stated the defect as a safety property ("a quiet channel is a fault to wait out"), which is why the wording read as considered. Proven: - MUTATION CONTROL: reverting the copy to the old sentence reddens exactly one test in each of the two files, so the new assertions are load-bearing rather than vacuous. Restored by sha256. - Whole-tree sweep for the retired wording comes back with only the two `doesNotMatch` guards that forbid it, with a control showing the sweep can see this class at all. - Frontend suite 587 passed rc 0; lint-js (which lints CSS too) rc 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KZuzdtxNX8ae4rnDtBqEqR --- .../mining_dashboard/web/static/backupview.mjs | 14 +++++++++----- dashboard/tests/frontend/backupnav.test.mjs | 3 ++- dashboard/tests/frontend/backupview.test.mjs | 10 +++++++--- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/dashboard/mining_dashboard/web/static/backupview.mjs b/dashboard/mining_dashboard/web/static/backupview.mjs index 068ce3798..7f2811cf8 100644 --- a/dashboard/mining_dashboard/web/static/backupview.mjs +++ b/dashboard/mining_dashboard/web/static/backupview.mjs @@ -128,14 +128,18 @@ export class BackupPanel extends Component { render() { if (!this.props.enabled) { - // The appliance has no shell and keeps the control channel on, so the host-CLI remedy below - // is advice its operator cannot act on (#1854). There, a quiet channel is a fault to wait - // out, not a setting to change — say that instead of naming a file they cannot open. + // The appliance has no shell, so the host-CLI remedy below is advice its operator cannot + // act on (#1854) — but "wait for the channel" was the WRONG replacement. `enabled` is + // DASHBOARD_CONTROL_ENABLED, a config constant with no liveness in it, and an appliance + // reaches this branch only in the "No login" case: apply_appliance_defaults turns the + // channel on when the key is null AND the dashboard password is non-empty, so an empty + // password leaves it off DELIBERATELY and permanently. Nothing returns. Name the login. if (this.props.appliance) { return html`

Backup

-

Backup is unavailable while the control channel is not answering. It returns with - the channel; nothing on this machine has to be changed by hand.

+

Backup is off because this machine was set up without a dashboard login. The + control channel it exports through sits behind that login, so it stays off until + this machine has one — set a password under Set up again in the boot menu.

`; } return html`
diff --git a/dashboard/tests/frontend/backupnav.test.mjs b/dashboard/tests/frontend/backupnav.test.mjs index f5be1ca8c..0148be3d2 100644 --- a/dashboard/tests/frontend/backupnav.test.mjs +++ b/dashboard/tests/frontend/backupnav.test.mjs @@ -42,6 +42,7 @@ test('the appliance flag reaches the card, so it never prints a host-CLI remedy const state = clone(); state.os_update = { status: 'idle' }; // what makes App call this machine an appliance const out = renderApp({ state, ui: { ...UI, view: 'backup' } }); - assert.match(out, /Backup is unavailable while the control channel is not answering/); + assert.match(out, /set up without a dashboard login/); + assert.doesNotMatch(out, /not answering|returns with the channel/); assert.doesNotMatch(out, /pithead apply/); }); diff --git a/dashboard/tests/frontend/backupview.test.mjs b/dashboard/tests/frontend/backupview.test.mjs index bd1ea2fca..82c321f74 100644 --- a/dashboard/tests/frontend/backupview.test.mjs +++ b/dashboard/tests/frontend/backupview.test.mjs @@ -141,10 +141,14 @@ test("BackupPanel failed phase surfaces the host's error", () => { }); // #1854: the appliance has no shell, so the host-CLI remedy in the explainer above is advice its -// operator cannot act on. The card has to say something true for a box you cannot log into. -test("BackupPanel on the appliance drops the remedy it has no shell to run (#1854)", () => { +// operator cannot act on. The card has to say something true for a box you cannot log into — +// and "wait for the channel" was not true. `enabled` carries no liveness, and the only way an +// appliance reaches this branch is the "No login" setup, where the off state is permanent. +test("BackupPanel on the appliance names the login, not a channel that is coming back (#1854)", () => { const out = renderToString(inst({ enabled: false, appliance: true }).render()); - assert.match(out, /Backup is unavailable while the control channel is not answering/); + assert.match(out, /set up without a dashboard login/); + // The defect this replaced: telling an operator to wait for something that never arrives. + assert.doesNotMatch(out, /not answering|returns with the channel/); assert.doesNotMatch(out, /pithead apply/); assert.doesNotMatch(out, /config\.json/); });