diff --git a/hassio/src/components/hassio-upload-snapshot.ts b/hassio/src/components/hassio-upload-snapshot.ts new file mode 100644 index 0000000000..dc838988a8 --- /dev/null +++ b/hassio/src/components/hassio-upload-snapshot.ts @@ -0,0 +1,80 @@ +import "../../../src/components/ha-file-upload"; +import "@material/mwc-icon-button/mwc-icon-button"; +import { mdiFolderUpload } from "@mdi/js"; +import "@polymer/iron-input/iron-input"; +import "@polymer/paper-input/paper-input-container"; +import { + customElement, + html, + internalProperty, + LitElement, + TemplateResult, +} from "lit-element"; +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, +} from "../../../src/data/hassio/snapshot"; +import { HomeAssistant } from "../../../src/types"; +import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box"; + +declare global { + interface HASSDomEvents { + "snapshot-uploaded": { snapshot: HassioSnapshot }; + } +} + +@customElement("hassio-upload-snapshot") +export class HassioUploadSnapshot extends LitElement { + public hass!: HomeAssistant; + + @internalProperty() public value: string | null = null; + + @internalProperty() private _uploading = false; + + public render(): TemplateResult { + return html` + + `; + } + + private async _uploadFile(ev) { + const file = ev.detail.files[0]; + + if (!["application/x-tar"].includes(file.type)) { + showAlertDialog(this, { + title: "Unsupported file format", + text: "Please choose a Home Assistant snapshot file (.tar)", + }); + return; + } + this._uploading = true; + try { + const snapshot = await uploadSnapshot(this.hass, file); + fireEvent(this, "snapshot-uploaded", { snapshot: snapshot.data }); + } catch (err) { + showAlertDialog(this, { + title: "Upload failed", + text: extractApiErrorMessage(err), + }); + } finally { + this._uploading = false; + } + } +} + +declare global { + interface HTMLElementTagNameMap { + "hassio-upload-snapshot": HassioUploadSnapshot; + } +} diff --git a/hassio/src/dialogs/snapshot/dialog-hassio-snapshot-upload.ts b/hassio/src/dialogs/snapshot/dialog-hassio-snapshot-upload.ts new file mode 100644 index 0000000000..38862da37d --- /dev/null +++ b/hassio/src/dialogs/snapshot/dialog-hassio-snapshot-upload.ts @@ -0,0 +1,75 @@ +import { + CSSResult, + customElement, + html, + internalProperty, + LitElement, + property, + TemplateResult, +} from "lit-element"; +import { fireEvent } from "../../../../src/common/dom/fire_event"; +import { createCloseHeading } from "../../../../src/components/ha-dialog"; +import { HassDialog } from "../../../../src/dialogs/make-dialog-manager"; +import { haStyleDialog } from "../../../../src/resources/styles"; +import type { HomeAssistant } from "../../../../src/types"; +import "../../components/hassio-upload-snapshot"; +import { HassioSnapshotUploadDialogParams } from "./show-dialog-snapshot-upload"; + +@customElement("dialog-hassio-snapshot-upload") +export class DialogHassioSnapshotUpload extends LitElement + implements HassDialog { + @property({ attribute: false }) public hass!: HomeAssistant; + + @internalProperty() private _params?: HassioSnapshotUploadDialogParams; + + public async showDialog( + params: HassioSnapshotUploadDialogParams + ): Promise { + this._params = params; + await this.updateComplete; + } + + public closeDialog(): void { + this._params?.reloadSnapshot(); + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render(): TemplateResult { + if (!this._params) { + return html``; + } + + return html` + + + + `; + } + + private _snapshotUploaded(ev) { + const snapshot = ev.detail.snapshot; + this._params?.showSnapshot(snapshot.slug); + this.closeDialog(); + } + + static get styles(): CSSResult { + return haStyleDialog; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-hassio-snapshot-upload": DialogHassioSnapshotUpload; + } +} diff --git a/hassio/src/dialogs/snapshot/show-dialog-snapshot-upload.ts b/hassio/src/dialogs/snapshot/show-dialog-snapshot-upload.ts new file mode 100644 index 0000000000..4d8f1986b9 --- /dev/null +++ b/hassio/src/dialogs/snapshot/show-dialog-snapshot-upload.ts @@ -0,0 +1,21 @@ +import { fireEvent } from "../../../../src/common/dom/fire_event"; +import "./dialog-hassio-snapshot-upload"; + +export interface HassioSnapshotUploadDialogParams { + showSnapshot: (slug: string) => void; + reloadSnapshot: () => Promise; +} + +export const showSnapshotUploadDialog = ( + element: HTMLElement, + dialogParams: HassioSnapshotUploadDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-hassio-snapshot-upload", + dialogImport: () => + import( + /* webpackChunkName: "dialog-hassio-snapshot-upload" */ "./dialog-hassio-snapshot-upload" + ), + dialogParams, + }); +}; diff --git a/hassio/src/snapshots/hassio-snapshots.ts b/hassio/src/snapshots/hassio-snapshots.ts index 18f2b1d0ed..8f040c906b 100644 --- a/hassio/src/snapshots/hassio-snapshots.ts +++ b/hassio/src/snapshots/hassio-snapshots.ts @@ -1,6 +1,12 @@ import "@material/mwc-button"; import "@material/mwc-icon-button"; -import { mdiPackageVariant, mdiPackageVariantClosed, mdiReload } from "@mdi/js"; +import { ActionDetail } from "@material/mwc-list/mwc-list-foundation"; +import "@material/mwc-list/mwc-list-item"; +import { + mdiDotsVertical, + mdiPackageVariant, + mdiPackageVariantClosed, +} from "@mdi/js"; import "@polymer/paper-checkbox/paper-checkbox"; import type { PaperCheckboxElement } from "@polymer/paper-checkbox/paper-checkbox"; import "@polymer/paper-input/paper-input"; @@ -19,8 +25,10 @@ import { PropertyValues, TemplateResult, } from "lit-element"; +import { atLeastVersion } from "../../../src/common/config/version"; import { fireEvent } from "../../../src/common/dom/fire_event"; import "../../../src/components/buttons/ha-progress-button"; +import "../../../src/components/ha-button-menu"; import "../../../src/components/ha-card"; import "../../../src/components/ha-svg-icon"; import { extractApiErrorMessage } from "../../../src/data/hassio/common"; @@ -39,7 +47,9 @@ import { PolymerChangedEvent } from "../../../src/polymer-types"; import { haStyle } from "../../../src/resources/styles"; import { HomeAssistant, Route } from "../../../src/types"; import "../components/hassio-card-content"; +import "../components/hassio-upload-snapshot"; import { showHassioSnapshotDialog } from "../dialogs/snapshot/show-dialog-hassio-snapshot"; +import { showSnapshotUploadDialog } from "../dialogs/snapshot/show-dialog-snapshot-upload"; import { supervisorTabs } from "../hassio-tabs"; import { hassioStyle } from "../resources/hassio-style"; @@ -101,14 +111,23 @@ class HassioSnapshots extends LitElement { .tabs=${supervisorTabs} > Snapshots - - - - + + + + + Reload + + ${atLeastVersion(this.hass.config.version, 0, 116) + ? html` + Upload snapshot + ` + : ""} +

@@ -257,6 +276,17 @@ class HassioSnapshots extends LitElement { } } + private _handleAction(ev: CustomEvent) { + switch (ev.detail.index) { + case 0: + this.refreshData(); + break; + case 1: + this._showUploadSnapshotDialog(); + break; + } + } + private _handleTextValueChanged(ev: PolymerChangedEvent) { const input = ev.currentTarget as PaperInputElement; this[`_${input.name}`] = ev.detail.value; @@ -362,6 +392,17 @@ class HassioSnapshots extends LitElement { }); } + private _showUploadSnapshotDialog() { + showSnapshotUploadDialog(this, { + showSnapshot: (slug: string) => + showHassioSnapshotDialog(this, { + slug, + onDelete: () => this._updateSnapshots(), + }), + reloadSnapshot: () => this.refreshData(), + }); + } + static get styles(): CSSResultArray { return [ haStyle, diff --git a/src/components/ha-file-upload.ts b/src/components/ha-file-upload.ts new file mode 100644 index 0000000000..c9199a4425 --- /dev/null +++ b/src/components/ha-file-upload.ts @@ -0,0 +1,174 @@ +import "@material/mwc-icon-button/mwc-icon-button"; +import { mdiClose } from "@mdi/js"; +import "@polymer/iron-input/iron-input"; +import "@polymer/paper-input/paper-input-container"; +import { + css, + customElement, + html, + internalProperty, + LitElement, + property, + PropertyValues, + 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"; + +declare global { + interface HASSDomEvents { + "file-picked": { files: FileList }; + } +} + +@customElement("ha-file-upload") +export class HaFileUpload extends LitElement { + public hass!: HomeAssistant; + + @property() public accept!: string; + + @property() public icon!: string; + + @property() public label!: string; + + @property() public value: string | TemplateResult | null = null; + + @property({ type: Boolean }) private uploading = false; + + @internalProperty() private _drag = false; + + protected updated(changedProperties: PropertyValues) { + if (changedProperties.has("_drag") && !this.uploading) { + (this.shadowRoot!.querySelector( + "paper-input-container" + ) as any)._setFocused(this._drag); + } + } + + public render(): TemplateResult { + return html` + ${this.uploading + ? html`` + : html` + + `} + `; + } + + private _handleDrop(ev: DragEvent) { + ev.preventDefault(); + ev.stopPropagation(); + if (ev.dataTransfer?.files) { + fireEvent(this, "file-picked", { files: ev.dataTransfer.files }); + } + this._drag = false; + } + + private _handleDragStart(ev: DragEvent) { + ev.preventDefault(); + ev.stopPropagation(); + this._drag = true; + } + + private _handleDragEnd(ev: DragEvent) { + ev.preventDefault(); + ev.stopPropagation(); + this._drag = false; + } + + private _handleFilePicked(ev) { + fireEvent(this, "file-picked", { files: ev.target.files }); + } + + private _clearValue(ev: Event) { + ev.preventDefault(); + this.value = null; + fireEvent(this, "change"); + } + + static get styles() { + return css` + paper-input-container { + position: relative; + padding: 8px; + margin: 0 -8px; + } + paper-input-container.dragged:before { + position: var(--layout-fit_-_position); + top: var(--layout-fit_-_top); + right: var(--layout-fit_-_right); + bottom: var(--layout-fit_-_bottom); + left: var(--layout-fit_-_left); + background: currentColor; + content: ""; + opacity: var(--dark-divider-opacity); + pointer-events: none; + border-radius: 4px; + } + input.file { + display: none; + } + img { + max-width: 125px; + max-height: 125px; + } + mwc-icon-button { + --mdc-icon-button-size: 24px; + --mdc-icon-size: 20px; + } + ha-circular-progress { + display: block; + text-align-last: center; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-file-upload": HaFileUpload; + } +} diff --git a/src/components/ha-picture-upload.ts b/src/components/ha-picture-upload.ts index 411984d555..38a55aed55 100644 --- a/src/components/ha-picture-upload.ts +++ b/src/components/ha-picture-upload.ts @@ -1,27 +1,26 @@ import "@material/mwc-icon-button/mwc-icon-button"; -import { mdiClose, mdiImagePlus } from "@mdi/js"; +import { mdiImagePlus } from "@mdi/js"; import "@polymer/iron-input/iron-input"; import "@polymer/paper-input/paper-input-container"; import { - css, customElement, html, internalProperty, LitElement, property, - PropertyValues, TemplateResult, } from "lit-element"; -import { classMap } from "lit-html/directives/class-map"; import { fireEvent } from "../common/dom/fire_event"; import { createImage, generateImageThumbnailUrl } from "../data/image"; +import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; +import { + CropOptions, + showImageCropperDialog, +} from "../dialogs/image-cropper-dialog/show-image-cropper-dialog"; import { HomeAssistant } from "../types"; import "./ha-circular-progress"; +import "./ha-file-upload"; import "./ha-svg-icon"; -import { - showImageCropperDialog, - CropOptions, -} from "../dialogs/image-cropper-dialog/show-image-cropper-dialog"; @customElement("ha-picture-upload") export class HaPictureUpload extends LitElement { @@ -37,110 +36,39 @@ export class HaPictureUpload extends LitElement { @property({ type: Number }) public size = 512; - @internalProperty() private _error = ""; - @internalProperty() private _uploading = false; - @internalProperty() private _drag = false; - - protected updated(changedProperties: PropertyValues) { - if (changedProperties.has("_drag")) { - (this.shadowRoot!.querySelector( - "paper-input-container" - ) as any)._setFocused(this._drag); - } - } - public render(): TemplateResult { return html` - ${this._uploading - ? html`` - : html` - ${this._error ? html`
${this._error}
` : ""} - - `} + ` : ""} + @file-picked=${this._handleFilePicked} + accept="image/png, image/jpeg, image/gif" + > `; } - private _handleDrop(ev: DragEvent) { - ev.preventDefault(); - ev.stopPropagation(); - if (ev.dataTransfer?.files) { - if (this.crop) { - this._cropFile(ev.dataTransfer.files[0]); - } else { - this._uploadFile(ev.dataTransfer.files[0]); - } - } - this._drag = false; - } - - private _handleDragStart(ev: DragEvent) { - ev.preventDefault(); - ev.stopPropagation(); - this._drag = true; - } - - private _handleDragEnd(ev: DragEvent) { - ev.preventDefault(); - ev.stopPropagation(); - this._drag = false; - } - private async _handleFilePicked(ev) { + const file = ev.detail.files[0]; if (this.crop) { - this._cropFile(ev.target.files[0]); + this._cropFile(file); } else { - this._uploadFile(ev.target.files[0]); + this._uploadFile(file); } } private async _cropFile(file: File) { if (!["image/png", "image/jpeg", "image/gif"].includes(file.type)) { - this._error = this.hass.localize( - "ui.components.picture-upload.unsupported_format" - ); + showAlertDialog(this, { + text: this.hass.localize( + "ui.components.picture-upload.unsupported_format" + ), + }); return; } showImageCropperDialog(this, { @@ -157,66 +85,26 @@ export class HaPictureUpload extends LitElement { private async _uploadFile(file: File) { if (!["image/png", "image/jpeg", "image/gif"].includes(file.type)) { - this._error = this.hass.localize( - "ui.components.picture-upload.unsupported_format" - ); + showAlertDialog(this, { + text: this.hass.localize( + "ui.components.picture-upload.unsupported_format" + ), + }); return; } this._uploading = true; - this._error = ""; try { const media = await createImage(this.hass, file); this.value = generateImageThumbnailUrl(media.id, this.size); fireEvent(this, "change"); } catch (err) { - this._error = err.toString(); + showAlertDialog(this, { + text: err.toString(), + }); } finally { this._uploading = false; } } - - private _clearPicture(ev: Event) { - ev.preventDefault(); - this.value = null; - this._error = ""; - fireEvent(this, "change"); - } - - static get styles() { - return css` - .error { - color: var(--error-color); - } - paper-input-container { - position: relative; - padding: 8px; - margin: 0 -8px; - } - paper-input-container.dragged:before { - position: var(--layout-fit_-_position); - top: var(--layout-fit_-_top); - right: var(--layout-fit_-_right); - bottom: var(--layout-fit_-_bottom); - left: var(--layout-fit_-_left); - background: currentColor; - content: ""; - opacity: var(--dark-divider-opacity); - pointer-events: none; - border-radius: 4px; - } - img { - max-width: 125px; - max-height: 125px; - } - input.file { - display: none; - } - mwc-icon-button { - --mdc-icon-button-size: 24px; - --mdc-icon-size: 20px; - } - `; - } } declare global { diff --git a/src/data/hassio/snapshot.ts b/src/data/hassio/snapshot.ts index 20d99cc5d4..f08d5a3428 100644 --- a/src/data/hassio/snapshot.ts +++ b/src/data/hassio/snapshot.ts @@ -79,3 +79,21 @@ export const createHassioPartialSnapshot = async ( data ); }; + +export const uploadSnapshot = async ( + hass: HomeAssistant, + file: File +): Promise> => { + const fd = new FormData(); + fd.append("file", file); + const resp = await hass.fetchWithAuth("/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"); + } + return await resp.json(); +};