Restore snapshot from onboarding (#7132)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Joakim Sørensen 2020-09-29 20:30:25 +02:00 committed by GitHub
parent f48a28264f
commit 590cd8500d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 441 additions and 85 deletions

View File

@ -13,7 +13,6 @@ import {
import { fireEvent } from "../../../src/common/dom/fire_event";
import "../../../src/components/ha-circular-progress";
import "../../../src/components/ha-svg-icon";
import { extractApiErrorMessage } from "../../../src/data/hassio/common";
import {
HassioSnapshot,
uploadSnapshot,
@ -38,12 +37,12 @@ export class HassioUploadSnapshot extends LitElement {
public render(): TemplateResult {
return html`
<ha-file-upload
.hass=${this.hass}
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
accept="application/x-tar"
label="Upload snapshot"
@file-picked=${this._uploadFile}
auto-open-file-dialog
></ha-file-upload>
`;
}
@ -55,6 +54,7 @@ export class HassioUploadSnapshot extends LitElement {
showAlertDialog(this, {
title: "Unsupported file format",
text: "Please choose a Home Assistant snapshot file (.tar)",
confirmText: "ok",
});
return;
}
@ -65,7 +65,8 @@ export class HassioUploadSnapshot extends LitElement {
} catch (err) {
showAlertDialog(this, {
title: "Upload failed",
text: extractApiErrorMessage(err),
text: err.toString(),
confirmText: "ok",
});
} finally {
this._uploading = false;

View File

@ -1,4 +1,6 @@
import { mdiClose } from "@mdi/js";
import {
css,
CSSResult,
customElement,
html,
@ -8,7 +10,7 @@ import {
TemplateResult,
} from "lit-element";
import { fireEvent } from "../../../../src/common/dom/fire_event";
import { createCloseHeading } from "../../../../src/components/ha-dialog";
import "../../../../src/components/ha-header-bar";
import { HassDialog } from "../../../../src/dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../src/resources/styles";
import type { HomeAssistant } from "../../../../src/types";
@ -30,7 +32,11 @@ export class DialogHassioSnapshotUpload extends LitElement
}
public closeDialog(): void {
this._params?.reloadSnapshot();
if (this._params && !this._params.onboarding) {
if (this._params.reloadSnapshot) {
this._params.reloadSnapshot();
}
}
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@ -46,9 +52,19 @@ export class DialogHassioSnapshotUpload extends LitElement
scrimClickAction
escapeKeyAction
hideActions
.heading=${true}
@closed=${this.closeDialog}
.heading=${createCloseHeading(this.hass, "Upload snapshot")}
>
<div slot="heading">
<ha-header-bar>
<span slot="title">
Upload snapshot
</span>
<mwc-icon-button slot="actionItems" dialogAction="cancel">
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
</mwc-icon-button>
</ha-header-bar>
</div>
<hassio-upload-snapshot
@snapshot-uploaded=${this._snapshotUploaded}
.hass=${this.hass}
@ -63,8 +79,24 @@ export class DialogHassioSnapshotUpload extends LitElement
this.closeDialog();
}
static get styles(): CSSResult {
return haStyleDialog;
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
ha-header-bar {
--mdc-theme-on-primary: var(--primary-text-color);
--mdc-theme-primary: var(--mdc-theme-surface);
flex-shrink: 0;
}
/* overrule the ha-style-dialog max-height on small screens */
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-header-bar {
--mdc-theme-primary: var(--app-header-background-color);
--mdc-theme-on-primary: var(--app-header-text-color, white);
}
}
`,
];
}
}

View File

@ -1,5 +1,5 @@
import "@material/mwc-button";
import { mdiDelete, mdiDownload, mdiHistory } from "@mdi/js";
import { mdiClose, mdiDelete, mdiDownload, mdiHistory } from "@mdi/js";
import { PaperCheckboxElement } from "@polymer/paper-checkbox/paper-checkbox";
import "@polymer/paper-input/paper-input";
import {
@ -12,7 +12,8 @@ import {
property,
TemplateResult,
} from "lit-element";
import { createCloseHeading } from "../../../../src/components/ha-dialog";
import { fireEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-header-bar";
import "../../../../src/components/ha-svg-icon";
import { getSignedPath } from "../../../../src/data/auth";
import { extractApiErrorMessage } from "../../../../src/data/hassio/common";
@ -22,7 +23,7 @@ import {
} from "../../../../src/data/hassio/snapshot";
import { showConfirmationDialog } from "../../../../src/dialogs/generic/show-dialog-box";
import { PolymerChangedEvent } from "../../../../src/polymer-types";
import { haStyleDialog } from "../../../../src/resources/styles";
import { haStyle, haStyleDialog } from "../../../../src/resources/styles";
import { HomeAssistant } from "../../../../src/types";
import { HassioSnapshotDialogParams } from "./show-dialog-hassio-snapshot";
@ -75,6 +76,8 @@ class HassioSnapshotDialog extends LitElement {
@internalProperty() private _error?: string;
@internalProperty() private _onboarding = false;
@internalProperty() private _snapshot?: HassioSnapshotDetail;
@internalProperty() private _folders!: FolderItem[];
@ -90,13 +93,14 @@ class HassioSnapshotDialog extends LitElement {
public async showDialog(params: HassioSnapshotDialogParams) {
this._snapshot = await fetchHassioSnapshotInfo(this.hass, params.slug);
this._folders = _computeFolders(
this._snapshot.folders
this._snapshot?.folders
).sort((a: FolderItem, b: FolderItem) => (a.name > b.name ? 1 : -1));
this._addons = _computeAddons(
this._snapshot.addons
this._snapshot?.addons
).sort((a: AddonItem, b: AddonItem) => (a.name > b.name ? 1 : -1));
this._dialogParams = params;
this._onboarding = params.onboarding ?? false;
}
protected render(): TemplateResult {
@ -104,12 +108,17 @@ class HassioSnapshotDialog extends LitElement {
return html``;
}
return html`
<ha-dialog
open
stacked
@closing=${this._closeDialog}
.heading=${createCloseHeading(this.hass, this._computeName)}
>
<ha-dialog open stacked @closing=${this._closeDialog} .heading=${true}>
<div slot="heading">
<ha-header-bar>
<span slot="title">
${this._computeName}
</span>
<mwc-icon-button slot="actionItems" dialogAction="cancel">
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
</mwc-icon-button>
</ha-header-bar>
</div>
<div class="details">
${this._snapshot.type === "full"
? "Full snapshot"
@ -182,11 +191,15 @@ class HassioSnapshotDialog extends LitElement {
${this._error ? html` <p class="error">Error: ${this._error}</p> ` : ""}
<div>Actions:</div>
<mwc-button @click=${this._downloadClicked} slot="primaryAction">
<ha-svg-icon path=${mdiDownload} class="icon"></ha-svg-icon>
Download Snapshot
</mwc-button>
${!this._onboarding
? html`<mwc-button
@click=${this._downloadClicked}
slot="primaryAction"
>
<ha-svg-icon path=${mdiDownload} class="icon"></ha-svg-icon>
Download Snapshot
</mwc-button>`
: ""}
<mwc-button
@click=${this._partialRestoreClicked}
@ -206,16 +219,22 @@ class HassioSnapshotDialog extends LitElement {
</mwc-button>
`
: ""}
<mwc-button @click=${this._deleteClicked} slot="secondaryAction">
<ha-svg-icon path=${mdiDelete} class="icon warning"></ha-svg-icon>
<span class="warning">Delete Snapshot</span>
</mwc-button>
${!this._onboarding
? html`<mwc-button
@click=${this._deleteClicked}
slot="secondaryAction"
>
<ha-svg-icon path=${mdiDelete} class="icon warning"></ha-svg-icon>
<span class="warning">Delete Snapshot</span>
</mwc-button>`
: ""}
</ha-dialog>
`;
}
static get styles(): CSSResult[] {
return [
haStyle,
haStyleDialog,
css`
paper-checkbox {
@ -242,6 +261,18 @@ class HassioSnapshotDialog extends LitElement {
.no-margin-top {
margin-top: 0;
}
ha-header-bar {
--mdc-theme-on-primary: var(--primary-text-color);
--mdc-theme-primary: var(--mdc-theme-surface);
flex-shrink: 0;
}
/* overrule the ha-style-dialog max-height on small screens */
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-header-bar {
--mdc-theme-primary: var(--app-header-background-color);
--mdc-theme-on-primary: var(--app-header-text-color, white);
}
}
`,
];
}
@ -272,6 +303,8 @@ class HassioSnapshotDialog extends LitElement {
if (
!(await showConfirmationDialog(this, {
title: "Are you sure you want partially to restore this snapshot?",
confirmText: "restore",
dismissText: "cancel",
}))
) {
return;
@ -300,22 +333,31 @@ class HassioSnapshotDialog extends LitElement {
data.password = this._snapshotPassword;
}
this.hass
.callApi(
"POST",
if (!this._onboarding) {
this.hass
.callApi(
"POST",
`hassio/snapshots/${this._snapshot!.slug}/restore/partial`,
data
)
.then(
() => {
alert("Snapshot restored!");
this._closeDialog();
},
(error) => {
this._error = error.body.message;
}
);
`hassio/snapshots/${this._snapshot!.slug}/restore/partial`,
data
)
.then(
() => {
alert("Snapshot restored!");
this._closeDialog();
},
(error) => {
this._error = error.body.message;
}
);
} else {
fireEvent(this, "restoring");
fetch(`/api/hassio/snapshots/${this._snapshot!.slug}/restore/partial`, {
method: "POST",
body: JSON.stringify(data),
});
this._closeDialog();
}
}
private async _fullRestoreClicked() {
@ -323,6 +365,8 @@ class HassioSnapshotDialog extends LitElement {
!(await showConfirmationDialog(this, {
title:
"Are you sure you want to wipe your system and restore this snapshot?",
confirmText: "restore",
dismissText: "cancel",
}))
) {
return;
@ -331,28 +375,38 @@ class HassioSnapshotDialog extends LitElement {
const data = this._snapshot!.protected
? { password: this._snapshotPassword }
: undefined;
this.hass
.callApi(
"POST",
`hassio/snapshots/${this._snapshot!.slug}/restore/full`,
data
)
.then(
() => {
alert("Snapshot restored!");
this._closeDialog();
},
(error) => {
this._error = error.body.message;
}
);
if (!this._onboarding) {
this.hass
.callApi(
"POST",
`hassio/snapshots/${this._snapshot!.slug}/restore/full`,
data
)
.then(
() => {
alert("Snapshot restored!");
this._closeDialog();
},
(error) => {
this._error = error.body.message;
}
);
} else {
fireEvent(this, "restoring");
fetch(`/api/hassio/snapshots/${this._snapshot!.slug}/restore/full`, {
method: "POST",
body: JSON.stringify(data),
});
this._closeDialog();
}
}
private async _deleteClicked() {
if (
!(await showConfirmationDialog(this, {
title: "Are you sure you want to delete this snapshot?",
confirmText: "delete",
dismissText: "cancel",
}))
) {
return;
@ -363,7 +417,9 @@ class HassioSnapshotDialog extends LitElement {
.callApi("POST", `hassio/snapshots/${this._snapshot!.slug}/remove`)
.then(
() => {
this._dialogParams!.onDelete();
if (this._dialogParams!.onDelete) {
this._dialogParams!.onDelete();
}
this._closeDialog();
},
(error) => {

View File

@ -2,7 +2,8 @@ import { fireEvent } from "../../../../src/common/dom/fire_event";
export interface HassioSnapshotDialogParams {
slug: string;
onDelete: () => void;
onDelete?: () => void;
onboarding?: boolean;
}
export const showHassioSnapshotDialog = (

View File

@ -3,7 +3,8 @@ import "./dialog-hassio-snapshot-upload";
export interface HassioSnapshotUploadDialogParams {
showSnapshot: (slug: string) => void;
reloadSnapshot: () => Promise<void>;
reloadSnapshot?: () => Promise<void>;
onboarding?: boolean;
}
export const showSnapshotUploadDialog = (

View File

@ -10,11 +10,11 @@ import {
LitElement,
property,
PropertyValues,
query,
TemplateResult,
} from "lit-element";
import { classMap } from "lit-html/directives/class-map";
import { fireEvent } from "../common/dom/fire_event";
import { HomeAssistant } from "../types";
import "./ha-circular-progress";
import "./ha-svg-icon";
@ -26,8 +26,6 @@ declare global {
@customElement("ha-file-upload")
export class HaFileUpload extends LitElement {
public hass!: HomeAssistant;
@property() public accept!: string;
@property() public icon!: string;
@ -38,8 +36,20 @@ export class HaFileUpload extends LitElement {
@property({ type: Boolean }) private uploading = false;
@property({ type: Boolean, attribute: "auto-open-file-dialog" })
private autoOpenFileDialog = false;
@internalProperty() private _drag = false;
@query("#input") private _input?: HTMLInputElement;
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
if (this.autoOpenFileDialog) {
this._input?.click();
}
}
protected updated(changedProperties: PropertyValues) {
if (changedProperties.has("_drag") && !this.uploading) {
(this.shadowRoot!.querySelector(

View File

@ -41,7 +41,6 @@ export class HaPictureUpload extends LitElement {
public render(): TemplateResult {
return html`
<ha-file-upload
.hass=${this.hass}
.icon=${mdiImagePlus}
.label=${this.label ||
this.hass.localize("ui.components.picture-upload.label")}

17
src/data/discovery.ts Normal file
View File

@ -0,0 +1,17 @@
export interface DiscoveryInformation {
uuid: string;
base_url: string | null;
external_url: string | null;
internal_url: string | null;
location_name: string;
installation_type: string;
requires_api_password: boolean;
version: string;
}
export const fetchDiscoveryInformation = async (): Promise<
DiscoveryInformation
> => {
const response = await fetch("/api/discovery_info", { method: "GET" });
return await response.json();
};

View File

@ -46,12 +46,20 @@ export const fetchHassioSnapshotInfo = async (
hass: HomeAssistant,
snapshot: string
) => {
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioSnapshotDetail>>(
"GET",
`hassio/snapshots/${snapshot}/info`
)
);
if (hass) {
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioSnapshotDetail>>(
"GET",
`hassio/snapshots/${snapshot}/info`
)
);
}
// When called from onboarding we don't have hass
const resp = await fetch(`/api/hassio/snapshots/${snapshot}/info`, {
method: "GET",
});
const data = (await resp.json()).data;
return data;
};
export const reloadHassioSnapshots = async (hass: HomeAssistant) => {
@ -85,15 +93,25 @@ export const uploadSnapshot = async (
file: File
): Promise<HassioResponse<HassioSnapshot>> => {
const fd = new FormData();
let resp;
fd.append("file", file);
const resp = await hass.fetchWithAuth("/api/hassio/snapshots/new/upload", {
method: "POST",
body: fd,
});
if (hass) {
resp = await hass.fetchWithAuth("/api/hassio/snapshots/new/upload", {
method: "POST",
body: fd,
});
} else {
// When called from onboarding we don't have hass
resp = await fetch("/api/hassio/snapshots/new/upload", {
method: "POST",
body: fd,
});
}
if (resp.status === 413) {
throw new Error("Uploaded snapshot is too large");
} else if (resp.status !== 200) {
throw new Error("Unknown error");
throw new Error(`${resp.status} ${resp.statusText}`);
}
return await resp.json();
};

View File

@ -1,21 +1,23 @@
import {
Auth,
createConnection,
genClientId,
getAuth,
subscribeConfig,
genClientId,
} from "home-assistant-js-websocket";
import {
customElement,
html,
property,
internalProperty,
property,
PropertyValues,
TemplateResult,
} from "lit-element";
import { HASSDomEvent } from "../common/dom/fire_event";
import { extractSearchParamsObject } from "../common/url/search-params";
import { subscribeOne } from "../common/util/subscribe-one";
import { hassUrl, AuthUrlSearchParams } from "../data/auth";
import { AuthUrlSearchParams, hassUrl } from "../data/auth";
import { fetchDiscoveryInformation } from "../data/discovery";
import {
fetchOnboardingOverview,
OnboardingResponses,
@ -29,7 +31,6 @@ import { HomeAssistant } from "../types";
import { registerServiceWorker } from "../util/register-service-worker";
import "./onboarding-create-user";
import "./onboarding-loading";
import { extractSearchParamsObject } from "../common/url/search-params";
type OnboardingEvent =
| {
@ -62,6 +63,10 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
@internalProperty() private _loading = false;
@internalProperty() private _restoring = false;
@internalProperty() private _supervisor?: boolean;
@internalProperty() private _steps?: OnboardingStep[];
protected render(): TemplateResult {
@ -72,10 +77,21 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
}
if (step.step === "user") {
return html`
<onboarding-create-user
.localize=${this.localize}
.language=${this.language}
></onboarding-create-user>
${!this._restoring
? html`<onboarding-create-user
.localize=${this.localize}
.language=${this.language}
>
</onboarding-create-user>`
: ""}
${this._supervisor
? html`<onboarding-restore-snapshot
.localize=${this.localize}
.restoring=${this._restoring}
@restoring=${this._restoringSnapshot}
>
</onboarding-restore-snapshot>`
: ""}
`;
}
if (step.step === "core_config") {
@ -100,6 +116,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._fetchOnboardingSteps();
this._fetchDiscoveryInformation();
import(
/* webpackChunkName: "onboarding-integrations" */ "./onboarding-integrations"
);
@ -127,6 +144,32 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
return this._steps ? this._steps.find((stp) => !stp.done) : undefined;
}
private _restoringSnapshot() {
this._restoring = true;
}
private async _fetchDiscoveryInformation(): Promise<void> {
try {
const response = await fetchDiscoveryInformation();
this._supervisor = [
"Home Assistant OS",
"Home Assistant Supervised",
].includes(response.installation_type);
if (this._supervisor) {
// Only load if we have supervisor
import(
/* webpackChunkName: "onboarding-restore-snapshot" */ "./onboarding-restore-snapshot"
);
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(
"Something went wrong loading onboarding-restore-snapshot",
err
);
}
}
private async _fetchOnboardingSteps() {
try {
const response = await (window.stepsPromise || fetchOnboardingOverview());

View File

@ -0,0 +1,172 @@
import "@material/mwc-button/mwc-button";
import {
css,
CSSResult,
customElement,
html,
internalProperty,
LitElement,
property,
TemplateResult,
} from "lit-element";
import "../../hassio/src/components/hassio-ansi-to-html";
import { showHassioSnapshotDialog } from "../../hassio/src/dialogs/snapshot/show-dialog-hassio-snapshot";
import { showSnapshotUploadDialog } from "../../hassio/src/dialogs/snapshot/show-dialog-snapshot-upload";
import { navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-card";
import { makeDialogManager } from "../dialogs/make-dialog-manager";
import { ProvideHassLitMixin } from "../mixins/provide-hass-lit-mixin";
import { haStyle } from "../resources/styles";
import "./onboarding-loading";
declare global {
interface HASSDomEvents {
restoring: undefined;
}
}
@customElement("onboarding-restore-snapshot")
class OnboardingRestoreSnapshot extends ProvideHassLitMixin(LitElement) {
@property() public localize!: LocalizeFunc;
@property() public language!: string;
@property({ type: Boolean }) private restoring = false;
@internalProperty() private _log?: string;
@internalProperty() private _showFullLog = false;
protected render(): TemplateResult {
return this.restoring
? html`<ha-card
.header=${this.localize(
"ui.panel.page-onboarding.restore.in_progress"
)}
>
${this._log
? this._showFullLog
? html`<hassio-ansi-to-html .content=${this._log}>
</hassio-ansi-to-html>`
: html`<onboarding-loading></onboarding-loading>
<hassio-ansi-to-html
class="logentry"
.content=${this._lastLogEntry(this._log)}
>
</hassio-ansi-to-html>`
: ""}
<div class="card-actions">
<mwc-button @click=${this._toggeFullLog}>
${this._showFullLog
? this.localize("ui.panel.page-onboarding.restore.hide_log")
: this.localize("ui.panel.page-onboarding.restore.show_log")}
</mwc-button>
</div>
</ha-card>`
: html`
<button class="link" @click=${this._uploadSnapshot}>
${this.localize("ui.panel.page-onboarding.restore.description")}
</button>
`;
}
private _toggeFullLog(): void {
this._showFullLog = !this._showFullLog;
}
private _filterLogs(logs: string): string {
// Filter out logs that is not relevant to show during the restore
return logs
.split("\n")
.filter(
(entry) =>
!entry.includes("/supervisor/logs") &&
!entry.includes("/supervisor/ping") &&
!entry.includes("DEBUG")
)
.join("\n")
.replace(/\s[A-Z]+\s\(\w+\)\s\[[\w.]+\]/gi, "")
.replace(/\d{2}-\d{2}-\d{2}\s/gi, "");
}
private _lastLogEntry(logs: string): string {
return logs
.split("\n")
.slice(-2)[0]
.replace(/\d{2}:\d{2}:\d{2}\s/gi, "");
}
private _uploadSnapshot(): void {
showSnapshotUploadDialog(this, {
showSnapshot: (slug: string) => this._showSnapshotDialog(slug),
onboarding: true,
});
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
makeDialogManager(this, this.shadowRoot!);
setInterval(() => this._getLogs(), 1000);
}
private async _getLogs(): Promise<void> {
if (this.restoring) {
try {
const response = await fetch("/api/hassio/supervisor/logs", {
method: "GET",
});
const logs = await response.text();
this._log = this._filterLogs(logs);
if (this._log.match(/\d{2}:\d{2}:\d{2}\s.*Restore\s\w+\sdone/)) {
// The log indicates that the restore done, navigate the user back to base
navigate(this, "/", true);
location.reload();
}
} catch (err) {
this._log = err.toString();
}
}
}
private _showSnapshotDialog(slug: string): void {
showHassioSnapshotDialog(this, {
slug,
onboarding: true,
});
}
static get styles(): CSSResult[] {
return [
haStyle,
css`
.logentry {
text-align: center;
}
ha-card {
padding: 4px;
margin-top: 8px;
}
hassio-ansi-to-html {
display: block;
line-height: 22px;
padding: 0 8px;
white-space: pre-wrap;
}
@media all and (min-width: 600px) {
ha-card {
width: 600px;
margin-left: -100px;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-snapshot": OnboardingRestoreSnapshot;
}
}

View File

@ -2827,6 +2827,12 @@
"intro": "Devices and services are represented in Home Assistant as integrations. You can set them up now, or do it later from the configuration screen.",
"more_integrations": "More",
"finish": "Finish"
},
"restore": {
"description": "Alternatively you can restore from a previous snapshot.",
"in_progress": "Restore in progress",
"show_log": "Show full log",
"hide_log": "Hide full log"
}
},
"custom": {