Use the update integration to provide updates

This commit is contained in:
Ludeeus 2022-03-04 09:45:20 +00:00
parent 604b79696e
commit 9ea8e13c87
8 changed files with 452 additions and 136 deletions

View File

@ -45,7 +45,6 @@ import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
import "../../../src/layouts/hass-loading-screen";
import "../../../src/layouts/hass-subpage";
import "../../../src/layouts/hass-tabs-subpage";
import { SUPERVISOR_UPDATE_NAMES } from "../../../src/panels/config/dashboard/ha-config-updates";
import { HomeAssistant, Route } from "../../../src/types";
import { addonArchIsSupported, extractChangelog } from "../util/addon";
@ -57,6 +56,12 @@ declare global {
type updateType = "os" | "supervisor" | "core" | "addon";
const SUPERVISOR_UPDATE_NAMES = {
core: "Home Assistant Core",
os: "Home Assistant Operating System",
supervisor: "Home Assistant Supervisor",
};
const changelogUrl = (
entry: updateType,
version: string

37
src/data/update.ts Normal file
View File

@ -0,0 +1,37 @@
import { HomeAssistant } from "../types";
export interface UpdateDescription {
identifier: string;
name: string;
domain: string;
current_version: string;
available_version: string;
changelog_content: string | null;
changelog_url: string | null;
icon_url: string | null;
supports_backup: boolean;
}
export interface SkipUpdateParams {
domain: string;
version: string;
identifier: string;
}
export interface PerformUpdateParams extends SkipUpdateParams {
backup?: boolean;
}
export const fetchUpdateInfo = (
hass: HomeAssistant
): Promise<UpdateDescription[]> => hass.callWS({ type: "update/info" });
export const skipUpdate = (
hass: HomeAssistant,
params: SkipUpdateParams
): Promise<void> => hass.callWS({ type: "update/skip", ...params });
export const performUpdate = (
hass: HomeAssistant,
params: PerformUpdateParams
): Promise<void> => hass.callWS({ type: "update/update", ...params });

View File

@ -0,0 +1,211 @@
import "@material/mwc-button/mwc-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/ha-alert";
import "../../components/ha-checkbox";
import "../../components/ha-circular-progress";
import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-faded";
import "../../components/ha-formfield";
import "../../components/ha-icon-button";
import "../../components/ha-markdown";
import {
performUpdate,
skipUpdate,
UpdateDescription,
} from "../../data/update";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { UpdateDialogParams } from "./show-ha-update-dialog";
@customElement("ha-update-dialog")
export class HaUpdateDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@state() private _updating = false;
@state() private _error?: string;
@state() private _update!: UpdateDescription;
_refreshCallback!: () => void;
public async showDialog(dialogParams: UpdateDialogParams): Promise<void> {
this._opened = true;
this._update = dialogParams.update;
this._refreshCallback = dialogParams.refreshCallback;
}
public async closeDialog(): Promise<void> {
this._opened = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
if (!this._opened) {
return html``;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
.heading=${createCloseHeading(
this.hass,
this.hass.localize("ui.panel.config.updates.dialog.title", {
name: this._update.name,
})
)}
>
<div>
${this._error
? html`<ha-alert alert-type="error" .rtl=${computeRTL(this.hass)}>
${this._error}
</ha-alert>`
: ""}
${!this._updating
? html`
${this._update.changelog_content
? html`
<ha-faded>
<ha-markdown .content=${this._update.changelog_content}>
</ha-markdown>
</ha-faded>
`
: ""}
${this._update.changelog_url
? html`<a href=${this._update.changelog_url} target="_blank">
Full changelog
</a> `
: ""}
<p>
${this.hass.localize(
"ui.panel.config.updates.dialog.description",
{
name: this._update.name,
version: this._update.current_version,
newest_version: this._update.available_version,
}
)}
</p>
${this._update.supports_backup
? html`
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.updates.dialog.create_backup"
)}
>
<ha-checkbox checked></ha-checkbox>
</ha-formfield>
`
: ""}
`
: html`<ha-circular-progress alt="Updating" size="large" active>
</ha-circular-progress>
<p class="progress-text">
${this.hass.localize(
"ui.panel.config.updates.dialog.updating",
{
name: this._update.name,
version: this._update.available_version,
}
)}
</p>`}
</div>
${!this._updating
? html`
<mwc-button slot="secondaryAction" @click=${this._skipUpdate}>
${this.hass.localize("ui.common.skip")}
</mwc-button>
<mwc-button
.disabled=${this._updating}
slot="primaryAction"
@click=${this._performUpdate}
>
${this.hass.localize("ui.panel.config.updates.dialog.update")}
</mwc-button>
`
: ""}
</ha-dialog>
`;
}
get _shouldCreateBackup(): boolean {
if (!this._update.supports_backup) {
return false;
}
const checkbox = this.shadowRoot?.querySelector("ha-checkbox");
if (checkbox) {
return checkbox.checked;
}
return true;
}
private async _performUpdate() {
this._error = undefined;
this._updating = true;
try {
await performUpdate(this.hass, {
domain: this._update.domain,
identifier: this._update.identifier,
version: this._update.available_version,
backup: this._shouldCreateBackup,
});
} catch (err: any) {
this._error = err.message;
this._updating = false;
return;
}
this._updating = false;
this._refreshCallback();
this.closeDialog();
}
private async _skipUpdate() {
this._error = undefined;
try {
await skipUpdate(this.hass, {
domain: this._update.domain,
identifier: this._update.identifier,
version: this._update.available_version,
});
} catch (err: any) {
this._error = err.message;
return;
}
this._refreshCallback();
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-circular-progress {
display: block;
margin: 32px;
text-align: center;
}
.progress-text {
text-align: center;
}
ha-markdown {
padding-bottom: 8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-update-dialog": HaUpdateDialog;
}
}

View File

@ -0,0 +1,18 @@
import { fireEvent } from "../../common/dom/fire_event";
import { UpdateDescription } from "../../data/update";
export interface UpdateDialogParams {
update: UpdateDescription;
refreshCallback: () => void;
}
export const showUpdateDialog = (
element: HTMLElement,
dialogParams: UpdateDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-update-dialog",
dialogImport: () => import("./ha-update-dialog"),
dialogParams,
});
};

View File

@ -26,10 +26,6 @@ import "../../../components/ha-menu-button";
import "../../../components/ha-button-menu";
import "../../../components/ha-svg-icon";
import { CloudStatus } from "../../../data/cloud";
import {
refreshSupervisorAvailableUpdates,
SupervisorAvailableUpdates,
} from "../../../data/supervisor/root";
import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
import "../../../layouts/ha-app-layout";
import { haStyle } from "../../../resources/styles";
@ -38,10 +34,11 @@ import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import "./ha-config-navigation";
import "./ha-config-updates";
import { fireEvent } from "../../../common/dom/fire_event";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import { showToast } from "../../../util/toast";
import { documentationUrl } from "../../../util/documentation-url";
import { UpdateDescription } from "../../../data/update";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeRTL } from "../../../common/util/compute_rtl";
const randomTip = (hass: HomeAssistant) => {
const weighted: string[] = [];
@ -114,14 +111,12 @@ class HaConfigDashboard extends LitElement {
@property() public cloudStatus?: CloudStatus;
// null means not available
@property() public supervisorUpdates?: SupervisorAvailableUpdates[] | null;
@property() public updates?: UpdateDescription[] | null;
@property() public showAdvanced!: boolean;
@state() private _tip?: string;
private _notifyUpdates = false;
protected render(): TemplateResult {
return html`
<ha-app-layout>
@ -160,50 +155,53 @@ class HaConfigDashboard extends LitElement {
.isWide=${this.isWide}
full-width
>
${this.supervisorUpdates === undefined
? // Hide everything until updates loaded
html``
: html`${this.supervisorUpdates?.length
? html`<ha-card>
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.supervisorUpdates=${this.supervisorUpdates}
></ha-config-updates>
</ha-card>`
: ""}
<ha-card>
${this.narrow && this.supervisorUpdates?.length
? html`<div class="title">
${this.hass.localize("panel.config")}
</div>`
: ""}
${this.cloudStatus && isComponentLoaded(this.hass, "cloud")
? html`
<ha-config-navigation
.hass=${this.hass}
.narrow=${this.narrow}
.showAdvanced=${this.showAdvanced}
.pages=${[
{
component: "cloud",
path: "/config/cloud",
name: "Home Assistant Cloud",
info: this.cloudStatus,
iconPath: mdiCloudLock,
iconColor: "#3B808E",
},
]}
></ha-config-navigation>
`
: ""}
${this.updates === undefined
? html`<ha-alert .rtl=${computeRTL(this.hass)}>
${this.hass.localize(
"ui.panel.config.updates.checking_updates"
)}
</ha-alert>`
: this.updates?.length
? html`<ha-card>
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updates=${this.updates}
></ha-config-updates>
</ha-card>`
: ""}
<ha-card>
${this.narrow && this.updates?.length
? html`<div class="title">
${this.hass.localize("panel.config")}
</div>`
: ""}
${this.cloudStatus && isComponentLoaded(this.hass, "cloud")
? html`
<ha-config-navigation
.hass=${this.hass}
.narrow=${this.narrow}
.showAdvanced=${this.showAdvanced}
.pages=${configSections.dashboard}
.pages=${[
{
component: "cloud",
path: "/config/cloud",
name: "Home Assistant Cloud",
info: this.cloudStatus,
iconPath: mdiCloudLock,
iconColor: "#3B808E",
},
]}
></ha-config-navigation>
</ha-card>`}
`
: ""}
<ha-config-navigation
.hass=${this.hass}
.narrow=${this.narrow}
.showAdvanced=${this.showAdvanced}
.pages=${configSections.dashboard}
></ha-config-navigation>
</ha-card>
<div class="tips">
<ha-svg-icon .path=${mdiLightbulbOutline}></ha-svg-icon>
<span class="tip-word">Tip!</span>
@ -220,22 +218,6 @@ class HaConfigDashboard extends LitElement {
if (!this._tip && changedProps.has("hass")) {
this._tip = randomTip(this.hass);
}
if (!changedProps.has("supervisorUpdates") || !this._notifyUpdates) {
return;
}
this._notifyUpdates = false;
if (this.supervisorUpdates?.length) {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.updates.updates_refreshed"
),
});
} else {
showToast(this, {
message: this.hass.localize("ui.panel.config.updates.no_new_updates"),
});
}
}
private _showQuickBar(): void {
@ -248,18 +230,16 @@ class HaConfigDashboard extends LitElement {
private async _handleMenuAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
if (isComponentLoaded(this.hass, "hassio")) {
this._notifyUpdates = true;
await refreshSupervisorAvailableUpdates(this.hass);
fireEvent(this, "ha-refresh-supervisor");
if (isComponentLoaded(this.hass, "update")) {
fireEvent(this, "ha-refresh-updates");
return;
}
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.updates.check_unavailable.title"
"ui.panel.config.updates.update_not_loaded.title"
),
text: this.hass.localize(
"ui.panel.config.updates.check_unavailable.description"
"ui.panel.config.updates.update_not_loaded.description"
),
warning: true,
});

