mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-10 02:46:38 +00:00
Support system health streaming (#7593)
This commit is contained in:
parent
c162e84383
commit
161561c48a
8
src/common/util/copy-clipboard.ts
Normal file
8
src/common/util/copy-clipboard.ts
Normal file
@ -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);
|
||||||
|
};
|
@ -11,7 +11,7 @@ export class HaCircularProgress extends CircularProgress {
|
|||||||
public alt = "Loading";
|
public alt = "Loading";
|
||||||
|
|
||||||
@property()
|
@property()
|
||||||
public size: "small" | "medium" | "large" = "medium";
|
public size: "tiny" | "small" | "medium" | "large" = "medium";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
public set density(_) {
|
public set density(_) {
|
||||||
@ -20,6 +20,8 @@ export class HaCircularProgress extends CircularProgress {
|
|||||||
|
|
||||||
public get density() {
|
public get density() {
|
||||||
switch (this.size) {
|
switch (this.size) {
|
||||||
|
case "tiny":
|
||||||
|
return -8;
|
||||||
case "small":
|
case "small":
|
||||||
return -5;
|
return -5;
|
||||||
case "medium":
|
case "medium":
|
||||||
|
@ -47,7 +47,6 @@ class HaMarkdownElement extends UpdatingElement {
|
|||||||
node.host !== document.location.host
|
node.host !== document.location.host
|
||||||
) {
|
) {
|
||||||
node.target = "_blank";
|
node.target = "_blank";
|
||||||
node.rel = "noreferrer";
|
|
||||||
|
|
||||||
// protect referrer on external links and deny window.opener access for security reasons
|
// protect referrer on external links and deny window.opener access for security reasons
|
||||||
// (see https://mathiasbynens.github.io/rel-noopener/)
|
// (see https://mathiasbynens.github.io/rel-noopener/)
|
||||||
|
@ -1,24 +1,111 @@
|
|||||||
import { HomeAssistant } from "../types";
|
import { HomeAssistant } from "../types";
|
||||||
|
|
||||||
export interface HomeAssistantSystemHealthInfo {
|
interface SystemCheckValueDateObject {
|
||||||
version: string;
|
type: "date";
|
||||||
dev: boolean;
|
value: string;
|
||||||
hassio: boolean;
|
|
||||||
virtualenv: string;
|
|
||||||
python_version: string;
|
|
||||||
docker: boolean;
|
|
||||||
arch: string;
|
|
||||||
timezone: string;
|
|
||||||
os_name: 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 {
|
export interface SystemHealthInfo {
|
||||||
[domain: string]: { [key: string]: string | number | boolean };
|
[domain: string]: {
|
||||||
|
manage_url?: string;
|
||||||
|
info: {
|
||||||
|
[key: string]: SystemCheckValue;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchSystemHealthInfo = (
|
interface SystemHealthEventInitial {
|
||||||
hass: HomeAssistant
|
type: "initial";
|
||||||
): Promise<SystemHealthInfo> =>
|
data: SystemHealthInfo;
|
||||||
hass.callWS({
|
}
|
||||||
type: "system_health/info",
|
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<SystemHealthEvent>(
|
||||||
|
(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;
|
||||||
|
};
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import "@material/mwc-button/mwc-button";
|
||||||
|
import "@material/mwc-icon-button";
|
||||||
import "../../../components/ha-circular-progress";
|
import "../../../components/ha-circular-progress";
|
||||||
import { mdiContentCopy } from "@mdi/js";
|
import { mdiContentCopy } from "@mdi/js";
|
||||||
import {
|
import {
|
||||||
@ -15,10 +17,13 @@ import "@polymer/paper-tooltip/paper-tooltip";
|
|||||||
import type { PaperTooltipElement } from "@polymer/paper-tooltip/paper-tooltip";
|
import type { PaperTooltipElement } from "@polymer/paper-tooltip/paper-tooltip";
|
||||||
import { domainToName } from "../../../data/integration";
|
import { domainToName } from "../../../data/integration";
|
||||||
import {
|
import {
|
||||||
fetchSystemHealthInfo,
|
subscribeSystemHealthInfo,
|
||||||
SystemHealthInfo,
|
SystemHealthInfo,
|
||||||
|
SystemCheckValueObject,
|
||||||
} from "../../../data/system_health";
|
} from "../../../data/system_health";
|
||||||
import { HomeAssistant } from "../../../types";
|
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) => {
|
const sortKeys = (a: string, b: string) => {
|
||||||
if (a === "homeassistant") {
|
if (a === "homeassistant") {
|
||||||
@ -60,19 +65,74 @@ class SystemHealthCard extends LitElement {
|
|||||||
} else {
|
} else {
|
||||||
const domains = Object.keys(this._info).sort(sortKeys);
|
const domains = Object.keys(this._info).sort(sortKeys);
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
|
const domainInfo = this._info[domain];
|
||||||
const keys: TemplateResult[] = [];
|
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`
|
||||||
|
<ha-circular-progress active size="tiny"></ha-circular-progress>
|
||||||
|
`;
|
||||||
|
} else if (info.type === "failed") {
|
||||||
|
value = html`
|
||||||
|
<span class="error">${info.error}</span>${!info.more_info
|
||||||
|
? ""
|
||||||
|
: html`
|
||||||
|
–
|
||||||
|
<a
|
||||||
|
href=${info.more_info}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.info.system_health.more_info"
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
} else if (info.type === "date") {
|
||||||
|
value = formatDateTime(new Date(info.value), this.hass.language);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value = domainInfo.info[key];
|
||||||
|
}
|
||||||
|
|
||||||
keys.push(html`
|
keys.push(html`
|
||||||
<tr>
|
<tr>
|
||||||
<td>${key}</td>
|
<td>
|
||||||
<td>${this._info[domain][key]}</td>
|
${this.hass.localize(
|
||||||
|
`ui.panel.config.info.system_health.checks.${domain}.${key}`
|
||||||
|
) || key}
|
||||||
|
</td>
|
||||||
|
<td>${value}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
if (domain !== "homeassistant") {
|
if (domain !== "homeassistant") {
|
||||||
sections.push(
|
sections.push(
|
||||||
html`<h3>${domainToName(this.hass.localize, domain)}</h3>`
|
html`
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>
|
||||||
|
${domainToName(this.hass.localize, domain)}
|
||||||
|
</h3>
|
||||||
|
${!domainInfo.manage_url
|
||||||
|
? ""
|
||||||
|
: html`
|
||||||
|
<a class="manage" href=${domainInfo.manage_url}>
|
||||||
|
<mwc-button>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.info.system_health.manage"
|
||||||
|
)}
|
||||||
|
</mwc-button>
|
||||||
|
</a>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
sections.push(html`
|
sections.push(html`
|
||||||
@ -109,45 +169,63 @@ class SystemHealthCard extends LitElement {
|
|||||||
|
|
||||||
protected firstUpdated(changedProps) {
|
protected firstUpdated(changedProps) {
|
||||||
super.firstUpdated(changedProps);
|
super.firstUpdated(changedProps);
|
||||||
this._fetchInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _fetchInfo() {
|
if (!this.hass!.config.components.includes("system_health")) {
|
||||||
try {
|
|
||||||
if (!this.hass!.config.components.includes("system_health")) {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
this._info = await fetchSystemHealthInfo(this.hass!);
|
|
||||||
} catch (err) {
|
|
||||||
this._info = {
|
this._info = {
|
||||||
system_health: {
|
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 {
|
private _copyInfo(): void {
|
||||||
const copyElement = this.shadowRoot?.querySelector(
|
let haContent: string | undefined;
|
||||||
".card-content"
|
const domainParts: string[] = [];
|
||||||
) as HTMLElement;
|
|
||||||
|
|
||||||
// Add temporary heading (fixed in EN since usually executed to provide support data)
|
for (const domain of Object.keys(this._info!).sort(sortKeys)) {
|
||||||
const tempTitle = document.createElement("h3");
|
const domainInfo = this._info![domain];
|
||||||
tempTitle.innerText = "System Health";
|
const parts = [`${domainToName(this.hass.localize, domain)}\n`];
|
||||||
copyElement.insertBefore(tempTitle, copyElement.firstElementChild);
|
|
||||||
|
|
||||||
const selection = window.getSelection()!;
|
for (const key of Object.keys(domainInfo.info)) {
|
||||||
selection.removeAllRanges();
|
let value: unknown;
|
||||||
const range = document.createRange();
|
|
||||||
range.selectNodeContents(copyElement);
|
|
||||||
selection.addRange(range);
|
|
||||||
|
|
||||||
document.execCommand("copy");
|
if (typeof domainInfo.info[key] === "object") {
|
||||||
window.getSelection()!.removeAllRanges();
|
const info = domainInfo.info[key] as SystemCheckValueObject;
|
||||||
|
|
||||||
// Remove temporary heading again
|
if (info.type === "pending") {
|
||||||
copyElement.removeChild(tempTitle);
|
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();
|
this._toolTip!.show();
|
||||||
setTimeout(() => this._toolTip?.hide(), 3000);
|
setTimeout(() => this._toolTip?.hide(), 3000);
|
||||||
@ -160,7 +238,7 @@ class SystemHealthCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
td:first-child {
|
td:first-child {
|
||||||
width: 33%;
|
width: 45%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
@ -172,6 +250,19 @@ class SystemHealthCard extends LitElement {
|
|||||||
.card-header {
|
.card-header {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.manage {
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -873,7 +873,43 @@
|
|||||||
"system_health_error": "System Health component is not loaded. Add 'system_health:' to configuration.yaml",
|
"system_health_error": "System Health component is not loaded. Add 'system_health:' to configuration.yaml",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations",
|
||||||
"documentation": "Documentation",
|
"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": {
|
"logs": {
|
||||||
"caption": "Logs",
|
"caption": "Logs",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user