1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 08:33:31 +01:00

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
This commit is contained in:
Josef Zweck
2026-03-09 16:12:54 +01:00
committed by GitHub
parent 623baea59d
commit 11fd10a011
8 changed files with 500 additions and 24 deletions

View File

@@ -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<boolean>
) =>
hass.connection.subscribeMessage<ManagerStateEvent>(
hass.connection.subscribeMessage<BackupSubscriptionEvent>(
callback,
{
type: "backup/subscribe_events",

View File

@@ -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<SummaryStatus, string> = {
const ICONS: Partial<Record<SummaryStatus, string>> = {
success: mdiCheck,
error: mdiAlertCircleOutline,
warning: mdiAlertOutline,
@@ -42,11 +48,13 @@ class HaBackupSummaryCard extends LitElement {
<div class="summary">
${this.status === "loading"
? html`<ha-spinner></ha-spinner>`
: html`
<div class="icon ${this.status}">
<ha-svg-icon .path=${ICONS[this.status]}></ha-svg-icon>
</div>
`}
: this.status === "none"
? nothing
: html`
<div class="icon ${this.status}">
<ha-svg-icon .path=${ICONS[this.status]}></ha-svg-icon>
</div>
`}
<div class="content">
<p class="heading">${this.heading}</p>
@@ -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;
}
}
`;
}

View File

@@ -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`<ha-svg-icon
slot="start"
.path=${mdiHarddisk}
></ha-svg-icon>`;
}
if (isNetworkMountAgent(agentId)) {
return html`<ha-svg-icon slot="start" .path=${mdiNas}></ha-svg-icon>`;
}
const domain = computeDomain(agentId);
return html`
<img
slot="start"
.src=${brandsUrl({
domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
/>
`;
}
private _renderSegmentedProgress() {
const managerState = this.manager.manager_state;
let segments: ProgressSegment[];
if (managerState === "create_backup") {
segments = this._computeCreateBackupSegments();
} else {
return nothing;
}
return html`
<div class="segmented-progress">
${segments.map(
(segment) => html`
<div class="segment" style="flex: ${segment.flex}">
<div
class="segment-bar ${classMap({
active: segment.state === "active",
completed: segment.state === "completed",
pending: segment.state === "pending",
})}"
></div>
<span class="segment-label">${segment.label}</span>
</div>
`
)}
</div>
`;
}
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`
<div class="agent-list-wrapper">
<ha-md-list class="agent-list">
${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`
<ha-md-list-item>
${this._renderAgentIcon(agent.agent_id)}
<div slot="headline">${name}</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.overview.progress.agent_status.uploaded"
)}
</div>
<ha-svg-icon
slot="end"
class="agent-complete"
.path=${mdiCheck}
></ha-svg-icon>
</ha-md-list-item>
`;
}
return html`
<ha-md-list-item>
${this._renderAgentIcon(agent.agent_id)}
<div slot="headline">${name}</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.overview.progress.agent_status.uploading"
)}
</div>
<span slot="end" class="progress-percentage">
${agentPercent}%
</span>
<ha-spinner slot="end" size="tiny"></ha-spinner>
</ha-md-list-item>
`;
}
return html`
<ha-md-list-item>
${this._renderAgentIcon(agent.agent_id)}
<div slot="headline">${name}</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.overview.progress.agent_status.uploading"
)}
</div>
<ha-spinner slot="end" size="tiny"></ha-spinner>
</ha-md-list-item>
`;
})}
</ha-md-list>
</div>
`;
}
protected render() {
const segmentedProgress = this._renderSegmentedProgress();
const agentProgress = this._renderAgentProgress();
const hasProgressContent =
segmentedProgress !== nothing || agentProgress !== nothing;
return html`
<ha-backup-summary-card
.hass=${this.hass}
.heading=${this._heading}
.description=${this._description}
status="loading"
status="none"
>
${hasProgressContent
? html`
<div class="progress-content">
${segmentedProgress} ${agentProgress}
</div>
`
: nothing}
</ha-backup-summary-card>
`;
}
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 {

View File

@@ -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
);

View File

@@ -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();
}

View File

@@ -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 {
<ha-backup-overview-progress
.hass=${this.hass}
.manager=${this.manager}
.agents=${this.agents}
.uploadProgress=${this.uploadProgress}
>
</ha-backup-overview-progress>
`

View File

@@ -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<UnsubscribeFunc>[] {
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) {

View File

@@ -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.",