diff --git a/build-scripts/webpack.js b/build-scripts/webpack.js index 185ef3aa65..c5d741a5af 100644 --- a/build-scripts/webpack.js +++ b/build-scripts/webpack.js @@ -3,10 +3,10 @@ const webpack = require("webpack"); const path = require("path"); const TerserPlugin = require("terser-webpack-plugin"); const { WebpackManifestPlugin } = require("webpack-manifest-plugin"); -const paths = require("./paths.js"); -const bundle = require("./bundle.js"); const log = require("fancy-log"); const WebpackBar = require("webpackbar"); +const paths = require("./paths.js"); +const bundle = require("./bundle.js"); class LogStartCompilePlugin { ignoredFirst = false; @@ -138,6 +138,8 @@ const createWebpackConfig = ({ "lit/directives/cache$": "lit/directives/cache.js", "lit/directives/repeat$": "lit/directives/repeat.js", "lit/polyfill-support$": "lit/polyfill-support.js", + "@lit-labs/virtualizer/layouts/grid": + "@lit-labs/virtualizer/layouts/grid.js", }, }, output: { diff --git a/gallery/src/pages/automation/describe-action.ts b/gallery/src/pages/automation/describe-action.ts index dd3d6c6e93..49fa3dc7f9 100644 --- a/gallery/src/pages/automation/describe-action.ts +++ b/gallery/src/pages/automation/describe-action.ts @@ -62,6 +62,45 @@ const ACTIONS = [ entity_id: "input_boolean.toggle_4", }, }, + { + parallel: [ + { scene: "scene.kitchen_morning" }, + { + service: "media_player.play_media", + target: { entity_id: "media_player.living_room" }, + data: { media_content_id: "", media_content_type: "" }, + metadata: { title: "Happy Song" }, + }, + ], + }, + { + stop: "No one is home!", + }, + { repeat: { count: 3, sequence: [{ delay: "00:00:01" }] } }, + { + repeat: { + for_each: ["bread", "butter", "cheese"], + sequence: [{ delay: "00:00:01" }], + }, + }, + { + if: [{ condition: "state" }], + then: [{ delay: "00:00:01" }], + else: [{ delay: "00:00:05" }], + }, + { + choose: [ + { + conditions: [{ condition: "state" }], + sequence: [{ delay: "00:00:01" }], + }, + { + conditions: [{ condition: "sun" }], + sequence: [{ delay: "00:00:05" }], + }, + ], + default: [{ delay: "00:00:03" }], + }, ]; @customElement("demo-automation-describe-action") diff --git a/gallery/src/pages/automation/editor-action.ts b/gallery/src/pages/automation/editor-action.ts index 1f7a0d8206..4551593c44 100644 --- a/gallery/src/pages/automation/editor-action.ts +++ b/gallery/src/pages/automation/editor-action.ts @@ -20,6 +20,10 @@ import { HaWaitForTriggerAction } from "../../../../src/panels/config/automation import { HaWaitAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-wait_template"; import { Action } from "../../../../src/data/script"; import { HaConditionAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-condition"; +import { HaParallelAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-parallel"; +import { HaIfAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-if"; +import { HaStopAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-stop"; +import { HaPlayMediaAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-play_media"; const SCHEMAS: { name: string; actions: Action[] }[] = [ { name: "Event", actions: [HaEventAction.defaultConfig] }, @@ -28,11 +32,15 @@ const SCHEMAS: { name: string; actions: Action[] }[] = [ { name: "Condition", actions: [HaConditionAction.defaultConfig] }, { name: "Delay", actions: [HaDelayAction.defaultConfig] }, { name: "Scene", actions: [HaSceneAction.defaultConfig] }, + { name: "Play media", actions: [HaPlayMediaAction.defaultConfig] }, { name: "Wait", actions: [HaWaitAction.defaultConfig] }, { name: "WaitForTrigger", actions: [HaWaitForTriggerAction.defaultConfig] }, { name: "Repeat", actions: [HaRepeatAction.defaultConfig] }, + { name: "If-Then", actions: [HaIfAction.defaultConfig] }, { name: "Choose", actions: [HaChooseAction.defaultConfig] }, { name: "Variables", actions: [{ variables: { hello: "1" } }] }, + { name: "Parallel", actions: [HaParallelAction.defaultConfig] }, + { name: "Stop", actions: [HaStopAction.defaultConfig] }, ]; @customElement("demo-automation-editor-action") @@ -86,6 +94,6 @@ class DemoHaAutomationEditorAction extends LitElement { declare global { interface HTMLElementTagNameMap { - "demo-ha-automation-editor-action": DemoHaAutomationEditorAction; + "demo-automation-editor-action": DemoHaAutomationEditorAction; } } diff --git a/gallery/src/pages/automation/editor-condition.ts b/gallery/src/pages/automation/editor-condition.ts index 77e42e6171..8f8ee17604 100644 --- a/gallery/src/pages/automation/editor-condition.ts +++ b/gallery/src/pages/automation/editor-condition.ts @@ -8,7 +8,7 @@ import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry"; import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry"; import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry"; import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor"; -import type { Condition } from "../../../../src/data/automation"; +import type { ConditionWithShorthand } from "../../../../src/data/automation"; import "../../../../src/panels/config/automation/condition/ha-automation-condition"; import { HaDeviceCondition } from "../../../../src/panels/config/automation/condition/types/ha-automation-condition-device"; import { HaLogicalCondition } from "../../../../src/panels/config/automation/condition/types/ha-automation-condition-logical"; @@ -20,7 +20,7 @@ import { HaTimeCondition } from "../../../../src/panels/config/automation/condit import { HaTriggerCondition } from "../../../../src/panels/config/automation/condition/types/ha-automation-condition-trigger"; import { HaZoneCondition } from "../../../../src/panels/config/automation/condition/types/ha-automation-condition-zone"; -const SCHEMAS: { name: string; conditions: Condition[] }[] = [ +const SCHEMAS: { name: string; conditions: ConditionWithShorthand[] }[] = [ { name: "State", conditions: [{ condition: "state", ...HaStateCondition.defaultConfig }], @@ -69,6 +69,14 @@ const SCHEMAS: { name: string; conditions: Condition[] }[] = [ name: "Trigger", conditions: [{ condition: "trigger", ...HaTriggerCondition.defaultConfig }], }, + { + name: "Shorthand", + conditions: [ + { and: HaLogicalCondition.defaultConfig.conditions }, + { or: HaLogicalCondition.defaultConfig.conditions }, + { not: HaLogicalCondition.defaultConfig.conditions }, + ], + }, ]; @customElement("demo-automation-editor-condition") diff --git a/gallery/src/pages/components/ha-alert.ts b/gallery/src/pages/components/ha-alert.ts index c4f44e80a9..ced4c5a44b 100644 --- a/gallery/src/pages/components/ha-alert.ts +++ b/gallery/src/pages/components/ha-alert.ts @@ -159,13 +159,19 @@ export class DemoHaAlert extends LitElement { firstUpdated(changedProps) { super.firstUpdated(changedProps); - applyThemesOnElement(this.shadowRoot!.querySelector(".dark"), { - default_theme: "default", - default_dark_theme: "default", - themes: {}, - darkMode: true, - theme: "default", - }); + applyThemesOnElement( + this.shadowRoot!.querySelector(".dark"), + { + default_theme: "default", + default_dark_theme: "default", + themes: {}, + darkMode: true, + theme: "default", + }, + undefined, + undefined, + true + ); } static get styles() { diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 972c7d7f26..6ce04e0995 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -170,6 +170,7 @@ const SCHEMAS: { select: { options: ["Option 1", "Option 2"], mode: "list" }, }, }, + template: { name: "Template", selector: { template: {} } }, select: { name: "Select", selector: { diff --git a/gallery/src/pages/components/ha-tip.markdown b/gallery/src/pages/components/ha-tip.markdown new file mode 100644 index 0000000000..a3bc162733 --- /dev/null +++ b/gallery/src/pages/components/ha-tip.markdown @@ -0,0 +1,3 @@ +--- +title: Tips +--- diff --git a/gallery/src/pages/components/ha-tip.ts b/gallery/src/pages/components/ha-tip.ts new file mode 100644 index 0000000000..49fa1f2c71 --- /dev/null +++ b/gallery/src/pages/components/ha-tip.ts @@ -0,0 +1,73 @@ +import { html, css, LitElement, TemplateResult } from "lit"; +import { customElement } from "lit/decorators"; +import "../../../../src/components/ha-tip"; +import "../../../../src/components/ha-card"; +import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element"; + +const tips: (string | TemplateResult)[] = [ + "Test tip", + "Bigger test tip, with some random text just to fill up as much space as possible without it looking like I'm really trying to to that", + html`Tip with HTML`, +]; + +@customElement("demo-components-ha-tip") +export class DemoHaTip extends LitElement { + protected render(): TemplateResult { + return html` ${["light", "dark"].map( + (mode) => html` +
+ +
+ ${tips.map((tip) => html`${tip}`)} +
+
+
+ ` + )}`; + } + + firstUpdated(changedProps) { + super.firstUpdated(changedProps); + applyThemesOnElement( + this.shadowRoot!.querySelector(".dark"), + { + default_theme: "default", + default_dark_theme: "default", + themes: {}, + darkMode: true, + theme: "default", + }, + undefined, + undefined, + true + ); + } + + static get styles() { + return css` + :host { + display: flex; + flex-direction: row; + justify-content: space-between; + } + .dark, + .light { + display: block; + background-color: var(--primary-background-color); + padding: 0 50px; + } + ha-tip { + margin-bottom: 14px; + } + ha-card { + margin: 24px auto; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "demo-components-ha-tip": DemoHaTip; + } +} diff --git a/gallery/src/pages/more-info/update.ts b/gallery/src/pages/more-info/update.ts index 65d8e0bf7f..872a7b2e41 100644 --- a/gallery/src/pages/more-info/update.ts +++ b/gallery/src/pages/more-info/update.ts @@ -133,6 +133,12 @@ const ENTITIES = [ friendly_name: "Update with auto update", auto_update: true, }), + getEntity("update", "update20", "on", { + ...base_attributes, + in_progress: true, + title: undefined, + friendly_name: "Installing without title", + }), ]; @customElement("demo-more-info-update") diff --git a/hassio/src/addon-store/hassio-addon-repository.ts b/hassio/src/addon-store/hassio-addon-repository.ts index 43e9822df2..8d55dbcb8c 100644 --- a/hassio/src/addon-store/hassio-addon-repository.ts +++ b/hassio/src/addon-store/hassio-addon-repository.ts @@ -68,6 +68,7 @@ class HassioAddonRepositoryEl extends LitElement { ${addons.map( (addon) => html`
diff --git a/hassio/src/addon-view/config/hassio-addon-config.ts b/hassio/src/addon-view/config/hassio-addon-config.ts index dfdc31dbbc..196d345bba 100644 --- a/hassio/src/addon-view/config/hassio-addon-config.ts +++ b/hassio/src/addon-view/config/hassio-addon-config.ts @@ -39,7 +39,14 @@ import type { HomeAssistant } from "../../../../src/types"; import { suggestAddonRestart } from "../../dialogs/suggestAddonRestart"; import { hassioStyle } from "../../resources/hassio-style"; -const SUPPORTED_UI_TYPES = ["string", "select", "boolean", "integer", "float"]; +const SUPPORTED_UI_TYPES = [ + "string", + "select", + "boolean", + "integer", + "float", + "schema", +]; const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([ new Type("!secret", { @@ -48,6 +55,8 @@ const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([ }), ]); +const MASKED_FIELDS = ["password", "secret", "token"]; + @customElement("hassio-addon-config") class HassioAddonConfig extends LitElement { @property({ attribute: false }) public addon!: HassioAddonDetails; @@ -75,19 +84,66 @@ class HassioAddonConfig extends LitElement { public computeLabel = (entry: HaFormSchema): string => this.addon.translations[this.hass.language]?.configuration?.[entry.name] ?.name || - this.addon.translations.en?.configuration?.[entry.name].name || + this.addon.translations.en?.configuration?.[entry.name]?.name || entry.name; - private _schema = memoizeOne((schema: HaFormSchema[]): HaFormSchema[] => - // @ts-expect-error supervisor does not implement [string, string] for select.options[] - schema.map((entry) => - entry.type === "select" - ? { - ...entry, - options: entry.options.map((option) => [option, option]), - } - : entry - ) + public computeHelper = (entry: HaFormSchema): string => + this.addon.translations[this.hass.language]?.configuration?.[entry.name] + ?.description || + this.addon.translations.en?.configuration?.[entry.name]?.description || + ""; + + private _convertSchema = memoizeOne( + // Convert supervisor schema to selectors + (schema: Record): HaFormSchema[] => + schema.map((entry) => + entry.type === "select" + ? { + name: entry.name, + required: entry.required, + selector: { select: { options: entry.options } }, + } + : entry.type === "string" + ? entry.multiple + ? { + name: entry.name, + required: entry.required, + selector: { + select: { options: [], multiple: true, custom_value: true }, + }, + } + : { + name: entry.name, + required: entry.required, + selector: { + text: { + type: + entry.format || MASKED_FIELDS.includes(entry.name) + ? "password" + : "text", + }, + }, + } + : entry.type === "boolean" + ? { + name: entry.name, + required: entry.required, + selector: { boolean: {} }, + } + : entry.type === "schema" + ? { + name: entry.name, + required: entry.required, + selector: { object: {} }, + } + : entry.type === "float" || entry.type === "integer" + ? { + name: entry.name, + required: entry.required, + selector: { number: { mode: "box" } }, + } + : entry + ) ); private _filteredShchema = memoizeOne( @@ -106,7 +162,7 @@ class HassioAddonConfig extends LitElement { ); return html`

${this.addon.name}

- +

${this.supervisor.localize("addon.configuration.options.header")} @@ -140,7 +196,8 @@ class HassioAddonConfig extends LitElement { .data=${this._options!} @value-changed=${this._configChanged} .computeLabel=${this.computeLabel} - .schema=${this._schema( + .computeHelper=${this.computeHelper} + .schema=${this._convertSchema( this._showOptional ? this.addon.schema! : this._filteredShchema( @@ -197,8 +254,9 @@ class HassioAddonConfig extends LitElement { protected firstUpdated(changedProps) { super.firstUpdated(changedProps); this._canShowSchema = !this.addon.schema!.find( - // @ts-ignore - (entry) => !SUPPORTED_UI_TYPES.includes(entry.type) || entry.multiple + (entry) => + // @ts-ignore + !SUPPORTED_UI_TYPES.includes(entry.type) ); this._yamlMode = !this._canShowSchema; } diff --git a/hassio/src/addon-view/config/hassio-addon-network.ts b/hassio/src/addon-view/config/hassio-addon-network.ts index ae3deed1db..2d3b11c5ad 100644 --- a/hassio/src/addon-view/config/hassio-addon-network.ts +++ b/hassio/src/addon-view/config/hassio-addon-network.ts @@ -1,4 +1,3 @@ -import { PaperInputElement } from "@polymer/paper-input/paper-input"; import { css, CSSResultGroup, @@ -8,10 +7,13 @@ import { TemplateResult, } from "lit"; import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; import { fireEvent } from "../../../../src/common/dom/fire_event"; import "../../../../src/components/buttons/ha-progress-button"; import "../../../../src/components/ha-alert"; import "../../../../src/components/ha-card"; +import "../../../../src/components/ha-form/ha-form"; +import type { HaFormSchema } from "../../../../src/components/ha-form/types"; import { HassioAddonDetails, HassioAddonSetOptionParams, @@ -24,16 +26,6 @@ import { HomeAssistant } from "../../../../src/types"; import { suggestAddonRestart } from "../../dialogs/suggestAddonRestart"; import { hassioStyle } from "../../resources/hassio-style"; -interface NetworkItem { - description: string; - container: string; - host: number | null; -} - -interface NetworkItemInput extends PaperInputElement { - container: string; -} - @customElement("hassio-addon-network") class HassioAddonNetwork extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -42,9 +34,13 @@ class HassioAddonNetwork extends LitElement { @property({ attribute: false }) public addon!: HassioAddonDetails; + @state() private _showOptional = false; + + @state() private _configHasChanged = false; + @state() private _error?: string; - @state() private _config?: NetworkItem[]; + @state() private _config?: Record; public connectedCallback(): void { super.connectedCallback(); @@ -56,59 +52,61 @@ class HassioAddonNetwork extends LitElement { return html``; } + const hasHiddenOptions = Object.keys(this._config).find( + (entry) => this._config![entry] === null + ); + return html`
+

+ ${this.supervisor.localize( + "addon.configuration.network.introduction" + )} +

${this._error ? html`${this._error}` : ""} - - - - - - - - ${this._config!.map( - (item) => html` - - - - - - ` - )} - -
- ${this.supervisor.localize( - "addon.configuration.network.container" - )} - - ${this.supervisor.localize( - "addon.configuration.network.host" - )} - ${this.supervisor.localize("common.description")}
${item.container} - - ${this._computeDescription(item)}
+
+ ${hasHiddenOptions + ? html` + + + ` + : ""}
${this.supervisor.localize("common.reset_defaults")} - + ${this.supervisor.localize("common.save")}
@@ -123,50 +121,60 @@ class HassioAddonNetwork extends LitElement { } } - private _computeDescription = (item: NetworkItem): string => - this.addon.translations[this.hass.language]?.network?.[item.container] - ?.description || - this.addon.translations.en?.network?.[item.container]?.description || - item.description; + private _createSchema = memoizeOne( + ( + config: Record, + showOptional: boolean, + advanced: boolean + ): HaFormSchema[] => + (showOptional + ? Object.keys(config) + : Object.keys(config).filter((entry) => config[entry] !== null) + ).map((entry) => ({ + name: entry, + selector: { + number: { + mode: "box", + min: 0, + max: 65535, + unit_of_measurement: advanced ? entry : undefined, + }, + }, + })) + ); + + private _computeLabel = (_: HaFormSchema): string => ""; + + private _computeHelper = (item: HaFormSchema): string => + this.addon.translations[this.hass.language]?.network?.[item.name] || + this.addon.translations.en?.network?.[item.name] || + this.addon.network_description?.[item.name] || + item.name; private _setNetworkConfig(): void { - const network = this.addon.network || {}; - const description = this.addon.network_description || {}; - const items: NetworkItem[] = Object.keys(network).map((key) => ({ - container: key, - host: network[key], - description: description[key], - })); - this._config = items.sort((a, b) => (a.container > b.container ? 1 : -1)); + this._config = this.addon.network || {}; } - private async _configChanged(ev: Event): Promise { - const target = ev.target as NetworkItemInput; - this._config!.forEach((item) => { - if ( - item.container === target.container && - item.host !== parseInt(String(target.value), 10) - ) { - item.host = target.value ? parseInt(String(target.value), 10) : null; - } - }); + private async _configChanged(ev: CustomEvent): Promise { + this._configHasChanged = true; + this._config! = ev.detail.value; } private async _resetTapped(ev: CustomEvent): Promise { const button = ev.currentTarget as any; - button.progress = true; - const data: HassioAddonSetOptionParams = { network: null, }; try { await setHassioAddonOption(this.hass, this.addon.slug, data); + this._configHasChanged = false; const eventdata = { success: true, response: undefined, path: "option", }; + button.actionSuccess(); fireEvent(this, "hass-api-called", eventdata); if (this.addon?.state === "started") { await suggestAddonRestart(this, this.hass, this.supervisor, this.addon); @@ -177,19 +185,21 @@ class HassioAddonNetwork extends LitElement { "error", extractApiErrorMessage(err) ); + button.actionError(); } + } - button.progress = false; + private _toggleOptional() { + this._showOptional = !this._showOptional; } private async _saveTapped(ev: CustomEvent): Promise { const button = ev.currentTarget as any; - button.progress = true; this._error = undefined; const networkconfiguration = {}; - this._config!.forEach((item) => { - networkconfiguration[item.container] = parseInt(String(item.host), 10); + Object.entries(this._config!).forEach(([key, value]) => { + networkconfiguration[key] = value ?? null; }); const data: HassioAddonSetOptionParams = { @@ -198,11 +208,13 @@ class HassioAddonNetwork extends LitElement { try { await setHassioAddonOption(this.hass, this.addon.slug, data); + this._configHasChanged = false; const eventdata = { success: true, response: undefined, path: "option", }; + button.actionSuccess(); fireEvent(this, "hass-api-called", eventdata); if (this.addon?.state === "started") { await suggestAddonRestart(this, this.hass, this.supervisor, this.addon); @@ -213,8 +225,8 @@ class HassioAddonNetwork extends LitElement { "error", extractApiErrorMessage(err) ); + button.actionError(); } - button.progress = false; } static get styles(): CSSResultGroup { @@ -232,6 +244,9 @@ class HassioAddonNetwork extends LitElement { display: flex; justify-content: space-between; } + .show-optional { + padding: 16px; + } `, ]; } diff --git a/hassio/src/addon-view/documentation/hassio-addon-documentation-tab.ts b/hassio/src/addon-view/documentation/hassio-addon-documentation-tab.ts index 411027275a..e16647b1e5 100644 --- a/hassio/src/addon-view/documentation/hassio-addon-documentation-tab.ts +++ b/hassio/src/addon-view/documentation/hassio-addon-documentation-tab.ts @@ -38,7 +38,7 @@ class HassioAddonDocumentationDashboard extends LitElement { } return html`
- + ${this._error ? html`${this._error}` : ""} diff --git a/hassio/src/addon-view/hassio-addon-dashboard.ts b/hassio/src/addon-view/hassio-addon-dashboard.ts index 4455982ecc..22e5bb32c9 100644 --- a/hassio/src/addon-view/hassio-addon-dashboard.ts +++ b/hassio/src/addon-view/hassio-addon-dashboard.ts @@ -17,7 +17,9 @@ import { HassioAddonDetails, } from "../../../src/data/hassio/addon"; import { extractApiErrorMessage } from "../../../src/data/hassio/common"; +import { setSupervisorOption } from "../../../src/data/hassio/supervisor"; import { Supervisor } from "../../../src/data/supervisor/supervisor"; +import { showConfirmationDialog } from "../../../src/dialogs/generic/show-dialog-box"; import "../../../src/layouts/hass-error-screen"; import "../../../src/layouts/hass-loading-screen"; import "../../../src/layouts/hass-tabs-subpage"; @@ -166,6 +168,42 @@ class HassioAddonDashboard extends LitElement { protected async firstUpdated(): Promise { if (this.route.path === "") { const requestedAddon = extractSearchParam("addon"); + const requestedAddonRepository = extractSearchParam("repository_url"); + if ( + requestedAddonRepository && + !this.supervisor.supervisor.addons_repositories.find( + (repo) => repo === requestedAddonRepository + ) + ) { + if ( + !(await showConfirmationDialog(this, { + title: this.supervisor.localize("my.add_addon_repository_title"), + text: this.supervisor.localize( + "my.add_addon_repository_description", + { addon: requestedAddon, repository: requestedAddonRepository } + ), + confirmText: this.supervisor.localize("common.add"), + dismissText: this.supervisor.localize("common.cancel"), + })) + ) { + this._error = this.supervisor.localize( + "my.error_repository_not_found" + ); + return; + } + + try { + await setSupervisorOption(this.hass, { + addons_repositories: [ + ...this.supervisor.supervisor.addons_repositories, + requestedAddonRepository, + ], + }); + } catch (err: any) { + this._error = extractApiErrorMessage(err); + } + } + if (requestedAddon) { const addonsInfo = await fetchHassioAddonsInfo(this.hass); const validAddon = addonsInfo.addons.some( diff --git a/hassio/src/addon-view/info/hassio-addon-info.ts b/hassio/src/addon-view/info/hassio-addon-info.ts index 47e6087e36..71a0a6f225 100644 --- a/hassio/src/addon-view/info/hassio-addon-info.ts +++ b/hassio/src/addon-view/info/hassio-addon-info.ts @@ -166,7 +166,7 @@ class HassioAddonInfo extends LitElement { ` : ""} - +
${!this.narrow ? this.addon.name : ""} @@ -649,7 +649,7 @@ class HassioAddonInfo extends LitElement { ${this.addon.long_description ? html` - +
${this.addon.name}

- + ${this._error ? html`${this._error}` : ""}
${this._content - ? html`` + >` : ""}
diff --git a/hassio/src/backups/hassio-backups.ts b/hassio/src/backups/hassio-backups.ts index 577b36a8ea..c8e8e5e30c 100644 --- a/hassio/src/backups/hassio-backups.ts +++ b/hassio/src/backups/hassio-backups.ts @@ -1,7 +1,7 @@ import "@material/mwc-button"; import { ActionDetail } from "@material/mwc-list"; import "@material/mwc-list/mwc-list-item"; -import { mdiDelete, mdiDotsVertical, mdiPlus } from "@mdi/js"; +import { mdiBackupRestore, mdiDelete, mdiDotsVertical, mdiPlus } from "@mdi/js"; import { css, CSSResultGroup, @@ -166,7 +166,15 @@ export class HassioBackups extends LitElement { } return html` ${!this.supervisor.supervisor.addons?.length ? html` - +
+ )}${entity.attributes.skipped_version + ? `(${this.hass.localize("ui.panel.config.updates.skipped")})` + : ""} + + ${!this.narrow + ? html`` + : ""} + ` - : ""} + )} + `; } @@ -84,22 +82,19 @@ class HaConfigUpdates extends LitElement { }); } - private _showAllClicked() { - this._showAll = true; - } - static get styles(): CSSResultGroup[] { return [ css` + :host { + --mdc-list-vertical-padding: 0; + } .title { font-size: 16px; padding: 16px; padding-bottom: 0; } - .icon { - display: inline-flex; - height: 100%; - align-items: center; + .skipped { + background: var(--secondary-background-color); } ha-icon-next { color: var(--secondary-text-color); @@ -122,8 +117,9 @@ class HaConfigUpdates extends LitElement { outline: none; text-decoration: underline; } - paper-icon-item { + mwc-list-item { cursor: pointer; + font-size: 16px; } `, ]; diff --git a/src/panels/config/devices/device-detail/ha-device-actions-card.ts b/src/panels/config/devices/device-detail/ha-device-actions-card.ts index bec915a9e3..4a85f1951a 100644 --- a/src/panels/config/devices/device-detail/ha-device-actions-card.ts +++ b/src/panels/config/devices/device-detail/ha-device-actions-card.ts @@ -1,5 +1,4 @@ import { customElement } from "lit/decorators"; -import "../../../../components/ha-card"; import { DeviceAction, localizeDeviceAutomationAction, diff --git a/src/panels/config/devices/device-detail/ha-device-automation-card.ts b/src/panels/config/devices/device-detail/ha-device-automation-card.ts index 47577805c3..369a31d405 100644 --- a/src/panels/config/devices/device-detail/ha-device-automation-card.ts +++ b/src/panels/config/devices/device-detail/ha-device-automation-card.ts @@ -1,7 +1,6 @@ -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { property } from "lit/decorators"; +import { css, html, LitElement, TemplateResult } from "lit"; +import { property, state } from "lit/decorators"; import { fireEvent } from "../../../../common/dom/fire_event"; -import "../../../../components/ha-card"; import "../../../../components/ha-chip"; import "../../../../components/ha-chip-set"; import { showAutomationEditor } from "../../../../data/automation"; @@ -10,6 +9,7 @@ import { DeviceAutomation, } from "../../../../data/device_automation"; import { showScriptEditor } from "../../../../data/script"; +import { buttonLinkStyle } from "../../../../resources/styles"; import { HomeAssistant } from "../../../../types"; declare global { @@ -29,6 +29,8 @@ export abstract class HaDeviceAutomationCard< @property() public automations: T[] = []; + @state() public _showSecondary = false; + protected headerKey = ""; protected type = ""; @@ -60,28 +62,47 @@ export abstract class HaDeviceAutomationCard< if (this.automations.length === 0) { return html``; } + const automations = this._showSecondary + ? this.automations + : this.automations.filter( + (automation) => automation.metadata?.secondary === false + ); return html`

${this.hass.localize(this.headerKey)}

- ${this.automations.map( + ${automations.map( (automation, idx) => html` - + ${this._localizeDeviceAutomation(this.hass, automation)} ` )} + ${!this._showSecondary && automations.length < this.automations.length + ? html`` + : ""}
`; } + private _toggleSecondary() { + this._showSecondary = !this._showSecondary; + } + private _handleAutomationClicked(ev: CustomEvent) { - const automation = this.automations[(ev.currentTarget as any).index]; + const automation = { ...this.automations[(ev.currentTarget as any).index] }; if (!automation) { return; } + delete automation.metadata; if (this.script) { showScriptEditor({ sequence: [automation as DeviceAction] }); fireEvent(this, "entry-selected"); @@ -93,11 +114,18 @@ export abstract class HaDeviceAutomationCard< fireEvent(this, "entry-selected"); } - static get styles(): CSSResultGroup { - return css` + static styles = [ + buttonLinkStyle, + css` h3 { color: var(--primary-text-color); } - `; - } + .secondary { + --ha-chip-background-color: rgba(var(--rgb-primary-text-color), 0.07); + } + button.link { + color: var(--primary-color); + } + `, + ]; } diff --git a/src/panels/config/devices/device-detail/ha-device-automation-dialog.ts b/src/panels/config/devices/device-detail/ha-device-automation-dialog.ts index cef11fc0fe..de0c862a0c 100644 --- a/src/panels/config/devices/device-detail/ha-device-automation-dialog.ts +++ b/src/panels/config/devices/device-detail/ha-device-automation-dialog.ts @@ -10,6 +10,7 @@ import { fetchDeviceActions, fetchDeviceConditions, fetchDeviceTriggers, + sortDeviceAutomations, } from "../../../../data/device_automation"; import { haStyleDialog } from "../../../../resources/styles"; import { HomeAssistant } from "../../../../types"; @@ -63,16 +64,16 @@ export class DialogDeviceAutomation extends LitElement { const { device, script } = this._params; fetchDeviceActions(this.hass, device.id).then((actions) => { - this._actions = actions; + this._actions = actions.sort(sortDeviceAutomations); }); if (script) { return; } fetchDeviceTriggers(this.hass, device.id).then((triggers) => { - this._triggers = triggers; + this._triggers = triggers.sort(sortDeviceAutomations); }); fetchDeviceConditions(this.hass, device.id).then((conditions) => { - this._conditions = conditions; + this._conditions = conditions.sort(sortDeviceAutomations); }); } diff --git a/src/panels/config/devices/device-detail/ha-device-conditions-card.ts b/src/panels/config/devices/device-detail/ha-device-conditions-card.ts index ffa084bf6c..3ed9548e84 100644 --- a/src/panels/config/devices/device-detail/ha-device-conditions-card.ts +++ b/src/panels/config/devices/device-detail/ha-device-conditions-card.ts @@ -1,5 +1,4 @@ import { customElement } from "lit/decorators"; -import "../../../../components/ha-card"; import { DeviceCondition, localizeDeviceAutomationCondition, diff --git a/src/panels/config/devices/device-detail/ha-device-entities-card.ts b/src/panels/config/devices/device-detail/ha-device-entities-card.ts index 04209e5947..ce1e310e54 100644 --- a/src/panels/config/devices/device-detail/ha-device-entities-card.ts +++ b/src/panels/config/devices/device-detail/ha-device-entities-card.ts @@ -62,7 +62,7 @@ export class HaDeviceEntitiesCard extends LitElement { protected render(): TemplateResult { if (!this.entities.length) { return html` - +
${this.hass.localize("ui.panel.config.devices.entities.none")}
@@ -89,7 +89,7 @@ export class HaDeviceEntitiesCard extends LitElement { }); return html` - +
${shownEntities.map((entry) => this.hass.states[entry.entity_id] diff --git a/src/panels/config/devices/device-detail/ha-device-info-card.ts b/src/panels/config/devices/device-detail/ha-device-info-card.ts index ce8e553b06..219031a403 100644 --- a/src/panels/config/devices/device-detail/ha-device-info-card.ts +++ b/src/panels/config/devices/device-detail/ha-device-info-card.ts @@ -1,5 +1,6 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; +import "../../../../components/ha-card"; import { AreaRegistryEntry } from "../../../../data/area_registry"; import { computeDeviceName, @@ -24,6 +25,7 @@ export class HaDeviceCard extends LitElement { protected render(): TemplateResult { return html` +

${this.hass.localize( "ui.panel.config.devices.automation.automations_heading" @@ -673,7 +673,7 @@ export class HaConfigDevicePage extends LitElement { ${ isComponentLoaded(this.hass, "scene") && entities.length ? html` - +

${this.hass.localize( "ui.panel.config.devices.scene.scenes_heading" @@ -771,7 +771,7 @@ export class HaConfigDevicePage extends LitElement { ${ isComponentLoaded(this.hass, "script") ? html` - +

${this.hass.localize( "ui.panel.config.devices.script.scripts_heading" diff --git a/src/panels/config/devices/ha-config-devices-dashboard.ts b/src/panels/config/devices/ha-config-devices-dashboard.ts index a2e23079bf..d7027a9512 100644 --- a/src/panels/config/devices/ha-config-devices-dashboard.ts +++ b/src/panels/config/devices/ha-config-devices-dashboard.ts @@ -197,7 +197,10 @@ export class HaConfigDeviceDashboard extends LitElement { ), model: device.model || "", manufacturer: device.manufacturer || "", - area: device.area_id ? areaLookup[device.area_id].name : "—", + area: + device.area_id && areaLookup[device.area_id] + ? areaLookup[device.area_id].name + : "—", integration: device.config_entries.length ? device.config_entries .filter((entId) => entId in entryLookup) diff --git a/src/panels/config/energy/components/ha-energy-battery-settings.ts b/src/panels/config/energy/components/ha-energy-battery-settings.ts index 8a1aa1cb86..121ec20697 100644 --- a/src/panels/config/energy/components/ha-energy-battery-settings.ts +++ b/src/panels/config/energy/components/ha-energy-battery-settings.ts @@ -51,7 +51,7 @@ export class EnergyBatterySettings extends LitElement { }); return html` - +

${this.hass.localize("ui.panel.config.energy.battery.title")} diff --git a/src/panels/config/energy/components/ha-energy-device-settings.ts b/src/panels/config/energy/components/ha-energy-device-settings.ts index b135201f13..333574ed67 100644 --- a/src/panels/config/energy/components/ha-energy-device-settings.ts +++ b/src/panels/config/energy/components/ha-energy-device-settings.ts @@ -36,7 +36,7 @@ export class EnergyDeviceSettings extends LitElement { protected render(): TemplateResult { return html` - +

${this.hass.localize( diff --git a/src/panels/config/energy/components/ha-energy-gas-settings.ts b/src/panels/config/energy/components/ha-energy-gas-settings.ts index 4e65150f2b..20d18b6fa7 100644 --- a/src/panels/config/energy/components/ha-energy-gas-settings.ts +++ b/src/panels/config/energy/components/ha-energy-gas-settings.ts @@ -51,7 +51,7 @@ export class EnergyGasSettings extends LitElement { }); return html` - +

${this.hass.localize("ui.panel.config.energy.gas.title")} diff --git a/src/panels/config/energy/components/ha-energy-grid-settings.ts b/src/panels/config/energy/components/ha-energy-grid-settings.ts index 46ac49d885..e903805a4f 100644 --- a/src/panels/config/energy/components/ha-energy-grid-settings.ts +++ b/src/panels/config/energy/components/ha-energy-grid-settings.ts @@ -80,7 +80,7 @@ export class EnergyGridSettings extends LitElement { } return html` - +

${this.hass.localize("ui.panel.config.energy.grid.title")} diff --git a/src/panels/config/energy/components/ha-energy-solar-settings.ts b/src/panels/config/energy/components/ha-energy-solar-settings.ts index eca1055649..87582644b5 100644 --- a/src/panels/config/energy/components/ha-energy-solar-settings.ts +++ b/src/panels/config/energy/components/ha-energy-solar-settings.ts @@ -54,7 +54,7 @@ export class EnergySolarSettings extends LitElement { }); return html` - +

${this.hass.localize("ui.panel.config.energy.solar.title")} diff --git a/src/panels/config/entities/entity-registry-settings.ts b/src/panels/config/entities/entity-registry-settings.ts index eabbf8fbaf..0c6ff704df 100644 --- a/src/panels/config/entities/entity-registry-settings.ts +++ b/src/panels/config/entities/entity-registry-settings.ts @@ -276,6 +276,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { @selected=${this._deviceClassChanged} @closed=${stopPropagation} > + ${this._deviceClassesSorted( domain, this._deviceClassOptions[0], @@ -355,6 +356,25 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { )} ` : ""} + ${this._helperConfigEntry + ? html` +
+ + ${this.hass.localize( + "ui.dialogs.entity_registry.editor.configure_state", + "integration", + domainToName( + this.hass.localize, + this._helperConfigEntry.domain + ) + )} + +
+ ` + : ""} ` : ""} - ${this._helperConfigEntry - ? html` -
- - ${this.hass.localize( - "ui.dialogs.entity_registry.editor.configure_state" - )} - -
- ` - : ""} import("./core/ha-config-section-analytics"), + }, areas: { tag: "ha-config-areas", load: () => import("./areas/ha-config-areas"), @@ -320,17 +374,13 @@ class HaPanelConfig extends HassRouterPage { tag: "ha-config-cloud", load: () => import("./cloud/ha-config-cloud"), }, - core: { - tag: "ha-config-core", - load: () => import("./core/ha-config-core"), - }, devices: { tag: "ha-config-devices", load: () => import("./devices/ha-config-devices"), }, - server_control: { - tag: "ha-config-server-control", - load: () => import("./server_control/ha-config-server-control"), + system: { + tag: "ha-config-system-navigation", + load: () => import("./core/ha-config-system-navigation"), }, logs: { tag: "ha-config-logs", @@ -354,6 +404,10 @@ class HaPanelConfig extends HassRouterPage { tag: "ha-config-energy", load: () => import("./energy/ha-config-energy"), }, + hardware: { + tag: "ha-config-hardware", + load: () => import("./hardware/ha-config-hardware"), + }, integrations: { tag: "ha-config-integrations", load: () => import("./integrations/ha-config-integrations"), @@ -362,6 +416,10 @@ class HaPanelConfig extends HassRouterPage { tag: "ha-config-lovelace", load: () => import("./lovelace/ha-config-lovelace"), }, + network: { + tag: "ha-config-section-network", + load: () => import("./network/ha-config-section-network"), + }, person: { tag: "ha-config-person", load: () => import("./person/ha-config-person"), @@ -378,6 +436,18 @@ class HaPanelConfig extends HassRouterPage { tag: "ha-config-helpers", load: () => import("./helpers/ha-config-helpers"), }, + storage: { + tag: "ha-config-section-storage", + load: () => import("./storage/ha-config-section-storage"), + }, + system_health: { + tag: "ha-config-system-health", + load: () => import("./system-health/ha-config-system-health"), + }, + updates: { + tag: "ha-config-section-updates", + load: () => import("./core/ha-config-section-updates"), + }, users: { tag: "ha-config-users", load: () => import("./users/ha-config-users"), @@ -386,6 +456,10 @@ class HaPanelConfig extends HassRouterPage { tag: "ha-config-zone", load: () => import("./zone/ha-config-zone"), }, + general: { + tag: "ha-config-section-general", + load: () => import("./core/ha-config-section-general"), + }, zha: { tag: "zha-config-dashboard-router", load: () => @@ -464,6 +538,10 @@ class HaPanelConfig extends HassRouterPage { "--app-header-border-bottom", "1px solid var(--divider-color)" ); + this.style.setProperty( + "--ha-card-border-radius", + "var(--ha-config-card-border-radius, 8px)" + ); } protected updatePageEl(el) { diff --git a/src/panels/config/hardware/dialog-hardware-available.ts b/src/panels/config/hardware/dialog-hardware-available.ts new file mode 100644 index 0000000000..9413c8acb7 --- /dev/null +++ b/src/panels/config/hardware/dialog-hardware-available.ts @@ -0,0 +1,213 @@ +import { mdiClose } from "@mdi/js"; +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 { stringCompare } from "../../../common/string/compare"; +import "../../../components/ha-dialog"; +import "../../../components/ha-expansion-panel"; +import "../../../components/ha-icon-next"; +import "../../../components/search-input"; +import { extractApiErrorMessage } from "../../../data/hassio/common"; +import { + fetchHassioHardwareInfo, + HassioHardwareInfo, +} from "../../../data/hassio/hardware"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; +import type { HassDialog } from "../../../dialogs/make-dialog-manager"; +import { dump } from "../../../resources/js-yaml-dump"; +import { haStyle, haStyleDialog } from "../../../resources/styles"; +import type { HomeAssistant } from "../../../types"; + +const _filterDevices = memoizeOne( + (showAdvanced: boolean, hardware: HassioHardwareInfo, filter: string) => + hardware.devices + .filter( + (device) => + (showAdvanced || + ["tty", "gpio", "input"].includes(device.subsystem)) && + (device.by_id?.toLowerCase().includes(filter) || + device.name.toLowerCase().includes(filter) || + device.dev_path.toLocaleLowerCase().includes(filter) || + JSON.stringify(device.attributes) + .toLocaleLowerCase() + .includes(filter)) + ) + .sort((a, b) => stringCompare(a.name, b.name)) +); + +@customElement("ha-dialog-hardware-available") +class DialogHardwareAvailable extends LitElement implements HassDialog { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _hardware?: HassioHardwareInfo; + + @state() private _filter?: string; + + public async showDialog(): Promise> { + try { + this._hardware = await fetchHassioHardwareInfo(this.hass); + } catch (err: any) { + await showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.hardware.available_hardware.failed_to_get" + ), + text: extractApiErrorMessage(err), + }); + } + } + + public closeDialog(): void { + this._hardware = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render(): TemplateResult { + if (!this._hardware) { + return html``; + } + + const devices = _filterDevices( + this.hass.userData?.showAdvanced || false, + this._hardware, + (this._filter || "").toLowerCase() + ); + + return html` + +
+

+ ${this.hass.localize( + "ui.panel.config.hardware.available_hardware.title" + )} +

+ + + +
+ ${devices.map( + (device) => + html` + +
+ + ${this.hass.localize( + "ui.panel.config.hardware.available_hardware.subsystem" + )}: + + ${device.subsystem} +
+
+ + ${this.hass.localize( + "ui.panel.config.hardware.available_hardware.device_path" + )}: + + ${device.dev_path} +
+ ${device.by_id + ? html` +
+ + ${this.hass.localize( + "ui.panel.config.hardware.available_hardware.id" + )}: + + ${device.by_id} +
+ ` + : ""} +
+ + ${this.hass.localize( + "ui.panel.config.hardware.available_hardware.attributes" + )}: + +
${dump(device.attributes, { indent: 2 })}
+
+
+ ` + )} +
+ `; + } + + private _handleSearchChange(ev: CustomEvent) { + this._filter = ev.detail.value; + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-icon-button { + position: absolute; + right: 16px; + top: 10px; + text-decoration: none; + color: var(--primary-text-color); + } + h2 { + margin: 18px 42px 0 18px; + color: var(--primary-text-color); + } + ha-expansion-panel { + margin: 4px 0; + } + pre, + code { + background-color: var(--markdown-code-background-color, none); + border-radius: 3px; + } + pre { + padding: 16px; + overflow: auto; + line-height: 1.45; + font-family: var(--code-font-family, monospace); + } + code { + font-size: 85%; + padding: 0.2em 0.4em; + } + search-input { + margin: 8px 16px 0; + display: block; + } + .device-property { + display: flex; + justify-content: space-between; + } + .attributes { + margin-top: 12px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-dialog-hardware-available": DialogHardwareAvailable; + } +} diff --git a/src/panels/config/hardware/ha-config-hardware.ts b/src/panels/config/hardware/ha-config-hardware.ts new file mode 100644 index 0000000000..5a080dae98 --- /dev/null +++ b/src/panels/config/hardware/ha-config-hardware.ts @@ -0,0 +1,257 @@ +import "@material/mwc-list/mwc-list-item"; +import { mdiDotsVertical } from "@mdi/js"; +import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import "../../../components/buttons/ha-progress-button"; +import "../../../components/ha-alert"; +import "../../../components/ha-button-menu"; +import "../../../components/ha-card"; +import "../../../components/ha-settings-row"; +import { BOARD_NAMES } from "../../../data/hardware"; +import { + extractApiErrorMessage, + ignoreSupervisorError, +} from "../../../data/hassio/common"; +import { + fetchHassioHassOsInfo, + fetchHassioHostInfo, + HassioHassOSInfo, + HassioHostInfo, + rebootHost, + shutdownHost, +} from "../../../data/hassio/host"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../dialogs/generic/show-dialog-box"; +import "../../../layouts/hass-subpage"; +import { haStyle } from "../../../resources/styles"; +import type { HomeAssistant } from "../../../types"; +import { showhardwareAvailableDialog } from "./show-dialog-hardware-available"; + +@customElement("ha-config-hardware") +class HaConfigHardware extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow!: boolean; + + @state() private _error?: { code: string; message: string }; + + @state() private _OSData?: HassioHassOSInfo; + + @state() private _hostData?: HassioHostInfo; + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + if (isComponentLoaded(this.hass, "hassio")) { + this._load(); + } + } + + protected render(): TemplateResult { + return html` + + + + ${this.hass.localize( + "ui.panel.config.hardware.available_hardware.title" + )} + + ${this._error + ? html` + ${this._error.message || this._error.code} + ` + : ""} + ${this._OSData || this._hostData + ? html` +
+ + ${this._OSData?.board + ? html` +
+ + ${BOARD_NAMES[this._OSData.board] || + this.hass.localize( + "ui.panel.config.hardware.board" + )} +
+ ${this._OSData.board} +
+
+
+ ` + : ""} + ${this._hostData + ? html` +
+ ${this._hostData.features.includes("reboot") + ? html` + + ${this.hass.localize( + "ui.panel.config.hardware.reboot_host" + )} + + ` + : ""} + ${this._hostData.features.includes("shutdown") + ? html` + + ${this.hass.localize( + "ui.panel.config.hardware.shutdown_host" + )} + + ` + : ""} +
+ ` + : ""} +
+
+ ` + : ""} +
+ `; + } + + private async _load() { + try { + this._OSData = await fetchHassioHassOsInfo(this.hass); + this._hostData = await fetchHassioHostInfo(this.hass); + } catch (err: any) { + this._error = err.message || err; + } + } + + private async _openHardware() { + showhardwareAvailableDialog(this); + } + + private async _hostReboot(ev: CustomEvent): Promise { + const button = ev.currentTarget as any; + button.progress = true; + + const confirmed = await showConfirmationDialog(this, { + title: this.hass.localize("ui.panel.config.hardware.reboot_host"), + text: this.hass.localize("ui.panel.config.hardware.reboot_host_confirm"), + confirmText: this.hass.localize("ui.panel.config.hardware.reboot_host"), + dismissText: this.hass.localize("common.cancel"), + }); + + if (!confirmed) { + button.progress = false; + return; + } + + try { + await rebootHost(this.hass); + } catch (err: any) { + // Ignore connection errors, these are all expected + if (this.hass.connection.connected && !ignoreSupervisorError(err)) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.hardware.failed_to_reboot_host" + ), + text: extractApiErrorMessage(err), + }); + } + } + button.progress = false; + } + + private async _hostShutdown(ev: CustomEvent): Promise { + const button = ev.currentTarget as any; + button.progress = true; + + const confirmed = await showConfirmationDialog(this, { + title: this.hass.localize("ui.panel.config.hardware.shutdown_host"), + text: this.hass.localize( + "ui.panel.config.hardware.shutdown_host_confirm" + ), + confirmText: this.hass.localize("ui.panel.config.hardware.shutdown_host"), + dismissText: this.hass.localize("common.cancel"), + }); + + if (!confirmed) { + button.progress = false; + return; + } + + try { + await shutdownHost(this.hass); + } catch (err: any) { + // Ignore connection errors, these are all expected + if (this.hass.connection.connected && !ignoreSupervisorError(err)) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.hardware.failed_to_shutdown_host" + ), + text: extractApiErrorMessage(err), + }); + } + } + button.progress = false; + } + + static styles = [ + haStyle, + 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; + } + .card-content { + display: flex; + justify-content: space-between; + flex-direction: column; + padding: 16px 16px 0 16px; + } + ha-button-menu { + color: var(--secondary-text-color); + --mdc-menu-min-width: 200px; + } + .card-actions { + height: 48px; + display: flex; + justify-content: space-between; + align-items: center; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-hardware": HaConfigHardware; + } +} diff --git a/src/panels/config/hardware/show-dialog-hardware-available.ts b/src/panels/config/hardware/show-dialog-hardware-available.ts new file mode 100644 index 0000000000..c11356ae8b --- /dev/null +++ b/src/panels/config/hardware/show-dialog-hardware-available.ts @@ -0,0 +1,12 @@ +import { fireEvent } from "../../../common/dom/fire_event"; + +export const loadHardwareAvailableDialog = () => + import("./dialog-hardware-available"); + +export const showhardwareAvailableDialog = (element: HTMLElement): void => { + fireEvent(element, "show-dialog", { + dialogTag: "ha-dialog-hardware-available", + dialogImport: loadHardwareAvailableDialog, + dialogParams: {}, + }); +}; diff --git a/src/panels/config/helpers/forms/ha-input_select-form.ts b/src/panels/config/helpers/forms/ha-input_select-form.ts index c7abc8e176..44c92f7e43 100644 --- a/src/panels/config/helpers/forms/ha-input_select-form.ts +++ b/src/panels/config/helpers/forms/ha-input_select-form.ts @@ -79,9 +79,11 @@ class HaInputSelectForm extends LitElement { "ui.dialogs.helper_settings.generic.icon" )} > - ${this.hass!.localize( - "ui.dialogs.helper_settings.input_select.options" - )}: +
+ ${this.hass!.localize( + "ui.dialogs.helper_settings.input_select.options" + )}: +
${this._options.length ? this._options.map( (option, index) => html` @@ -206,6 +208,10 @@ class HaInputSelectForm extends LitElement { #option_input { margin-top: 8px; } + .header { + margin-top: 8px; + margin-bottom: 8px; + } `, ]; } diff --git a/src/panels/config/helpers/ha-config-helpers.ts b/src/panels/config/helpers/ha-config-helpers.ts index 4173fd69ef..fbaa0e1c2f 100644 --- a/src/panels/config/helpers/ha-config-helpers.ts +++ b/src/panels/config/helpers/ha-config-helpers.ts @@ -6,21 +6,29 @@ import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { domainIcon } from "../../../common/entity/domain_icon"; +import { navigate } from "../../../common/navigate"; import { LocalizeFunc } from "../../../common/translations/localize"; +import { extractSearchParam } from "../../../common/url/search-params"; import { DataTableColumnContainer, RowClickedEvent, } from "../../../components/data-table/ha-data-table"; import "../../../components/ha-fab"; -import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-icon"; +import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-svg-icon"; import { ConfigEntry, getConfigEntries } from "../../../data/config_entries"; +import { getConfigFlowHandlers } from "../../../data/config_flow"; import { EntityRegistryEntry, subscribeEntityRegistry, } from "../../../data/entity_registry"; import { domainToName } from "../../../data/integration"; +import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-tabs-subpage-data-table"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; @@ -29,14 +37,6 @@ import { showEntityEditorDialog } from "../entities/show-dialog-entity-editor"; import { configSections } from "../ha-panel-config"; import { HELPER_DOMAINS } from "./const"; import { showHelperDetailDialog } from "./show-dialog-helper-detail"; -import { navigate } from "../../../common/navigate"; -import { extractSearchParam } from "../../../common/url/search-params"; -import { getConfigFlowHandlers } from "../../../data/config_flow"; -import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; -import { - showAlertDialog, - showConfirmationDialog, -} from "../../../dialogs/generic/show-dialog-box"; // This groups items by a key but only returns last entry per key. const groupByOne = ( @@ -196,7 +196,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { .narrow=${this.narrow} back-path="/config" .route=${this.route} - .tabs=${configSections.automations} + .tabs=${configSections.devices} .columns=${this._columns(this.narrow, this.hass.localize)} .data=${this._getItems( this._stateItems, diff --git a/src/panels/config/info/ha-config-info.ts b/src/panels/config/info/ha-config-info.ts index a2459d1565..b046e5ff18 100644 --- a/src/panels/config/info/ha-config-info.ts +++ b/src/panels/config/info/ha-config-info.ts @@ -1,13 +1,18 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { property } from "lit/decorators"; -import "../../../layouts/hass-tabs-subpage"; +import { property, state } from "lit/decorators"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import "../../../components/ha-logo-svg"; +import { + fetchHassioHassOsInfo, + fetchHassioHostInfo, + HassioHassOSInfo, + HassioHostInfo, +} from "../../../data/hassio/host"; +import { fetchHassioInfo, HassioInfo } from "../../../data/hassio/supervisor"; +import "../../../layouts/hass-subpage"; import { haStyle } from "../../../resources/styles"; import { HomeAssistant, Route } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; -import { configSections } from "../ha-panel-config"; -import "./integrations-card"; -import "./system-health-card"; const JS_TYPE = __BUILD__; const JS_VERSION = __VERSION__; @@ -15,13 +20,19 @@ const JS_VERSION = __VERSION__; class HaConfigInfo extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property() public narrow!: boolean; + @property({ type: Boolean }) public narrow!: boolean; - @property() public isWide!: boolean; + @property({ type: Boolean }) public isWide!: boolean; - @property() public showAdvanced!: boolean; + @property({ type: Boolean }) public showAdvanced!: boolean; - @property() public route!: Route; + @property({ attribute: false }) public route!: Route; + + @state() private _hostInfo?: HassioHostInfo; + + @state() private _osInfo?: HassioHassOSInfo; + + @state() private _hassioInfo?: HassioInfo; protected render(): TemplateResult { const hass = this.hass; @@ -29,12 +40,11 @@ class HaConfigInfo extends LitElement { (window as any).CUSTOM_UI_LIST || []; return html` -

-

Home Assistant ${hass.connection.haVersion}

+

Home Assistant Core ${hass.connection.haVersion}

+ ${this._hassioInfo + ? html` +

+ Home Assistant Supervisor ${this._hassioInfo.supervisor} +

+ ` + : ""} + ${this._osInfo?.version + ? html`

Home Assistant OS ${this._osInfo.version}

` + : ""} + ${this._hostInfo + ? html` +

Kernel version ${this._hostInfo.kernel}

+

Agent version ${this._hostInfo.agent_version}

+ ` + : ""}

${this.hass.localize( "ui.panel.config.info.path_configuration", @@ -130,14 +156,7 @@ class HaConfigInfo extends LitElement { : ""}

-
- - -
-
+ `; } @@ -151,6 +170,22 @@ class HaConfigInfo extends LitElement { this.requestUpdate(); } }, 1000); + + if (isComponentLoaded(this.hass, "hassio")) { + this._loadSupervisorInfo(); + } + } + + private async _loadSupervisorInfo(): Promise { + const [hostInfo, osInfo, hassioInfo] = await Promise.all([ + fetchHassioHostInfo(this.hass), + fetchHassioHassOsInfo(this.hass), + fetchHassioInfo(this.hass), + ]); + + this._hassioInfo = hassioInfo; + this._osInfo = osInfo; + this._hostInfo = hostInfo; } static get styles(): CSSResultGroup { @@ -179,19 +214,15 @@ class HaConfigInfo extends LitElement { .about a { color: var(--primary-color); } - - system-health-card, - integrations-card { - display: block; - max-width: 600px; - margin: 0 auto; - padding-bottom: 16px; - } ha-logo-svg { padding: 12px; height: 180px; width: 180px; } + + h4 { + font-weight: 400; + } `, ]; } diff --git a/src/panels/config/info/integrations-card.ts b/src/panels/config/info/integrations-card.ts deleted file mode 100644 index f4b9922e28..0000000000 --- a/src/panels/config/info/integrations-card.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import memoizeOne from "memoize-one"; -import "../../../components/ha-card"; -import { - domainToName, - fetchIntegrationManifests, - fetchIntegrationSetups, - integrationIssuesUrl, - IntegrationManifest, - IntegrationSetup, -} from "../../../data/integration"; -import { HomeAssistant } from "../../../types"; -import { brandsUrl } from "../../../util/brands-url"; -import { documentationUrl } from "../../../util/documentation-url"; - -@customElement("integrations-card") -class IntegrationsCard extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property({ type: Boolean }) public narrow = false; - - @state() private _manifests?: { - [domain: string]: IntegrationManifest; - }; - - @state() private _setups?: { - [domain: string]: IntegrationSetup; - }; - - private _sortedIntegrations = memoizeOne((components: string[]) => - Array.from( - new Set( - components.map((comp) => - comp.includes(".") ? comp.split(".")[1] : comp - ) - ) - ).sort() - ); - - firstUpdated(changedProps) { - super.firstUpdated(changedProps); - this._fetchManifests(); - this._fetchSetups(); - } - - protected render(): TemplateResult { - return html` - - - - - - ${!this.narrow - ? html` - - ` - : ""} - - - - - ${this._sortedIntegrations(this.hass!.config.components).map( - (domain) => { - const manifest = this._manifests && this._manifests[domain]; - const docLink = manifest - ? html`${this.hass.localize( - "ui.panel.config.info.documentation" - )}` - : ""; - const issueLink = - manifest && (manifest.is_built_in || manifest.issue_tracker) - ? html` - ${this.hass.localize( - "ui.panel.config.info.issues" - )} - ` - : ""; - const setupSeconds = - this._setups?.[domain]?.seconds?.toFixed(2); - return html` - - - - ${this.narrow - ? "" - : html` - - - - `} - - `; - } - )} - -
${this.hass.localize("ui.panel.config.info.setup_time")}
- - - ${domainToName( - this.hass.localize, - domain, - manifest - )}
- ${domain} - ${this.narrow - ? html`
-
${docLink} ${issueLink}
- ${setupSeconds ? html`${setupSeconds} s` : ""} -
` - : ""} -
${docLink}${issueLink} - ${setupSeconds ? html`${setupSeconds} s` : ""} -
-
- `; - } - - private async _fetchManifests() { - const manifests = {}; - for (const manifest of await fetchIntegrationManifests(this.hass)) { - manifests[manifest.domain] = manifest; - } - this._manifests = manifests; - } - - private async _fetchSetups() { - const setups = {}; - for (const setup of await fetchIntegrationSetups(this.hass)) { - setups[setup.domain] = setup; - } - this._setups = setups; - } - - static get styles(): CSSResultGroup { - return css` - table { - width: 100%; - } - td, - th { - padding: 0 8px; - } - td:first-child { - padding-left: 0; - } - td.name { - padding: 8px; - } - td.setup { - text-align: right; - white-space: nowrap; - direction: ltr; - } - th { - text-align: right; - } - .domain { - color: var(--secondary-text-color); - } - .mobile-row { - display: flex; - justify-content: space-between; - } - .mobile-row a:not(:last-of-type) { - margin-right: 4px; - } - img { - display: block; - max-height: 40px; - max-width: 40px; - } - a { - color: var(--primary-color); - } - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "integrations-card": IntegrationsCard; - } -} diff --git a/src/panels/config/info/system-health-card.ts b/src/panels/config/info/system-health-card.ts deleted file mode 100644 index 301d9c1e64..0000000000 --- a/src/panels/config/info/system-health-card.ts +++ /dev/null @@ -1,298 +0,0 @@ -import "@material/mwc-button/mwc-button"; -import { ActionDetail } from "@material/mwc-list/mwc-list-foundation"; -import "@material/mwc-list/mwc-list-item"; -import { mdiContentCopy } from "@mdi/js"; -import "@polymer/paper-tooltip/paper-tooltip"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { property, state } from "lit/decorators"; -import { isComponentLoaded } from "../../../common/config/is_component_loaded"; -import { formatDateTime } from "../../../common/datetime/format_date_time"; -import { copyToClipboard } from "../../../common/util/copy-clipboard"; -import "../../../components/ha-button-menu"; -import "../../../components/ha-card"; -import "../../../components/ha-circular-progress"; -import "../../../components/ha-icon-button"; -import { domainToName } from "../../../data/integration"; -import { - subscribeSystemHealthInfo, - SystemCheckValueObject, - SystemHealthInfo, -} from "../../../data/system_health"; -import { HomeAssistant } from "../../../types"; -import { showToast } from "../../../util/toast"; - -const sortKeys = (a: string, b: string) => { - if (a === "homeassistant") { - return -1; - } - if (b === "homeassistant") { - return 1; - } - if (a < b) { - return -1; - } - if (b < a) { - return 1; - } - return 0; -}; - -class SystemHealthCard extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @state() private _info?: SystemHealthInfo; - - protected render(): TemplateResult { - if (!this.hass) { - return html``; - } - const sections: TemplateResult[] = []; - - if (!this._info) { - sections.push( - html` -
- -
- ` - ); - } 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(domainInfo.info)) { - let value: unknown; - - if ( - domainInfo.info[key] && - 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.locale); - } - } else { - value = domainInfo.info[key]; - } - - keys.push(html` - - - ${this.hass.localize( - `component.${domain}.system_health.info.${key}` - ) || key} - - ${value} - - `); - } - if (domain !== "homeassistant") { - sections.push( - html` -
-

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

- ${!domainInfo.manage_url - ? "" - : html` - - - ${this.hass.localize( - "ui.panel.config.info.system_health.manage" - )} - - - `} -
- ` - ); - } - sections.push(html` - - ${keys} -
- `); - } - } - - return html` - -

-
- ${domainToName(this.hass.localize, "system_health")} -
- - - - ${this.hass.localize("ui.panel.config.info.copy_raw")} - - - ${this.hass.localize("ui.panel.config.info.copy_github")} - - -

-
${sections}
-
- `; - } - - protected firstUpdated(changedProps) { - super.firstUpdated(changedProps); - - this.hass!.loadBackendTranslation("system_health"); - - if (!isComponentLoaded(this.hass!, "system_health")) { - this._info = { - system_health: { - info: { - error: this.hass.localize( - "ui.panel.config.info.system_health_error" - ), - }, - }, - }; - return; - } - - subscribeSystemHealthInfo(this.hass!, (info) => { - this._info = info; - }); - } - - private async _copyInfo(ev: CustomEvent): Promise { - const github = ev.detail.index === 1; - let haContent: string | undefined; - const domainParts: string[] = []; - - for (const domain of Object.keys(this._info!).sort(sortKeys)) { - const domainInfo = this._info![domain]; - let first = true; - const parts = [ - `${ - github && domain !== "homeassistant" - ? `
${domainToName( - this.hass.localize, - domain - )}\n` - : "" - }`, - ]; - - 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 = "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.locale); - } - } else { - value = domainInfo.info[key]; - } - if (github && first) { - parts.push(`${key} | ${value}\n-- | --`); - first = false; - } else { - parts.push(`${key}${github ? " | " : ": "}${value}`); - } - } - - if (domain === "homeassistant") { - haContent = parts.join("\n"); - } else { - domainParts.push(parts.join("\n")); - if (github && domain !== "homeassistant") { - domainParts.push("
"); - } - } - } - - await copyToClipboard( - `${github ? "## " : ""}System Health\n${haContent}\n\n${domainParts.join( - "\n\n" - )}` - ); - - showToast(this, { - message: this.hass.localize("ui.common.copied_clipboard"), - }); - } - - static get styles(): CSSResultGroup { - return css` - table { - width: 100%; - } - - td:first-child { - width: 45%; - } - - td:last-child { - direction: ltr; - } - - .loading-container { - display: flex; - align-items: center; - justify-content: center; - } - - .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; - } - `; - } -} - -customElements.define("system-health-card", SystemHealthCard); diff --git a/src/panels/config/integrations/ha-config-integrations.ts b/src/panels/config/integrations/ha-config-integrations.ts index 79cd2d7828..945cf5279f 100644 --- a/src/panels/config/integrations/ha-config-integrations.ts +++ b/src/panels/config/integrations/ha-config-integrations.ts @@ -111,7 +111,7 @@ const groupByIntegration = ( class HaConfigIntegrations extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; - @property() public narrow!: boolean; + @property({ type: Boolean, reflect: true }) public narrow!: boolean; @property() public isWide!: boolean; @@ -709,6 +709,9 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { return [ haStyle, css` + :host([narrow]) hass-tabs-subpage { + --main-title-margin: 0; + } ha-button-menu { margin-left: 8px; } diff --git a/src/panels/config/integrations/ha-integration-header.ts b/src/panels/config/integrations/ha-integration-header.ts index 72d40619be..05506898cc 100644 --- a/src/panels/config/integrations/ha-integration-header.ts +++ b/src/panels/config/integrations/ha-integration-header.ts @@ -143,6 +143,10 @@ export class HaIntegrationHeader extends LitElement { width: 40px; height: 40px; } + :host-context([style*="direction: rtl;"]) .header img { + margin-right: auto !important; + margin-left: 16px; + } .header .info { flex: 1; align-self: center; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts index bc84b9a78a..e5d0142239 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts @@ -179,7 +179,7 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {

- ${this._nodeMetadata.comments.length > 0 + ${this._nodeMetadata.comments?.length > 0 ? html`
${this._nodeMetadata.comments.map( @@ -214,7 +214,9 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
${item.property} - ${item.metadata.label} + + ${item.metadata.label} + ${item.metadata.description} ${item.metadata.description !== null && !item.metadata.writeable diff --git a/src/panels/config/logs/dialog-system-log-detail.ts b/src/panels/config/logs/dialog-system-log-detail.ts index 2765ffa532..f73f0b8f43 100644 --- a/src/panels/config/logs/dialog-system-log-detail.ts +++ b/src/panels/config/logs/dialog-system-log-detail.ts @@ -228,6 +228,7 @@ class DialogSystemLogDetail extends LitElement { .contents { padding: 16px; outline: none; + direction: ltr; } .error { color: var(--error-color); diff --git a/src/panels/config/logs/error-log-card.ts b/src/panels/config/logs/error-log-card.ts index 3008ec2484..2a7f91fba2 100644 --- a/src/panels/config/logs/error-log-card.ts +++ b/src/panels/config/logs/error-log-card.ts @@ -1,124 +1,248 @@ import "@material/mwc-button"; +import "@material/mwc-list/mwc-list-item"; import { mdiRefresh } from "@mdi/js"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { property, state } from "lit/decorators"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import "../../../components/ha-alert"; +import "../../../components/ha-ansi-to-html"; +import "../../../components/ha-card"; import "../../../components/ha-icon-button"; +import "../../../components/ha-select"; +import { computeRTLDirection } from "../../../common/util/compute_rtl"; import { fetchErrorLog } from "../../../data/error_log"; +import { extractApiErrorMessage } from "../../../data/hassio/common"; +import { fetchHassioLogs } from "../../../data/hassio/supervisor"; import { HomeAssistant } from "../../../types"; +import { debounce } from "../../../common/util/debounce"; +@customElement("error-log-card") class ErrorLogCard extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property() public filter = ""; + @property() public provider!: string; + + @property({ type: Boolean, attribute: true }) public show = false; + @state() private _isLogLoaded = false; - @state() private _errorHTML!: TemplateResult[] | string; + @state() private _logHTML?: TemplateResult[] | TemplateResult | string; + + @state() private _error?: string; protected render(): TemplateResult { return html`
- ${this._errorHTML + ${this._error + ? html`${this._error}` + : ""} + ${this._logHTML ? html` - - -
${this._errorHTML}
+ +
+

+ ${this.hass.localize("ui.panel.config.logs.full_logs")} +

+ +
+
${this._logHTML}
` - : html` - - ${this.hass.localize("ui.panel.config.logs.load_full_log")} + : ""} + ${!this._logHTML + ? html` + + ${this.hass.localize("ui.panel.config.logs.load_logs")} - `} + ` + : ""}
`; } - protected firstUpdated(changedProps) { + private _debounceSearch = debounce( + () => (this._isLogLoaded ? this._refreshLogs() : this._debounceSearch()), + 150, + false + ); + + protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); - if (this.hass?.config.safe_mode) { + if (this.hass?.config.safe_mode || this.show) { this.hass.loadFragmentTranslation("config"); - this._refreshErrorLog(); + this._refreshLogs(); } } protected updated(changedProps) { super.updated(changedProps); - if (changedProps.has("filter") && this._isLogLoaded) { - this._refreshErrorLog(); + if (changedProps.has("provider")) { + this._logHTML = undefined; + } + + if ( + (changedProps.has("show") && this.show) || + (changedProps.has("provider") && this.show) + ) { + this._refreshLogs(); + return; + } + + if (changedProps.has("filter")) { + this._debounceSearch(); } } - static get styles(): CSSResultGroup { - return css` - .error-log-intro { - text-align: center; - margin: 16px; - } + private async _refresh(ev: CustomEvent): Promise { + const button = ev.currentTarget as any; + button.progress = true; - ha-icon-button { - float: right; - } - - .error-log { - font-family: var(--code-font-family, monospace); - clear: both; - text-align: left; - padding-top: 12px; - } - - .error-log > div:hover { - background-color: var(--secondary-background-color); - } - - .error { - color: var(--error-color); - } - - .warning { - color: var(--warning-color); - } - `; + await this._refreshLogs(); + button.progress = false; } - private async _refreshErrorLog(): Promise { - this._errorHTML = this.hass.localize("ui.panel.config.logs.loading_log"); - const log = await fetchErrorLog(this.hass!); + private async _refreshLogs(): Promise { + this._logHTML = this.hass.localize("ui.panel.config.logs.loading_log"); + let log: string; + + if (isComponentLoaded(this.hass, "hassio")) { + try { + log = await fetchHassioLogs(this.hass, this.provider); + if (this.filter) { + log = log + .split("\n") + .filter((entry) => + entry.toLowerCase().includes(this.filter.toLowerCase()) + ) + .join("\n"); + } + if (!log) { + this._logHTML = this.hass.localize("ui.panel.config.logs.no_errors"); + return; + } + this._logHTML = html` + `; + this._isLogLoaded = true; + return; + } catch (err: any) { + this._error = this.hass.localize( + "ui.panel.config.logs.failed_get_logs", + "provider", + this.provider, + "error", + extractApiErrorMessage(err) + ); + return; + } + } else { + log = await fetchErrorLog(this.hass!); + } + this._isLogLoaded = true; - this._errorHTML = log - ? log - .split("\n") - .filter((entry) => { - if (this.filter) { - return entry.toLowerCase().includes(this.filter.toLowerCase()); - } - return entry; - }) - .map((entry) => { - if (entry.includes("INFO")) - return html`
${entry}
`; + const split = log && log.split("\n"); - if (entry.includes("WARNING")) - return html`
${entry}
`; + this._logHTML = split + ? (this.filter + ? split.filter((entry) => { + if (this.filter) { + return entry.toLowerCase().includes(this.filter.toLowerCase()); + } + return entry; + }) + : split + ).map((entry) => { + if (entry.includes("INFO")) + return html`
${entry}
`; - if ( - entry.includes("ERROR") || - entry.includes("FATAL") || - entry.includes("CRITICAL") - ) - return html`
${entry}
`; + if (entry.includes("WARNING")) + return html`
${entry}
`; - return html`
${entry}
`; - }) + if ( + entry.includes("ERROR") || + entry.includes("FATAL") || + entry.includes("CRITICAL") + ) + return html`
${entry}
`; + + return html`
${entry}
`; + }) : this.hass.localize("ui.panel.config.logs.no_errors"); } + + static styles: CSSResultGroup = css` + .error-log-intro { + text-align: center; + margin: 16px; + } + + .header { + display: flex; + justify-content: space-between; + padding: 16px; + } + + ha-select { + display: block; + max-width: 500px; + width: 100%; + } + + ha-icon-button { + float: right; + } + + .error-log { + font-family: var(--code-font-family, monospace); + clear: both; + text-align: left; + padding-top: 12px; + } + + .error-log > div { + overflow: auto; + overflow-wrap: break-word; + } + + .error-log > div:hover { + background-color: var(--secondary-background-color); + } + + .error { + color: var(--error-color); + } + + .warning { + color: var(--warning-color); + } + + :host-context([style*="direction: rtl;"]) mwc-button { + direction: rtl; + } + `; } -customElements.define("error-log-card", ErrorLogCard); +declare global { + interface HTMLElementTagNameMap { + "error-log-card": ErrorLogCard; + } +} diff --git a/src/panels/config/logs/ha-config-logs.ts b/src/panels/config/logs/ha-config-logs.ts index 4ebed750f6..1981a08a25 100644 --- a/src/panels/config/logs/ha-config-logs.ts +++ b/src/panels/config/logs/ha-config-logs.ts @@ -1,31 +1,67 @@ +import { mdiChevronDown } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import { extractSearchParam } from "../../../common/url/search-params"; +import "../../../components/ha-button-menu"; +import "../../../components/search-input"; +import { LogProvider } from "../../../data/error_log"; +import { fetchHassioSupervisorInfo } from "../../../data/hassio/supervisor"; +import "../../../layouts/hass-subpage"; import "../../../layouts/hass-tabs-subpage"; import { haStyle } from "../../../resources/styles"; import { HomeAssistant, Route } from "../../../types"; -import { configSections } from "../ha-panel-config"; -import "../../../components/search-input"; -import { extractSearchParam } from "../../../common/url/search-params"; import "./error-log-card"; import "./system-log-card"; import type { SystemLogCard } from "./system-log-card"; +const logProviders: LogProvider[] = [ + { + key: "core", + name: "Home Assistant Core", + }, + { + key: "supervisor", + name: "Supervisor", + }, + { + key: "host", + name: "Host", + }, + { + key: "dns", + name: "DNS", + }, + { + key: "audio", + name: "Audio", + }, + { + key: "multicast", + name: "Multicast", + }, +]; + @customElement("ha-config-logs") export class HaConfigLogs extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property() public narrow!: boolean; + @property({ type: Boolean }) public narrow!: boolean; - @property() public isWide!: boolean; + @property({ type: Boolean }) public isWide!: boolean; - @property() public showAdvanced!: boolean; + @property({ type: Boolean }) public showAdvanced!: boolean; - @property() public route!: Route; + @property({ attribute: false }) public route!: Route; @state() private _filter = extractSearchParam("filter") || ""; @query("system-log-card", true) private systemLog?: SystemLogCard; + @state() private _selectedLogProvider = "core"; + + @state() private _logProviders = logProviders; + public connectedCallback() { super.connectedCallback(); if (this.systemLog && this.systemLog.loaded) { @@ -33,6 +69,13 @@ export class HaConfigLogs extends LitElement { } } + protected firstUpdated(changedProps): void { + super.firstUpdated(changedProps); + if (isComponentLoaded(this.hass, "hassio")) { + this._getInstalledAddons(); + } + } + private async _filterChanged(ev) { this._filter = ev.detail.value; } @@ -62,28 +105,81 @@ export class HaConfigLogs extends LitElement { `; return html` - + ${isComponentLoaded(this.hass, "hassio") && + this.hass.userData?.showAdvanced + ? html` + + p.key === this._selectedLogProvider + )!.name} + > + + + ${this._logProviders.map( + (provider) => html` + + ${provider.name} + + ` + )} + + ` + : ""} ${search}
- + ${this._selectedLogProvider === "core" + ? html` + + ` + : ""}
-
+ `; } + private _selectProvider(ev) { + this._selectedLogProvider = (ev.currentTarget as any).provider; + } + + private async _getInstalledAddons() { + try { + const supervisorInfo = await fetchHassioSupervisorInfo(this.hass); + this._logProviders = [ + ...this._logProviders, + ...supervisorInfo.addons.map((addon) => ({ + key: addon.slug, + name: addon.name, + })), + ]; + } catch (err) { + // Ignore, nothing the user can do anyway + } + } + static get styles(): CSSResultGroup { return [ haStyle, @@ -109,6 +205,11 @@ export class HaConfigLogs extends LitElement { .content { direction: ltr; } + + mwc-button[slot="trigger"] { + --mdc-theme-primary: var(--primary-text-color); + --mdc-icon-size: 36px; + } `, ]; } diff --git a/src/panels/config/logs/system-log-card.ts b/src/panels/config/logs/system-log-card.ts index 373b797a06..f774a657c3 100644 --- a/src/panels/config/logs/system-log-card.ts +++ b/src/panels/config/logs/system-log-card.ts @@ -18,6 +18,7 @@ import { import { HomeAssistant } from "../../../types"; import { showSystemLogDetailDialog } from "./show-dialog-system-log-detail"; import { formatSystemLogTime } from "./util"; +import { computeRTLDirection } from "../../../common/util/compute_rtl"; @customElement("system-log-card") export class SystemLogCard extends LitElement { @@ -75,7 +76,7 @@ export class SystemLogCard extends LitElement { : []; return html`
- + ${this._items === undefined ? html`
@@ -131,7 +132,7 @@ export class SystemLogCard extends LitElement { ` )} -
+
+ ${this.hass.userData?.showAdvanced + ? html` + + + + ${this.hass.localize( + "ui.panel.config.lovelace.resources.caption" + )} + + + ` + : ""} +
- ${this._error ? html`
${this._error}
` : ""} + ${this._error + ? html` + ${this._error.message || this._error.code} + ` + : ""}

Configure which network adapters integrations will use. Currently this setting only affects multicast traffic. A restart is required diff --git a/src/panels/config/network/ha-config-section-network.ts b/src/panels/config/network/ha-config-section-network.ts new file mode 100644 index 0000000000..c7edf88f3b --- /dev/null +++ b/src/panels/config/network/ha-config-section-network.ts @@ -0,0 +1,64 @@ +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import "../../../layouts/hass-subpage"; +import type { HomeAssistant, Route } from "../../../types"; +import "./ha-config-network"; +import "./ha-config-url-form"; +import "./supervisor-hostname"; +import "./supervisor-network"; + +@customElement("ha-config-section-network") +class HaConfigSectionNetwork extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public route!: Route; + + @property({ type: Boolean }) public narrow!: boolean; + + protected render(): TemplateResult { + return html` + +

+ ${isComponentLoaded(this.hass, "hassio") + ? html` + ` + : ""} + + +
+ + `; + } + + static styles = css` + .content { + padding: 28px 20px 0; + max-width: 1040px; + margin: 0 auto; + } + supervisor-hostname, + supervisor-network, + ha-config-url-form, + ha-config-network { + display: block; + margin: 0 auto; + margin-bottom: 24px; + max-width: 600px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-section-network": HaConfigSectionNetwork; + } +} diff --git a/src/panels/config/core/ha-config-url-form.ts b/src/panels/config/network/ha-config-url-form.ts similarity index 98% rename from src/panels/config/core/ha-config-url-form.ts rename to src/panels/config/network/ha-config-url-form.ts index 80830d6c7a..e5fb9ef4e9 100644 --- a/src/panels/config/core/ha-config-url-form.ts +++ b/src/panels/config/network/ha-config-url-form.ts @@ -9,17 +9,17 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; -import "../../../components/ha-card"; -import "../../../components/ha-switch"; +import { isIPAddress } from "../../../common/string/is_ip_address"; import "../../../components/ha-alert"; +import "../../../components/ha-card"; import "../../../components/ha-formfield"; +import "../../../components/ha-switch"; import "../../../components/ha-textfield"; import type { HaTextField } from "../../../components/ha-textfield"; import { CloudStatus, fetchCloudStatus } from "../../../data/cloud"; import { saveCoreConfig } from "../../../data/core"; import type { PolymerChangedEvent } from "../../../polymer-types"; import type { HomeAssistant } from "../../../types"; -import { isIPAddress } from "../../../common/string/is_ip_address"; @customElement("ha-config-url-form") class ConfigUrlForm extends LitElement { @@ -74,7 +74,10 @@ class ConfigUrlForm extends LitElement { } return html` - +
${!canEdit ? html` @@ -249,6 +252,8 @@ class ConfigUrlForm extends LitElement { this._cloudStatus = cloudStatus; if (cloudStatus.logged_in) { this._showCustomExternalUrl = this._externalUrlValue !== null; + } else { + this._showCustomExternalUrl = true; } }); } else { @@ -335,6 +340,7 @@ class ConfigUrlForm extends LitElement { a { color: var(--primary-color); + text-decoration: none; } `; } diff --git a/src/panels/config/network/supervisor-hostname.ts b/src/panels/config/network/supervisor-hostname.ts new file mode 100644 index 0000000000..45a42ff711 --- /dev/null +++ b/src/panels/config/network/supervisor-hostname.ts @@ -0,0 +1,124 @@ +import "@material/mwc-button/mwc-button"; +import "@material/mwc-list/mwc-list"; +import "@material/mwc-list/mwc-list-item"; +import "@material/mwc-tab"; +import "@material/mwc-tab-bar"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../components/ha-alert"; +import "../../../components/ha-card"; +import "../../../components/ha-circular-progress"; +import "../../../components/ha-expansion-panel"; +import "../../../components/ha-formfield"; +import "../../../components/ha-header-bar"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-radio"; +import "../../../components/ha-related-items"; +import "../../../components/ha-settings-row"; +import "../../../components/ha-textfield"; +import { extractApiErrorMessage } from "../../../data/hassio/common"; +import { + changeHostOptions, + fetchHassioHostInfo, +} from "../../../data/hassio/host"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; +import type { HomeAssistant } from "../../../types"; + +@customElement("supervisor-hostname") +export class HassioHostname extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) narrow!: boolean; + + @state() private _processing = false; + + @state() private _hostname?: string; + + protected firstUpdated() { + this._fetchHostInfo(); + } + + private async _fetchHostInfo() { + const hostInfo = await fetchHassioHostInfo(this.hass); + this._hostname = hostInfo.hostname; + } + + protected render(): TemplateResult { + if (!this._hostname) { + return html``; + } + + return html` + +
+ + Hostname + The name your instance will have on your network + + + +
+
+ + ${this._processing + ? html` + ` + : this.hass.localize("ui.common.save")} + +
+
+ `; + } + + private _handleChange(ev) { + this._hostname = ev.target.value; + } + + private async _save() { + this._processing = true; + try { + await changeHostOptions(this.hass, { hostname: this._hostname }); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.network.hostname.failed_to_set_hostname" + ), + text: extractApiErrorMessage(err), + }); + } finally { + this._processing = false; + } + } + + static styles: CSSResultGroup = css` + ha-textfield { + width: 100%; + } + .card-actions { + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + align-items: center; + } + ha-settings-row { + border-top: none; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "supervisor-hostname": HassioHostname; + } +} diff --git a/src/panels/config/network/supervisor-network.ts b/src/panels/config/network/supervisor-network.ts new file mode 100644 index 0000000000..5621a666e6 --- /dev/null +++ b/src/panels/config/network/supervisor-network.ts @@ -0,0 +1,577 @@ +import "@material/mwc-button/mwc-button"; +import "@material/mwc-list/mwc-list"; +import "@material/mwc-list/mwc-list-item"; +import "@material/mwc-tab"; +import "@material/mwc-tab-bar"; +import { PaperInputElement } from "@polymer/paper-input/paper-input"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { cache } from "lit/directives/cache"; +import "../../../components/ha-alert"; +import "../../../components/ha-circular-progress"; +import "../../../components/ha-expansion-panel"; +import "../../../components/ha-formfield"; +import "../../../components/ha-header-bar"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-radio"; +import "../../../components/ha-related-items"; +import { extractApiErrorMessage } from "../../../data/hassio/common"; +import { + AccessPoints, + accesspointScan, + fetchNetworkInfo, + NetworkInterface, + updateNetworkInterface, + WifiConfiguration, +} from "../../../data/hassio/network"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../dialogs/generic/show-dialog-box"; +import type { HomeAssistant } from "../../../types"; +import "../../../components/ha-card"; + +const IP_VERSIONS = ["ipv4", "ipv6"]; + +@customElement("supervisor-network") +export class HassioNetwork extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _accessPoints?: AccessPoints; + + @state() private _curTabIndex = 0; + + @state() private _dirty = false; + + @state() private _interface?: NetworkInterface; + + @state() private _interfaces!: NetworkInterface[]; + + @state() private _processing = false; + + @state() private _scanning = false; + + @state() private _wifiConfiguration?: WifiConfiguration; + + protected firstUpdated() { + this._fetchNetworkInfo(); + } + + private async _fetchNetworkInfo() { + const network = await fetchNetworkInfo(this.hass); + this._interfaces = network.interfaces.sort((a, b) => + a.primary > b.primary ? -1 : 1 + ); + this._interface = { ...this._interfaces[this._curTabIndex] }; + } + + protected render(): TemplateResult { + if (!this._interface) { + return html``; + } + + return html` + + ${this._interfaces.length > 1 + ? html`${this._interfaces.map( + (device) => + html` + ` + )} + ` + : ""} + ${cache(this._renderTab())} + + `; + } + + private _renderTab() { + return html`
+ ${IP_VERSIONS.map((version) => + this._interface![version] ? this._renderIPConfiguration(version) : "" + )} + ${this._interface?.type === "wireless" + ? html` + + ${this._interface?.wifi?.ssid + ? html`

+ ${this.hass.localize( + "ui.panel.config.network.supervisor.connected_to", + "ssid", + this._interface?.wifi?.ssid + )} +

` + : ""} + + ${this._scanning + ? html` + ` + : this.hass.localize( + "ui.panel.config.network.supervisor.scan_ap" + )} + + ${this._accessPoints && + this._accessPoints.accesspoints && + this._accessPoints.accesspoints.length !== 0 + ? html` + + ${this._accessPoints.accesspoints + .filter((ap) => ap.ssid) + .map( + (ap) => + html` + + ${ap.ssid} + + ${ap.mac} - Strength: ${ap.signal} + + + ` + )} + + ` + : ""} + ${this._wifiConfiguration + ? html` +
+ + + + + + + + + + + + +
+ ${this._wifiConfiguration.auth === "wpa-psk" || + this._wifiConfiguration.auth === "wep" + ? html` + + + ` + : ""} + ` + : ""} +
+ ` + : ""} + ${this._dirty + ? html` + ${this.hass.localize( + "ui.panel.config.network.supervisor.warning" + )} + ` + : ""} +
+
+ + ${this._processing + ? html` + ` + : this.hass.localize("ui.common.save")} + +
`; + } + + private _selectAP(event) { + this._wifiConfiguration = event.currentTarget.ap; + this._dirty = true; + } + + private async _scanForAP() { + if (!this._interface) { + return; + } + this._scanning = true; + try { + this._accessPoints = await accesspointScan( + this.hass, + this._interface.interface + ); + } catch (err: any) { + showAlertDialog(this, { + title: "Failed to scan for accesspoints", + text: extractApiErrorMessage(err), + }); + } finally { + this._scanning = false; + } + } + + private _renderIPConfiguration(version: string) { + return html` + +
+ + + + + + + + + + + + +
+ ${this._interface![version].method === "static" + ? html` + + + + + + + ` + : ""} +
+ `; + } + + _toArray(data: string | string[]): string[] { + if (Array.isArray(data)) { + if (data && typeof data[0] === "string") { + data = data[0]; + } + } + if (!data) { + return []; + } + if (typeof data === "string") { + return data.replace(/ /g, "").split(","); + } + return data; + } + + _toString(data: string | string[]): string { + if (!data) { + return ""; + } + if (Array.isArray(data)) { + return data.join(", "); + } + return data; + } + + private async _updateNetwork() { + this._processing = true; + let interfaceOptions: Partial = {}; + + IP_VERSIONS.forEach((version) => { + interfaceOptions[version] = { + method: this._interface![version]?.method || "auto", + }; + if (this._interface![version]?.method === "static") { + interfaceOptions[version] = { + ...interfaceOptions[version], + address: this._toArray(this._interface![version]?.address), + gateway: this._interface![version]?.gateway, + nameservers: this._toArray(this._interface![version]?.nameservers), + }; + } + }); + + if (this._wifiConfiguration) { + interfaceOptions = { + ...interfaceOptions, + wifi: { + ssid: this._wifiConfiguration.ssid, + mode: this._wifiConfiguration.mode, + auth: this._wifiConfiguration.auth || "open", + }, + }; + if (interfaceOptions.wifi!.auth !== "open") { + interfaceOptions.wifi = { + ...interfaceOptions.wifi, + psk: this._wifiConfiguration.psk, + }; + } + } + + interfaceOptions.enabled = + this._wifiConfiguration !== undefined || + interfaceOptions.ipv4?.method !== "disabled" || + interfaceOptions.ipv6?.method !== "disabled"; + + try { + await updateNetworkInterface( + this.hass, + this._interface!.interface, + interfaceOptions + ); + this._dirty = false; + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.network.supervisor.failed_to_change" + ), + text: extractApiErrorMessage(err), + }); + } finally { + this._processing = false; + } + } + + private async _handleTabActivated(ev: CustomEvent): Promise { + if (this._dirty) { + const confirm = await showConfirmationDialog(this, { + text: this.hass.localize("ui.panel.config.network.supervisor.unsaved"), + confirmText: this.hass.localize("ui.common.yes"), + dismissText: this.hass.localize("ui.common.no"), + }); + if (!confirm) { + this.requestUpdate("_interface"); + return; + } + } + this._curTabIndex = ev.detail.index; + this._interface = { ...this._interfaces[ev.detail.index] }; + } + + private _handleRadioValueChanged(ev: CustomEvent): void { + const value = (ev.target as any).value as "disabled" | "auto" | "static"; + const version = (ev.target as any).version as "ipv4" | "ipv6"; + + if ( + !value || + !this._interface || + this._interface[version]!.method === value + ) { + return; + } + this._dirty = true; + + this._interface[version]!.method = value; + this.requestUpdate("_interface"); + } + + private _handleRadioValueChangedAp(ev: CustomEvent): void { + const value = (ev.target as any).value as string as + | "open" + | "wep" + | "wpa-psk"; + this._wifiConfiguration!.auth = value; + this._dirty = true; + this.requestUpdate("_wifiConfiguration"); + } + + private _handleInputValueChanged(ev: CustomEvent): void { + const value: string | null | undefined = (ev.target as PaperInputElement) + .value; + const version = (ev.target as any).version as "ipv4" | "ipv6"; + const id = (ev.target as PaperInputElement).id; + + if ( + !value || + !this._interface || + this._toString(this._interface[version]![id]) === this._toString(value) + ) { + return; + } + + this._dirty = true; + this._interface[version]![id] = value; + } + + private _handleInputValueChangedWifi(ev: CustomEvent): void { + const value: string | null | undefined = (ev.target as PaperInputElement) + .value; + const id = (ev.target as PaperInputElement).id; + + if ( + !value || + !this._wifiConfiguration || + this._wifiConfiguration![id] === value + ) { + return; + } + this._dirty = true; + this._wifiConfiguration![id] = value; + } + + static get styles(): CSSResultGroup { + return [ + css` + ha-header-bar { + --mdc-theme-on-primary: var(--primary-text-color); + --mdc-theme-primary: var(--mdc-theme-surface); + flex-shrink: 0; + } + + mwc-tab-bar { + border-bottom: 1px solid + var(--mdc-dialog-scroll-divider-color, rgba(0, 0, 0, 0.12)); + margin-bottom: 24px; + } + + .content { + display: block; + padding: 20px 24px; + } + + mwc-button.warning { + --mdc-theme-primary: var(--error-color); + } + + mwc-button.scan { + margin-left: 8px; + } + + :host([rtl]) app-toolbar { + direction: rtl; + text-align: right; + } + ha-expansion-panel { + --expansion-panel-summary-padding: 0 16px; + margin: 4px 0; + } + paper-input { + padding: 0 14px; + } + mwc-list-item { + --mdc-list-side-padding: 10px; + } + .card-actions { + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + align-items: center; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "supervisor-network": HassioNetwork; + } +} diff --git a/src/panels/config/person/ha-config-person.ts b/src/panels/config/person/ha-config-person.ts index 4c9744eb65..4546e79507 100644 --- a/src/panels/config/person/ha-config-person.ts +++ b/src/panels/config/person/ha-config-person.ts @@ -88,7 +88,7 @@ class HaConfigPerson extends LitElement { - + ${this._storageItems.map( (entry) => html` @@ -117,7 +117,7 @@ class HaConfigPerson extends LitElement { ${this._configItems.length > 0 ? html` - + ${this._configItems.map( (entry) => html` diff --git a/src/panels/config/scene/ha-scene-editor.ts b/src/panels/config/scene/ha-scene-editor.ts index 3ecd3b026a..5c0e8f1884 100644 --- a/src/panels/config/scene/ha-scene-editor.ts +++ b/src/panels/config/scene/ha-scene-editor.ts @@ -287,7 +287,7 @@ export class HaSceneEditor extends SubscribeMixin( "ui.panel.config.scene.editor.introduction" )}
- +
html` - +

${device.name} - +
${this._blueprints ? Object.keys(this._blueprints).length diff --git a/src/panels/config/script/ha-script-editor.ts b/src/panels/config/script/ha-script-editor.ts index 611e52abec..3a6f383f3a 100644 --- a/src/panels/config/script/ha-script-editor.ts +++ b/src/panels/config/script/ha-script-editor.ts @@ -290,7 +290,7 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) { "ui.panel.config.script.editor.introduction" )} - +
${this._config?.alias}
+ +
${this._config?.alias}
@@ -412,8 +412,8 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) { .defaultValue=${this._preprocessYaml()} @value-changed=${this._yamlChanged} > -
+ +
${this.hass.localize( "ui.panel.config.automation.editor.copy_to_clipboard" diff --git a/src/panels/config/server_control/ha-config-server-control.ts b/src/panels/config/server_control/ha-config-server-control.ts deleted file mode 100644 index 9c0011c049..0000000000 --- a/src/panels/config/server_control/ha-config-server-control.ts +++ /dev/null @@ -1,262 +0,0 @@ -import "@material/mwc-button"; -import "@polymer/app-layout/app-header/app-header"; -import "@polymer/app-layout/app-toolbar/app-toolbar"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { componentsWithService } from "../../../common/config/components_with_service"; -import "../../../components/buttons/ha-call-service-button"; -import "../../../components/ha-card"; -import { checkCoreConfig } from "../../../data/core"; -import { domainToName } from "../../../data/integration"; -import "../../../layouts/hass-tabs-subpage"; -import { haStyle } from "../../../resources/styles"; -import { HomeAssistant, Route } from "../../../types"; -import "../ha-config-section"; -import { configSections } from "../ha-panel-config"; - -@customElement("ha-config-server-control") -export class HaConfigServerControl extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property() public isWide!: boolean; - - @property() public narrow!: boolean; - - @property() public route!: Route; - - @property() public showAdvanced!: boolean; - - @state() private _validating = false; - - @state() private _reloadableDomains: string[] = []; - - private _validateLog = ""; - - private _isValid: boolean | null = null; - - protected updated(changedProperties) { - const oldHass = changedProperties.get("hass"); - if ( - changedProperties.has("hass") && - (!oldHass || oldHass.config.components !== this.hass.config.components) - ) { - this._reloadableDomains = componentsWithService( - this.hass, - "reload" - ).sort(); - } - } - - protected render(): TemplateResult { - return html` - - - ${this.hass.localize( - "ui.panel.config.server_control.caption" - )} - ${this.hass.localize( - "ui.panel.config.server_control.description" - )} - - ${this.showAdvanced - ? html` -
- ${this.hass.localize( - "ui.panel.config.server_control.section.validation.introduction" - )} - ${!this._validateLog - ? html` -
- ${!this._validating - ? html` - ${this._isValid - ? html`
- ${this.hass.localize( - "ui.panel.config.server_control.section.validation.valid" - )} -
` - : ""} - - ${this.hass.localize( - "ui.panel.config.server_control.section.validation.check_config" - )} - - ` - : html` - - `} -
- ` - : html` -
- - ${this.hass.localize( - "ui.panel.config.server_control.section.validation.invalid" - )} - - - ${this.hass.localize( - "ui.panel.config.server_control.section.validation.check_config" - )} - -
-
- ${this._validateLog} -
- `} -
-
` - : ""} - - -
- ${this.hass.localize( - "ui.panel.config.server_control.section.server_management.introduction" - )} -
-
- ${this.hass.localize( - "ui.panel.config.server_control.section.server_management.restart" - )} - -
-
- - ${this.showAdvanced - ? html` - -
- ${this.hass.localize( - "ui.panel.config.server_control.section.reloading.introduction" - )} -
-
- ${this.hass.localize( - "ui.panel.config.server_control.section.reloading.core" - )} - -
- ${this._reloadableDomains.map( - (domain) => - html`
- ${this.hass.localize( - `ui.panel.config.server_control.section.reloading.${domain}` - ) || - this.hass.localize( - "ui.panel.config.server_control.section.reloading.reload", - "domain", - domainToName(this.hass.localize, domain) - )} - -
` - )} -
- ` - : ""} -
-
- `; - } - - private async _validateConfig() { - this._validating = true; - this._validateLog = ""; - this._isValid = null; - - const configCheck = await checkCoreConfig(this.hass); - this._validating = false; - this._isValid = configCheck.result === "valid"; - - if (configCheck.errors) { - this._validateLog = configCheck.errors; - } - } - - static get styles(): CSSResultGroup { - return [ - haStyle, - css` - .validate-container { - height: 140px; - } - - .validate-result { - color: var(--success-color); - font-weight: 500; - margin-bottom: 1em; - } - - .config-invalid { - margin: 1em 0; - } - - .config-invalid .text { - color: var(--error-color); - font-weight: 500; - } - - .config-invalid mwc-button { - float: right; - } - - .validate-log { - white-space: pre-line; - direction: ltr; - } - - ha-config-section { - padding-bottom: 24px; - } - `, - ]; - } -} diff --git a/src/panels/config/storage/dialog-move-datadisk.ts b/src/panels/config/storage/dialog-move-datadisk.ts new file mode 100644 index 0000000000..ad9dec13f4 --- /dev/null +++ b/src/panels/config/storage/dialog-move-datadisk.ts @@ -0,0 +1,200 @@ +import "@material/mwc-list/mwc-list-item"; +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-circular-progress"; +import "../../../components/ha-markdown"; +import "../../../components/ha-select"; +import { + extractApiErrorMessage, + ignoreSupervisorError, +} from "../../../data/hassio/common"; +import { + DatadiskList, + fetchHassioHassOsInfo, + HassioHassOSInfo, + HassioHostInfo, + listDatadisks, + moveDatadisk, +} from "../../../data/hassio/host"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; +import { haStyle, haStyleDialog } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import { MoveDatadiskDialogParams } from "./show-dialog-move-datadisk"; + +const calculateMoveTime = memoizeOne((hostInfo: HassioHostInfo): number => { + const speed = hostInfo.disk_life_time !== "" ? 30 : 10; + const moveTime = (hostInfo.disk_used * 1000) / 60 / speed; + const rebootTime = (hostInfo.startup_time * 4) / 60; + return Math.ceil((moveTime + rebootTime) / 10) * 10; +}); + +@customElement("dialog-move-datadisk") +class MoveDatadiskDialog extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _hostInfo?: HassioHostInfo; + + @state() private _selectedDevice?: string; + + @state() private _devices?: DatadiskList["devices"]; + + @state() private _osInfo?: HassioHassOSInfo; + + @state() private _moving = false; + + public async showDialog( + dialogParams: MoveDatadiskDialogParams + ): Promise> { + this._hostInfo = dialogParams.hostInfo; + + try { + this._osInfo = await fetchHassioHassOsInfo(this.hass); + } catch (err: any) { + await showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.hardware.available_hardware.failed_to_get" + ), + text: extractApiErrorMessage(err), + }); + } + + listDatadisks(this.hass).then((data) => { + this._devices = data.devices; + }); + } + + public closeDialog(): void { + this._selectedDevice = undefined; + this._devices = undefined; + this._moving = false; + this._hostInfo = undefined; + this._osInfo = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render(): TemplateResult { + if (!this._hostInfo || !this._osInfo) { + return html``; + } + return html` + + ${this._moving + ? html` + +

+ ${this.hass.localize( + "ui.panel.config.storage.datadisk.moving_desc" + )} +

` + : html` ${this._devices?.length + ? html` + ${this.hass.localize( + "ui.panel.config.storage.datadisk.description", + { + current_path: this._osInfo.data_disk, + time: calculateMoveTime(this._hostInfo), + } + )} +

+ + + ${this._devices.map( + (device) => + html`${device}` + )} + + ` + : this._devices === undefined + ? this.hass.localize( + "ui.panel.config.storage.datadisk.loading_devices" + ) + : this.hass.localize( + "ui.panel.config.storage.datadisk.no_devices" + )} + + + ${this.hass.localize("ui.panel.config.storage.datadisk.cancel")} + + + + ${this.hass.localize("ui.panel.config.storage.datadisk.move")} + `} +
+ `; + } + + private _select_device(ev) { + this._selectedDevice = ev.target.value; + } + + private async _moveDatadisk() { + this._moving = true; + try { + await moveDatadisk(this.hass, this._selectedDevice!); + } catch (err: any) { + if (this.hass.connection.connected && !ignoreSupervisorError(err)) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.storage.datadisk.failed_to_move" + ), + text: extractApiErrorMessage(err), + }); + this.closeDialog(); + } + } + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-select { + width: 100%; + } + ha-circular-progress { + display: block; + margin: 32px; + text-align: center; + } + + .progress-text { + text-align: center; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-move-datadisk": MoveDatadiskDialog; + } +} diff --git a/src/panels/config/storage/ha-config-section-storage.ts b/src/panels/config/storage/ha-config-section-storage.ts new file mode 100644 index 0000000000..29945f919a --- /dev/null +++ b/src/panels/config/storage/ha-config-section-storage.ts @@ -0,0 +1,150 @@ +import { mdiDotsVertical } from "@mdi/js"; +import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import "../../../components/ha-alert"; +import "../../../components/ha-button-menu"; +import "../../../components/ha-metric"; +import { fetchHassioHostInfo, HassioHostInfo } from "../../../data/hassio/host"; +import "../../../layouts/hass-subpage"; +import type { HomeAssistant, Route } from "../../../types"; +import { + getValueInPercentage, + roundWithOneDecimal, +} from "../../../util/calculate"; +import "../core/ha-config-analytics"; +import { showMoveDatadiskDialog } from "./show-dialog-move-datadisk"; + +@customElement("ha-config-section-storage") +class HaConfigSectionStorage extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public route!: Route; + + @property({ type: Boolean }) public narrow!: boolean; + + @state() private _error?: { code: string; message: string }; + + @state() private _hostInfo?: HassioHostInfo; + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + if (isComponentLoaded(this.hass, "hassio")) { + this._load(); + } + } + + protected render(): TemplateResult { + return html` + + ${this._hostInfo + ? html` + + + + ${this.hass.localize( + "ui.panel.config.storage.datadisk.title" + )} + + + ` + : ""} +
+ ${this._error + ? html` + ${this._error.message || this._error.code} + ` + : ""} + ${this._hostInfo + ? html` + +
+ + ${this._hostInfo.disk_life_time !== "" && + this._hostInfo.disk_life_time >= 10 + ? html` + + ` + : ""} +
+
+ ` + : ""} +
+
+ `; + } + + private async _load() { + try { + this._hostInfo = await fetchHassioHostInfo(this.hass); + } catch (err: any) { + this._error = err.message || err; + } + } + + private _moveDatadisk(): void { + showMoveDatadiskDialog(this, { + hostInfo: this._hostInfo!, + }); + } + + private _getUsedSpace = (used: number, total: number) => + roundWithOneDecimal(getValueInPercentage(used, 0, total)); + + static styles = css` + .content { + padding: 28px 20px 0; + max-width: 1040px; + margin: 0 auto; + } + ha-card { + max-width: 600px; + margin: 0 auto; + justify-content: space-between; + flex-direction: column; + display: flex; + } + .card-content { + display: flex; + justify-content: space-between; + flex-direction: column; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-section-storage": HaConfigSectionStorage; + } +} diff --git a/src/panels/config/storage/show-dialog-move-datadisk.ts b/src/panels/config/storage/show-dialog-move-datadisk.ts new file mode 100644 index 0000000000..11a3b296c1 --- /dev/null +++ b/src/panels/config/storage/show-dialog-move-datadisk.ts @@ -0,0 +1,17 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { HassioHostInfo } from "../../../data/hassio/host"; + +export interface MoveDatadiskDialogParams { + hostInfo: HassioHostInfo; +} + +export const showMoveDatadiskDialog = ( + element: HTMLElement, + dialogParams: MoveDatadiskDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-move-datadisk", + dialogImport: () => import("./dialog-move-datadisk"), + dialogParams, + }); +}; diff --git a/src/panels/config/system-health/ha-config-system-health.ts b/src/panels/config/system-health/ha-config-system-health.ts new file mode 100644 index 0000000000..ba3c2862b0 --- /dev/null +++ b/src/panels/config/system-health/ha-config-system-health.ts @@ -0,0 +1,522 @@ +import { ActionDetail } from "@material/mwc-list"; +import "@material/mwc-list/mwc-list-item"; +import { mdiContentCopy } from "@mdi/js"; +import { UnsubscribeFunc } from "home-assistant-js-websocket/dist/types"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import { formatDateTime } from "../../../common/datetime/format_date_time"; +import { copyToClipboard } from "../../../common/util/copy-clipboard"; +import { subscribePollingCollection } from "../../../common/util/subscribe-polling"; +import "../../../components/ha-alert"; +import "../../../components/ha-button-menu"; +import "../../../components/ha-card"; +import "../../../components/ha-circular-progress"; +import "../../../components/ha-metric"; +import { fetchHassioStats, HassioStats } from "../../../data/hassio/common"; +import { + fetchHassioResolution, + HassioResolution, +} from "../../../data/hassio/resolution"; +import { domainToName } from "../../../data/integration"; +import { + subscribeSystemHealthInfo, + SystemCheckValueObject, + SystemHealthInfo, +} from "../../../data/system_health"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; +import "../../../layouts/hass-subpage"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; +import type { HomeAssistant } from "../../../types"; +import { documentationUrl } from "../../../util/documentation-url"; +import { showToast } from "../../../util/toast"; +import "./integrations-card"; + +const sortKeys = (a: string, b: string) => { + if (a === "homeassistant") { + return -1; + } + if (b === "homeassistant") { + return 1; + } + if (a < b) { + return -1; + } + if (b < a) { + return 1; + } + return 0; +}; + +export const UNSUPPORTED_REASON_URL = {}; +export const UNHEALTHY_REASON_URL = { + privileged: "/more-info/unsupported/privileged", +}; + +@customElement("ha-config-system-health") +class HaConfigSystemHealth extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow!: boolean; + + @state() private _info?: SystemHealthInfo; + + @state() private _supervisorStats?: HassioStats; + + @state() private _resolutionInfo?: HassioResolution; + + @state() private _coreStats?: HassioStats; + + @state() private _error?: { code: string; message: string }; + + public hassSubscribe(): Array> { + const subs: Array> = []; + if (isComponentLoaded(this.hass, "system_health")) { + subs.push( + subscribeSystemHealthInfo(this.hass!, (info) => { + this._info = info; + }) + ); + } + + if (isComponentLoaded(this.hass, "hassio")) { + subs.push( + subscribePollingCollection( + this.hass, + async () => { + this._supervisorStats = await fetchHassioStats( + this.hass, + "supervisor" + ); + this._coreStats = await fetchHassioStats(this.hass, "core"); + }, + 10000 + ) + ); + fetchHassioResolution(this.hass).then((data) => { + this._resolutionInfo = data; + }); + } + + return subs; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + + this.hass!.loadBackendTranslation("system_health"); + } + + protected render(): TemplateResult { + const sections: TemplateResult[] = []; + + if (!this._info) { + sections.push( + html` +
+ +
+ ` + ); + } 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(domainInfo.info)) { + let value: unknown; + + if ( + domainInfo.info[key] && + 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.locale); + } + } else { + value = domainInfo.info[key]; + } + + keys.push(html` + + + ${this.hass.localize( + `component.${domain}.system_health.info.${key}` + ) || key} + + ${value} + + `); + } + if (domain !== "homeassistant") { + sections.push( + html` +
+

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

+ ${!domainInfo.manage_url + ? "" + : html` + + + ${this.hass.localize( + "ui.panel.config.info.system_health.manage" + )} + + + `} +
+ ` + ); + } + sections.push(html` + + ${keys} +
+ `); + } + } + + return html` + + ${this._error + ? html` + ${this._error.message || this._error.code} + ` + : ""} + ${this._info + ? html` + + + + ${this.hass.localize("ui.panel.config.info.copy_raw")} + + + ${this.hass.localize("ui.panel.config.info.copy_github")} + + + ` + : ""} +
+ ${this._resolutionInfo + ? html`${this._resolutionInfo.unhealthy.length + ? html` + ${this.hass.localize("ui.dialogs.unhealthy.title")} + + ` + : ""} + ${this._resolutionInfo.unsupported.length + ? html` + ${this.hass.localize("ui.dialogs.unsupported.title")} + + + ` + : ""} ` + : ""} + + +
${sections}
+
+ ${!this._coreStats && !this._supervisorStats + ? "" + : html` + +
+ ${this._coreStats + ? html` +

+ ${this.hass.localize( + "ui.panel.config.system_health.core_stats" + )} +

+ + + ` + : ""} + ${this._supervisorStats + ? html` +

+ ${this.hass.localize( + "ui.panel.config.system_health.supervisor_stats" + )} +

+ + + ` + : ""} +
+
+ `} + + +
+
+ `; + } + + private async _unsupportedDialog(): Promise { + await showAlertDialog(this, { + title: this.hass.localize("ui.dialogs.unsupported.title"), + text: html`${this.hass.localize("ui.dialogs.unsupported.description")} +

+ `, + }); + } + + private async _unhealthyDialog(): Promise { + await showAlertDialog(this, { + title: this.hass.localize("ui.dialogs.unhealthy.title"), + text: html`${this.hass.localize("ui.dialogs.unhealthy.description")} +

+ `, + }); + } + + private async _copyInfo(ev: CustomEvent): Promise { + const github = ev.detail.index === 1; + let haContent: string | undefined; + const domainParts: string[] = []; + + for (const domain of Object.keys(this._info!).sort(sortKeys)) { + const domainInfo = this._info![domain]; + let first = true; + const parts = [ + `${ + github && domain !== "homeassistant" + ? `
${domainToName( + this.hass.localize, + domain + )}\n` + : "" + }`, + ]; + + 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 = "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.locale); + } + } else { + value = domainInfo.info[key]; + } + if (github && first) { + parts.push(`${key} | ${value}\n-- | --`); + first = false; + } else { + parts.push(`${key}${github ? " | " : ": "}${value}`); + } + } + + if (domain === "homeassistant") { + haContent = parts.join("\n"); + } else { + domainParts.push(parts.join("\n")); + if (github && domain !== "homeassistant") { + domainParts.push("
"); + } + } + } + + await copyToClipboard( + `${github ? "## " : ""}System Health\n${haContent}\n\n${domainParts.join( + "\n\n" + )}` + ); + + showToast(this, { + message: this.hass.localize("ui.common.copied_clipboard"), + }); + } + + static styles: CSSResultGroup = css` + .content { + padding: 28px 20px 0; + max-width: 1040px; + margin: 0 auto; + } + integrations-card { + max-width: 600px; + display: block; + max-width: 600px; + margin: 0 auto; + margin-bottom: 24px; + margin-bottom: max(24px, env(safe-area-inset-bottom)); + } + ha-card { + display: block; + max-width: 600px; + margin: 0 auto; + padding-bottom: 16px; + margin-bottom: 24px; + } + ha-alert { + display: block; + max-width: 500px; + margin: 0 auto; + margin-bottom: max(24px, env(safe-area-inset-bottom)); + } + table { + width: 100%; + } + + td:first-child { + width: 45%; + } + + td:last-child { + direction: ltr; + } + + .loading-container { + display: flex; + align-items: center; + justify-content: center; + } + + .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; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-system-health": HaConfigSystemHealth; + } +} diff --git a/src/panels/config/system-health/integrations-card.ts b/src/panels/config/system-health/integrations-card.ts new file mode 100644 index 0000000000..2188160eb7 --- /dev/null +++ b/src/panels/config/system-health/integrations-card.ts @@ -0,0 +1,154 @@ +import "@material/mwc-list/mwc-list"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../components/ha-card"; +import "../../../components/ha-clickable-list-item"; +import { + domainToName, + fetchIntegrationManifests, + fetchIntegrationSetups, + IntegrationManifest, + IntegrationSetup, +} from "../../../data/integration"; +import type { HomeAssistant } from "../../../types"; +import { brandsUrl } from "../../../util/brands-url"; +import { documentationUrl } from "../../../util/documentation-url"; + +@customElement("integrations-card") +class IntegrationsCard extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow = false; + + @state() private _manifests?: { + [domain: string]: IntegrationManifest; + }; + + @state() private _setups?: IntegrationSetup[]; + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + this._fetchManifests(); + this._fetchSetups(); + } + + protected render(): TemplateResult { + if (!this._setups) { + return html``; + } + + return html` + + + ${this._setups?.map((setup) => { + const manifest = this._manifests && this._manifests[setup.domain]; + const docLink = manifest + ? manifest.is_built_in + ? documentationUrl( + this.hass, + `/integrations/${manifest.domain}` + ) + : manifest.documentation + : ""; + + const setupSeconds = setup.seconds?.toFixed(2); + return html` + + + + ${domainToName(this.hass.localize, setup.domain, manifest)} + + ${setup.domain} +
+ ${setupSeconds ? html`${setupSeconds} s` : ""} +
+
+ `; + })} +
+
+ `; + } + + private async _fetchManifests() { + const manifests = {}; + for (const manifest of await fetchIntegrationManifests(this.hass)) { + manifests[manifest.domain] = manifest; + } + this._manifests = manifests; + } + + private async _fetchSetups() { + const setups = await fetchIntegrationSetups(this.hass); + this._setups = setups.sort((a, b) => { + if (a.seconds === b.seconds) { + return 0; + } + if (a.seconds === undefined) { + return 1; + } + if (b.seconds === undefined) { + return 1; + } + return b.seconds - a.seconds; + }); + } + + private _entryClicked(ev) { + ev.currentTarget.blur(); + } + + static get styles(): CSSResultGroup { + return css` + ha-clickable-list-item { + --mdc-list-item-meta-size: 64px; + --mdc-typography-caption-font-size: 12px; + } + img { + display: block; + max-height: 40px; + max-width: 40px; + } + div[slot="meta"] { + display: flex; + justify-content: center; + align-items: center; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "integrations-card": IntegrationsCard; + } +} diff --git a/src/panels/config/tags/ha-config-tags.ts b/src/panels/config/tags/ha-config-tags.ts index 26a0c6f1e8..87a4b01b18 100644 --- a/src/panels/config/tags/ha-config-tags.ts +++ b/src/panels/config/tags/ha-config-tags.ts @@ -9,7 +9,6 @@ import { html, LitElement, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { DataTableColumnContainer } from "../../../components/data-table/ha-data-table"; -import "../../../components/ha-card"; import "../../../components/ha-fab"; import "../../../components/ha-icon-button"; import "../../../components/ha-relative-time"; diff --git a/src/panels/config/zone/ha-config-zone.ts b/src/panels/config/zone/ha-config-zone.ts index 5533dcff03..1f90d1e312 100644 --- a/src/panels/config/zone/ha-config-zone.ts +++ b/src/panels/config/zone/ha-config-zone.ts @@ -13,7 +13,6 @@ import { TemplateResult, } from "lit"; import { customElement, property, query, state } from "lit/decorators"; -import { ifDefined } from "lit/directives/if-defined"; import memoizeOne from "memoize-one"; import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { navigate } from "../../../common/navigate"; @@ -37,7 +36,10 @@ import { Zone, ZoneMutableParams, } from "../../../data/zone"; -import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-tabs-subpage"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; @@ -185,36 +187,26 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
- - ${stateObject.entity_id === "zone.home" - ? hass.localize( - `ui.panel.config.zone.${ - this.narrow - ? "edit_home_zone_narrow" - : "edit_home_zone" - }` - ) - : hass.localize( - "ui.panel.config.zone.configured_in_yaml" - )} - + ${stateObject.entity_id !== "zone.home" + ? html` + + ${hass.localize( + "ui.panel.config.zone.configured_in_yaml" + )} + + ` + : ""}
` @@ -228,7 +220,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) { .narrow=${this.narrow} .route=${this.route} back-path="/config" - .tabs=${configSections.persons} + .tabs=${configSections.areas} > ${this.narrow ? html` @@ -236,7 +228,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) { ${hass.localize("ui.panel.config.zone.introduction")} - ${listBox} + ${listBox} ` : ""} @@ -391,22 +383,16 @@ export class HaConfigZone extends SubscribeMixin(LitElement) { this._openDialog(entry); } - private async _openCoreConfig(ev: Event) { - const entityId: string = (ev.currentTarget! as any).entityId; - if (entityId !== "zone.home" || !this.narrow || !this._canEditCore) { + private async _openCoreConfig(ev) { + if (ev.currentTarget.noEdit) { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.config.zone.can_not_edit"), + text: this.hass.localize("ui.panel.config.zone.configured_in_yaml"), + confirm: () => {}, + }); return; } - if ( - !(await showConfirmationDialog(this, { - title: this.hass.localize("ui.panel.config.zone.go_to_core_config"), - text: this.hass.localize("ui.panel.config.zone.home_zone_core_config"), - confirmText: this.hass!.localize("ui.common.yes"), - dismissText: this.hass!.localize("ui.common.no"), - })) - ) { - return; - } - navigate("/config/core"); + navigate("/config/general"); } private async _createEntry(values: ZoneMutableParams) { @@ -485,7 +471,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) { color: var(--primary-color); } ha-card { - max-width: 600px; margin: 16px auto; overflow: hidden; } diff --git a/src/panels/developer-tools/developer-tools-router.ts b/src/panels/developer-tools/developer-tools-router.ts index 308a1cc348..7fc389ae84 100644 --- a/src/panels/developer-tools/developer-tools-router.ts +++ b/src/panels/developer-tools/developer-tools-router.ts @@ -41,6 +41,10 @@ class DeveloperToolsRouter extends HassRouterPage { tag: "developer-tools-statistics", load: () => import("./statistics/developer-tools-statistics"), }, + yaml: { + tag: "developer-yaml-config", + load: () => import("./yaml_configuration/developer-yaml-config"), + }, }, }; diff --git a/src/panels/developer-tools/ha-panel-developer-tools.ts b/src/panels/developer-tools/ha-panel-developer-tools.ts index a3c1c7a897..cc8e80500c 100644 --- a/src/panels/developer-tools/ha-panel-developer-tools.ts +++ b/src/panels/developer-tools/ha-panel-developer-tools.ts @@ -67,6 +67,9 @@ class PanelDeveloperTools extends LitElement { "ui.panel.developer-tools.tabs.statistics.title" )} + + ${this.hass.localize("ui.panel.developer-tools.tabs.yaml.title")} + + [[localize('ui.tips.key_e_hint')]] + +
+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.yaml.section.validation.introduction" + )} + ${!this._validateLog + ? html` +
+ ${!this._validating + ? html` + ${this._isValid + ? html`
+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.yaml.section.validation.valid" + )} +
` + : ""} + ` + : html` + + `} +
+ ` + : html` +
+ + ${this.hass.localize( + "ui.panel.developer-tools.tabs.yaml.section.validation.invalid" + )} + +
+
+ ${this._validateLog} +
+ `} +
+
+ + ${this.hass.localize( + "ui.panel.developer-tools.tabs.yaml.section.validation.check_config" + )} + + + ${this.hass.localize( + "ui.panel.developer-tools.tabs.yaml.section.server_management.restart" + )} + +
+
+ +
+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.yaml.section.reloading.introduction" + )} +
+
+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.yaml.section.reloading.core" + )} + +
+ ${this._reloadableDomains.map( + (domain) => + html` +
+ ${this.hass.localize( + `ui.panel.developer-tools.tabs.yaml.section.reloading.${domain}` + ) || + this.hass.localize( + "ui.panel.developer-tools.tabs.yaml.section.reloading.reload", + "domain", + domainToName(this.hass.localize, domain) + )} + +
+ ` + )} +
+
+ `; + } + + private async _validateConfig() { + this._validating = true; + this._validateLog = ""; + this._isValid = null; + + const configCheck = await checkCoreConfig(this.hass); + this._validating = false; + this._isValid = configCheck.result === "valid"; + + if (configCheck.errors) { + this._validateLog = configCheck.errors; + } + } + + private _restart() { + showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.developer-tools.tabs.yaml.section.server_management.confirm_restart_title" + ), + text: this.hass.localize( + "ui.panel.developer-tools.tabs.yaml.section.server_management.confirm_restart_text" + ), + confirmText: this.hass.localize( + "ui.panel.developer-tools.tabs.yaml.section.server_management.restart" + ), + confirm: () => { + this.hass.callService("homeassistant", "restart").catch((reason) => { + this._isValid = false; + this._validateLog = reason.message; + }); + }, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + .validate-container { + height: 60px; + } + + .validate-result { + color: var(--success-color); + font-weight: 500; + } + + .config-invalid { + margin: 1em 0; + text-align: center; + } + + .config-invalid .text { + color: var(--error-color); + font-weight: 500; + } + + .validate-log { + white-space: pre-line; + direction: ltr; + } + + .content { + padding: 28px 20px 16px; + max-width: 1040px; + margin: 0 auto; + } + + ha-card { + margin-top: 24px; + } + + .card-actions { + display: flex; + justify-content: space-between; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "developer-yaml-config": DeveloperYamlConfig; + } +} diff --git a/src/panels/lovelace/cards/hui-area-card.ts b/src/panels/lovelace/cards/hui-area-card.ts index ad7e7f028d..41d5345728 100644 --- a/src/panels/lovelace/cards/hui-area-card.ts +++ b/src/panels/lovelace/cards/hui-area-card.ts @@ -113,6 +113,7 @@ export class HuiAreaCard .filter( (entry) => !entry.entity_category && + !entry.hidden_by && (entry.area_id ? entry.area_id === areaId : entry.device_id && devicesInArea.has(entry.device_id)) diff --git a/src/panels/lovelace/cards/hui-gauge-card.ts b/src/panels/lovelace/cards/hui-gauge-card.ts index 5253f95b1e..4a6d1f60d5 100644 --- a/src/panels/lovelace/cards/hui-gauge-card.ts +++ b/src/panels/lovelace/cards/hui-gauge-card.ts @@ -174,6 +174,26 @@ class HuiGaugeCard extends LitElement implements LovelaceCard { if (this._config!.needle) { return undefined; } + + // new format + let segments = this._config!.segments; + if (segments) { + segments = [...segments].sort((a, b) => a?.from - b?.from); + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + if ( + segment && + numberValue >= segment.from && + (i + 1 === segments.length || numberValue < segments[i + 1]?.from) + ) { + return segment.color; + } + } + return severityMap.normal; + } + + // old format const sections = this._config!.severity; if (!sections) { @@ -206,6 +226,16 @@ class HuiGaugeCard extends LitElement implements LovelaceCard { } private _severityLevels() { + // new format + const segments = this._config!.segments; + if (segments) { + return segments.map((segment) => ({ + level: segment?.from, + stroke: segment?.color, + })); + } + + // old format const sections = this._config!.severity; if (!sections) { diff --git a/src/panels/lovelace/cards/hui-picture-entity-card.ts b/src/panels/lovelace/cards/hui-picture-entity-card.ts index 087e5e5069..aaab63cb0f 100644 --- a/src/panels/lovelace/cards/hui-picture-entity-card.ts +++ b/src/panels/lovelace/cards/hui-picture-entity-card.ts @@ -200,6 +200,7 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard { font-size: 16px; line-height: 16px; color: var(--ha-picture-card-text-color, white); + pointer-events: none; } .both { diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 9c043e4699..b4c2a74abc 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -176,6 +176,11 @@ export interface SeverityConfig { red?: number; } +export interface GaugeSegment { + from: number; + color: string; +} + export interface GaugeCardConfig extends LovelaceCardConfig { entity: string; name?: string; @@ -185,6 +190,7 @@ export interface GaugeCardConfig extends LovelaceCardConfig { severity?: SeverityConfig; theme?: string; needle?: boolean; + segments?: GaugeSegment[]; } export interface ConfigEntity extends EntityConfig { diff --git a/src/panels/lovelace/common/compute-card-size.ts b/src/panels/lovelace/common/compute-card-size.ts index 60ca74c4a6..ee22dcbe2d 100644 --- a/src/panels/lovelace/common/compute-card-size.ts +++ b/src/panels/lovelace/common/compute-card-size.ts @@ -1,10 +1,17 @@ +import { promiseTimeout } from "../../../common/util/promise-timeout"; import { LovelaceCard, LovelaceHeaderFooter } from "../types"; export const computeCardSize = ( card: LovelaceCard | LovelaceHeaderFooter ): number | Promise => { if (typeof card.getCardSize === "function") { - return card.getCardSize(); + try { + return promiseTimeout(500, card.getCardSize()).catch( + () => 1 + ) as Promise; + } catch (_e: any) { + return 1; + } } if (customElements.get(card.localName)) { return 1; diff --git a/src/panels/lovelace/create-element/create-row-element.ts b/src/panels/lovelace/create-element/create-row-element.ts index 6794fdfb58..b805707259 100644 --- a/src/panels/lovelace/create-element/create-row-element.ts +++ b/src/panels/lovelace/create-element/create-row-element.ts @@ -76,8 +76,9 @@ const DOMAIN_TO_ELEMENT_TYPE = { script: "script", select: "select", sensor: "sensor", - timer: "timer", + siren: "toggle", switch: "toggle", + timer: "timer", vacuum: "toggle", // Temporary. Once climate is rewritten, // water heater should get its own row. diff --git a/src/panels/lovelace/editor/config-elements/hui-gauge-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-gauge-card-editor.ts index 2469a18a5a..8ae5fcbf5e 100644 --- a/src/panels/lovelace/editor/config-elements/hui-gauge-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-gauge-card-editor.ts @@ -2,6 +2,7 @@ import "../../../../components/ha-form/ha-form"; import { html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { + array, assert, assign, boolean, @@ -18,6 +19,11 @@ import type { GaugeCardConfig } from "../../cards/types"; import type { LovelaceCardEditor } from "../../types"; import { baseLovelaceCardConfig } from "../structs/base-card-struct"; +const gaugeSegmentStruct = object({ + from: number(), + color: string(), +}); + const cardConfigStruct = assign( baseLovelaceCardConfig, object({ @@ -29,6 +35,7 @@ const cardConfigStruct = assign( severity: optional(object()), theme: optional(string()), needle: optional(boolean()), + segments: optional(array(gaugeSegmentStruct)), }) ); diff --git a/src/panels/lovelace/editor/config-elements/hui-markdown-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-markdown-card-editor.ts index 54dc74bcd7..e22892d392 100644 --- a/src/panels/lovelace/editor/config-elements/hui-markdown-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-markdown-card-editor.ts @@ -20,7 +20,7 @@ const cardConfigStruct = assign( const SCHEMA: HaFormSchema[] = [ { name: "title", selector: { text: {} } }, - { name: "content", required: true, selector: { text: { multiline: true } } }, + { name: "content", required: true, selector: { template: {} } }, { name: "theme", selector: { theme: {} } }, ]; diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index d2c2a1a6b7..83e2ebdfd6 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -8,6 +8,7 @@ import { mdiFormatListBulletedTriangle, mdiHelp, mdiHelpCircle, + mdiMagnify, mdiMicrophone, mdiPencil, mdiPlus, @@ -28,7 +29,7 @@ import { PropertyValues, TemplateResult, } from "lit"; -import { property, state, query } from "lit/decorators"; +import { property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; @@ -60,6 +61,7 @@ import { showAlertDialog, showConfirmationDialog, } from "../../dialogs/generic/show-dialog-box"; +import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar"; import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog"; import "../../layouts/ha-app-layout"; import type { haAppLayout } from "../../layouts/ha-app-layout"; @@ -264,6 +266,14 @@ class HUIRoot extends LitElement { ` : html`
${this.config.title}
`} + ${!this.narrow + ? html` + + ` + : ""} ${!this.narrow && this._conversation(this.hass.config.components) ? html` @@ -286,6 +296,28 @@ class HUIRoot extends LitElement { )} .path=${mdiDotsVertical} > + + ${this.narrow + ? html` + + ${this.hass!.localize( + "ui.panel.lovelace.menu.search" + )} + + + ` + : ""} ${this.narrow && this._conversation(this.hass.config.components) ? html` @@ -551,7 +583,8 @@ class HUIRoot extends LitElement { let newSelectView; let force = false; - const viewPath = this.route!.path.split("/")[1]; + let viewPath: string | undefined = this.route!.path.split("/")[1]; + viewPath = viewPath ? decodeURI(viewPath) : undefined; if (changedProperties.has("route")) { const views = this.config.views; @@ -673,6 +706,13 @@ class HUIRoot extends LitElement { }); } + private _showQuickBar(): void { + showQuickBar(this, { + commandMode: false, + hint: this.hass.localize("ui.dialogs.quick-bar.key_e_hint"), + }); + } + private _handleRawEditor(ev: CustomEvent): void { if (!shouldHandleRequestSelectedEvent(ev)) { return; diff --git a/src/panels/lovelace/views/hui-view.ts b/src/panels/lovelace/views/hui-view.ts index 685adce5c9..bcf440f328 100644 --- a/src/panels/lovelace/views/hui-view.ts +++ b/src/panels/lovelace/views/hui-view.ts @@ -10,10 +10,18 @@ import type { LovelaceViewElement, } from "../../../data/lovelace"; import type { HomeAssistant } from "../../../types"; +import { + createErrorBadgeConfig, + createErrorBadgeElement, +} from "../badges/hui-error-badge"; import type { HuiErrorCard } from "../cards/hui-error-card"; import { processConfigEntities } from "../common/process-config-entities"; import { createBadgeElement } from "../create-element/create-badge-element"; import { createCardElement } from "../create-element/create-card-element"; +import { + createErrorCardConfig, + createErrorCardElement, +} from "../create-element/create-element-base"; import { createViewElement } from "../create-element/create-view-element"; import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog"; import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog"; @@ -54,7 +62,13 @@ export class HUIView extends ReactiveElement { // Public to make demo happy public createCardElement(cardConfig: LovelaceCardConfig) { const element = createCardElement(cardConfig) as LovelaceCard; - element.hass = this.hass; + try { + element.hass = this.hass; + } catch (e: any) { + return createErrorCardElement( + createErrorCardConfig(e.message, cardConfig) + ); + } element.addEventListener( "ll-rebuild", (ev: Event) => { @@ -71,7 +85,11 @@ export class HUIView extends ReactiveElement { public createBadgeElement(badgeConfig: LovelaceBadgeConfig) { const element = createBadgeElement(badgeConfig) as LovelaceBadge; - element.hass = this.hass; + try { + element.hass = this.hass; + } catch (e: any) { + return createErrorBadgeElement(createErrorBadgeConfig(e.message)); + } element.addEventListener( "ll-badge-rebuild", () => { @@ -121,11 +139,19 @@ export class HUIView extends ReactiveElement { // Config has not changed. Just props if (changedProperties.has("hass")) { this._badges.forEach((badge) => { - badge.hass = this.hass; + try { + badge.hass = this.hass; + } catch (e: any) { + this._rebuildBadge(badge, createErrorBadgeConfig(e.message)); + } }); this._cards.forEach((element) => { - element.hass = this.hass; + try { + element.hass = this.hass; + } catch (e: any) { + this._rebuildCard(element, createErrorCardConfig(e.message, null)); + } }); this._layoutElement.hass = this.hass; @@ -238,7 +264,11 @@ export class HUIView extends ReactiveElement { const badges = processConfigEntities(config.badges as any); this._badges = badges.map((badge) => { const element = createBadgeElement(badge); - element.hass = this.hass; + try { + element.hass = this.hass; + } catch (e: any) { + return createErrorBadgeElement(createErrorBadgeConfig(e.message)); + } return element; }); } @@ -251,7 +281,13 @@ export class HUIView extends ReactiveElement { this._cards = config.cards.map((cardConfig) => { const element = this.createCardElement(cardConfig); - element.hass = this.hass; + try { + element.hass = this.hass; + } catch (e: any) { + return createErrorCardElement( + createErrorCardConfig(e.message, cardConfig) + ); + } return element; }); } @@ -260,8 +296,14 @@ export class HUIView extends ReactiveElement { cardElToReplace: LovelaceCard, config: LovelaceCardConfig ): void { - const newCardEl = this.createCardElement(config); - newCardEl.hass = this.hass; + let newCardEl = this.createCardElement(config); + try { + newCardEl.hass = this.hass; + } catch (e: any) { + newCardEl = createErrorCardElement( + createErrorCardConfig(e.message, config) + ); + } if (cardElToReplace.parentElement) { cardElToReplace.parentElement!.replaceChild(newCardEl, cardElToReplace); } @@ -274,8 +316,12 @@ export class HUIView extends ReactiveElement { badgeElToReplace: LovelaceBadge, config: LovelaceBadgeConfig ): void { - const newBadgeEl = this.createBadgeElement(config); - newBadgeEl.hass = this.hass; + let newBadgeEl = this.createBadgeElement(config); + try { + newBadgeEl.hass = this.hass; + } catch (e: any) { + newBadgeEl = createErrorBadgeElement(createErrorBadgeConfig(e.message)); + } if (badgeElToReplace.parentElement) { badgeElToReplace.parentElement!.replaceChild( newBadgeEl, diff --git a/src/panels/media-browser/ha-bar-media-player.ts b/src/panels/media-browser/ha-bar-media-player.ts index bfc2e63656..efff417516 100644 --- a/src/panels/media-browser/ha-bar-media-player.ts +++ b/src/panels/media-browser/ha-bar-media-player.ts @@ -686,6 +686,16 @@ export class BarMediaPlayer extends LitElement { mwc-list-item[selected] { font-weight: bold; } + + :host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] { + margin-left: 8px !important; + margin-right: 8px !important; + } + :host-context([style*="direction: rtl;"]) + ha-svg-icon[slot="trailingIcon"] { + margin-left: 0px !important; + margin-right: 8px !important; + } `; } } diff --git a/src/panels/media-browser/ha-panel-media-browser.ts b/src/panels/media-browser/ha-panel-media-browser.ts index 30cc866b17..1123806c3a 100644 --- a/src/panels/media-browser/ha-panel-media-browser.ts +++ b/src/panels/media-browser/ha-panel-media-browser.ts @@ -16,6 +16,7 @@ import { fireEvent, HASSDomEvent } from "../../common/dom/fire_event"; import { navigate } from "../../common/navigate"; import "../../components/ha-menu-button"; import "../../components/ha-icon-button"; +import "../../components/ha-icon-button-arrow-prev"; import "../../components/media-player/ha-media-player-browse"; import "../../components/media-player/ha-media-manage-button"; import type { @@ -85,10 +86,10 @@ class PanelMediaBrowser extends LitElement { ${this._navigateIds.length > 1 ? html` - + > ` : html` { - const [media_content_type, media_content_id] = - decodeURIComponent(navigateId).split(","); + const decoded = decodeURIComponent(navigateId); + // Don't use split because media_content_id could contain commas + const delimiter = decoded.indexOf(","); return { - media_content_type, - media_content_id, + media_content_type: decoded.substring(0, delimiter), + media_content_id: decoded.substring(delimiter + 1), }; }), ]; @@ -277,6 +279,7 @@ class PanelMediaBrowser extends LitElement { ha-media-player-browse { height: calc(100vh - (100px + var(--header-height))); + direction: ltr; } :host([narrow]) ha-media-player-browse { diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index 14015d2629..9883f6a20b 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -12,172 +12,204 @@ import "../../layouts/hass-error-screen"; import { HomeAssistant, Route } from "../../types"; import { documentationUrl } from "../../util/documentation-url"; +export const getMyRedirects = (hasSupervisor: boolean): Redirects => ({ + developer_states: { + redirect: "/developer-tools/state", + }, + developer_services: { + redirect: "/developer-tools/service", + }, + developer_call_service: { + redirect: "/developer-tools/service", + params: { + service: "string", + }, + }, + developer_template: { + redirect: "/developer-tools/template", + }, + developer_events: { + redirect: "/developer-tools/event", + }, + developer_statistics: { + redirect: "/developer-tools/statistics", + }, + server_controls: { + redirect: "/developer-tools/yaml", + }, + config: { + redirect: "/config/dashboard", + }, + cloud: { + component: "cloud", + redirect: "/config/cloud", + }, + config_flow_start: { + redirect: "/config/integrations/add", + params: { + domain: "string", + }, + }, + integrations: { + redirect: "/config/integrations", + }, + config_mqtt: { + component: "mqtt", + redirect: "/config/mqtt", + }, + config_zha: { + component: "zha", + redirect: "/config/zha/dashboard", + }, + config_zwave_js: { + component: "zwave_js", + redirect: "/config/zwave_js/dashboard", + }, + config_energy: { + component: "energy", + redirect: "/config/energy/dashboard", + }, + devices: { + redirect: "/config/devices/dashboard", + }, + entities: { + redirect: "/config/entities", + }, + energy: { + component: "energy", + redirect: "/energy", + }, + areas: { + redirect: "/config/areas/dashboard", + }, + blueprint_import: { + component: "blueprint", + redirect: "/config/blueprint/dashboard/import", + params: { + blueprint_url: "url", + }, + }, + blueprints: { + component: "blueprint", + redirect: "/config/blueprint/dashboard", + }, + automations: { + component: "automation", + redirect: "/config/automation/dashboard", + }, + scenes: { + component: "scene", + redirect: "/config/scene/dashboard", + }, + scripts: { + component: "script", + redirect: "/config/script/dashboard", + }, + helpers: { + redirect: "/config/helpers", + }, + tags: { + component: "tag", + redirect: "/config/tags", + }, + lovelace_dashboards: { + component: "lovelace", + redirect: "/config/lovelace/dashboards", + }, + lovelace_resources: { + component: "lovelace", + redirect: "/config/lovelace/resources", + }, + people: { + component: "person", + redirect: "/config/person", + }, + zones: { + component: "zone", + redirect: "/config/zone", + }, + users: { + redirect: "/config/users", + }, + general: { + redirect: "/config/general", + }, + logs: { + redirect: "/config/logs", + }, + info: { + redirect: "/config/info", + }, + system_health: { + redirect: "/config/system_health", + }, + hardware: { + redirect: "/config/hardware", + }, + storage: { + redirect: "/config/storage", + }, + network: { + redirect: "/config/network", + }, + analytics: { + redirect: "/config/analytics", + }, + updates: { + redirect: "/config/updates", + }, + system_dashboard: { + redirect: "/config/system", + }, + customize: { + // customize was removed in 2021.12, fallback to dashboard + redirect: "/config/dashboard", + }, + profile: { + redirect: "/profile", + }, + logbook: { + component: "logbook", + redirect: "/logbook", + }, + history: { + component: "history", + redirect: "/history", + }, + media_browser: { + component: "media_source", + redirect: "/media-browser", + }, + backup: { + component: hasSupervisor ? "hassio" : "backup", + redirect: hasSupervisor ? "/hassio/backups" : "/config/backup", + }, + supervisor_snapshots: { + component: hasSupervisor ? "hassio" : "backup", + redirect: hasSupervisor ? "/hassio/backups" : "/config/backup", + }, + supervisor_backups: { + component: hasSupervisor ? "hassio" : "backup", + redirect: hasSupervisor ? "/hassio/backups" : "/config/backup", + }, + supervisor_system: { + // Moved from Supervisor panel in 2022.5 + redirect: "/config/system", + }, + supervisor_logs: { + // Moved from Supervisor panel in 2022.5 + redirect: "/config/logs", + }, + supervisor_info: { + // Moved from Supervisor panel in 2022.5 + redirect: "/config/info", + }, +}); + const getRedirect = ( path: string, hasSupervisor: boolean -): Redirect | undefined => - (( - { - developer_states: { - redirect: "/developer-tools/state", - }, - developer_services: { - redirect: "/developer-tools/service", - }, - developer_call_service: { - redirect: "/developer-tools/service", - params: { - service: "string", - }, - }, - developer_template: { - redirect: "/developer-tools/template", - }, - developer_events: { - redirect: "/developer-tools/event", - }, - developer_statistics: { - redirect: "/developer-tools/statistics", - }, - config: { - redirect: "/config", - }, - cloud: { - component: "cloud", - redirect: "/config/cloud", - }, - integrations: { - redirect: "/config/integrations", - }, - config_flow_start: { - redirect: "/config/integrations/add", - params: { - domain: "string", - }, - }, - config_mqtt: { - component: "mqtt", - redirect: "/config/mqtt", - }, - config_zha: { - component: "zha", - redirect: "/config/zha/dashboard", - }, - config_zwave_js: { - component: "zwave_js", - redirect: "/config/zwave_js/dashboard", - }, - config_energy: { - component: "energy", - redirect: "/config/energy/dashboard", - }, - devices: { - redirect: "/config/devices/dashboard", - }, - entities: { - redirect: "/config/entities", - }, - energy: { - component: "energy", - redirect: "/energy", - }, - areas: { - redirect: "/config/areas/dashboard", - }, - blueprints: { - component: "blueprint", - redirect: "/config/blueprint/dashboard", - }, - blueprint_import: { - component: "blueprint", - redirect: "/config/blueprint/dashboard/import", - params: { - blueprint_url: "url", - }, - }, - automations: { - component: "automation", - redirect: "/config/automation/dashboard", - }, - scenes: { - component: "scene", - redirect: "/config/scene/dashboard", - }, - scripts: { - component: "script", - redirect: "/config/script/dashboard", - }, - helpers: { - redirect: "/config/helpers", - }, - tags: { - component: "tag", - redirect: "/config/tags", - }, - lovelace_dashboards: { - component: "lovelace", - redirect: "/config/lovelace/dashboards", - }, - lovelace_resources: { - component: "lovelace", - redirect: "/config/lovelace/resources", - }, - people: { - component: "person", - redirect: "/config/person", - }, - zones: { - component: "zone", - redirect: "/config/zone", - }, - users: { - redirect: "/config/users", - }, - general: { - redirect: "/config/core", - }, - server_controls: { - redirect: "/config/server_control", - }, - logs: { - redirect: "/config/logs", - }, - info: { - redirect: "/config/info", - }, - customize: { - // customize was removed in 2021.12, fallback to dashboard - redirect: "/config/dashboard", - }, - profile: { - redirect: "/profile/dashboard", - }, - logbook: { - component: "logbook", - redirect: "/logbook", - }, - history: { - component: "history", - redirect: "/history", - }, - media_browser: { - component: "media_source", - redirect: "/media-browser", - }, - backup: { - component: hasSupervisor ? "hassio" : "backup", - redirect: hasSupervisor ? "/hassio/backups" : "/config/backup", - }, - supervisor_snapshots: { - component: hasSupervisor ? "hassio" : "backup", - redirect: hasSupervisor ? "/hassio/backups" : "/config/backup", - }, - supervisor_backups: { - component: hasSupervisor ? "hassio" : "backup", - redirect: hasSupervisor ? "/hassio/backups" : "/config/backup", - }, - } as Redirects - )[path]); +): Redirect | undefined => getMyRedirects(hasSupervisor)?.[path]; export type ParamType = "url" | "string"; @@ -188,6 +220,9 @@ export interface Redirect { params?: { [key: string]: ParamType; }; + optional_params?: { + [key: string]: ParamType; + }; } @customElement("ha-panel-my") diff --git a/src/state-summary/state-card-input_number.js b/src/state-summary/state-card-input_number.js index 2fabea717a..85cda4ffc1 100644 --- a/src/state-summary/state-card-input_number.js +++ b/src/state-summary/state-card-input_number.js @@ -30,7 +30,8 @@ class StateCardInputNumber extends mixinBehaviors( .sliderstate { min-width: 45px; } - ha-slider[hidden] { + ha-slider[hidden], + ha-textfield[hidden] { display: none !important; } ha-textfield { diff --git a/src/state/quick-bar-mixin.ts b/src/state/quick-bar-mixin.ts index d70d0d1b4d..532ffbe49e 100644 --- a/src/state/quick-bar-mixin.ts +++ b/src/state/quick-bar-mixin.ts @@ -1,16 +1,20 @@ import type { PropertyValues } from "lit"; import tinykeys from "tinykeys"; +import { isComponentLoaded } from "../common/config/is_component_loaded"; +import { mainWindow } from "../common/dom/get_main_window"; import { QuickBarParams, showQuickBar, } from "../dialogs/quick-bar/show-dialog-quick-bar"; import { Constructor, HomeAssistant } from "../types"; import { storeState } from "../util/ha-pref-storage"; +import { showToast } from "../util/toast"; import { HassElement } from "./hass-element"; declare global { interface HASSDomEvents { "hass-quick-bar": QuickBarParams; + "hass-quick-bar-trigger": KeyboardEvent; "hass-enable-shortcuts": HomeAssistant["enableShortcuts"]; } } @@ -25,6 +29,20 @@ export default >(superClass: T) => storeState(this.hass!); }); + mainWindow.addEventListener("hass-quick-bar-trigger", (ev) => { + switch (ev.detail.key) { + case "e": + this._showQuickBar(ev.detail); + break; + case "c": + this._showQuickBar(ev.detail, true); + break; + case "m": + this._createMyLink(ev.detail); + break; + } + }); + this._registerShortcut(); } @@ -32,6 +50,7 @@ export default >(superClass: T) => tinykeys(window, { e: (ev) => this._showQuickBar(ev), c: (ev) => this._showQuickBar(ev, true), + m: (ev) => this._createMyLink(ev), }); } @@ -43,6 +62,63 @@ export default >(superClass: T) => showQuickBar(this, { commandMode }); } + private async _createMyLink(e: KeyboardEvent) { + if ( + !this.hass?.enableShortcuts || + !this._canOverrideAlphanumericInput(e) + ) { + return; + } + + const targetPath = mainWindow.location.pathname; + const isHassio = isComponentLoaded(this.hass, "hassio"); + const myParams = new URLSearchParams(); + + if (isHassio && targetPath.startsWith("/hassio")) { + const myPanelSupervisor = await import( + "../../hassio/src/hassio-my-redirect" + ); + for (const [slug, redirect] of Object.entries( + myPanelSupervisor.REDIRECTS + )) { + if (targetPath.startsWith(redirect.redirect)) { + myParams.append("redirect", slug); + if (redirect.redirect === "/hassio/addon") { + myParams.append("addon", targetPath.split("/")[3]); + } + window.open( + `https://my.home-assistant.io/create-link/?${myParams.toString()}`, + "_blank" + ); + return; + } + } + } + + const myPanel = await import("../panels/my/ha-panel-my"); + + for (const [slug, redirect] of Object.entries( + myPanel.getMyRedirects(isHassio) + )) { + if (targetPath.startsWith(redirect.redirect)) { + myParams.append("redirect", slug); + window.open( + `https://my.home-assistant.io/create-link/?${myParams.toString()}`, + "_blank" + ); + return; + } + } + showToast(this, { + message: this.hass.localize( + "ui.notification_toast.no_matching_link_found", + { + path: targetPath, + } + ), + }); + } + private _canShowQuickBar(e: KeyboardEvent) { return ( this.hass?.user?.is_admin && diff --git a/src/translations/en.json b/src/translations/en.json index e4cde9f090..4255e3aaab 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2,7 +2,7 @@ "panel": { "energy": "Energy", "calendar": "Calendar", - "config": "Configuration", + "config": "Settings", "states": "Overview", "map": "Map", "logbook": "Logbook", @@ -314,6 +314,7 @@ "undo": "Undo", "move": "Move", "save": "Save", + "edit": "Edit", "submit": "Submit", "rename": "Rename", "yes": "Yes", @@ -624,43 +625,43 @@ "quick-bar": { "commands": { "reload": { - "reload": "[%key:ui::panel::config::server_control::section::reloading::reload%]", - "core": "[%key:ui::panel::config::server_control::section::reloading::core%]", - "group": "[%key:ui::panel::config::server_control::section::reloading::group%]", - "automation": "[%key:ui::panel::config::server_control::section::reloading::automation%]", - "script": "[%key:ui::panel::config::server_control::section::reloading::script%]", - "scene": "[%key:ui::panel::config::server_control::section::reloading::scene%]", - "person": "[%key:ui::panel::config::server_control::section::reloading::person%]", - "zone": "[%key:ui::panel::config::server_control::section::reloading::zone%]", - "input_boolean": "[%key:ui::panel::config::server_control::section::reloading::input_boolean%]", - "input_text": "[%key:ui::panel::config::server_control::section::reloading::input_text%]", - "input_number": "[%key:ui::panel::config::server_control::section::reloading::input_number%]", - "input_datetime": "[%key:ui::panel::config::server_control::section::reloading::input_datetime%]", - "input_select": "[%key:ui::panel::config::server_control::section::reloading::input_select%]", - "template": "[%key:ui::panel::config::server_control::section::reloading::template%]", - "universal": "[%key:ui::panel::config::server_control::section::reloading::universal%]", - "rest": "[%key:ui::panel::config::server_control::section::reloading::rest%]", - "command_line": "[%key:ui::panel::config::server_control::section::reloading::command_line%]", - "filter": "[%key:ui::panel::config::server_control::section::reloading::filter%]", - "statistics": "[%key:ui::panel::config::server_control::section::reloading::statistics%]", - "generic": "[%key:ui::panel::config::server_control::section::reloading::generic%]", - "generic_thermostat": "[%key:ui::panel::config::server_control::section::reloading::generic_thermostat%]", - "homekit": "[%key:ui::panel::config::server_control::section::reloading::homekit%]", - "min_max": "[%key:ui::panel::config::server_control::section::reloading::min_max%]", - "history_stats": "[%key:ui::panel::config::server_control::section::reloading::history_stats%]", - "trend": "[%key:ui::panel::config::server_control::section::reloading::trend%]", - "ping": "[%key:ui::panel::config::server_control::section::reloading::ping%]", - "filesize": "[%key:ui::panel::config::server_control::section::reloading::filesize%]", - "telegram": "[%key:ui::panel::config::server_control::section::reloading::telegram%]", - "smtp": "[%key:ui::panel::config::server_control::section::reloading::smtp%]", - "mqtt": "[%key:ui::panel::config::server_control::section::reloading::mqtt%]", - "rpi_gpio": "[%key:ui::panel::config::server_control::section::reloading::rpi_gpio%]", - "themes": "[%key:ui::panel::config::server_control::section::reloading::themes%]" + "reload": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::reload%]", + "core": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::core%]", + "group": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::group%]", + "automation": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::automation%]", + "script": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::script%]", + "scene": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::scene%]", + "person": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::person%]", + "zone": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::zone%]", + "input_boolean": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::input_boolean%]", + "input_text": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::input_text%]", + "input_number": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::input_number%]", + "input_datetime": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::input_datetime%]", + "input_select": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::input_select%]", + "template": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::template%]", + "universal": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::universal%]", + "rest": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::rest%]", + "command_line": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::command_line%]", + "filter": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::filter%]", + "statistics": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::statistics%]", + "generic": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::generic%]", + "generic_thermostat": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::generic_thermostat%]", + "homekit": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::homekit%]", + "min_max": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::min_max%]", + "history_stats": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::history_stats%]", + "trend": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::trend%]", + "ping": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::ping%]", + "filesize": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::filesize%]", + "telegram": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::telegram%]", + "smtp": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::smtp%]", + "mqtt": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::mqtt%]", + "rpi_gpio": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::rpi_gpio%]", + "themes": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::themes%]" }, "server_control": { "perform_action": "{action} server", - "restart": "[%key:ui::panel::config::server_control::section::server_management::restart%]", - "stop": "[%key:ui::panel::config::server_control::section::server_management::stop%]" + "restart": "[%key:ui::panel::developer-tools::tabs::yaml::section::server_management::restart%]", + "stop": "[%key:ui::panel::developer-tools::tabs::yaml::section::server_management::stop%]" }, "types": { "reload": "Reload", @@ -675,23 +676,35 @@ "areas": "[%key:ui::panel::config::areas::caption%]", "scene": "[%key:ui::panel::config::scene::caption%]", "helpers": "[%key:ui::panel::config::helpers::caption%]", - "tag": "[%key:ui::panel::config::tag::caption%]", + "tags": "[%key:ui::panel::config::tag::caption%]", "person": "[%key:ui::panel::config::person::caption%]", "devices": "[%key:ui::panel::config::devices::caption%]", "entities": "[%key:ui::panel::config::entities::caption%]", "energy": "Energy Configuration", "lovelace": "[%key:ui::panel::config::lovelace::caption%]", - "core": "[%key:ui::panel::config::core::caption%]", "zone": "[%key:ui::panel::config::zone::caption%]", "users": "[%key:ui::panel::config::users::caption%]", "info": "[%key:ui::panel::config::info::caption%]", + "network": "[%key:ui::panel::config::network::caption%]", + "updates": "[%key:ui::panel::config::updates::caption%]", + "hardware": "[%key:ui::panel::config::hardware::caption%]", + "storage": "[%key:ui::panel::config::storage::caption%]", + "general": "[%key:ui::panel::config::core::caption%]", + "backups": "[%key:ui::panel::config::backup::caption%]", + "backup": "[%key:ui::panel::config::backup::caption%]", + "analytics": "[%key:ui::panel::config::analytics::caption%]", + "system_health": "[%key:ui::panel::config::system_health::caption%]", "blueprint": "[%key:ui::panel::config::blueprint::caption%]", - "server_control": "[%key:ui::panel::config::server_control::caption%]" + "server_control": "[%key:ui::panel::developer-tools::tabs::yaml::title%]", + "system": "[%key:ui::panel::config::dashboard::system::main%]", + "addon_dashboard": "Add-on Dashboard", + "addon_store": "Add-on Store", + "addon_info": "{addon} Info" } }, "filter_placeholder": "Entity Filter", "title": "Quick Search", - "key_c_hint": "Press 'c' on any page to open this search bar", + "key_c_hint": "Press 'c' on any page to open the search bar", "nothing_found": "Nothing found!" }, "voice_command": { @@ -735,6 +748,7 @@ "latest_version": "Latest version", "release_announcement": "Read release announcement", "skip": "Skip", + "clear_skipped": "Clear skipped", "install": "Install", "create_backup": "Create backup before updating" }, @@ -856,7 +870,7 @@ "area_note": "By default the entities of a device are in the same area as the device. If you change the area of this entity, it will no longer follow the area of the device.", "follow_device_area": "Follow device area", "change_device_area": "Change device area", - "configure_state": "Configure State" + "configure_state": "{integration} options" } }, "helper_settings": { @@ -993,6 +1007,45 @@ "recent_tx_messages": "{n} most recently transmitted message(s)", "show_as_yaml": "Show as YAML", "triggers": "Triggers" + }, + "unsupported": { + "title": "[%key:supervisor::system::supervisor::unsupported_title%]", + "description": "[%key:supervisor::system::supervisor::unsupported_description%]", + "reasons": { + "apparmor": "[%key:supervisor::system::supervisor::unsupported_reason::apparmor%]", + "content_trust": "[%key:supervisor::system::supervisor::unsupported_reason::content_trust%]", + "dbus": "[%key:supervisor::system::supervisor::unsupported_reason::dbus%]", + "docker_configuration": "[%key:supervisor::system::supervisor::unsupported_reason::docker_configuration%]", + "docker_version": "[%key:supervisor::system::supervisor::unsupported_reason::docker_version%]", + "job_conditions": "[%key:supervisor::system::supervisor::unsupported_reason::job_conditions%]", + "lxc": "[%key:supervisor::system::supervisor::unsupported_reason::lxc%]", + "network_manager": "[%key:supervisor::system::supervisor::unsupported_reason::network_manager%]", + "os": "[%key:supervisor::system::supervisor::unsupported_reason::os%]", + "os_agent": "[%key:supervisor::system::supervisor::unsupported_reason::os_agent%]", + "privileged": "[%key:supervisor::system::supervisor::unsupported_reason::privileged%]", + "software": "[%key:supervisor::system::supervisor::unsupported_reason::software%]", + "source_mods": "[%key:supervisor::system::supervisor::unsupported_reason::source_mods%]", + "systemd": "[%key:supervisor::system::supervisor::unsupported_reason::systemd%]", + "systemd_resolved": "[%key:supervisor::system::supervisor::unsupported_reason::systemd_resolved%]" + } + }, + "unhealthy": { + "title": "[%key:supervisor::system::supervisor::unhealthy_title%]", + "description": "[%key:supervisor::system::supervisor::unhealthy_description%]", + "reasons": { + "privileged": "[%key:supervisor::system::supervisor::unhealthy_reason::privileged%]", + "supervisor": "[%key:supervisor::system::supervisor::unhealthy_reason::supervisor%]", + "setup": "[%key:supervisor::system::supervisor::unhealthy_reason::setup%]", + "docker": "[%key:supervisor::system::supervisor::unhealthy_reason::docker%]", + "untrusted": "[%key:supervisor::system::supervisor::unhealthy_reason::untrusted%]" + } + }, + "join_beta_channel": { + "title": "Join the beta channel", + "warning": "[%key:supervisor::system::supervisor::beta_warning%]", + "backup": "[%key:supervisor::system::supervisor::beta_backup%]", + "release_items": "[%key:supervisor::system::supervisor::beta_release_items%]", + "confirm": "[%key:supervisor::system::supervisor::beta_join_confirm%]" } }, "duration": { @@ -1043,7 +1096,8 @@ "wrapping_up_startup": "Wrapping up startup, not everything will be available until it is finished.", "integration_starting": "Starting {integration}, not everything will be available until it is finished.", "triggered": "Triggered {name}", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "no_matching_link_found": "No matching My link found for {path}" }, "sidebar": { "external_app_configuration": "App Configuration", @@ -1065,48 +1119,52 @@ "header": "Configure Home Assistant", "dashboard": { "devices": { - "title": "Devices & Services", - "description": "Integrations, devices, entities and areas" + "main": "Devices & Services", + "secondary": "Integrations, devices, entities and helpers" }, "automations": { - "title": "Automations & Scenes", - "description": "Manage automations, scenes, scripts and helpers" + "main": "Automations & Scenes", + "secondary": "Automations, scenes, scripts and blueprints" }, "backup": { - "title": "Backup", - "description": "Generate backups of your Home Assistant configuration" - }, - "blueprints": { - "title": "Blueprints", - "description": "Pre-made automations and scripts by the community" + "main": "Backup", + "secondary": "Generate backups of your Home Assistant configuration" }, "supervisor": { - "title": "Add-ons, Backups & Supervisor", - "description": "Create backups, check logs or reboot your system" + "main": "Add-ons", + "secondary": "Run extra applications next to Home Assistant" }, "dashboards": { - "title": "Dashboards", - "description": "Create customized sets of cards to control your home" + "main": "Dashboards", + "secondary": "Organize how you interact with your home" }, "energy": { - "title": "Energy", - "description": "Monitor your energy production and consumption" + "main": "Energy", + "secondary": "Monitor your energy production and consumption" }, "tags": { - "title": "Tags", - "description": "Trigger automations when an NFC tag, QR code, etc. is scanned" + "main": "Tags", + "secondary": "Setup NFC tags and QR codes" }, "people": { - "title": "People & Zones", - "description": "Manage the people and zones that Home Assistant tracks" + "main": "People", + "secondary": "Manage who can access your home" + }, + "areas": { + "main": "Areas & Zones", + "secondary": "Manage locations in and around your house" }, "companion": { - "title": "Companion App", - "description": "Location and notifications" + "main": "Companion App", + "secondary": "Location and notifications" }, - "settings": { - "title": "Settings", - "description": "Basic settings, server controls, logs and info" + "system": { + "main": "System", + "secondary": "Create backups, check logs or reboot your system" + }, + "about": { + "main": "About", + "secondary": "Version information, credits and more" } }, "common": { @@ -1116,6 +1174,9 @@ "learn_more": "Learn more" }, "updates": { + "caption": "Updates", + "description": "Manage updates of Home Assistant, Add-ons and devices", + "no_updates": "No updates available", "no_update_entities": { "title": "Unable to check for updates", "description": "You do not have any integrations that provide updates." @@ -1127,7 +1188,12 @@ "unable_to_fetch": "Unable to load updates", "version_available": "Version {version_available} is available", "more_updates": "+{count} updates", - "show": "show" + "show": "show", + "show_skipped": "Show skipped", + "hide_skipped": "Hide skipped", + "join_beta": "[%key:supervisor::system::supervisor::join_beta_action%]", + "leave_beta": "[%key:supervisor::system::supervisor::leave_beta_action%]", + "skipped": "Skipped" }, "areas": { "caption": "Areas", @@ -1167,7 +1233,9 @@ } }, "backup": { - "caption": "[%key:ui::panel::config::dashboard::backup::title%]", + "caption": "Backups", + "description": "Last backup {relative_time}", + "description_no_backup": "Manage backups and restore Home Assistant to a previous state", "create_backup": "[%key:supervisor::backup::create_backup%]", "creating_backup": "Backup is currently being created", "download_backup": "[%key:supervisor::backup::download_backup%]", @@ -1185,6 +1253,9 @@ "title": "Remove backup", "description": "Are you sure you want to remove the backup with the name {name}?", "confirm": "[%key:ui::common::remove%]" + }, + "picker": { + "search": "Search backups" } }, "tag": { @@ -1419,14 +1490,14 @@ }, "core": { "caption": "General", - "description": "Location, network and analytics", + "description": "Name, Timezone and locale settings", "section": { "core": { "header": "General Configuration", "introduction": "Manage your location, network and analytics.", "core_config": { "edit_requires_storage": "Editor disabled because config stored in configuration.yaml.", - "location_name": "Name of your Home Assistant installation", + "location_name": "Name", "latitude": "Latitude", "longitude": "Longitude", "elevation": "Elevation", @@ -1437,9 +1508,11 @@ "unit_system_metric": "Metric", "imperial_example": "Fahrenheit, pounds", "metric_example": "Celsius, kilograms", - "find_currency_value": "Find your value", + "find_currency_value": "Find my value", "save_button": "Save", - "currency": "Currency" + "currency": "Currency", + "edit_location": "Edit location", + "edit_location_description": "Location can be changed in zone settings" } } } @@ -1450,19 +1523,38 @@ "internal_url_label": "Local Network", "external_url_label": "Internet", "external_use_ha_cloud": "Use Home Assistant Cloud", - "external_get_ha_cloud": "Access from anywhere using Home Assistant Cloud", + "external_get_ha_cloud": "Access from anywhere, add Google & Alexa easily", "ha_cloud_remote_not_enabled": "Your Home Assistant Cloud remote connection is currently not enabled.", "enable_remote": "[%key:ui::common::enable%]", "internal_url_automatic": "Automatic", "internal_url_https_error_title": "Invalid local network URL", "internal_url_https_error_description": "You have configured an HTTPS certificate in Home Assistant. This means that your internal URL needs to be set to a domain covered by the certficate." }, + "hardware": { + "caption": "Hardware", + "description": "Configure your hub and connected hardware", + "available_hardware": { + "failed_to_get": "Failed to get available hardware", + "title": "All Hardware", + "subsystem": "Subsystem", + "device_path": "Device path", + "id": "ID", + "attributes": "Attributes" + }, + "reboot_host": "Reboot host", + "reboot_host_confirm": "Are you sure you want to reboot your host?", + "failed_to_reboot_host": "Failed to reboot host", + "shutdown_host": "Shutdown host", + "shutdown_host_confirm": "Are you sure you want to shutdown your host?", + "failed_to_shutdown_host": "Failed to shutdown host", + "board": "Board" + }, "info": { - "caption": "Info", + "caption": "About", "copy_menu": "Copy menu", "copy_raw": "Raw Text", "copy_github": "For GitHub", - "description": "Version, system health and links to documentation", + "description": "Version, loaded integrations and links to documentation", "home_assistant_logo": "Home Assistant logo", "path_configuration": "Path to configuration.yaml: {path}", "developed_by": "Developed by a bunch of awesome people.", @@ -1475,7 +1567,6 @@ "frontend_version": "Frontend version: {version} - {type}", "custom_uis": "Custom UIs:", "system_health_error": "System Health component is not loaded. Add 'system_health:' to configuration.yaml", - "integrations": "Integrations", "documentation": "Documentation", "issues": "Issues", "setup_time": "Setup time", @@ -1486,17 +1577,19 @@ }, "logs": { "caption": "Logs", - "description": "View the Home Assistant logs", + "description": "View and search logs to diagnose issues", "details": "Log Details ({level})", "search": "Search logs", + "failed_get_logs": "Failed to get {provider} logs, {error}", "no_issues_search": "No issues found for search term ''{term}''", - "load_full_log": "Load Full Home Assistant Log", + "load_logs": "Load Full Logs", "loading_log": "Loading error log…", "no_errors": "No errors have been reported", "no_issues": "There are no new issues!", "clear": "Clear", "refresh": "Refresh", "copy": "Copy log entry", + "log_provider": "Log Provider", "multiple_messages": "message first occurred at {time} and shows up {counter} times", "level": { "critical": "CRITICAL", @@ -1506,7 +1599,8 @@ "debug": "DEBUG" }, "custom_integration": "custom integration", - "error_from_custom_integration": "This error originated from a custom integration." + "error_from_custom_integration": "This error originated from a custom integration.", + "full_logs": "Full logs" }, "lovelace": { "caption": "Dashboards", @@ -1588,65 +1682,6 @@ } } }, - "server_control": { - "caption": "Server Controls", - "description": "Validate and restart the Home Assistant server", - "section": { - "validation": { - "heading": "Configuration validation", - "introduction": "Validate your configuration if you recently made some changes to your configuration and want to make sure that it is all valid.", - "check_config": "Check configuration", - "valid": "Configuration valid!", - "invalid": "Configuration invalid" - }, - "reloading": { - "heading": "YAML configuration reloading", - "introduction": "Some parts of Home Assistant can reload without requiring a restart. Clicking one of the options below will unload their current YAML configuration and load the new one.", - "reload": "{domain}", - "core": "Location & customizations", - "group": "Groups, group entities, and notify services", - "automation": "Automations", - "script": "Scripts", - "scene": "Scenes", - "person": "People", - "zone": "Zones", - "input_boolean": "Input booleans", - "input_button": "Input buttons", - "input_text": "Input texts", - "input_number": "Input numbers", - "input_datetime": "Input date times", - "input_select": "Input selects", - "template": "Template entities", - "universal": "Universal media player entities", - "rest": "Rest entities and notify services", - "command_line": "Command line entities", - "filter": "Filter entities", - "statistics": "Statistics entities", - "generic": "Generic IP camera entities", - "generic_thermostat": "Generic thermostat entities", - "homekit": "HomeKit", - "min_max": "Min/max entities", - "history_stats": "History stats entities", - "trend": "Trend entities", - "ping": "Ping binary sensor entities", - "filesize": "File size entities", - "telegram": "Telegram notify services", - "smtp": "SMTP notify services", - "mqtt": "Manually configured MQTT entities", - "rpi_gpio": "Raspberry Pi GPIO entities", - "timer": "Timers", - "themes": "Themes" - }, - "server_management": { - "heading": "Home Assistant", - "introduction": "Restarting Home Assistant will stop your dashboard and automations. After the reboot, each configuration will be reloaded.", - "restart": "Restart", - "confirm_restart": "Are you sure you want to restart Home Assistant?", - "stop": "Stop", - "confirm_stop": "Are you sure you want to stop Home Assistant?" - } - } - }, "automation": { "caption": "Automations", "description": "Create custom behavior rules for your home", @@ -1655,7 +1690,7 @@ "introduction": "The automation editor allows you to create and edit automations. Please follow the link below to read the instructions to make sure that you have configured Home Assistant correctly.", "learn_more": "Learn more about automations", "pick_automation": "Pick automation to edit", - "no_automations": "We couldn’t find any automations", + "no_automations": "We couldn't find any automations", "add_automation": "Create automation", "only_editable": "Only automations in automations.yaml are editable.", "dev_only_editable": "Only automations that have a unique ID assigned are debuggable.", @@ -1742,6 +1777,12 @@ "unsupported_platform": "No visual editor support for platform: {platform}", "type_select": "Trigger type", "type": { + "calendar": { + "label": "Calendar", + "event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]", + "start": "Event Start", + "end": "Event End" + }, "device": { "label": "Device", "trigger": "Trigger", @@ -1935,6 +1976,9 @@ "run_action_error": "Error running action", "run_action_success": "Action run successfully", "duplicate": "[%key:ui::panel::config::automation::editor::triggers::duplicate%]", + "enable": "Enable", + "disable": "Disable", + "disabled": "Disabled", "delete": "[%key:ui::panel::mailbox::delete_button%]", "delete_confirm": "[%key:ui::panel::config::automation::editor::triggers::delete_confirm%]", "unsupported_action": "No visual editor support for action: {action}", @@ -2013,6 +2057,20 @@ "remove_option": "Remove option", "conditions": "Conditions", "sequence": "Actions" + }, + "if": { + "label": "If-then", + "if": "If", + "then": "Then", + "else": "Else" + }, + "stop": { + "label": "Stop", + "stop": "Reason for stopping", + "error": "Stop because of an unexpected error" + }, + "parallel": { + "label": "Run in parallel" } } } @@ -2188,7 +2246,7 @@ } }, "cloud": { - "description_login": "Logged in as {email}", + "description_login": "Logged in and connected", "description_not_login": "Not logged in", "description_features": "Control home when away and integrate with Alexa and Google Assistant", "login": { @@ -2594,7 +2652,9 @@ "create_zone": "Add Zone", "add_zone": "Add Zone", "edit_zone": "Edit Zone", + "edit_home": "Edit Home", "confirm_delete": "Are you sure you want to delete this zone?", + "can_not_edit": "Unable to edit zone", "configured_in_yaml": "Zones configured via configuration.yaml cannot be edited via the UI.", "edit_home_zone": "The radius of the Home zone can't be edited from the frontend yet. Drag the marker on the map to move the home zone.", "edit_home_zone_narrow": "The radius of the Home zone can't be edited from the frontend yet. The location can be changed from the general configuration.", @@ -2614,7 +2674,8 @@ "delete": "Delete", "create": "Add", "update": "Update" - } + }, + "core_location_dialog": "Home Assistant Location" }, "integrations": { "caption": "Integrations", @@ -2711,12 +2772,14 @@ "open_configuration_url": "Visit device" }, "config_flow": { + "success": "Success", "aborted": "Aborted", "close": "Close", "dismiss": "Dismiss dialog", "finish": "Finish", "submit": "Submit", "next": "Next", + "found_following_devices": "We found the following devices", "no_config_flow": "This integration does not support configuration via the UI. If you followed this link from the Home Assistant website, make sure you run the latest version of Home Assistant.", "not_all_required_fields": "Not all required fields are filled in.", "error_saving_area": "Error saving area: {error}", @@ -3073,6 +3136,66 @@ "tips": { "tip": "Tip!", "join": "Join the community on our {forums}, {twitter}, {discord}, {blog} or {newsletter}" + }, + "analytics": { + "caption": "Analytics", + "description": "Learn how to share data to better the Open Home" + }, + "network": { + "caption": "Network", + "description": "External access {state}", + "enabled": "enabled", + "disabled": "disabled", + "supervisor": { + "title": "Configure network interfaces", + "connected_to": "Connected to {ssid}", + "scan_ap": "Scan for access points", + "open": "Open", + "wep": "WEP", + "wpa": "wpa-psk", + "warning": "If you are changing the Wi-Fi, IP or gateway addresses, you might lose the connection!", + "static": "Static", + "dhcp": "DHCP", + "disabled": "Disabled", + "ip_netmask": "IP address/Netmask", + "gateway": "Gateway address", + "dns_servers": "DNS Servers", + "unsaved": "You have unsaved changes, these will get lost if you change tabs, do you want to continue?", + "failed_to_change": "Failed to change network settings" + } + }, + "storage": { + "caption": "Storage", + "description": "{percent_used} used - {free_space} free", + "used_space": "Used Space", + "emmc_lifetime_used": "eMMC Lifetime Used", + "datadisk": { + "title": "Move datadisk", + "description": "You are currently using ''{current_path}'' as datadisk. Moving data disks will reboot your device and it's estimated to take {time} minutes. Your Home Assistant installation will not be accessible during this period. Do not disconnect the power during the move!", + "select_device": "Select new datadisk", + "no_devices": "No suitable attached devices found", + "moving_desc": "Rebooting and moving datadisk. Please have patience", + "moving": "Moving datadisk", + "loading_devices": "Loading devices", + "cancel": "[%key:ui::common::cancel%]", + "failed_to_move": "Failed to move datadisk", + "move": "Move" + } + }, + "system_health": { + "caption": "System Health", + "description": "Status, Stats and Integration startup time", + "cpu_usage": "Processor Usage", + "ram_usage": "Memory Usage", + "core_stats": "Core Stats", + "supervisor_stats": "Supervisor Stats", + "integration_start_time": "Integration Startup Time" + }, + "system_dashboard": { + "confirm_restart_text": "Restarting Home Assistant will stop all your active dashboards, automations and scripts.", + "confirm_restart_title": "Restart Home Assistant?", + "restart_homeassistant_short": "Restart", + "restart_error": "Failed to restart Home Assistant" } }, "lovelace": { @@ -3213,6 +3336,7 @@ "menu": { "configure_ui": "Edit Dashboard", "help": "Help", + "search": "Search", "start_conversation": "Start conversation", "reload_resources": "Reload resources", "exit_edit_mode": "Done", @@ -3666,7 +3790,7 @@ } }, "map": { - "edit_zones": "Edit Zones" + "edit_zones": "Edit zones" }, "profile": { "current_user": "You are currently logged in as {fullName}.", @@ -4052,6 +4176,65 @@ } }, "adjust_sum": "Adjust sum" + }, + "yaml": { + "title": "YAML", + "section": { + "validation": { + "heading": "Configuration validation", + "introduction": "Validate your configuration if you recently made some changes to it and want to make sure that it is all valid.", + "check_config": "Check configuration", + "valid": "Configuration valid!", + "invalid": "Configuration invalid!" + }, + "reloading": { + "heading": "YAML configuration reloading", + "introduction": "Some parts of Home Assistant can reload without requiring a restart. Clicking one of the options below will unload their current YAML configuration and load the new one.", + "reload": "{domain}", + "core": "Location & customizations", + "group": "Groups, group entities, and notify services", + "automation": "Automations", + "script": "Scripts", + "scene": "Scenes", + "person": "People", + "zone": "Zones", + "input_boolean": "Input booleans", + "input_button": "Input buttons", + "input_text": "Input texts", + "input_number": "Input numbers", + "input_datetime": "Input date times", + "input_select": "Input selects", + "template": "Template entities", + "universal": "Universal media player entities", + "rest": "Rest entities and notify services", + "command_line": "Command line entities", + "filter": "Filter entities", + "statistics": "Statistics entities", + "generic": "Generic IP camera entities", + "generic_thermostat": "Generic thermostat entities", + "homekit": "HomeKit", + "min_max": "Min/max entities", + "history_stats": "History stats entities", + "trend": "Trend entities", + "ping": "Ping binary sensor entities", + "filesize": "File size entities", + "telegram": "Telegram notify services", + "smtp": "SMTP notify services", + "mqtt": "Manually configured MQTT entities", + "rpi_gpio": "Raspberry Pi GPIO entities", + "timer": "Timers", + "themes": "Themes" + }, + "server_management": { + "heading": "Home Assistant", + "confirm_restart_text": "Restarting Home Assistant will stop all your active dashboards, automations and scripts.", + "confirm_restart_title": "Restart Home Assistant?", + "restart": "Restart", + "stop": "Stop", + "confirm_stop": "Are you sure you want to stop Home Assistant?", + "restart_error": "Failed to restart Home Assistant" + } + } } } }, @@ -4135,6 +4318,11 @@ "energy_devices_graph_title": "Monitor individual devices" } } + }, + "tips": { + "key_c_hint": "Press 'c' on any page to open the command dialog", + "key_e_hint": "Press 'e' on any page to open the entity search dialog", + "key_m_hint": "Press 'm' on any page to get the My Home Assistant link" } }, "supervisor": { @@ -4171,7 +4359,8 @@ "container": "Container", "disabled": "Disabled", "header": "Network", - "host": "Host" + "show_disabled": "Show disabled ports", + "introduction": "Change the ports on your host that are exposed by the add-on" } }, "dashboard": { @@ -4202,7 +4391,7 @@ }, "rating": { "title": "Add-on Security Rating", - "description": "Home Assistant provides a security rating to each of the add-ons, which indicates the risks involved when using this add-on. The more access an add-on requires on your system, the lower the score, thus raising the possible security risks.\n\nA score is on a scale from 1 to 6. Where 1 is the lowest score (considered the most insecure and highest risk) and a score of 6 is the highest score (considered the most secure and lowest risk)." + "description": "Home Assistant provides a security rating to each of the add-ons, which indicates the risks involved when using this add-on. The more access an add-on requires on your system, the lower the score, thus raising the possible security risks.\n\nA score is on a scale from 1 to 8. Where 1 is the lowest score (considered the most insecure and highest risk) and a score of 8 is the highest score (considered the most secure and lowest risk)." }, "host_network": { "title": "Host Network", @@ -4314,6 +4503,7 @@ "cancel": "[%key:ui::common::cancel%]", "yes": "[%key:ui::common::yes%]", "no": "[%key:ui::common::no%]", + "add": "[%key:supervisor::dialog::repositories::add%]", "description": "Description", "failed_to_restart_name": "Failed to restart {name}", "failed_to_update_name": "Failed to update {name}", @@ -4383,8 +4573,11 @@ "my": { "not_supported": "[%key:ui::panel::my::not_supported%]", "faq_link": "[%key:ui::panel::my::faq_link%]", + "add_addon_repository_title": "Missing add-on repository", + "add_addon_repository_description": "The addon ''{addon}'' is a part of the add-on repository ''{repository}'', this repository is missing on your system, do you want to add that now?", "error": "[%key:ui::panel::my::error%]", "error_addon_not_found": "Add-on not found", + "error_repository_not_found": "The required repository for this Add-on was not found", "error_addon_not_started": "The requested add-on is not running. Please start it first", "error_addon_not_installed": "The requested add-on is not installed. Please install it first", "error_addon_no_ingress": "The requested add-on does not support ingress" diff --git a/test/common/datetime/duration.ts b/test/common/datetime/duration.ts new file mode 100644 index 0000000000..3e3a037451 --- /dev/null +++ b/test/common/datetime/duration.ts @@ -0,0 +1,34 @@ +import { assert } from "chai"; + +import { formatDuration } from "../../../src/common/datetime/duration"; + +describe("formatDuration", () => { + it("works", () => { + assert.strictEqual(formatDuration("0", "s"), "0"); + assert.strictEqual(formatDuration("65", "s"), "1:05"); + assert.strictEqual(formatDuration("3665", "s"), "1:01:05"); + assert.strictEqual(formatDuration("39665", "s"), "11:01:05"); + assert.strictEqual(formatDuration("932093", "s"), "258:54:53"); + + assert.strictEqual(formatDuration("0", "min"), "0"); + assert.strictEqual(formatDuration("65", "min"), "1:05:00"); + assert.strictEqual(formatDuration("3665", "min"), "61:05:00"); + assert.strictEqual(formatDuration("39665", "min"), "661:05:00"); + assert.strictEqual(formatDuration("932093", "min"), "15534:53:00"); + assert.strictEqual(formatDuration("12.4", "min"), "12:24"); + + assert.strictEqual(formatDuration("0", "h"), "0"); + assert.strictEqual(formatDuration("65", "h"), "65:00:00"); + assert.strictEqual(formatDuration("3665", "h"), "3665:00:00"); + assert.strictEqual(formatDuration("39665", "h"), "39665:00:00"); + assert.strictEqual(formatDuration("932093", "h"), "932093:00:00"); + assert.strictEqual(formatDuration("24.3", "h"), "24:18:00"); + assert.strictEqual(formatDuration("24.32423", "h"), "24:19:27"); + + assert.strictEqual(formatDuration("0", "d"), "0"); + assert.strictEqual(formatDuration("65", "d"), "1560:00:00"); + assert.strictEqual(formatDuration("3665", "d"), "87960:00:00"); + assert.strictEqual(formatDuration("39665", "d"), "951960:00:00"); + assert.strictEqual(formatDuration("932093", "d"), "22370232:00:00"); + }); +}); diff --git a/test/common/datetime/seconds_to_duration_test.ts b/test/common/datetime/seconds_to_duration_test.ts index 33f7a9a9a5..3b361a8635 100644 --- a/test/common/datetime/seconds_to_duration_test.ts +++ b/test/common/datetime/seconds_to_duration_test.ts @@ -8,5 +8,6 @@ describe("secondsToDuration", () => { assert.strictEqual(secondsToDuration(65), "1:05"); assert.strictEqual(secondsToDuration(3665), "1:01:05"); assert.strictEqual(secondsToDuration(39665), "11:01:05"); + assert.strictEqual(secondsToDuration(932093), "258:54:53"); }); }); diff --git a/test/common/string/sequence_matching.test.ts b/test/common/string/sequence_matching.test.ts index f631a23285..8f8f63bada 100644 --- a/test/common/string/sequence_matching.test.ts +++ b/test/common/string/sequence_matching.test.ts @@ -1,8 +1,7 @@ -import { assert } from "chai"; +import { assert, expect } from "chai"; import { - fuzzyFilterSort, - fuzzySequentialMatch, + fuzzySortFilterSort, ScorableTextItem, } from "../../../src/common/string/filter/sequence-matching"; @@ -11,45 +10,34 @@ describe("fuzzySequentialMatch", () => { strings: ["automation.ticker", "Stocks"], }; - const createExpectation: ( - pattern, - expected - ) => { - pattern: string; - expected: string | number | undefined; - } = (pattern, expected) => ({ - pattern, - expected, - }); - const shouldMatchEntity = [ - createExpectation("automation.ticker", 131), - createExpectation("automation.ticke", 121), - createExpectation("automation.", 82), - createExpectation("au", 10), - createExpectation("automationticker", 85), - createExpectation("tion.tick", 8), - createExpectation("ticker", -4), - createExpectation("automation.r", 73), - createExpectation("tick", -8), - createExpectation("aumatick", 9), - createExpectation("aion.tck", 4), - createExpectation("ioticker", -4), - createExpectation("atmto.ikr", -34), - createExpectation("uoaintce", -39), - createExpectation("au.tce", -3), - createExpectation("tomaontkr", -19), - createExpectation("s", 1), - createExpectation("stocks", 42), - createExpectation("sks", -5), + "", + " ", + "automation.ticker", + "stocks", + "automation.ticke", + "automation. ticke", + "automation.", + "automationticker", + "automation.r", + "aumatick", + "tion.tick", + "aion.tck", + "s", + "au.tce", + "au", + "ticker", + "tick", + "ioticker", + "sks", + "tomaontkr", + "atmto.ikr", + "uoaintce", ]; const shouldNotMatchEntity = [ - "", - " ", "abcdefghijklmnopqrstuvwxyz", "automation.tickerz", - "automation. ticke", "1", "noitamotua", "autostocks", @@ -57,23 +45,23 @@ describe("fuzzySequentialMatch", () => { ]; describe(`Entity '${item.strings[0]}'`, () => { - for (const expectation of shouldMatchEntity) { - it(`matches '${expectation.pattern}' with return of '${expectation.expected}'`, () => { - const res = fuzzySequentialMatch(expectation.pattern, item); - assert.equal(res, expectation.expected); + for (const filter of shouldMatchEntity) { + it(`Should matches ${filter}`, () => { + const res = fuzzySortFilterSort(filter, [item]); + assert.lengthOf(res, 1); }); } for (const badFilter of shouldNotMatchEntity) { it(`fails to match with '${badFilter}'`, () => { - const res = fuzzySequentialMatch(badFilter, item); - assert.equal(res, undefined); + const res = fuzzySortFilterSort(badFilter, [item]); + assert.lengthOf(res, 0); }); } }); }); -describe("fuzzyFilterSort", () => { +describe("fuzzyFilterSort original tests", () => { const filter = "ticker"; const automationTicker = { strings: ["automation.ticker", "Stocks"], @@ -105,14 +93,137 @@ describe("fuzzyFilterSort", () => { it(`filters and sorts correctly`, () => { const expectedItemsAfterFilter = [ - { ...ticker, score: 44 }, - { ...sensorTicker, score: 1 }, - { ...automationTicker, score: -4 }, - { ...timerCheckRouter, score: -8 }, + { ...ticker, score: 0 }, + { ...sensorTicker, score: -14 }, + { ...automationTicker, score: -22 }, + { ...timerCheckRouter, score: -32012 }, ]; - const res = fuzzyFilterSort(filter, itemsBeforeFilter); + const res = fuzzySortFilterSort(filter, itemsBeforeFilter); assert.deepEqual(res, expectedItemsAfterFilter); }); }); + +describe("Fuzzy filter new tests", () => { + const testEntities = [ + { + id: "binary_sensor.garage_door_opened", + name: "Garage Door Opened (Sensor, Binary)", + }, + { + id: "sensor.garage_door_status", + name: "Garage Door Opened (Sensor)", + }, + { + id: "sensor.temperature_living_room", + name: "[Living room] temperature", + }, + { + id: "sensor.temperature_parents_bedroom", + name: "[Parents bedroom] temperature", + }, + { + id: "sensor.temperature_children_bedroom", + name: "[Children bedroom] temperature", + }, + ]; + + function testEntitySearch( + searchInput: string | null, + expectedResults: string[] + ) { + const sortableEntities = testEntities.map((entity) => ({ + strings: [entity.id, entity.name], + entity: entity, + })); + const sortedEntities = fuzzySortFilterSort( + searchInput || "", + sortableEntities + ); + // console.log(sortedEntities); + expect(sortedEntities.map((it) => it.entity.id)).to.have.ordered.members( + expectedResults + ); + } + + it(`test empty or null query`, () => { + testEntitySearch( + "", + testEntities.map((it) => it.id) + ); + testEntitySearch( + null, + testEntities.map((it) => it.id) + ); + }); + + it(`test single word search`, () => { + testEntitySearch("bedroom", [ + "sensor.temperature_parents_bedroom", + "sensor.temperature_children_bedroom", + ]); + }); + + it(`test no result`, () => { + testEntitySearch("does not exist", []); + testEntitySearch("betroom", []); + }); + + it(`test single word search with typo`, () => { + testEntitySearch("bedorom", [ + "sensor.temperature_parents_bedroom", + "sensor.temperature_children_bedroom", + ]); + }); + + it(`test multi word search`, () => { + testEntitySearch("bedroom children", [ + "sensor.temperature_children_bedroom", + ]); + }); + + it(`test partial word search`, () => { + testEntitySearch("room", [ + "sensor.temperature_living_room", + "sensor.temperature_parents_bedroom", + "sensor.temperature_children_bedroom", + ]); + }); + + it(`test mixed cased word search`, () => { + testEntitySearch("garage binary", ["binary_sensor.garage_door_opened"]); + }); + + it(`test mixed id and name search`, () => { + testEntitySearch("status opened", ["sensor.garage_door_status"]); + }); + + it(`test special chars in query`, () => { + testEntitySearch("sensor.temperature", [ + "sensor.temperature_living_room", + "sensor.temperature_parents_bedroom", + "sensor.temperature_children_bedroom", + ]); + + testEntitySearch("sensor.temperature parents", [ + "sensor.temperature_parents_bedroom", + ]); + testEntitySearch("parents_Bedroom", ["sensor.temperature_parents_bedroom"]); + }); + + it(`test search in name`, () => { + testEntitySearch("Binary)", ["binary_sensor.garage_door_opened"]); + + testEntitySearch("Binary)NotExists", []); + }); + + it(`test regex special chars`, () => { + // Should return an empty result, but no error + testEntitySearch("\\{}()*+?.,[])", []); + + testEntitySearch("[Children bedroom]", [ + "sensor.temperature_children_bedroom", + ]); + }); +}); diff --git a/yarn.lock b/yarn.lock index a055a2b6f8..1275415a57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8433,6 +8433,13 @@ fsevents@^1.2.7: languageName: node linkType: hard +"fuzzysort@npm:^1.2.1": + version: 1.2.1 + resolution: "fuzzysort@npm:1.2.1" + checksum: 74dad902a0aef6c3237d5ae5330aacca23d408f0e07125fcc39b57561b4c29da512fbf3826c3f3918da89f132f5b393cf5d56b3217282ecfb80a90124bdf03d1 + languageName: node + linkType: hard + "gauge@npm:~2.7.3": version: 2.7.4 resolution: "gauge@npm:2.7.4" @@ -9119,6 +9126,7 @@ fsevents@^1.2.7: fancy-log: ^1.3.3 fs-extra: ^7.0.1 fuse.js: ^6.0.0 + fuzzysort: ^1.2.1 glob: ^7.2.0 google-timezones-json: ^1.0.2 gulp: ^4.0.2 @@ -9128,7 +9136,7 @@ fsevents@^1.2.7: gulp-rename: ^2.0.0 gulp-zopfli-green: ^3.0.1 hls.js: ^1.1.5 - home-assistant-js-websocket: ^7.0.1 + home-assistant-js-websocket: ^7.0.3 html-minifier: ^4.0.0 husky: ^1.3.1 idb-keyval: ^5.1.3 @@ -9198,10 +9206,10 @@ fsevents@^1.2.7: languageName: unknown linkType: soft -"home-assistant-js-websocket@npm:^7.0.1": - version: 7.0.1 - resolution: "home-assistant-js-websocket@npm:7.0.1" - checksum: c9a87f11222571226adff43f022008d35df1f78799efae43e9a36f768eef10d21aed99886c905086c42c24d85d47c78e328c1be9593c117b397a18ee86b2fe64 +"home-assistant-js-websocket@npm:^7.0.3": + version: 7.0.3 + resolution: "home-assistant-js-websocket@npm:7.0.3" + checksum: f2647fab4599069a6422b53661de0c8c5177408e297e89f35b442c5d8e65d31d7f607e6e6a813f4ec8af9d581b45a20b88b44bfedbe25b6c3f01fcc38d0e396e languageName: node linkType: hard