Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions dashboard/mining_dashboard/web/static/backupview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`<div class="card">
<h3>Backup</h3>
<p>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.</p>
</div>`;
}
return html`<div class="card">
<h3>Backup</h3>
<p>Backup export is off with the rest of the control channel. To enable it, set
Expand All @@ -146,6 +160,9 @@ export class BackupPanel extends Component {
<p>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.</p>
<p class="text-muted text-xs">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.</p>
<button class="btn-toggle active" disabled=${phase !== "idle"}
onClick=${() => this.setState({ phase: "confirm" })}>Back up now</button>
</div>${modal}`;
Expand Down
16 changes: 13 additions & 3 deletions dashboard/mining_dashboard/web/static/components.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -1030,22 +1033,29 @@ function DashboardView({
<div id="dashboard-view" class=${advanced ? "mode-advanced" : ""}>
<div class="view-controls">
<div class="toggle-group" role="group" aria-label="Dashboard view">
<button class=${"btn-toggle" + (!advanced && !configView ? " active" : "")} aria-pressed=${!advanced && !configView}
<button class=${"btn-toggle" + (!advanced && !configView && !backupView ? " active" : "")} aria-pressed=${!advanced && !configView && !backupView}
title="Chart, workers and the headline numbers" onClick=${() => onView("simple")}>Simple</button>
<button class=${"btn-toggle" + (advanced ? " active" : "")} aria-pressed=${advanced}
title="Every stats card, calculators and diagnostics" onClick=${() => onView("advanced")}>Advanced</button>
<button class=${"btn-toggle" + (configView ? " active" : "")} aria-pressed=${configView}
title="View or edit the stack configuration" onClick=${() => onView("config")}>Configuration</button>
<button class=${"btn-toggle" + (backupView ? " active" : "")} aria-pressed=${backupView}
title="Export an encrypted copy of this machine's configuration and secrets" onClick=${() => onView("backup")}>Backup</button>
</div>
</div>
<${AdvancedHint} ui=${ui} onView=${onView} onDismissHint=${onDismissHint} />
${
configView
? html`<div class="card-stack"><${ConfigView} appliance=${!!state.os_update} /><${BackupPanel} enabled=${state.control_enabled} /><${DiagnosticsPanel} enabled=${state.control_enabled} /><${SecurityPanel} /></div>`
? html`<div class="card-stack"><${ConfigView} appliance=${!!state.os_update} /><${DiagnosticsPanel} enabled=${state.control_enabled} /><${SecurityPanel} /></div>`
: null
}
${
configView
backupView
? html`<div class="card-stack"><${BackupPanel} enabled=${state.control_enabled} appliance=${!!state.os_update} /></div>`
: null
}
${
configView || backupView
? null
: html`
<div class="grid">
Expand Down
2 changes: 1 addition & 1 deletion dashboard/mining_dashboard/web/static/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
48 changes: 48 additions & 0 deletions dashboard/tests/frontend/backupnav.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
24 changes: 24 additions & 0 deletions dashboard/tests/frontend/backupview.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
10 changes: 10 additions & 0 deletions dashboard/tests/frontend/dashboard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
4 changes: 2 additions & 2 deletions docs/appliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down