View File

@ -1,21 +1,48 @@
import "@material/mwc-button/mwc-button";
import { mdiPackageVariant } from "@mdi/js";
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-item/paper-item-body";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-icon-next";
import "../../../components/ha-logo-svg";
import "../../../components/ha-svg-icon";
import { SupervisorAvailableUpdates } from "../../../data/supervisor/root";
import { UpdateDescription } from "../../../data/update";
import { showUpdateDialog } from "../../../dialogs/update-dialog/show-ha-update-dialog";
import { HomeAssistant } from "../../../types";
import "../../../components/ha-icon-next";
import { brandsUrl } from "../../../util/brands-url";
export const SUPERVISOR_UPDATE_NAMES = {
core: "Home Assistant Core",
os: "Home Assistant Operating System",
supervisor: "Home Assistant Supervisor",
};
const sortUpdates = memoizeOne((a: UpdateDescription, b: UpdateDescription) => {
if (a.domain === "hassio" && b.domain === "hassio") {
if (a.identifier === "core") {
return -1;
}
if (b.identifier === "core") {
return 1;
}
if (a.identifier === "supervisor") {
return -1;
}
if (b.identifier === "supervisor") {
return 1;
}
if (a.identifier === "os") {
return -1;
}
if (b.identifier === "os") {
return 1;
}
}
if (a.domain === "hassio") {
return -1;
}
if (b.domain === "hassio") {
return 1;
}
return a.name.toUpperCase() < b.name.toUpperCase() ? -1 : 1;
});
@customElement("ha-config-updates")
class HaConfigUpdates extends LitElement {
@ -24,62 +51,62 @@ class HaConfigUpdates extends LitElement {
@property({ type: Boolean }) public narrow!: boolean;
@property({ attribute: false })
public supervisorUpdates?: SupervisorAvailableUpdates[] | null;
public updates?: UpdateDescription[] | null;
@state() private _showAll = false;
protected render(): TemplateResult {
if (!this.supervisorUpdates?.length) {
if (!this.updates?.length) {
return html``;
}
// Make sure the first updates shown are for the Supervisor
const sortedUpdates = this.updates.sort((a, b) => sortUpdates(a, b));
const updates =
this._showAll || this.supervisorUpdates.length <= 3
? this.supervisorUpdates
: this.supervisorUpdates.slice(0, 2);
this._showAll || sortedUpdates.length <= 3
? sortedUpdates
: sortedUpdates.slice(0, 2);
return html`
<div class="title">
${this.hass.localize("ui.panel.config.updates.title", {
count: this.supervisorUpdates.length,
count: sortedUpdates.length,
})}
</div>
${updates.map(
(update) => html`
<a href="/hassio${update.panel_path}">
<paper-icon-item>
<span slot="item-icon" class="icon">
${update.update_type === "addon"
? update.icon
? html`<img src="/api/hassio${update.icon}" />`
: html`<ha-svg-icon
.path=${mdiPackageVariant}
></ha-svg-icon>`
: html`<ha-logo-svg></ha-logo-svg>`}
</span>
<paper-item-body two-line>
${update.update_type === "addon"
? update.name
: SUPERVISOR_UPDATE_NAMES[update.update_type!]}
<div secondary>
${this.hass.localize(
"ui.panel.config.updates.version_available",
{
version_available: update.version_latest,
}
)}
</div>
</paper-item-body>
${!this.narrow ? html`<ha-icon-next></ha-icon-next>` : ""}
</paper-icon-item>
</a>
<paper-icon-item @click=${this._showUpdate} .update=${update}>
<span slot="item-icon" class="icon">
<img
src=${update.icon_url ||
brandsUrl({
domain: update.domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
/>
</span>
<paper-item-body two-line>
${update.name}
<div secondary>
${this.hass.localize(
"ui.panel.config.updates.version_available",
{
version_available: update.available_version,
}
)}
</div>
</paper-item-body>
</paper-icon-item>
`
)}
${!this._showAll && this.supervisorUpdates.length >= 4
${!this._showAll && this.updates.length >= 4
? html`
<button class="show-more" @click=${this._showAllClicked}>
${this.hass.localize("ui.panel.config.updates.more_updates", {
count: this.supervisorUpdates!.length - updates.length,
count: this.updates!.length - updates.length,
})}
</button>
`
@ -91,6 +118,14 @@ class HaConfigUpdates extends LitElement {
this._showAll = true;
}
private _showUpdate(ev) {
const update = ev.currentTarget.update as UpdateDescription;
showUpdateDialog(this, {
update,
refreshCallback: () => fireEvent(this, "ha-refresh-updates"),
});
}
static get styles(): CSSResultGroup[] {
return [
css`
@ -139,6 +174,9 @@ class HaConfigUpdates extends LitElement {
outline: none;
text-decoration: underline;
}
paper-icon-item {
cursor: pointer;
}
`,
];
}

View File

@ -27,20 +27,18 @@ import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { listenMediaQuery } from "../../common/dom/media_query";
import { CloudStatus, fetchCloudStatus } from "../../data/cloud";
import {
fetchSupervisorAvailableUpdates,
SupervisorAvailableUpdates,
} from "../../data/supervisor/root";
import { fetchUpdateInfo, UpdateDescription } from "../../data/update";
import "../../layouts/hass-loading-screen";
import { HassRouterPage, RouterOptions } from "../../layouts/hass-router-page";
import { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { HomeAssistant, Route } from "../../types";
import { showToast } from "../../util/toast";
declare global {
// for fire event
interface HASSDomEvents {
"ha-refresh-cloud-status": undefined;
"ha-refresh-supervisor": undefined;
"ha-refresh-updates": undefined;
}
}
@ -407,7 +405,7 @@ class HaPanelConfig extends HassRouterPage {
@state() private _cloudStatus?: CloudStatus;
@state() private _supervisorUpdates?: SupervisorAvailableUpdates[] | null;
@state() private _updates?: UpdateDescription[] | null;
private _listeners: Array<() => void> = [];
@ -443,18 +441,18 @@ class HaPanelConfig extends HassRouterPage {
}
});
}
if (isComponentLoaded(this.hass, "hassio")) {
this._loadSupervisorUpdates();
this.addEventListener("ha-refresh-supervisor", () => {
this._loadSupervisorUpdates();
if (isComponentLoaded(this.hass, "update")) {
this._loadUpdates();
this.addEventListener("ha-refresh-updates", () => {
this._loadUpdates();
});
this.addEventListener("connection-status", (ev) => {
if (ev.detail === "connected") {
this._loadSupervisorUpdates();
this._loadUpdates();
}
});
} else {
this._supervisorUpdates = null;
this._updates = null;
}
this.addEventListener("ha-refresh-cloud-status", () =>
this._updateCloudStatus()
@ -486,7 +484,7 @@ class HaPanelConfig extends HassRouterPage {
isWide,
narrow: this.narrow,
cloudStatus: this._cloudStatus,
supervisorUpdates: this._supervisorUpdates,
updates: this._updates,
});
} else {
el.route = this.routeTail;
@ -495,7 +493,7 @@ class HaPanelConfig extends HassRouterPage {
el.isWide = isWide;
el.narrow = this.narrow;
el.cloudStatus = this._cloudStatus;
el.supervisorUpdates = this._supervisorUpdates;
el.updates = this._updates;
}
}
@ -514,13 +512,33 @@ class HaPanelConfig extends HassRouterPage {
}
}
private async _loadSupervisorUpdates(): Promise<void> {
private async _loadUpdates(): Promise<void> {
const _showToast = this._updates !== undefined;
if (_showToast) {
showToast(this, {
message: this.hass.localize("ui.panel.config.updates.checking_updates"),
});
}
try {
this._supervisorUpdates = await fetchSupervisorAvailableUpdates(
this.hass
);
this._updates = await fetchUpdateInfo(this.hass);
} catch (err) {
this._supervisorUpdates = null;
this._updates = null;
}
if (_showToast) {
if (this._updates?.length) {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.updates.updates_refreshed"
),
});
} else {
showToast(this, {
message: this.hass.localize("ui.panel.config.updates.no_new_updates"),
});
}
}
}
}

View File

@ -1047,18 +1047,27 @@
"learn_more": "Learn more"
},
"updates": {
"check_unavailable": {
"update_not_loaded": {
"title": "Unable to check for updates",
"description": "You need to run the Home Assistant operating system to be able to check and install updates from the Home Assistant user interface."
"description": "You need to enable the update integrtion to be able to check and install updates from the Home Assistant user interface."
},
"check_updates": "Check for updates",
"checking_updates": "Checking for available updates",
"no_new_updates": "No new updates found",
"updates_refreshed": "Updates refreshed",
"title": "{count} {count, plural,\n one {update}\n other {updates}\n}",
"unable_to_fetch": "Unable to load updates",
"version_available": "Version {version_available} is available",
"more_updates": "+{count} updates",
"show": "show"
"show": "show",
"dialog": {
"title": "[%key:supervisor::update_available::update_name%]",
"create_backup": "[%key:supervisor::update_available::create_backup%]",
"open_changelog": "Open changelog",
"updating": "[%key:supervisor::update_available::updating%]",
"update": "[%key:supervisor::common::update%]",
"description": "[%key:supervisor::update_available::description%]"
}
},
"areas": {
"caption": "Areas",