From 161561c48a4cf291781cc6e7b7fd2d3e6abafbdc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 9 Nov 2020 16:11:01 +0100 Subject: [PATCH] Support system health streaming (#7593) --- src/common/util/copy-clipboard.ts | 8 + src/components/ha-circular-progress.ts | 4 +- src/components/ha-markdown-element.ts | 1 - src/data/system_health.ts | 121 +++++++++++++-- src/panels/config/info/system-health-card.ts | 155 +++++++++++++++---- src/translations/en.json | 38 ++++- 6 files changed, 275 insertions(+), 52 deletions(-) create mode 100644 src/common/util/copy-clipboard.ts diff --git a/src/common/util/copy-clipboard.ts b/src/common/util/copy-clipboard.ts new file mode 100644 index 0000000000..9d7e6885fa --- /dev/null +++ b/src/common/util/copy-clipboard.ts @@ -0,0 +1,8 @@ +export const copyToClipboard = (str) => { + const el = document.createElement("textarea"); + el.value = str; + document.body.appendChild(el); + el.select(); + document.execCommand("copy"); + document.body.removeChild(el); +}; diff --git a/src/components/ha-circular-progress.ts b/src/components/ha-circular-progress.ts index 1e70908291..c3e188d267 100644 --- a/src/components/ha-circular-progress.ts +++ b/src/components/ha-circular-progress.ts @@ -11,7 +11,7 @@ export class HaCircularProgress extends CircularProgress { public alt = "Loading"; @property() - public size: "small" | "medium" | "large" = "medium"; + public size: "tiny" | "small" | "medium" | "large" = "medium"; // @ts-ignore public set density(_) { @@ -20,6 +20,8 @@ export class HaCircularProgress extends CircularProgress { public get density() { switch (this.size) { + case "tiny": + return -8; case "small": return -5; case "medium": diff --git a/src/components/ha-markdown-element.ts b/src/components/ha-markdown-element.ts index b6e78c99b9..cd07cf0a01 100644 --- a/src/components/ha-markdown-element.ts +++ b/src/components/ha-markdown-element.ts @@ -47,7 +47,6 @@ class HaMarkdownElement extends UpdatingElement { node.host !== document.location.host ) { node.target = "_blank"; - node.rel = "noreferrer"; // protect referrer on external links and deny window.opener access for security reasons // (see https://mathiasbynens.github.io/rel-noopener/) diff --git a/src/data/system_health.ts b/src/data/system_health.ts index fdf5007d10..72d5e20068 100644 --- a/src/data/system_health.ts +++ b/src/data/system_health.ts @@ -1,24 +1,111 @@ import { HomeAssistant } from "../types"; -export interface HomeAssistantSystemHealthInfo { - version: string; - dev: boolean; - hassio: boolean; - virtualenv: string; - python_version: string; - docker: boolean; - arch: string; - timezone: string; - os_name: string; +interface SystemCheckValueDateObject { + type: "date"; + value: string; } +interface SystemCheckValueErrorObject { + type: "failed"; + error: string; + more_info?: string; +} + +interface SystemCheckValuePendingObject { + type: "pending"; +} + +export type SystemCheckValueObject = + | SystemCheckValueDateObject + | SystemCheckValueErrorObject + | SystemCheckValuePendingObject; + +export type SystemCheckValue = + | string + | number + | boolean + | SystemCheckValueObject; + export interface SystemHealthInfo { - [domain: string]: { [key: string]: string | number | boolean }; + [domain: string]: { + manage_url?: string; + info: { + [key: string]: SystemCheckValue; + }; + }; } -export const fetchSystemHealthInfo = ( - hass: HomeAssistant -): Promise => - hass.callWS({ - type: "system_health/info", - }); +interface SystemHealthEventInitial { + type: "initial"; + data: SystemHealthInfo; +} +interface SystemHealthEventUpdateSuccess { + type: "update"; + success: true; + domain: string; + key: string; + data: SystemCheckValue; +} + +interface SystemHealthEventUpdateError { + type: "update"; + success: false; + domain: string; + key: string; + error: { + msg: string; + }; +} + +interface SystemHealthEventFinish { + type: "finish"; +} + +type SystemHealthEvent = + | SystemHealthEventInitial + | SystemHealthEventUpdateSuccess + | SystemHealthEventUpdateError + | SystemHealthEventFinish; + +export const subscribeSystemHealthInfo = ( + hass: HomeAssistant, + callback: (info: SystemHealthInfo) => void +) => { + let data = {}; + + const unsubProm = hass.connection.subscribeMessage( + (updateEvent) => { + if (updateEvent.type === "initial") { + data = updateEvent.data; + callback(data); + return; + } + if (updateEvent.type === "finish") { + unsubProm.then((unsub) => unsub()); + return; + } + + data = { + ...data, + [updateEvent.domain]: { + ...data[updateEvent.domain], + info: { + ...data[updateEvent.domain].info, + [updateEvent.key]: updateEvent.success + ? updateEvent.data + : { + error: true, + value: updateEvent.error.msg, + }, + }, + }, + }; + callback(data); + }, + { + type: "system_health/info", + } + ); + + return unsubProm; +}; diff --git a/src/panels/config/info/system-health-card.ts b/src/panels/config/info/system-health-card.ts index 69df18b5b5..b660328ff4 100644 --- a/src/panels/config/info/system-health-card.ts +++ b/src/panels/config/info/system-health-card.ts @@ -1,3 +1,5 @@ +import "@material/mwc-button/mwc-button"; +import "@material/mwc-icon-button"; import "../../../components/ha-circular-progress"; import { mdiContentCopy } from "@mdi/js"; import { @@ -15,10 +17,13 @@ import "@polymer/paper-tooltip/paper-tooltip"; import type { PaperTooltipElement } from "@polymer/paper-tooltip/paper-tooltip"; import { domainToName } from "../../../data/integration"; import { - fetchSystemHealthInfo, + subscribeSystemHealthInfo, SystemHealthInfo, + SystemCheckValueObject, } from "../../../data/system_health"; import { HomeAssistant } from "../../../types"; +import { formatDateTime } from "../../../common/datetime/format_date_time"; +import { copyToClipboard } from "../../../common/util/copy-clipboard"; const sortKeys = (a: string, b: string) => { if (a === "homeassistant") { @@ -60,19 +65,74 @@ class SystemHealthCard extends LitElement { } else { const domains = Object.keys(this._info).sort(sortKeys); for (const domain of domains) { + const domainInfo = this._info[domain]; const keys: TemplateResult[] = []; - for (const key of Object.keys(this._info[domain]).sort()) { + for (const key of Object.keys(domainInfo.info)) { + let value: unknown; + + if (typeof domainInfo.info[key] === "object") { + const info = domainInfo.info[key] as SystemCheckValueObject; + + if (info.type === "pending") { + value = html` + + `; + } else if (info.type === "failed") { + value = html` + ${info.error}${!info.more_info + ? "" + : html` + – + + ${this.hass.localize( + "ui.panel.config.info.system_health.more_info" + )} + + `} + `; + } else if (info.type === "date") { + value = formatDateTime(new Date(info.value), this.hass.language); + } + } else { + value = domainInfo.info[key]; + } + keys.push(html` - ${key} - ${this._info[domain][key]} + + ${this.hass.localize( + `ui.panel.config.info.system_health.checks.${domain}.${key}` + ) || key} + + ${value} `); } if (domain !== "homeassistant") { sections.push( - html`

${domainToName(this.hass.localize, domain)}

` + html` +
+

+ ${domainToName(this.hass.localize, domain)} +

+ ${!domainInfo.manage_url + ? "" + : html` + + + ${this.hass.localize( + "ui.panel.config.info.system_health.manage" + )} + + + `} +
+ ` ); } sections.push(html` @@ -109,45 +169,63 @@ class SystemHealthCard extends LitElement { protected firstUpdated(changedProps) { super.firstUpdated(changedProps); - this._fetchInfo(); - } - private async _fetchInfo() { - try { - if (!this.hass!.config.components.includes("system_health")) { - throw new Error(); - } - this._info = await fetchSystemHealthInfo(this.hass!); - } catch (err) { + if (!this.hass!.config.components.includes("system_health")) { this._info = { system_health: { - error: this.hass.localize("ui.panel.config.info.system_health_error"), + info: { + error: this.hass.localize( + "ui.panel.config.info.system_health_error" + ), + }, }, }; + return; } + + subscribeSystemHealthInfo(this.hass!, (info) => { + this._info = info; + }); } private _copyInfo(): void { - const copyElement = this.shadowRoot?.querySelector( - ".card-content" - ) as HTMLElement; + let haContent: string | undefined; + const domainParts: string[] = []; - // Add temporary heading (fixed in EN since usually executed to provide support data) - const tempTitle = document.createElement("h3"); - tempTitle.innerText = "System Health"; - copyElement.insertBefore(tempTitle, copyElement.firstElementChild); + for (const domain of Object.keys(this._info!).sort(sortKeys)) { + const domainInfo = this._info![domain]; + const parts = [`${domainToName(this.hass.localize, domain)}\n`]; - const selection = window.getSelection()!; - selection.removeAllRanges(); - const range = document.createRange(); - range.selectNodeContents(copyElement); - selection.addRange(range); + for (const key of Object.keys(domainInfo.info)) { + let value: unknown; - document.execCommand("copy"); - window.getSelection()!.removeAllRanges(); + if (typeof domainInfo.info[key] === "object") { + const info = domainInfo.info[key] as SystemCheckValueObject; - // Remove temporary heading again - copyElement.removeChild(tempTitle); + if (info.type === "pending") { + value = "pending"; + } else if (info.type === "failed") { + value = `failed to load: ${info.error}`; + } else if (info.type === "date") { + value = formatDateTime(new Date(info.value), this.hass.language); + } + } else { + value = domainInfo.info[key]; + } + + parts.push(`${key}: ${value}`); + } + + if (domain === "homeassistant") { + haContent = parts.join("\n"); + } else { + domainParts.push(parts.join("\n")); + } + } + + copyToClipboard( + `System Health\n\n${haContent}\n\n${domainParts.join("\n\n")}` + ); this._toolTip!.show(); setTimeout(() => this._toolTip?.hide(), 3000); @@ -160,7 +238,7 @@ class SystemHealthCard extends LitElement { } td:first-child { - width: 33%; + width: 45%; } .loading-container { @@ -172,6 +250,19 @@ class SystemHealthCard extends LitElement { .card-header { justify-content: space-between; display: flex; + align-items: center; + } + + .error { + color: var(--error-color); + } + + a { + color: var(--primary-color); + } + + a.manage { + text-decoration: none; } `; } diff --git a/src/translations/en.json b/src/translations/en.json index 43bf26ec79..30c4e91891 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -873,7 +873,43 @@ "system_health_error": "System Health component is not loaded. Add 'system_health:' to configuration.yaml", "integrations": "Integrations", "documentation": "Documentation", - "issues": "Issues" + "issues": "Issues", + "system_health": { + "manage": "Manage", + "more_info": "more info", + "checks": { + "homeassistant": { + "arch": "CPU Architecture", + "dev": "Development", + "docker": "Docker", + "hassio": "HassOS", + "installation_type": "Installation Type", + "os_name": "Operating System Name", + "os_version": "Operating System Version", + "python_version": "Python Version", + "timezone": "Timezone", + "version": "Version", + "virtualenv": "Virtual Environment" + }, + "cloud": { + "can_reach_cert_server": "Reach Certificate Server", + "can_reach_cloud": "Reach Home Assistant Cloud", + "can_reach_cloud_auth": "Reach Authentication Server", + "relayer_connected": "Relayer Connected", + "remote_connected": "Remote Connected", + "remote_enabled": "Remote Enabled", + "alexa_enabled": "Alexa Enabled", + "google_enabled": "Google Enabled", + "logged_in": "Logged In", + "subscription_expiration": "Subscription Expiration" + }, + "lovelace": { + "dashboards": "Dashboards", + "mode": "Mode", + "resources": "Resources" + } + } + } }, "logs": { "caption": "Logs",