diff --git a/dashboard/mining_dashboard/web/static/backupview.mjs b/dashboard/mining_dashboard/web/static/backupview.mjs index 971535fa..7f2811cf 100644 --- a/dashboard/mining_dashboard/web/static/backupview.mjs +++ b/dashboard/mining_dashboard/web/static/backupview.mjs @@ -128,6 +128,20 @@ export class BackupPanel extends Component { render() { if (!this.props.enabled) { + // 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 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`

Backup

Backup export is off with the rest of the control channel. To enable it, set @@ -146,6 +160,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 eab21ded..ef93548c 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 6a04658b..60c33ccf 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 00000000..0148be3d --- /dev/null +++ b/dashboard/tests/frontend/backupnav.test.mjs @@ -0,0 +1,48 @@ +// 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, /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 aed1f053..82c321f7 100644 --- a/dashboard/tests/frontend/backupview.test.mjs +++ b/dashboard/tests/frontend/backupview.test.mjs @@ -139,3 +139,27 @@ 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 — +// 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, /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/); +}); + +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 27ac3468..e757751a 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 647458c0..2d3c7012 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 0b2a7f6f..74d92a68 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