From 11fd10a011337e9600a5db25e1dfda4fb1173aca Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 9 Mar 2026 16:12:54 +0100 Subject: [PATCH] Report progress for backup upload (#29748) * Add progress bar for backup uploads * add sort * visual fixes * fix sorting * react to event * cleanup * remove log * remove fom union type * different progress bar * styling fixes * guard against empty space in other backup states * cleanup * xleanup * add checkmark on completion * remove progress bar * remove spinner during upload * remove spinner, animate * add subtext * prettier * review comments * linesbreaks --- src/data/backup_manager.ts | 13 +- .../components/ha-backup-summary-card.ts | 33 +- .../overview/ha-backup-overview-progress.ts | 434 +++++++++++++++++- .../overview/ha-backup-overview-summary.ts | 4 +- .../backup/dialogs/dialog-restore-backup.ts | 3 + .../backup/ha-config-backup-overview.ts | 7 + src/panels/config/backup/ha-config-backup.ts | 18 + src/translations/en.json | 12 +- 8 files changed, 500 insertions(+), 24 deletions(-) diff --git a/src/data/backup_manager.ts b/src/data/backup_manager.ts index f391e35d87..e92f0f7c99 100644 --- a/src/data/backup_manager.ts +++ b/src/data/backup_manager.ts @@ -65,6 +65,13 @@ interface RestoreBackupEvent { state: RestoreBackupState; } +export interface UploadBackupEvent { + manager_state: BackupManagerState; + agent_id: string; + uploaded_bytes: number; + total_bytes: number; +} + export type ManagerState = | "idle" | "create_backup" @@ -77,12 +84,14 @@ export type ManagerStateEvent = | ReceiveBackupEvent | RestoreBackupEvent; +export type BackupSubscriptionEvent = ManagerStateEvent | UploadBackupEvent; + export const subscribeBackupEvents = ( hass: HomeAssistant, - callback: (event: ManagerStateEvent) => void, + callback: (event: BackupSubscriptionEvent) => void, preCheck?: () => boolean | Promise ) => - hass.connection.subscribeMessage( + hass.connection.subscribeMessage( callback, { type: "backup/subscribe_events", diff --git a/src/panels/config/backup/components/ha-backup-summary-card.ts b/src/panels/config/backup/components/ha-backup-summary-card.ts index a9d4e063de..2104ad0af0 100644 --- a/src/panels/config/backup/components/ha-backup-summary-card.ts +++ b/src/panels/config/backup/components/ha-backup-summary-card.ts @@ -12,9 +12,15 @@ import "../../../../components/ha-card"; import "../../../../components/ha-icon"; import "../../../../components/ha-spinner"; -type SummaryStatus = "success" | "error" | "info" | "warning" | "loading"; +type SummaryStatus = + | "success" + | "error" + | "info" + | "warning" + | "loading" + | "none"; -const ICONS: Record = { +const ICONS: Partial> = { success: mdiCheck, error: mdiAlertCircleOutline, warning: mdiAlertOutline, @@ -42,11 +48,13 @@ class HaBackupSummaryCard extends LitElement {
${this.status === "loading" ? html`` - : html` -
- -
- `} + : this.status === "none" + ? nothing + : html` +
+ +
+ `}

${this.heading}

