From bd50d6a6a3975b45479617eb1d297812568a67ec Mon Sep 17 00:00:00 2001 From: Zack Barett Date: Tue, 19 Jul 2022 09:25:47 -0500 Subject: [PATCH] Start the Repairs dashboard (#13192) Co-authored-by: Bram Kragten --- src/common/const.ts | 1 + src/data/repairs.ts | 62 ++++++ src/data/translation.ts | 3 +- src/panels/config/ha-panel-config.ts | 11 + .../config/repairs/dialog-repairs-issue.ts | 107 ++++++++++ .../repairs/ha-config-repairs-dashboard.ts | 100 ++++++++++ .../config/repairs/ha-config-repairs.ts | 129 ++++++++++++ .../config/repairs/show-dialog-repair-flow.ts | 188 ++++++++++++++++++ .../repairs/show-repair-issue-dialog.ts | 19 ++ src/translations/en.json | 23 +++ 10 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 src/data/repairs.ts create mode 100644 src/panels/config/repairs/dialog-repairs-issue.ts create mode 100644 src/panels/config/repairs/ha-config-repairs-dashboard.ts create mode 100644 src/panels/config/repairs/ha-config-repairs.ts create mode 100644 src/panels/config/repairs/show-dialog-repair-flow.ts create mode 100644 src/panels/config/repairs/show-repair-issue-dialog.ts diff --git a/src/common/const.ts b/src/common/const.ts index ee9f94000c..0e16d6db32 100644 --- a/src/common/const.ts +++ b/src/common/const.ts @@ -76,6 +76,7 @@ export const FIXED_DOMAIN_ICONS = { configurator: mdiCog, conversation: mdiTextToSpeech, counter: mdiCounter, + demo: mdiHomeAssistant, fan: mdiFan, google_assistant: mdiGoogleAssistant, group: mdiGoogleCirclesCommunities, diff --git a/src/data/repairs.ts b/src/data/repairs.ts new file mode 100644 index 0000000000..e9459d9b1b --- /dev/null +++ b/src/data/repairs.ts @@ -0,0 +1,62 @@ +import type { HomeAssistant } from "../types"; +import { DataEntryFlowStep } from "./data_entry_flow"; + +export interface RepairsIssue { + domain: string; + issue_id: string; + active: boolean; + is_fixable: boolean; + severity?: "error" | "warning" | "critical"; + breaks_in_ha_version?: string; + ignored: boolean; + created: string; + dismissed_version?: string; + learn_more_url?: string; + translation_key?: string; + translation_placeholders?: Record; +} + +export const fetchRepairsIssues = async (hass: HomeAssistant) => + hass.callWS<{ issues: RepairsIssue[] }>({ + type: "resolution_center/list_issues", + }); + +export const dismissRepairsIssue = async ( + hass: HomeAssistant, + issue: RepairsIssue +) => + hass.callWS({ + type: "resolution_center/dismiss_issue", + issue_id: issue.issue_id, + domain: issue.domain, + }); + +export const createRepairsFlow = ( + hass: HomeAssistant, + handler: string, + issue_id: string +) => + hass.callApi("POST", "resolution_center/issues/fix", { + handler, + issue_id, + }); + +export const fetchRepairsFlow = (hass: HomeAssistant, flowId: string) => + hass.callApi( + "GET", + `resolution_center/issues/fix/${flowId}` + ); + +export const handleRepairsFlowStep = ( + hass: HomeAssistant, + flowId: string, + data: Record +) => + hass.callApi( + "POST", + `resolution_center/issues/fix/${flowId}`, + data + ); + +export const deleteRepairsFlow = (hass: HomeAssistant, flowId: string) => + hass.callApi("DELETE", `resolution_center/issues/fix/${flowId}`); diff --git a/src/data/translation.ts b/src/data/translation.ts index e7c632ea57..cadcfe0ca7 100644 --- a/src/data/translation.ts +++ b/src/data/translation.ts @@ -39,7 +39,8 @@ export type TranslationCategory = | "mfa_setup" | "system_health" | "device_class" - | "application_credentials"; + | "application_credentials" + | "issues"; export const fetchTranslationPreferences = (hass: HomeAssistant) => fetchFrontendUserData(hass.connection, "language"); diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index 7f7b93df45..67cbdcfa62 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -9,6 +9,7 @@ import { mdiHeart, mdiInformation, mdiInformationOutline, + mdiLifebuoy, mdiLightningBolt, mdiMapMarkerRadius, mdiMathLog, @@ -267,6 +268,12 @@ export const configSections: { [name: string]: PageNavigation[] } = { iconPath: mdiUpdate, iconColor: "#3B808E", }, + { + path: "/config/repairs", + translationKey: "repairs", + iconPath: mdiLifebuoy, + iconColor: "#5c995c", + }, { component: "logs", path: "/config/logs", @@ -448,6 +455,10 @@ class HaPanelConfig extends HassRouterPage { tag: "ha-config-section-updates", load: () => import("./core/ha-config-section-updates"), }, + repairs: { + tag: "ha-config-repairs-dashboard", + load: () => import("./repairs/ha-config-repairs-dashboard"), + }, users: { tag: "ha-config-users", load: () => import("./users/ha-config-users"), diff --git a/src/panels/config/repairs/dialog-repairs-issue.ts b/src/panels/config/repairs/dialog-repairs-issue.ts new file mode 100644 index 0000000000..88054fc805 --- /dev/null +++ b/src/panels/config/repairs/dialog-repairs-issue.ts @@ -0,0 +1,107 @@ +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 "../../../components/ha-alert"; +import { createCloseHeading } from "../../../components/ha-dialog"; +import type { RepairsIssue } from "../../../data/repairs"; +import { haStyleDialog } from "../../../resources/styles"; +import type { HomeAssistant } from "../../../types"; +import type { RepairsIssueDialogParams } from "./show-repair-issue-dialog"; + +@customElement("dialog-repairs-issue") +class DialogRepairsIssue extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _issue?: RepairsIssue; + + @state() private _params?: RepairsIssueDialogParams; + + @state() private _error?: string; + + public showDialog(params: RepairsIssueDialogParams): void { + this._params = params; + this._issue = this._params.issue; + } + + public closeDialog() { + this._params = undefined; + this._issue = undefined; + this._error = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render(): TemplateResult { + if (!this._issue) { + return html``; + } + + return html` + +
+ ${this._error + ? html`${this._error}` + : ""} + ${this.hass.localize( + `component.${this._issue.domain}.issues.${this._issue.issue_id}.${ + this._issue.translation_key || "description" + }`, + this._issue.translation_placeholders + )} + ${this._issue.breaks_in_ha_version + ? html` + This will no longer work as of the + ${this._issue.breaks_in_ha_version} release of Home Assistant. + ` + : ""} + The issue is ${this._issue.severity} severity + ${this._issue.is_fixable ? "and fixable" : "but not fixable"}. + ${this._issue.dismissed_version + ? html` + This issue has been dismissed in version + ${this._issue.dismissed_version}. + ` + : ""} +
+ ${this._issue.learn_more_url + ? html` + + + + ` + : ""} +
+ `; + } + + static styles: CSSResultGroup = [ + haStyleDialog, + css` + a { + text-decoration: none; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-repairs-issue": DialogRepairsIssue; + } +} diff --git a/src/panels/config/repairs/ha-config-repairs-dashboard.ts b/src/panels/config/repairs/ha-config-repairs-dashboard.ts new file mode 100644 index 0000000000..c36b810683 --- /dev/null +++ b/src/panels/config/repairs/ha-config-repairs-dashboard.ts @@ -0,0 +1,100 @@ +import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../components/ha-card"; +import type { RepairsIssue } from "../../../data/repairs"; +import { fetchRepairsIssues } from "../../../data/repairs"; +import "../../../layouts/hass-subpage"; +import type { HomeAssistant } from "../../../types"; +import "./ha-config-repairs"; + +@customElement("ha-config-repairs-dashboard") +class HaConfigRepairsDashboard extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow!: boolean; + + @state() private _repairsIssues: RepairsIssue[] = []; + + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + this._fetchIssues(); + } + + protected render(): TemplateResult { + return html` + +
+ +
+ ${this._repairsIssues.length + ? html` + + ` + : html` +
+ ${this.hass.localize( + "ui.panel.config.repairs.no_repairs" + )} +
+ `} +
+
+
+
+ `; + } + + private async _fetchIssues(): Promise { + const [, repairsIssues] = await Promise.all([ + this.hass.loadBackendTranslation("issues"), + fetchRepairsIssues(this.hass), + ]); + + this._repairsIssues = repairsIssues.issues; + } + + static styles = css` + .content { + padding: 28px 20px 0; + max-width: 1040px; + margin: 0 auto; + } + + ha-card { + max-width: 600px; + margin: 0 auto; + height: 100%; + justify-content: space-between; + flex-direction: column; + display: flex; + margin-bottom: max(24px, env(safe-area-inset-bottom)); + } + + .card-content { + display: flex; + justify-content: space-between; + flex-direction: column; + padding: 0; + } + + .no-repairs { + padding: 16px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-repairs-dashboard": HaConfigRepairsDashboard; + } +} diff --git a/src/panels/config/repairs/ha-config-repairs.ts b/src/panels/config/repairs/ha-config-repairs.ts new file mode 100644 index 0000000000..625753ac2c --- /dev/null +++ b/src/panels/config/repairs/ha-config-repairs.ts @@ -0,0 +1,129 @@ +import "@material/mwc-list/mwc-list"; +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import { relativeTime } from "../../../common/datetime/relative_time"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-alert"; +import "../../../components/ha-card"; +import "../../../components/ha-list-item"; +import "../../../components/ha-svg-icon"; +import { domainToName } from "../../../data/integration"; +import type { RepairsIssue } from "../../../data/repairs"; +import "../../../layouts/hass-subpage"; +import type { HomeAssistant } from "../../../types"; +import { brandsUrl } from "../../../util/brands-url"; +import { showRepairsFlowDialog } from "./show-dialog-repair-flow"; +import { showRepairsIssueDialog } from "./show-repair-issue-dialog"; + +@customElement("ha-config-repairs") +class HaConfigRepairs extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow!: boolean; + + @property({ attribute: false }) + public repairsIssues?: RepairsIssue[]; + + protected render(): TemplateResult { + if (!this.repairsIssues?.length) { + return html``; + } + + const issues = this.repairsIssues; + + return html` +
+ ${this.hass.localize("ui.panel.config.repairs.title", { + count: this.repairsIssues.length, + })} +
+ + ${issues.map((issue) => + issue.ignored + ? "" + : html` + + + ${this.hass.localize( + `component.${issue.domain}.issues.${issue.issue_id}.title` + )} + + ${issue.created + ? relativeTime(new Date(issue.created), this.hass.locale) + : ""} + + + ` + )} + + `; + } + + private _openShowMoreDialog(ev): void { + const issue = ev.currentTarget.issue as RepairsIssue; + if (issue.is_fixable) { + showRepairsFlowDialog(this, issue, () => { + // @ts-ignore + fireEvent(this, "update-issues"); + }); + } else { + showRepairsIssueDialog(this, { issue: (ev.currentTarget as any).issue }); + } + } + + static styles = css` + :host { + --mdc-list-vertical-padding: 0; + } + .title { + font-size: 16px; + padding: 16px; + padding-bottom: 0; + } + button.show-more { + color: var(--primary-color); + text-align: left; + cursor: pointer; + background: none; + border-width: initial; + border-style: none; + border-color: initial; + border-image: initial; + padding: 16px; + font: inherit; + } + button.show-more:focus { + outline: none; + text-decoration: underline; + } + ha-list-item { + cursor: pointer; + font-size: 16px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-repairs": HaConfigRepairs; + } +} diff --git a/src/panels/config/repairs/show-dialog-repair-flow.ts b/src/panels/config/repairs/show-dialog-repair-flow.ts new file mode 100644 index 0000000000..837ad1a9fa --- /dev/null +++ b/src/panels/config/repairs/show-dialog-repair-flow.ts @@ -0,0 +1,188 @@ +import { html } from "lit"; +import { domainToName } from "../../../data/integration"; +import { + createRepairsFlow, + deleteRepairsFlow, + fetchRepairsFlow, + handleRepairsFlowStep, + RepairsIssue, +} from "../../../data/repairs"; +import { + loadDataEntryFlowDialog, + showFlowDialog, +} from "../../../dialogs/config-flow/show-dialog-data-entry-flow"; + +export const loadRepairFlowDialog = loadDataEntryFlowDialog; + +export const showRepairsFlowDialog = ( + element: HTMLElement, + issue: RepairsIssue, + dialogClosedCallback?: (params: { flowFinished: boolean }) => void +): void => + showFlowDialog( + element, + { + startFlowHandler: issue.domain, + domain: issue.domain, + dialogClosedCallback, + }, + { + loadDevicesAndAreas: false, + createFlow: async (hass, handler) => { + const [step] = await Promise.all([ + createRepairsFlow(hass, handler, issue.issue_id), + hass.loadBackendTranslation("issues", issue.domain), + ]); + return step; + }, + fetchFlow: async (hass, flowId) => { + const [step] = await Promise.all([ + fetchRepairsFlow(hass, flowId), + hass.loadBackendTranslation("issues", issue.domain), + ]); + return step; + }, + handleFlowStep: handleRepairsFlowStep, + deleteFlow: deleteRepairsFlow, + + renderAbortDescription(hass, step) { + const description = hass.localize( + `component.${issue.domain}.issues.abort.${step.reason}`, + step.description_placeholders + ); + + return description + ? html` + + ` + : ""; + }, + + renderShowFormStepHeader(hass, step) { + return ( + hass.localize( + `component.${issue.domain}.issues.${issue.issue_id}.fix_flow.step.${step.step_id}.title` + ) || hass.localize(`ui.dialogs.issues_flow.form.header`) + ); + }, + + renderShowFormStepDescription(hass, step) { + const description = hass.localize( + `component.${issue.domain}.issues.${issue.issue_id}.fix_flow.step.${step.step_id}.description`, + step.description_placeholders + ); + return description + ? html` + + ` + : ""; + }, + + renderShowFormStepFieldLabel(hass, step, field) { + return hass.localize( + `component.${issue.domain}.issues.${issue.issue_id}.fix_flow.step.${step.step_id}.data.${field.name}` + ); + }, + + renderShowFormStepFieldHelper(hass, step, field) { + return hass.localize( + `component.${issue.domain}.issues.${issue.issue_id}.fix_flow.step.${step.step_id}.data_description.${field.name}` + ); + }, + + renderShowFormStepFieldError(hass, step, error) { + return hass.localize( + `component.${issue.domain}.issues.${issue.issue_id}.fix_flow.error.${error}`, + step.description_placeholders + ); + }, + + renderExternalStepHeader(_hass, _step) { + return ""; + }, + + renderExternalStepDescription(_hass, _step) { + return ""; + }, + + renderCreateEntryDescription(hass, _step) { + return html` +

${hass.localize(`ui.dialogs.repairs.success.description`)}

+ `; + }, + + renderShowFormProgressHeader(hass, step) { + return ( + hass.localize( + `component.${issue.domain}.issues.step.${issue.issue_id}.fix_flow.${step.step_id}.title` + ) || hass.localize(`component.${issue.domain}.title`) + ); + }, + + renderShowFormProgressDescription(hass, step) { + const description = hass.localize( + `component.${issue.domain}.issues.${issue.issue_id}.fix_flow.progress.${step.progress_action}`, + step.description_placeholders + ); + return description + ? html` + + ` + : ""; + }, + + renderMenuHeader(hass, step) { + return ( + hass.localize( + `component.${issue.domain}.issues.${issue.issue_id}.fix_flow.step.${step.step_id}.title` + ) || hass.localize(`component.${issue.domain}.title`) + ); + }, + + renderMenuDescription(hass, step) { + const description = hass.localize( + `component.${issue.domain}.issues.${issue.issue_id}.fix_flow.step.${step.step_id}.description`, + step.description_placeholders + ); + return description + ? html` + + ` + : ""; + }, + + renderMenuOption(hass, step, option) { + return hass.localize( + `component.${issue.domain}.issues.${issue.issue_id}.fix_flow.step.${step.step_id}.menu_issues.${option}`, + step.description_placeholders + ); + }, + + renderLoadingDescription(hass, reason) { + return ( + hass.localize( + `component.${issue.domain}.issues.${issue.issue_id}.fix_flow.loading` + ) || + hass.localize(`ui.dialogs.repairs.loading.${reason}`, { + integration: domainToName(hass.localize, issue.domain), + }) + ); + }, + } + ); diff --git a/src/panels/config/repairs/show-repair-issue-dialog.ts b/src/panels/config/repairs/show-repair-issue-dialog.ts new file mode 100644 index 0000000000..4591591c7c --- /dev/null +++ b/src/panels/config/repairs/show-repair-issue-dialog.ts @@ -0,0 +1,19 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import type { RepairsIssue } from "../../../data/repairs"; + +export interface RepairsIssueDialogParams { + issue: RepairsIssue; +} + +export const loadRepairsIssueDialog = () => import("./dialog-repairs-issue"); + +export const showRepairsIssueDialog = ( + element: HTMLElement, + repairsIssueParams: RepairsIssueDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-repairs-issue", + dialogImport: loadRepairsIssueDialog, + dialogParams: repairsIssueParams, + }); +}; diff --git a/src/translations/en.json b/src/translations/en.json index a807666c00..7e91122e20 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -955,6 +955,18 @@ "description": "Options successfully saved." } }, + "repair_flow": { + "form": { + "header": "Repair issue" + }, + "loading": { + "loading_flow": "Please wait while the repair for {integration} is being initialized", + "loading_step": "[%key:ui::panel::config::integrations::config_flow::loading::loading_step%]" + }, + "success": { + "description": "The issue is repaired!" + } + }, "config_entry_system_options": { "title": "System Options for {integration}", "enable_new_entities_label": "Enable newly added entities.", @@ -1216,6 +1228,17 @@ "leave_beta": "[%key:supervisor::system::supervisor::leave_beta_action%]", "skipped": "Skipped" }, + "repairs": { + "caption": "Repairs", + "description": "Find and fix issues with your Home Assistant instance", + "title": "{count} {count, plural,\n one {repair}\n other {repairs}\n}", + "no_repairs": "There are currently no repairs available", + "dialog": { + "title": "Repair", + "fix": "Repair", + "learn": "Learn more" + } + }, "areas": { "caption": "Areas", "description": "Group devices and entities into areas",