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({
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);
+});
+
+test('choosing Backup shows the card and stands the dashboard grid down (#1854)', () => {
+ const backup = renderApp({ ui: { ...UI, view: 'backup' } });
+ assert.match(backup, /class="btn-toggle active"[^>]*>Backup);
+ assert.match(backup, CARD);
+ assert.doesNotMatch(backup, /class="grid"/);
+ // The control: the grid is what every other view shows, so its absence above is the Backup
+ // view's doing and not a fixture that renders no grid anywhere.
+ assert.match(renderApp({ ui: { ...UI, view: 'simple' } }), /class="grid"/);
+});
+
+test('Backup has left the Configuration stack — it moved, it did not multiply (#1854)', () => {
+ const config = renderApp({ ui: { ...UI, view: 'config' } });
+ assert.doesNotMatch(config, CARD);
+ assert.doesNotMatch(config, /class="btn-toggle active"[^>]*>Backup);
+});
+
+test('the appliance flag reaches the card, so it never prints a host-CLI remedy (#1854)', () => {
+ 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