@@ -92,6 +100,7 @@ class HaBackupSummaryCard extends LitElement { justify-content: center; overflow: hidden; --icon-color: var(--primary-color); + animation: pop-in var(--ha-animation-duration-normal, 250ms) ease-out; } .icon.success { --icon-color: var(--success-color); @@ -155,6 +164,16 @@ class HaBackupSummaryCard extends LitElement { justify-content: flex-end; } } + @keyframes pop-in { + from { + transform: scale(0); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } + } `; } diff --git a/src/panels/config/backup/components/overview/ha-backup-overview-progress.ts b/src/panels/config/backup/components/overview/ha-backup-overview-progress.ts index 223a351325..d5a249cbeb 100644 --- a/src/panels/config/backup/components/overview/ha-backup-overview-progress.ts +++ b/src/panels/config/backup/components/overview/ha-backup-overview-progress.ts @@ -1,34 +1,93 @@ -import { html, LitElement } from "lit"; +import { mdiCheck, mdiHarddisk, mdiNas } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; +import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; -import type { ManagerStateEvent } from "../../../../../data/backup_manager"; +import { classMap } from "lit/directives/class-map"; +import { isComponentLoaded } from "../../../../../common/config/is_component_loaded"; +import { computeDomain } from "../../../../../common/entity/compute_domain"; +import "../../../../../components/ha-md-list"; +import "../../../../../components/ha-md-list-item"; +import "../../../../../components/ha-spinner"; +import "../../../../../components/ha-svg-icon"; +import type { BackupAgent } from "../../../../../data/backup"; +import { + computeBackupAgentName, + isLocalAgent, + isNetworkMountAgent, +} from "../../../../../data/backup"; +import type { + CreateBackupStage, + ManagerStateEvent, +} from "../../../../../data/backup_manager"; import type { HomeAssistant } from "../../../../../types"; +import { brandsUrl } from "../../../../../util/brands-url"; import "../ha-backup-summary-card"; +type SegmentState = "pending" | "active" | "completed"; + +interface ProgressSegment { + label: string; + state: SegmentState; + flex: number; +} + +const HA_STAGES: CreateBackupStage[] = ["home_assistant"]; + +const ADDON_STAGES: CreateBackupStage[] = [ + "addons", + "apps", + "addon_repositories", + "app_repositories", + "docker_config", + "await_addon_restarts", + "await_app_restarts", +]; + +const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"]; + +// Ordered groups matching actual backend execution order +const STAGE_ORDER: CreateBackupStage[][] = [ + ADDON_STAGES, + MEDIA_STAGES, + HA_STAGES, + ["upload_to_agents"], +]; + @customElement("ha-backup-overview-progress") export class HaBackupOverviewProgress extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public manager!: ManagerStateEvent; + @property({ attribute: false }) public agents: BackupAgent[] = []; + + @property({ attribute: false }) public uploadProgress: Record< + string, + { uploaded_bytes: number; total_bytes: number } + > = {}; + private get _heading() { - const state = this.manager.manager_state; - if (state === "idle") { + const managerState = this.manager.manager_state; + if (managerState === "idle") { return ""; } return this.hass.localize( - `ui.panel.config.backup.overview.progress.heading.${state}` + `ui.panel.config.backup.overview.progress.heading.${managerState}` ); } + private get _isUploadStage(): boolean { + if (this.manager.manager_state === "idle") { + return false; + } + return this.manager.stage === "upload_to_agents"; + } + private get _description() { switch (this.manager.manager_state) { case "create_backup": - if (!this.manager.stage) { - return ""; - } - return this.hass.localize( - `ui.panel.config.backup.overview.progress.description.create_backup.${this.manager.stage}` - ); + return ""; + case "restore_backup": if (!this.manager.stage) { return ""; @@ -44,22 +103,373 @@ export class HaBackupOverviewProgress extends LitElement { return this.hass.localize( `ui.panel.config.backup.overview.progress.description.receive_backup.${this.manager.stage}` ); + default: return ""; } } + private _computeAgentPercent(agentId: string): number | undefined { + const progress = this.uploadProgress[agentId]; + if (!progress || progress.total_bytes === 0) { + return undefined; + } + return Math.round((progress.uploaded_bytes / progress.total_bytes) * 100); + } + + private _getStageGroupIndex(stage: CreateBackupStage): number { + return STAGE_ORDER.findIndex((group) => group.includes(stage)); + } + + private _getSegmentState( + segmentGroupIndex: number, + currentGroupIndex: number + ): SegmentState { + if (currentGroupIndex > segmentGroupIndex) { + return "completed"; + } + if (currentGroupIndex === segmentGroupIndex) { + return "active"; + } + return "pending"; + } + + private _computeCreateBackupSegments(): ProgressSegment[] { + const stage = + this.manager.manager_state === "create_backup" + ? this.manager.stage + : null; + + const currentGroupIndex = stage ? this._getStageGroupIndex(stage) : -1; + const isHassio = isComponentLoaded(this.hass, "hassio"); + + if (isHassio) { + // Split creation into 3 sub-segments + Upload + return [ + { + label: this.hass.localize( + "ui.panel.config.backup.overview.progress.segments.apps" + ), + state: this._getSegmentState(0, currentGroupIndex), + flex: 1, + }, + { + label: this.hass.localize( + "ui.panel.config.backup.overview.progress.segments.media" + ), + state: this._getSegmentState(1, currentGroupIndex), + flex: 1, + }, + { + label: this.hass.localize( + "ui.panel.config.backup.overview.progress.segments.home_assistant" + ), + state: this._getSegmentState(2, currentGroupIndex), + flex: 1, + }, + { + label: this.hass.localize( + "ui.panel.config.backup.overview.progress.segments.upload" + ), + state: this._getSegmentState(3, currentGroupIndex), + flex: 3, + }, + ]; + } + + // Non-HAOS: No app segment, just Media, HA and Upload + return [ + { + label: this.hass.localize( + "ui.panel.config.backup.overview.progress.segments.media" + ), + state: this._getSegmentState(1, currentGroupIndex), + flex: 1, + }, + { + label: this.hass.localize( + "ui.panel.config.backup.overview.progress.segments.home_assistant" + ), + state: this._getSegmentState(2, currentGroupIndex), + flex: 1, + }, + { + label: this.hass.localize( + "ui.panel.config.backup.overview.progress.segments.upload" + ), + state: this._getSegmentState(3, currentGroupIndex), + flex: 3, + }, + ]; + } + + private _renderAgentIcon(agentId: string) { + if (isLocalAgent(agentId)) { + return html``; + } + if (isNetworkMountAgent(agentId)) { + return html``; + } + const domain = computeDomain(agentId); + return html` + + `; + } + + private _renderSegmentedProgress() { + const managerState = this.manager.manager_state; + + let segments: ProgressSegment[]; + + if (managerState === "create_backup") { + segments = this._computeCreateBackupSegments(); + } else { + return nothing; + } + + return html` +
+ ${segments.map( + (segment) => html` +
+
+ ${segment.label} +
+ ` + )} +
+ `; + } + + private _renderAgentProgress() { + if (!this._isUploadStage || this.agents.length === 0) { + return nothing; + } + + const hasProgress = Object.keys(this.uploadProgress).length > 0; + + if (!hasProgress) { + return nothing; + } + + return html` +
+ + ${this.agents.map((agent) => { + const name = computeBackupAgentName( + this.hass.localize, + agent.agent_id, + this.agents + ); + const agentPercent = this._computeAgentPercent(agent.agent_id); + + if (agentPercent !== undefined) { + if (agentPercent >= 100) { + return html` + + ${this._renderAgentIcon(agent.agent_id)} +
${name}
+
+ ${this.hass.localize( + "ui.panel.config.backup.overview.progress.agent_status.uploaded" + )} +
+ +
+ `; + } + return html` + + ${this._renderAgentIcon(agent.agent_id)} +
${name}
+
+ ${this.hass.localize( + "ui.panel.config.backup.overview.progress.agent_status.uploading" + )} +
+ + ${agentPercent}% + + +
+ `; + } + + return html` + + ${this._renderAgentIcon(agent.agent_id)} +
${name}
+
+ ${this.hass.localize( + "ui.panel.config.backup.overview.progress.agent_status.uploading" + )} +
+ +
+ `; + })} +
+
+ `; + } + protected render() { + const segmentedProgress = this._renderSegmentedProgress(); + const agentProgress = this._renderAgentProgress(); + const hasProgressContent = + segmentedProgress !== nothing || agentProgress !== nothing; + return html` + ${hasProgressContent + ? html` +
+ ${segmentedProgress} ${agentProgress} +
+ ` + : nothing}
`; } + + static get styles(): CSSResultGroup { + return [ + css` + .progress-content { + padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4); + } + .segmented-progress { + display: flex; + gap: var(--ha-space-2); + } + .segment { + display: flex; + flex-direction: column; + gap: var(--ha-space-1); + min-width: 0; + } + .segment-bar { + height: 8px; + border-radius: var(--ha-border-radius-pill); + transition: background-color 0.3s ease; + } + .segment-bar.pending { + background-color: var(--divider-color); + } + .segment-bar.active { + background-color: var(--primary-color); + animation: pulse 1.5s ease-in-out infinite; + } + .segment-bar.completed { + background-color: var(--primary-color); + } + .segment-label { + font-size: var(--ha-font-size-xs); + color: var(--secondary-text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + @keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + } + @media (prefers-reduced-motion: reduce) { + .segment-bar.active { + animation: none; + } + } + .agent-list-wrapper { + display: grid; + grid-template-rows: 1fr; + animation: expand var(--ha-animation-duration-slow, 350ms) ease-out; + } + @keyframes expand { + from { + grid-template-rows: 0fr; + opacity: 0; + } + to { + grid-template-rows: 1fr; + opacity: 1; + } + } + .agent-list { + background: none; + padding: 0; + margin-top: var(--ha-space-4); + overflow: hidden; + } + ha-md-list-item { + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + } + ha-md-list-item img { + width: 48px; + } + ha-md-list-item ha-svg-icon[slot="start"] { + --mdc-icon-size: 48px; + color: var(--primary-text-color); + } + .progress-percentage { + font-size: var(--ha-font-size-s); + color: var(--secondary-text-color); + } + ha-md-list-item [slot="supporting-text"] { + display: flex; + align-items: center; + } + .agent-complete { + color: var(--success-color); + --mdc-icon-size: 24px; + animation: pop-in var(--ha-animation-duration-normal, 250ms) ease-out; + } + @keyframes pop-in { + from { + transform: scale(0); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } + } + `, + ]; + } } declare global { diff --git a/src/panels/config/backup/components/overview/ha-backup-overview-summary.ts b/src/panels/config/backup/components/overview/ha-backup-overview-summary.ts index f114e322fb..a9f0e6c2c6 100644 --- a/src/panels/config/backup/components/overview/ha-backup-overview-summary.ts +++ b/src/panels/config/backup/components/overview/ha-backup-overview-summary.ts @@ -58,7 +58,7 @@ class HaBackupOverviewBackups extends LitElement { private _renderSummaryCard( heading: string, - status: "error" | "info" | "warning" | "loading" | "success", + status: "error" | "info" | "warning" | "loading" | "success" | "none", headline: string | null, description?: string | null, lastCompletedDate?: Date @@ -103,7 +103,7 @@ class HaBackupOverviewBackups extends LitElement { if (this.fetching) { return this._renderSummaryCard( this.hass.localize("ui.panel.config.backup.overview.summary.loading"), - "loading", + "none", null, null ); diff --git a/src/panels/config/backup/dialogs/dialog-restore-backup.ts b/src/panels/config/backup/dialogs/dialog-restore-backup.ts index c8d1a2904c..e73e0e15a3 100644 --- a/src/panels/config/backup/dialogs/dialog-restore-backup.ts +++ b/src/panels/config/backup/dialogs/dialog-restore-backup.ts @@ -291,6 +291,9 @@ class DialogRestoreBackup extends LitElement implements HassDialog { this._unsub = subscribeBackupEvents( this.hass!, (event) => { + if ("agent_id" in event) { + return; + } if (event.manager_state === "idle" && this._state === "in_progress") { this.closeDialog(); } diff --git a/src/panels/config/backup/ha-config-backup-overview.ts b/src/panels/config/backup/ha-config-backup-overview.ts index b11fcfc0db..2ce9f42952 100644 --- a/src/panels/config/backup/ha-config-backup-overview.ts +++ b/src/panels/config/backup/ha-config-backup-overview.ts @@ -63,6 +63,11 @@ class HaConfigBackupOverview extends LitElement { @property({ attribute: false }) public agents: BackupAgent[] = []; + @property({ attribute: false }) public uploadProgress: Record< + string, + { uploaded_bytes: number; total_bytes: number } + > = {}; + private _uploadBackup = async () => { await showUploadBackupDialog(this, {}); }; @@ -182,6 +187,8 @@ class HaConfigBackupOverview extends LitElement { ` diff --git a/src/panels/config/backup/ha-config-backup.ts b/src/panels/config/backup/ha-config-backup.ts index d61eb33a36..ea2876ef63 100644 --- a/src/panels/config/backup/ha-config-backup.ts +++ b/src/panels/config/backup/ha-config-backup.ts @@ -52,6 +52,11 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) { @state() private _config?: BackupConfig; + @state() private _uploadProgress: Record< + string, + { uploaded_bytes: number; total_bytes: number } + > = {}; + protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); this._fetchAll(); @@ -138,6 +143,7 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) { pageEl.config = this._config; pageEl.agents = this._agents; pageEl.fetching = this._fetching; + pageEl.uploadProgress = this._uploadProgress; if (!changedProps || changedProps.has("route")) { switch (this._currentPage) { @@ -154,6 +160,17 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) { public hassSubscribe(): Promise[] { return [ subscribeBackupEvents(this.hass!, (event) => { + if ("agent_id" in event) { + this._uploadProgress = { + ...this._uploadProgress, + [event.agent_id]: { + uploaded_bytes: event.uploaded_bytes, + total_bytes: event.total_bytes, + }, + }; + return; + } + const curState = this._manager.manager_state; this._manager = event; @@ -161,6 +178,7 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) { event.manager_state === "idle" && event.manager_state !== curState ) { + this._uploadProgress = {}; this._fetchAll(); } if ("state" in event) { diff --git a/src/translations/en.json b/src/translations/en.json index 08de04400b..95aa19cc42 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3297,6 +3297,16 @@ "receive_file": "Receiving file", "upload_to_agents": "Uploading to locations" } + }, + "segments": { + "home_assistant": "Home Assistant", + "apps": "Apps", + "media": "Media", + "upload": "Uploading backup" + }, + "agent_status": { + "uploading": "Uploading...", + "uploaded": "Uploaded" } }, "summary": { @@ -3304,7 +3314,7 @@ "next_automatic_backup": "Next automatic backup {day} at {time}", "today": "today", "tomorrow": "tomorrow", - "loading": "Loading backups...", + "loading": "Loading backups", "last_backup_failed_heading": "Last automatic backup failed", "last_backup_failed_description": "The last automatic backup triggered {relative_time} wasn't successful.", "last_backup_failed_locations_description": "The last automatic backup created {relative_time} wasn't stored in all locations.",