diff --git a/build/dashboard/mining_dashboard/web/static/wizard.css b/build/dashboard/mining_dashboard/web/static/wizard.css index 60d2c43f..7a8bb988 100644 --- a/build/dashboard/mining_dashboard/web/static/wizard.css +++ b/build/dashboard/mining_dashboard/web/static/wizard.css @@ -31,3 +31,14 @@ padding-left: 0.75rem; border-left: 2px solid var(--border); } + +/* The restore-at-setup toggle and its "back" link (#909): a plain button styled as text. */ +.wizard-link { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--accent); + text-decoration: underline; + cursor: pointer; +} diff --git a/build/dashboard/mining_dashboard/web/static/wizard.mjs b/build/dashboard/mining_dashboard/web/static/wizard.mjs index 49f6183e..462bbab1 100644 --- a/build/dashboard/mining_dashboard/web/static/wizard.mjs +++ b/build/dashboard/mining_dashboard/web/static/wizard.mjs @@ -44,6 +44,11 @@ const FIELDS = { dashPassword: { path: "dashboard.auth.password" }, }; +// Restore-at-setup (#909, #786 sub-issue B): mirrors the pithead script's and wizard.py's own +// cap — a Pithead backup holds only config, keys and the dashboard database, never the +// blockchains. Three languages, one number kept in step by hand (no shared source across them). +const RESTORE_MAX_BYTES = 64 * 1024 * 1024; + const TIMEZONES = [ "auto", "UTC", @@ -162,6 +167,25 @@ export const InstallSection = ({ `; }; +// Restore-at-setup (#909): the config form's alternative — an uploaded encrypted backup + +// its emergency-kit passphrase. Validation is host-side (the same "container asks, host +// decides" split as everything else here); this just carries the two answers up. +export const RestoreSection = ({ file, passphrase, onFile, onPassphrase }) => html`
${file.name} (${Math.round(file.size / 1024)} KB)
`} + <${Field} label="Passphrase"> + + /> +Installing. Takes a few minutes. Do not power it off.
${ @@ -244,6 +268,12 @@ export class WizardApp extends Component { rigWorker: "", rigPassword: "", rigDefaults: {}, + // Restore-at-setup (#909): an alternative to the whole form above, toggled independently + // of role/install-target — an uploaded backup replaces the config the operator would + // otherwise type in. + restoreMode: false, + restoreFile: null, + restorePassphrase: "", status: "", handoff: null, }; @@ -419,6 +449,53 @@ export class WizardApp extends Component { this.poll(); }; + // The restore-at-setup alternative (#909): an uploaded archive + passphrase instead of the + // typed config. Multipart, not URLSearchParams — the archive is a file, not a form field. + // Validation is entirely host-side; the client only enforces the size cap it can check + // without a round trip. + submitRestore = async (e) => { + e.preventDefault(); + if (!this.state.restoreFile) { + this.setState({ error: "Choose a backup archive to upload." }); + return; + } + if (this.state.restoreFile.size > RESTORE_MAX_BYTES) { + this.setState({ + error: `Archive is too large (max ${RESTORE_MAX_BYTES / (1024 * 1024)} MB) — a Pithead backup holds only config, keys and the dashboard database, never the blockchains.`, + }); + return; + } + const body = new FormData(); + body.append("archive", this.state.restoreFile); + body.append("passphrase", this.state.restorePassphrase); + if (this.state.installer) { + if (!this.state.chosen) { + this.setState({ error: "Choose the disk to install onto." }); + return; + } + if (this.state.confirm !== this.state.chosen) { + this.setState({ error: `Type ${this.state.chosen} exactly to confirm the erase.` }); + return; + } + body.append("disk", this.state.chosen); + body.append("confirm", this.state.confirm); + body.append("wipe", this.state.wipe); + } + const res = await fetch("/submit-restore", { method: "POST", body }); + if (!res.ok) { + let msg = "Restore failed — check the archive and passphrase, and retry."; + try { + msg = (await res.json()).error || msg; + } catch {} + this.setState({ error: msg }); + return; + } + // Same in-place wait as a typed submission: no optimistic page swap, the server's stage + // moves the page when it actually does. + this.setState({ submitting: true, error: "" }); + this.poll(); + }; + ack = async () => { await fetch("/handoff-ack", { method: "POST" }); await this.loadState(); // the server drops out of the handoff stage; the view follows @@ -457,7 +534,43 @@ export class WizardApp extends Component { it appears by this name in the Pithead's Workers view./>`; } + // Restore-at-setup (#909): its own small card, reached by the toggle at the top of the + // normal form and left by the "back" link here — independent of role/disk state, which + // this branch handles on its own (the install target still needs picking on the medium). + renderRestore() { + const { error, installer, disks, chosen, confirm, wipe, restorePassphrase, submitting } = + this.state; + const diskPicked = !installer || Boolean(chosen); + return html`Upload an encrypted Pithead backup instead of filling in the form below. The machine + decrypts, validates and provisions itself from what it restores.
+ <${Err}>${error}/> + + +