diff --git a/src/panels/config/apps/app-view/components/supervisor-app-metric.ts b/src/panels/config/apps/app-view/components/supervisor-app-metric.ts
new file mode 100644
index 0000000000..e081321a7a
--- /dev/null
+++ b/src/panels/config/apps/app-view/components/supervisor-app-metric.ts
@@ -0,0 +1,75 @@
+import type { TemplateResult } from "lit";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
+import "../../../../../components/ha-bar";
+import "../../../../../components/ha-settings-row";
+import { roundWithOneDecimal } from "../../../../../util/calculate";
+
+@customElement("supervisor-app-metric")
+class SupervisorAppMetric extends LitElement {
+ @property({ type: Number }) public value!: number;
+
+ @property({ type: String }) public description!: string;
+
+ @property({ type: String }) public tooltip?: string;
+
+ protected render(): TemplateResult {
+ const roundedValue = roundWithOneDecimal(this.value);
+ return html`
+ ${this.description}
+
+ ${roundedValue} %
+ 50,
+ "target-critical": roundedValue > 85,
+ })}
+ .value=${this.value}
+ >
+
+ `;
+ }
+
+ static styles = css`
+ ha-settings-row {
+ padding: 0;
+ height: 54px;
+ width: 100%;
+ }
+ ha-settings-row > div[slot="description"] {
+ white-space: normal;
+ color: var(--secondary-text-color);
+ display: flex;
+ justify-content: space-between;
+ }
+ ha-bar {
+ --ha-bar-primary-color: var(--hassio-bar-ok-color, var(--success-color));
+ }
+ .target-warning {
+ --ha-bar-primary-color: var(
+ --hassio-bar-warning-color,
+ var(--warning-color)
+ );
+ }
+ .target-critical {
+ --ha-bar-primary-color: var(
+ --hassio-bar-critical-color,
+ var(--error-color)
+ );
+ }
+ .value {
+ width: 48px;
+ padding-right: 4px;
+ padding-inline-start: initial;
+ padding-inline-end: 4px;
+ flex-shrink: 0;
+ }
+ `;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-app-metric": SupervisorAppMetric;
+ }
+}
diff --git a/src/panels/config/apps/app-view/components/supervisor-app-update-available-card.ts b/src/panels/config/apps/app-view/components/supervisor-app-update-available-card.ts
new file mode 100644
index 0000000000..7141341d1a
--- /dev/null
+++ b/src/panels/config/apps/app-view/components/supervisor-app-update-available-card.ts
@@ -0,0 +1,295 @@
+import {
+ css,
+ type CSSResultGroup,
+ html,
+ LitElement,
+ nothing,
+ type PropertyValues,
+} from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { atLeastVersion } from "../../../../../common/config/version";
+import { fireEvent } from "../../../../../common/dom/fire_event";
+import "../../../../../components/buttons/ha-progress-button";
+import "../../../../../components/ha-alert";
+import "../../../../../components/ha-button";
+import "../../../../../components/ha-card";
+import "../../../../../components/ha-spinner";
+import "../../../../../components/ha-faded";
+import "../../../../../components/ha-markdown";
+import "../../../../../components/ha-md-list";
+import "../../../../../components/ha-md-list-item";
+import "../../../../../components/ha-switch";
+import type { HaSwitch } from "../../../../../components/ha-switch";
+import type { HassioAddonDetails } from "../../../../../data/hassio/addon";
+import {
+ fetchHassioAddonChangelog,
+ updateHassioAddon,
+} from "../../../../../data/hassio/addon";
+import {
+ extractApiErrorMessage,
+ ignoreSupervisorError,
+} from "../../../../../data/hassio/common";
+import { haStyle } from "../../../../../resources/styles";
+import type { HomeAssistant } from "../../../../../types";
+import { extractChangelog } from "../util/supervisor-app";
+
+declare global {
+ interface HASSDomEvents {
+ "update-complete": undefined;
+ }
+}
+
+@customElement("supervisor-app-update-available-card")
+class SupervisorAppUpdateAvailableCard extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ type: Boolean }) public narrow = false;
+
+ @property({ attribute: false }) public addon!: HassioAddonDetails;
+
+ @state() private _changelogContent?: string;
+
+ @state() private _updating = false;
+
+ @state() private _error?: string;
+
+ protected render() {
+ if (!this.addon) {
+ return nothing;
+ }
+
+ const createBackupTexts = this._computeCreateBackupTexts();
+
+ return html`
+
+
+ ${this._error
+ ? html`
${this._error}`
+ : ""}
+ ${this.addon.version === this.addon.version_latest
+ ? html`
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.update_available.no_update",
+ {
+ name: this.addon.name,
+ }
+ )}
+
`
+ : !this._updating
+ ? html`
+ ${this._changelogContent
+ ? html`
+
+
+
+
+ `
+ : nothing}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.update_available.description",
+ {
+ name: this.addon.name,
+ version: this.addon.version,
+ newest_version: this.addon.version_latest,
+ }
+ )}
+
+
+ ${createBackupTexts
+ ? html`
+
+
+
+
+ ${createBackupTexts.title}
+
+
+ ${createBackupTexts.description
+ ? html`
+
+ ${createBackupTexts.description}
+
+ `
+ : nothing}
+
+
+
+ `
+ : nothing}
+ `
+ : html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.update_available.updating",
+ {
+ name: this.addon.name,
+ version: this.addon.version_latest,
+ }
+ )}
+
`}
+
+ ${this.addon.version !== this.addon.version_latest && !this._updating
+ ? html`
+
+
+
+ ${this.hass.localize("ui.common.update")}
+
+
+ `
+ : nothing}
+
+ `;
+ }
+
+ protected firstUpdated(changedProps: PropertyValues) {
+ super.firstUpdated(changedProps);
+ this._loadAddonData();
+ }
+
+ private _computeCreateBackupTexts():
+ | { title: string; description?: string }
+ | undefined {
+ if (atLeastVersion(this.hass.config.version, 2025, 2, 0)) {
+ const version = this.addon.version;
+ return {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.update_available.create_backup.addon"
+ ),
+ description: this.hass.localize(
+ "ui.panel.config.apps.dashboard.update_available.create_backup.addon_description",
+ { version: version }
+ ),
+ };
+ }
+
+ return {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.update_available.create_backup.generic"
+ ),
+ };
+ }
+
+ get _shouldCreateBackup(): boolean {
+ const createBackupSwitch = this.shadowRoot?.getElementById(
+ "create-backup"
+ ) as HaSwitch;
+ if (createBackupSwitch) {
+ return createBackupSwitch.checked;
+ }
+ return true;
+ }
+
+ private async _loadAddonData() {
+ if (this.addon.changelog) {
+ try {
+ const content = await fetchHassioAddonChangelog(
+ this.hass,
+ this.addon.slug
+ );
+ this._changelogContent = extractChangelog(
+ this.addon as HassioAddonDetails,
+ content
+ );
+ } catch (err) {
+ this._error = extractApiErrorMessage(err);
+ }
+ }
+ }
+
+ private async _update() {
+ this._error = undefined;
+ this._updating = true;
+
+ try {
+ await updateHassioAddon(
+ this.hass,
+ this.addon.slug,
+ this._shouldCreateBackup
+ );
+ } catch (err: any) {
+ if (this.hass.connection.connected && !ignoreSupervisorError(err)) {
+ this._error = extractApiErrorMessage(err);
+ this._updating = false;
+ return;
+ }
+ }
+ fireEvent(this, "update-complete");
+ this._updating = false;
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ css`
+ :host {
+ display: block;
+ }
+ ha-card {
+ margin: auto;
+ }
+ a {
+ text-decoration: none;
+ color: var(--primary-text-color);
+ }
+ .card-actions {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ ha-spinner {
+ display: block;
+ margin: 32px;
+ text-align: center;
+ }
+
+ .progress-text {
+ text-align: center;
+ }
+
+ ha-markdown {
+ padding-bottom: var(--ha-space-2);
+ }
+
+ hr {
+ border-color: var(--divider-color);
+ border-bottom: none;
+ margin: var(--ha-space-4) 0 0 0;
+ }
+
+ ha-md-list {
+ padding: 0;
+ margin-bottom: calc(-1 * var(--ha-space-4));
+ }
+
+ ha-md-list-item {
+ --md-list-item-leading-space: 0;
+ --md-list-item-trailing-space: 0;
+ --md-item-overflow: visible;
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-app-update-available-card": SupervisorAppUpdateAvailableCard;
+ }
+}
diff --git a/src/panels/config/apps/app-view/config/supervisor-app-audio.ts b/src/panels/config/apps/app-view/config/supervisor-app-audio.ts
new file mode 100644
index 0000000000..8c39d11d4e
--- /dev/null
+++ b/src/panels/config/apps/app-view/config/supervisor-app-audio.ts
@@ -0,0 +1,216 @@
+import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
+import { css, html, LitElement, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { stopPropagation } from "../../../../../common/dom/stop_propagation";
+import "../../../../../components/buttons/ha-progress-button";
+import "../../../../../components/ha-alert";
+import "../../../../../components/ha-card";
+import "../../../../../components/ha-list-item";
+import "../../../../../components/ha-select";
+import type {
+ HassioAddonDetails,
+ HassioAddonSetOptionParams,
+} from "../../../../../data/hassio/addon";
+import { setHassioAddonOption } from "../../../../../data/hassio/addon";
+import type { HassioHardwareAudioDevice } from "../../../../../data/hassio/hardware";
+import { fetchHassioHardwareAudio } from "../../../../../data/hassio/hardware";
+import { haStyle } from "../../../../../resources/styles";
+import type { HomeAssistant } from "../../../../../types";
+import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
+import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
+
+@customElement("supervisor-app-audio")
+class SupervisorAppAudio extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public addon!: HassioAddonDetails;
+
+ @property({ type: Boolean }) public disabled = false;
+
+ @state() private _error?: string;
+
+ @state() private _inputDevices?: HassioHardwareAudioDevice[];
+
+ @state() private _outputDevices?: HassioHardwareAudioDevice[];
+
+ @state() private _selectedInput!: null | string;
+
+ @state() private _selectedOutput!: null | string;
+
+ protected render(): TemplateResult {
+ return html`
+
+
+ ${this._error
+ ? html`${this._error}`
+ : nothing}
+ ${this._inputDevices &&
+ html`
+ ${this._inputDevices.map(
+ (item) => html`
+
+ ${item.name}
+
+ `
+ )}
+ `}
+ ${this._outputDevices &&
+ html`
+ ${this._outputDevices.map(
+ (item) => html`
+ ${item.name}
+ `
+ )}
+ `}
+
+
+
+ ${this.hass.localize("ui.common.save")}
+
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ supervisorAppsStyle,
+ css`
+ :host,
+ ha-card {
+ display: block;
+ }
+ .card-actions {
+ text-align: right;
+ }
+ ha-select {
+ width: 100%;
+ }
+ ha-select:last-child {
+ margin-top: var(--ha-space-2);
+ }
+ `,
+ ];
+ }
+
+ protected willUpdate(changedProperties: PropertyValues): void {
+ super.willUpdate(changedProperties);
+ if (changedProperties.has("addon")) {
+ this._addonChanged();
+ }
+ }
+
+ private _setInputDevice(ev): void {
+ const device = ev.target.value;
+ this._selectedInput = device;
+ }
+
+ private _setOutputDevice(ev): void {
+ const device = ev.target.value;
+ this._selectedOutput = device;
+ }
+
+ private async _addonChanged(): Promise {
+ this._selectedInput =
+ this.addon.audio_input === null ? "default" : this.addon.audio_input;
+ this._selectedOutput =
+ this.addon.audio_output === null ? "default" : this.addon.audio_output;
+ if (this._outputDevices) {
+ return;
+ }
+
+ const noDevice: HassioHardwareAudioDevice = {
+ device: "default",
+ name: this.hass.localize(
+ "ui.panel.config.apps.configuration.audio.default"
+ ),
+ };
+
+ try {
+ const { audio } = await fetchHassioHardwareAudio(this.hass);
+ const input = Object.keys(audio.input).map((key) => ({
+ device: key,
+ name: audio.input[key],
+ }));
+ const output = Object.keys(audio.output).map((key) => ({
+ device: key,
+ name: audio.output[key],
+ }));
+
+ this._inputDevices = [noDevice, ...input];
+ this._outputDevices = [noDevice, ...output];
+ } catch {
+ this._error = this.hass.localize(
+ "ui.panel.config.apps.configuration.audio.failed_to_load_hardware"
+ );
+ this._inputDevices = [noDevice];
+ this._outputDevices = [noDevice];
+ }
+ }
+
+ private async _saveSettings(ev: CustomEvent): Promise {
+ if (this.disabled) {
+ return;
+ }
+
+ const button = ev.currentTarget as any;
+ button.progress = true;
+
+ this._error = undefined;
+ const data: HassioAddonSetOptionParams = {
+ audio_input:
+ this._selectedInput === "default" ? null : this._selectedInput,
+ audio_output:
+ this._selectedOutput === "default" ? null : this._selectedOutput,
+ };
+ try {
+ await setHassioAddonOption(this.hass, this.addon.slug, data);
+ if (this.addon?.state === "started") {
+ await suggestSupervisorAppRestart(this, this.hass, this.addon);
+ }
+ } catch {
+ this._error = this.hass.localize(
+ "ui.panel.config.apps.configuration.audio.failed_to_save"
+ );
+ }
+
+ button.progress = false;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-app-audio": SupervisorAppAudio;
+ }
+}
diff --git a/src/panels/config/apps/app-view/config/supervisor-app-config-tab.ts b/src/panels/config/apps/app-view/config/supervisor-app-config-tab.ts
new file mode 100644
index 0000000000..b68976930e
--- /dev/null
+++ b/src/panels/config/apps/app-view/config/supervisor-app-config-tab.ts
@@ -0,0 +1,109 @@
+import type { CSSResultGroup, TemplateResult } from "lit";
+import { css, html, LitElement, nothing } from "lit";
+import { customElement, property } from "lit/decorators";
+import "../../../../../components/ha-spinner";
+import type { HassioAddonDetails } from "../../../../../data/hassio/addon";
+import { haStyle } from "../../../../../resources/styles";
+import type { HomeAssistant } from "../../../../../types";
+import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
+import "../info/supervisor-app-system-managed";
+import "./supervisor-app-audio";
+import "./supervisor-app-config";
+import "./supervisor-app-network";
+
+@customElement("supervisor-app-config-tab")
+class SupervisorAppConfigDashboard extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public addon?: HassioAddonDetails;
+
+ @property({ type: Boolean }) public narrow = false;
+
+ @property({ type: Boolean, attribute: "control-enabled" })
+ public controlEnabled = false;
+
+ protected render(): TemplateResult {
+ if (!this.addon) {
+ return html``;
+ }
+ const hasConfiguration =
+ (this.addon.options && Object.keys(this.addon.options).length) ||
+ (this.addon.schema && Object.keys(this.addon.schema).length);
+
+ return html`
+
+ ${this.addon.system_managed &&
+ (hasConfiguration || this.addon.network || this.addon.audio)
+ ? html`
+
+ `
+ : nothing}
+ ${hasConfiguration || this.addon.network || this.addon.audio
+ ? html`
+ ${hasConfiguration
+ ? html`
+
+ `
+ : nothing}
+ ${this.addon.network
+ ? html`
+
+ `
+ : nothing}
+ ${this.addon.audio
+ ? html`
+
+ `
+ : nothing}
+ `
+ : this.hass.localize(
+ "ui.panel.config.apps.configuration.no_configuration"
+ )}
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ supervisorAppsStyle,
+ css`
+ .content {
+ margin: auto;
+ padding: var(--ha-space-2);
+ max-width: 1024px;
+ }
+ supervisor-app-network,
+ supervisor-app-audio,
+ supervisor-app-config {
+ margin-bottom: var(--ha-space-6);
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-app-config-tab": SupervisorAppConfigDashboard;
+ }
+}
diff --git a/src/panels/config/apps/app-view/config/supervisor-app-config.ts b/src/panels/config/apps/app-view/config/supervisor-app-config.ts
new file mode 100644
index 0000000000..21f9c57e9b
--- /dev/null
+++ b/src/panels/config/apps/app-view/config/supervisor-app-config.ts
@@ -0,0 +1,518 @@
+import type { ActionDetail } from "@material/mwc-list";
+import { mdiDotsVertical } from "@mdi/js";
+import { DEFAULT_SCHEMA, Type } from "js-yaml";
+import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
+import { css, html, LitElement } from "lit";
+import { customElement, property, query, state } from "lit/decorators";
+import memoizeOne from "memoize-one";
+import { fireEvent } from "../../../../../common/dom/fire_event";
+import "../../../../../components/buttons/ha-progress-button";
+import "../../../../../components/ha-alert";
+import "../../../../../components/ha-button-menu";
+import "../../../../../components/ha-card";
+import "../../../../../components/ha-form/ha-form";
+import type {
+ HaFormSchema,
+ HaFormDataContainer,
+} from "../../../../../components/ha-form/types";
+import "../../../../../components/ha-formfield";
+import "../../../../../components/ha-icon-button";
+import "../../../../../components/ha-list-item";
+import "../../../../../components/ha-switch";
+import "../../../../../components/ha-yaml-editor";
+import type { HaYamlEditor } from "../../../../../components/ha-yaml-editor";
+import type {
+ HassioAddonDetails,
+ HassioAddonSetOptionParams,
+} from "../../../../../data/hassio/addon";
+import {
+ setHassioAddonOption,
+ validateHassioAddonOption,
+} from "../../../../../data/hassio/addon";
+import { extractApiErrorMessage } from "../../../../../data/hassio/common";
+import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
+import { haStyle } from "../../../../../resources/styles";
+import type { HomeAssistant } from "../../../../../types";
+import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
+import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
+import type { ObjectSelector, Selector } from "../../../../../data/selector";
+
+const SUPPORTED_UI_TYPES = [
+ "string",
+ "select",
+ "boolean",
+ "integer",
+ "float",
+ "schema",
+];
+
+const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
+ new Type("!secret", {
+ kind: "scalar",
+ construct: (data) => `!secret ${data}`,
+ }),
+]);
+
+const MASKED_FIELDS = ["password", "secret", "token"];
+
+@customElement("supervisor-app-config")
+class SupervisorAppConfig extends LitElement {
+ @property({ attribute: false }) public addon!: HassioAddonDetails;
+
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ type: Boolean }) public disabled = false;
+
+ @state() private _configHasChanged = false;
+
+ @state() private _valid = true;
+
+ @state() private _canShowSchema = false;
+
+ @state() private _showOptional = false;
+
+ @state() private _error?: string;
+
+ @state() private _options?: Record;
+
+ @state() private _yamlMode = false;
+
+ @query("ha-yaml-editor") private _editor?: HaYamlEditor;
+
+ private _getTranslationEntry(
+ language: string,
+ entry: HaFormSchema,
+ options?: { path?: string[] }
+ ) {
+ let parent = this.addon.translations[language]?.configuration;
+ if (!parent) return undefined;
+ if (options?.path) {
+ for (const key of options.path) {
+ parent = parent[key]?.fields;
+ if (!parent) return undefined;
+ }
+ }
+ return parent[entry.name];
+ }
+
+ public computeLabel = (
+ entry: HaFormSchema,
+ _data: HaFormDataContainer,
+ options?: { path?: string[] }
+ ): string =>
+ this._getTranslationEntry(this.hass.language, entry, options)?.name ||
+ this._getTranslationEntry("en", entry, options)?.name ||
+ entry.name;
+
+ public computeHelper = (
+ entry: HaFormSchema,
+ options?: { path?: string[] }
+ ): string =>
+ this._getTranslationEntry(this.hass.language, entry, options)
+ ?.description ||
+ this._getTranslationEntry("en", entry, options)?.description ||
+ "";
+
+ private _convertSchema = memoizeOne(
+ // Convert supervisor schema to selectors
+ (schema: readonly HaFormSchema[]): HaFormSchema[] =>
+ this._convertSchemaElements(schema)
+ );
+
+ private _convertSchemaElements(
+ schema: readonly HaFormSchema[]
+ ): HaFormSchema[] {
+ return schema.map((entry) => this._convertSchemaElement(entry));
+ }
+
+ private _convertSchemaElement(entry: any): HaFormSchema {
+ if (entry.type === "schema" && !entry.multiple) {
+ return {
+ name: entry.name,
+ type: "expandable",
+ required: entry.required,
+ schema: this._convertSchemaElements(entry.schema),
+ };
+ }
+ const selector = this._convertSchemaElementToSelector(entry, false);
+ if (selector) {
+ return {
+ name: entry.name,
+ required: entry.required,
+ selector,
+ };
+ }
+ return entry;
+ }
+
+ private _convertSchemaElementToSelector(
+ entry: any,
+ force: boolean
+ ): Selector | null {
+ if (entry.type === "select") {
+ return { select: { options: entry.options } };
+ }
+ if (entry.type === "string") {
+ return entry.multiple
+ ? { select: { options: [], multiple: true, custom_value: true } }
+ : {
+ text: {
+ type: entry.format
+ ? entry.format
+ : MASKED_FIELDS.includes(entry.name)
+ ? "password"
+ : "text",
+ },
+ };
+ }
+ if (entry.type === "boolean") {
+ return { boolean: {} };
+ }
+ if (entry.type === "schema") {
+ const fields: NonNullable["fields"] = {};
+ for (const child_entry of entry.schema) {
+ fields[child_entry.name] = {
+ required: child_entry.required,
+ selector: this._convertSchemaElementToSelector(child_entry, true)!,
+ };
+ }
+ return {
+ object: {
+ multiple: entry.multiple,
+ fields,
+ },
+ };
+ }
+ if (entry.type === "float" || entry.type === "integer") {
+ return {
+ number: {
+ mode: "box",
+ step: entry.type === "float" ? "any" : undefined,
+ },
+ };
+ }
+ if (force) {
+ return { object: {} };
+ }
+ return null;
+ }
+
+ private _filteredSchema = memoizeOne(
+ (options: Record, schema: HaFormSchema[]) =>
+ schema.filter((entry) => entry.name in options || entry.required)
+ );
+
+ protected render(): TemplateResult {
+ const showForm =
+ !this._yamlMode && this._canShowSchema && this.addon.schema;
+ const hasHiddenOptions =
+ showForm &&
+ JSON.stringify(this.addon.schema) !==
+ JSON.stringify(
+ this._filteredSchema(this.addon.options, this.addon.schema!)
+ );
+ return html`
+ ${this.addon.name}
+
+
+
+
+ ${showForm
+ ? html``
+ : html``}
+ ${this._error
+ ? html`${this._error}`
+ : ""}
+ ${!this._yamlMode ||
+ (this._canShowSchema && this.addon.schema) ||
+ this._valid
+ ? ""
+ : html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.configuration.options.invalid_yaml"
+ )}
+
+ `}
+
+ ${hasHiddenOptions
+ ? html`
+
+
+ `
+ : ""}
+
+
+ ${this.hass.localize("ui.common.save")}
+
+
+
+ `;
+ }
+
+ protected firstUpdated(changedProps) {
+ super.firstUpdated(changedProps);
+ this._canShowSchema =
+ this.addon.schema !== null &&
+ !this.addon.schema!.find(
+ (entry) =>
+ // @ts-ignore
+ !SUPPORTED_UI_TYPES.includes(entry.type)
+ );
+ this._yamlMode = !this._canShowSchema;
+ }
+
+ protected updated(changedProperties: PropertyValues): void {
+ if (changedProperties.has("addon")) {
+ this._options = { ...this.addon.options };
+ }
+ super.updated(changedProperties);
+ if (
+ changedProperties.has("_yamlMode") ||
+ changedProperties.has("_options")
+ ) {
+ if (this._yamlMode) {
+ const editor = this._editor;
+ if (editor) {
+ editor.setValue(this._options!);
+ }
+ }
+ }
+ }
+
+ private _handleAction(ev: CustomEvent) {
+ switch (ev.detail.index) {
+ case 0:
+ this._yamlMode = !this._yamlMode;
+ break;
+ case 1:
+ this._resetTapped(ev);
+ break;
+ }
+ }
+
+ private _toggleOptional() {
+ this._showOptional = !this._showOptional;
+ }
+
+ private _configChanged(ev): void {
+ if (this.addon.schema && this._canShowSchema && !this._yamlMode) {
+ this._valid = true;
+ this._configHasChanged = true;
+ this._options = ev.detail.value;
+ } else {
+ this._configHasChanged = true;
+ this._valid = ev.detail.isValid;
+ }
+ }
+
+ private async _resetTapped(ev: CustomEvent): Promise {
+ const button = ev.currentTarget as any;
+ button.progress = true;
+
+ const confirmed = await showConfirmationDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.configuration.confirm.reset_options.title"
+ ),
+ text: this.hass.localize(
+ "ui.panel.config.apps.configuration.confirm.reset_options.text"
+ ),
+ confirmText: this.hass.localize("ui.common.reset_options"),
+ dismissText: this.hass.localize("ui.common.cancel"),
+ destructive: true,
+ });
+
+ if (!confirmed) {
+ button.progress = false;
+ return;
+ }
+
+ this._error = undefined;
+ const data: HassioAddonSetOptionParams = {
+ options: null,
+ };
+ try {
+ await setHassioAddonOption(this.hass, this.addon.slug, data);
+ this._configHasChanged = false;
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "options",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ } catch (err: any) {
+ this._error = this.hass.localize(
+ "ui.panel.config.apps.dashboard.failed_to_reset",
+ {
+ error: extractApiErrorMessage(err),
+ }
+ );
+ }
+ button.progress = false;
+ }
+
+ private async _saveTapped(ev: CustomEvent): Promise {
+ if (this.disabled || !this._configHasChanged || !this._valid) {
+ return;
+ }
+
+ const button = ev.currentTarget as any;
+ const options: Record = this._yamlMode
+ ? this._editor?.value
+ : this._options;
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "options",
+ };
+ button.progress = true;
+
+ this._error = undefined;
+
+ try {
+ const validation = await validateHassioAddonOption(
+ this.hass,
+ this.addon.slug,
+ options
+ );
+ if (!validation.valid) {
+ throw Error(validation.message);
+ }
+ await setHassioAddonOption(this.hass, this.addon.slug, {
+ options,
+ });
+
+ this._configHasChanged = false;
+ if (this.addon?.state === "started") {
+ await suggestSupervisorAppRestart(this, this.hass, this.addon);
+ }
+ } catch (err: any) {
+ this._error = this.hass.localize(
+ "ui.panel.config.apps.dashboard.failed_to_save",
+ {
+ error: extractApiErrorMessage(err),
+ }
+ );
+ eventdata.success = false;
+ }
+ button.progress = false;
+ fireEvent(this, "hass-api-called", eventdata);
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ supervisorAppsStyle,
+ css`
+ :host {
+ display: block;
+ }
+ ha-card {
+ display: block;
+ }
+ .card-actions {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ .card-menu {
+ float: right;
+ z-index: 3;
+ --mdc-theme-text-primary-on-background: var(--primary-text-color);
+ }
+ ha-list-item[disabled] {
+ --mdc-theme-text-primary-on-background: var(--disabled-text-color);
+ }
+ .header {
+ display: flex;
+ justify-content: space-between;
+ }
+ .header h2 {
+ color: var(--ha-card-header-color, var(--primary-text-color));
+ font-family: var(--ha-card-header-font-family, inherit);
+ font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
+ letter-spacing: -0.012em;
+ line-height: var(--ha-line-height-expanded);
+ padding: 12px 16px 16px;
+ display: block;
+ margin-block: 0px;
+ font-weight: var(--ha-font-weight-normal);
+ }
+ .card-actions.right {
+ justify-content: flex-end;
+ }
+
+ .show-additional {
+ padding: 16px;
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-app-config": SupervisorAppConfig;
+ }
+}
diff --git a/src/panels/config/apps/app-view/config/supervisor-app-network.ts b/src/panels/config/apps/app-view/config/supervisor-app-network.ts
new file mode 100644
index 0000000000..7b4659d4c5
--- /dev/null
+++ b/src/panels/config/apps/app-view/config/supervisor-app-network.ts
@@ -0,0 +1,266 @@
+import type { CSSResultGroup, PropertyValues } from "lit";
+import { css, html, LitElement, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import memoizeOne from "memoize-one";
+import { fireEvent } from "../../../../../common/dom/fire_event";
+import "../../../../../components/buttons/ha-progress-button";
+import "../../../../../components/ha-alert";
+import "../../../../../components/ha-card";
+import "../../../../../components/ha-formfield";
+import "../../../../../components/ha-form/ha-form";
+import type { HaFormSchema } from "../../../../../components/ha-form/types";
+import type {
+ HassioAddonDetails,
+ HassioAddonSetOptionParams,
+} from "../../../../../data/hassio/addon";
+import { setHassioAddonOption } from "../../../../../data/hassio/addon";
+import { extractApiErrorMessage } from "../../../../../data/hassio/common";
+import { haStyle } from "../../../../../resources/styles";
+import type { HomeAssistant } from "../../../../../types";
+import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
+import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
+
+@customElement("supervisor-app-network")
+class SupervisorAppNetwork extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public addon!: HassioAddonDetails;
+
+ @property({ type: Boolean }) public disabled = false;
+
+ @state() private _showOptional = false;
+
+ @state() private _configHasChanged = false;
+
+ @state() private _error?: string;
+
+ @state() private _config?: Record;
+
+ protected render() {
+ if (!this._config) {
+ return nothing;
+ }
+
+ const hasHiddenOptions = Object.keys(this._config).find(
+ (entry) => this._config![entry] === null
+ );
+
+ return html`
+
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.configuration.network.introduction"
+ )}
+
+ ${this._error
+ ? html`
${this._error}`
+ : nothing}
+
+
+
+ ${hasHiddenOptions
+ ? html`
+
+
+ `
+ : nothing}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.configuration.network.reset_defaults"
+ )}
+
+
+ ${this.hass.localize("ui.common.save")}
+
+
+
+ `;
+ }
+
+ protected willUpdate(changedProperties: PropertyValues): void {
+ super.willUpdate(changedProperties);
+ if (changedProperties.has("addon")) {
+ this._setNetworkConfig();
+ }
+ }
+
+ 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 {
+ this._config = this.addon.network || {};
+ }
+
+ private async _configChanged(ev: CustomEvent): Promise {
+ this._configHasChanged = true;
+ this._config = ev.detail.value;
+ }
+
+ private async _resetTapped(ev: CustomEvent): Promise {
+ if (this.disabled) {
+ return;
+ }
+
+ const button = ev.currentTarget as any;
+ 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 suggestSupervisorAppRestart(this, this.hass, this.addon);
+ }
+ } catch (err: any) {
+ this._error = this.hass.localize(
+ "ui.panel.config.apps.dashboard.failed_to_reset",
+ {
+ error: extractApiErrorMessage(err),
+ }
+ );
+ button.actionError();
+ }
+ }
+
+ private _toggleOptional() {
+ this._showOptional = !this._showOptional;
+ }
+
+ private async _saveTapped(ev: CustomEvent): Promise {
+ if (!this._configHasChanged || this.disabled) {
+ return;
+ }
+
+ const button = ev.currentTarget as any;
+
+ this._error = undefined;
+ const networkconfiguration = {};
+ Object.entries(this._config!).forEach(([key, value]) => {
+ networkconfiguration[key] = value ?? null;
+ });
+
+ const data: HassioAddonSetOptionParams = {
+ network: networkconfiguration,
+ };
+
+ 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 suggestSupervisorAppRestart(this, this.hass, this.addon);
+ }
+ } catch (err: any) {
+ this._error = this.hass.localize(
+ "ui.panel.config.apps.dashboard.failed_to_save",
+ {
+ error: extractApiErrorMessage(err),
+ }
+ );
+ button.actionError();
+ }
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ supervisorAppsStyle,
+ css`
+ :host {
+ display: block;
+ }
+ ha-card {
+ display: block;
+ }
+ .card-actions {
+ display: flex;
+ justify-content: space-between;
+ }
+ .show-optional {
+ padding: 16px;
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-app-network": SupervisorAppNetwork;
+ }
+}
diff --git a/src/panels/config/apps/app-view/dialogs/suggestSupervisorAppRestart.ts b/src/panels/config/apps/app-view/dialogs/suggestSupervisorAppRestart.ts
new file mode 100644
index 0000000000..3bff5788e5
--- /dev/null
+++ b/src/panels/config/apps/app-view/dialogs/suggestSupervisorAppRestart.ts
@@ -0,0 +1,44 @@
+import type { LitElement } from "lit";
+import type { HassioAddonDetails } from "../../../../../data/hassio/addon";
+import { restartHassioAddon } from "../../../../../data/hassio/addon";
+import { extractApiErrorMessage } from "../../../../../data/hassio/common";
+import {
+ showAlertDialog,
+ showConfirmationDialog,
+} from "../../../../../dialogs/generic/show-dialog-box";
+import type { HomeAssistant } from "../../../../../types";
+
+export const suggestSupervisorAppRestart = async (
+ element: LitElement,
+ hass: HomeAssistant,
+ addon: HassioAddonDetails
+): Promise => {
+ const confirmed = await showConfirmationDialog(element, {
+ title: hass.localize(
+ "ui.panel.config.apps.dashboard.restart_dialog.title",
+ {
+ name: addon.name,
+ }
+ ),
+ text: hass.localize("ui.panel.config.apps.dashboard.restart_dialog.text"),
+ confirmText: hass.localize(
+ "ui.panel.config.apps.dashboard.restart_dialog.restart"
+ ),
+ dismissText: hass.localize("ui.common.cancel"),
+ });
+ if (confirmed) {
+ try {
+ await restartHassioAddon(hass, addon.slug);
+ } catch (err: any) {
+ showAlertDialog(element, {
+ title: hass.localize(
+ "ui.panel.config.apps.dashboard.failed_to_restart",
+ {
+ name: addon.name,
+ }
+ ),
+ text: extractApiErrorMessage(err),
+ });
+ }
+ }
+};
diff --git a/src/panels/config/apps/app-view/documentation/supervisor-app-documentation-tab.ts b/src/panels/config/apps/app-view/documentation/supervisor-app-documentation-tab.ts
new file mode 100644
index 0000000000..cb4b401dae
--- /dev/null
+++ b/src/panels/config/apps/app-view/documentation/supervisor-app-documentation-tab.ts
@@ -0,0 +1,94 @@
+import "../../../../../components/ha-card";
+import type { CSSResultGroup, TemplateResult } from "lit";
+import { css, html, LitElement } from "lit";
+import "../../../../../components/ha-alert";
+import "../../../../../components/ha-spinner";
+import "../../../../../components/ha-markdown";
+import { customElement, property, state } from "lit/decorators";
+import type { HassioAddonDetails } from "../../../../../data/hassio/addon";
+import { fetchHassioAddonDocumentation } from "../../../../../data/hassio/addon";
+import { extractApiErrorMessage } from "../../../../../data/hassio/common";
+import "../../../../../layouts/hass-loading-screen";
+import { haStyle } from "../../../../../resources/styles";
+import type { HomeAssistant } from "../../../../../types";
+import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
+
+@customElement("supervisor-app-documentation-tab")
+class SupervisorAppDocumentationDashboard extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public addon?: HassioAddonDetails;
+
+ @state() private _error?: string;
+
+ @state() private _content?: string;
+
+ public async connectedCallback(): Promise {
+ super.connectedCallback();
+ await this._loadData();
+ }
+
+ protected render(): TemplateResult {
+ if (!this.addon) {
+ return html``;
+ }
+ return html`
+
+
+ ${this._error
+ ? html`${this._error}`
+ : ""}
+
+ ${this._content
+ ? html``
+ : html``}
+
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ supervisorAppsStyle,
+ css`
+ ha-card {
+ display: block;
+ }
+ .content {
+ margin: auto;
+ padding: var(--ha-space-2);
+ max-width: 1024px;
+ }
+ ha-markdown {
+ padding: var(--ha-space-4);
+ }
+ `,
+ ];
+ }
+
+ private async _loadData(): Promise {
+ this._error = undefined;
+ try {
+ this._content = await fetchHassioAddonDocumentation(
+ this.hass,
+ this.addon!.slug
+ );
+ } catch (err: any) {
+ this._error = this.hass.localize(
+ "ui.panel.config.apps.documentation.get_documentation",
+ { error: extractApiErrorMessage(err) }
+ );
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-app-documentation-tab": SupervisorAppDocumentationDashboard;
+ }
+}
diff --git a/src/panels/config/apps/app-view/info/supervisor-app-info-tab.ts b/src/panels/config/apps/app-view/info/supervisor-app-info-tab.ts
new file mode 100644
index 0000000000..273083511e
--- /dev/null
+++ b/src/panels/config/apps/app-view/info/supervisor-app-info-tab.ts
@@ -0,0 +1,61 @@
+import type { CSSResultGroup, TemplateResult } from "lit";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators";
+import "../../../../../components/ha-spinner";
+import type { HassioAddonDetails } from "../../../../../data/hassio/addon";
+import { haStyle } from "../../../../../resources/styles";
+import type { HomeAssistant, Route } from "../../../../../types";
+import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
+import "./supervisor-app-info";
+
+@customElement("supervisor-app-info-tab")
+class SupervisorAppInfoDashboard extends LitElement {
+ @property({ type: Boolean }) public narrow = false;
+
+ @property({ attribute: false }) public route!: Route;
+
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public addon?: HassioAddonDetails;
+
+ @property({ type: Boolean, attribute: "control-enabled" })
+ public controlEnabled = false;
+
+ protected render(): TemplateResult {
+ if (!this.addon) {
+ return html``;
+ }
+
+ return html`
+
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ supervisorAppsStyle,
+ css`
+ .content {
+ margin: auto;
+ padding: var(--ha-space-2);
+ max-width: 1024px;
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-app-info-tab": SupervisorAppInfoDashboard;
+ }
+}
diff --git a/src/panels/config/apps/app-view/info/supervisor-app-info.ts b/src/panels/config/apps/app-view/info/supervisor-app-info.ts
new file mode 100644
index 0000000000..74a18ed9ae
--- /dev/null
+++ b/src/panels/config/apps/app-view/info/supervisor-app-info.ts
@@ -0,0 +1,1472 @@
+import {
+ mdiCheckCircle,
+ mdiChip,
+ mdiCircleOffOutline,
+ mdiCursorDefaultClickOutline,
+ mdiDocker,
+ mdiExclamationThick,
+ mdiFlask,
+ mdiKey,
+ mdiLinkLock,
+ mdiNetwork,
+ mdiNumeric1,
+ mdiNumeric2,
+ mdiNumeric3,
+ mdiNumeric4,
+ mdiNumeric5,
+ mdiNumeric6,
+ mdiNumeric7,
+ mdiNumeric8,
+ mdiPlayCircle,
+ mdiPound,
+ mdiShield,
+} from "@mdi/js";
+import type { CSSResultGroup, TemplateResult } from "lit";
+import { LitElement, css, html, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
+import { ifDefined } from "lit/directives/if-defined";
+import memoizeOne from "memoize-one";
+import { atLeastVersion } from "../../../../../common/config/version";
+import { fireEvent } from "../../../../../common/dom/fire_event";
+import { navigate } from "../../../../../common/navigate";
+import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
+import "../../../../../components/buttons/ha-progress-button";
+import "../../../../../components/chips/ha-assist-chip";
+import "../../../../../components/chips/ha-chip-set";
+import "../../../../../components/ha-alert";
+import "../../../../../components/ha-button";
+import "../../../../../components/ha-card";
+import "../../../../../components/ha-formfield";
+import "../../../../../components/ha-markdown";
+import "../../../../../components/ha-settings-row";
+import "../../../../../components/ha-svg-icon";
+import "../../../../../components/ha-switch";
+import type { HaSwitch } from "../../../../../components/ha-switch";
+import type {
+ AddonCapability,
+ HassioAddonDetails,
+ HassioAddonSetOptionParams,
+ HassioAddonSetSecurityParams,
+} from "../../../../../data/hassio/addon";
+import {
+ fetchHassioAddonChangelog,
+ fetchHassioAddonInfo,
+ installHassioAddon,
+ rebuildLocalAddon,
+ restartHassioAddon,
+ setHassioAddonOption,
+ setHassioAddonSecurity,
+ startHassioAddon,
+ stopHassioAddon,
+ uninstallHassioAddon,
+ validateHassioAddonOption,
+} from "../../../../../data/hassio/addon";
+import type { HassioStats } from "../../../../../data/hassio/common";
+import {
+ extractApiErrorMessage,
+ fetchHassioStats,
+} from "../../../../../data/hassio/common";
+import type { StoreAddonDetails } from "../../../../../data/supervisor/store";
+import {
+ showAlertDialog,
+ showConfirmationDialog,
+} from "../../../../../dialogs/generic/show-dialog-box";
+import { mdiHomeAssistant } from "../../../../../resources/home-assistant-logo-svg";
+import { haStyle } from "../../../../../resources/styles";
+import type { HomeAssistant, Route } from "../../../../../types";
+import { bytesToString } from "../../../../../util/bytes-to-string";
+import "../../components/supervisor-apps-card-content";
+import "../components/supervisor-app-metric";
+import { extractChangelog } from "../util/supervisor-app";
+import "./supervisor-app-system-managed";
+import "../components/supervisor-app-update-available-card";
+
+const STAGE_ICON = {
+ stable: mdiCheckCircle,
+ experimental: mdiFlask,
+ deprecated: mdiExclamationThick,
+};
+
+const RATING_ICON = {
+ 1: mdiNumeric1,
+ 2: mdiNumeric2,
+ 3: mdiNumeric3,
+ 4: mdiNumeric4,
+ 5: mdiNumeric5,
+ 6: mdiNumeric6,
+ 7: mdiNumeric7,
+ 8: mdiNumeric8,
+};
+
+@customElement("supervisor-app-info")
+class SupervisorAppInfo extends LitElement {
+ @property({ type: Boolean }) public narrow = false;
+
+ @property({ attribute: false }) public route!: Route;
+
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public addon!:
+ | HassioAddonDetails
+ | StoreAddonDetails;
+
+ @property({ type: Boolean, attribute: "control-enabled" })
+ public controlEnabled = false;
+
+ @state() private _metrics?: HassioStats;
+
+ @state() private _error?: string;
+
+ private _fetchDataTimeout?: number;
+
+ public disconnectedCallback() {
+ super.disconnectedCallback();
+
+ if (this._fetchDataTimeout) {
+ clearTimeout(this._fetchDataTimeout);
+ this._fetchDataTimeout = undefined;
+ }
+ }
+
+ protected render(): TemplateResult {
+ const metrics = [
+ {
+ description: this.hass.localize(
+ "ui.panel.config.apps.dashboard.cpu_usage"
+ ),
+ value: this._metrics?.cpu_percent,
+ },
+ {
+ description: this.hass.localize(
+ "ui.panel.config.apps.dashboard.ram_usage"
+ ),
+ value: this._metrics?.memory_percent,
+ tooltip: `${bytesToString(this._metrics?.memory_usage)}/${bytesToString(
+ this._metrics?.memory_limit
+ )}`,
+ },
+ ];
+
+ const systemManaged = this._isSystemManaged(this.addon);
+
+ return html`
+ ${this.addon.update_available
+ ? html`
+
+ `
+ : nothing}
+ ${"protected" in this.addon && !this.addon.protected
+ ? html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.protection_mode.content"
+ )}
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.protection_mode.enable"
+ )}
+
+
+ `
+ : nothing}
+ ${systemManaged
+ ? html`
+
+ `
+ : nothing}
+
+
+
+
+
+ ${this.addon.version
+ ? html`
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.current_version",
+ { version: this.addon.version }
+ )}
+
+ (${this.hass.localize(
+ "ui.panel.config.apps.dashboard.changelog"
+ )})
+
+ `
+ : html`
${this.hass.localize(
+ "ui.panel.config.apps.dashboard.changelog"
+ )}`}
+
+
+
+ ${this.addon.stage !== "stable"
+ ? html`
+
+
+
+
+ `
+ : nothing}
+
+ = 6,
+ yellow: [3, 4, 5].includes(Number(this.addon.rating)),
+ red: Number(this.addon.rating) <= 2,
+ })}
+ @click=${this._showMoreInfo}
+ id="rating"
+ .label=${capitalizeFirstLetter(
+ this.hass.localize(
+ "ui.panel.config.apps.dashboard.capability.label.rating"
+ )
+ )}
+ >
+
+
+
+ ${this.addon.host_network
+ ? html`
+
+
+
+ `
+ : nothing}
+ ${this.addon.full_access
+ ? html`
+
+
+
+ `
+ : nothing}
+ ${this.addon.homeassistant_api
+ ? html`
+
+
+
+ `
+ : nothing}
+ ${this._computeHassioApi
+ ? html`
+
+
+
+ `
+ : nothing}
+ ${this.addon.docker_api
+ ? html`
+
+
+
+ `
+ : nothing}
+ ${this.addon.host_pid
+ ? html`
+
+
+
+ `
+ : nothing}
+ ${this.addon.apparmor !== "default"
+ ? html`
+
+
+
+ `
+ : nothing}
+ ${this.addon.auth_api
+ ? html`
+
+
+
+ `
+ : nothing}
+ ${this.addon.ingress
+ ? html`
+
+
+
+ `
+ : nothing}
+ ${this.addon.signed
+ ? html`
+
+
+
+ `
+ : nothing}
+ ${systemManaged
+ ? html`
+
+
+
+ `
+ : nothing}
+
+
+
+ ${this.addon.description}.
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.visit_addon_page",
+ {
+ name: html`
${this.addon.name}`,
+ }
+ )}
+
+
+
+ ${this.addon.logo
+ ? html`
+

+ `
+ : nothing}
+ ${this.addon.version
+ ? html`
+
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.option.boot.title"
+ )}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.option.boot.description"
+ )}
+
+
+
+
+ ${this.addon.startup !== "once"
+ ? html`
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.option.watchdog.title"
+ )}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.option.watchdog.description"
+ )}
+
+
+
+ `
+ : nothing}
+ ${this.addon.auto_update ||
+ this.hass.userData?.showAdvanced
+ ? html`
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.option.auto_update.title"
+ )}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.option.auto_update.description"
+ )}
+
+
+
+ `
+ : nothing}
+ ${!this._computeCannotIngressSidebar && this.addon.ingress
+ ? html`
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.option.ingress_panel.title"
+ )}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.option.ingress_panel.description"
+ )}
+
+
+
+ `
+ : nothing}
+ ${this._computeUsesProtectedOptions
+ ? html`
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.option.protected.title"
+ )}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.option.protected.description"
+ )}
+
+
+
+ `
+ : nothing}
+
+ `
+ : nothing}
+
+
+ ${this.addon.version && this.addon.state === "started"
+ ? html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.hostname"
+ )}
+
+ ${this.addon.hostname}
+
+ ${metrics.map(
+ (metric) => html`
+
+ `
+ )}`
+ : nothing}
+
+
+ ${this._error
+ ? html`
${this._error}`
+ : nothing}
+
+
+
+ ${this.addon.version
+ ? this._computeIsRunning
+ ? html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.stop"
+ )}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.restart"
+ )}
+
+ `
+ : html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.start"
+ )}
+
+ `
+ : nothing}
+
+
+ ${this.addon.version
+ ? html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.uninstall"
+ )}
+
+ ${this.addon.build
+ ? html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.rebuild"
+ )}
+
+ `
+ : nothing}
+ ${this._computeShowWebUI || this._computeShowIngressUI
+ ? html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.open_web_ui"
+ )}
+
+ `
+ : nothing}
+ `
+ : html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.install"
+ )}
+
+ `}
+
+
+
+
+ ${this.addon.long_description
+ ? html`
+
+
+
+
+
+ `
+ : nothing}
+ `;
+ }
+
+ protected updated(changedProps) {
+ super.updated(changedProps);
+ if (changedProps.has("addon")) {
+ this._loadData();
+ if (
+ !this._fetchDataTimeout &&
+ this.addon &&
+ "state" in this.addon &&
+ this.addon.state === "startup"
+ ) {
+ // Addon is starting up, wait for it to start
+ this._scheduleDataUpdate();
+ }
+ }
+ }
+
+ private _scheduleDataUpdate() {
+ this._fetchDataTimeout = window.setTimeout(async () => {
+ const addon = await fetchHassioAddonInfo(this.hass, this.addon.slug);
+ if (addon.state !== "startup") {
+ this._fetchDataTimeout = undefined;
+ this.addon = addon;
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "start",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ } else {
+ this._scheduleDataUpdate();
+ }
+ }, 500);
+ }
+
+ private async _loadData(): Promise {
+ if ("state" in this.addon && this.addon.state === "started") {
+ this._metrics = await fetchHassioStats(
+ this.hass,
+ `addons/${this.addon.slug}`
+ );
+ }
+ }
+
+ private get _computeHassioApi(): boolean {
+ return (
+ this.addon.hassio_api &&
+ (this.addon.hassio_role === "manager" ||
+ this.addon.hassio_role === "admin")
+ );
+ }
+
+ private get _computeApparmorClassName(): string {
+ if (this.addon.apparmor === "profile") {
+ return "green";
+ }
+ if (this.addon.apparmor === "disable") {
+ return "red";
+ }
+ return "";
+ }
+
+ private _showMoreInfo(ev): void {
+ const id = ev.currentTarget.id as AddonCapability;
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ `ui.panel.config.apps.dashboard.capability.${id}.title`
+ ),
+ text: this.hass.localize(
+ `ui.panel.config.apps.dashboard.capability.${id}.description`
+ ),
+ });
+ }
+
+ private _showSystemManagedInfo() {
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.system_managed.title"
+ ),
+ text: this.hass.localize(
+ "ui.panel.config.apps.dashboard.system_managed.description"
+ ),
+ });
+ }
+
+ private get _computeIsRunning(): boolean {
+ return (this.addon as HassioAddonDetails)?.state === "started";
+ }
+
+ private get _pathWebui(): string | null {
+ return (this.addon as HassioAddonDetails).webui!.replace(
+ "[HOST]",
+ document.location.hostname
+ );
+ }
+
+ private get _computeShowWebUI(): boolean | "" | null {
+ return (
+ !this.addon.ingress &&
+ (this.addon as HassioAddonDetails).webui &&
+ this._computeIsRunning
+ );
+ }
+
+ private _openIngress(): void {
+ navigate(`/hassio/ingress/${this.addon.slug}`);
+ }
+
+ private get _computeShowIngressUI(): boolean {
+ return this.addon.ingress && this._computeIsRunning;
+ }
+
+ private get _computeCannotIngressSidebar(): boolean {
+ return (
+ !this.addon.ingress || !atLeastVersion(this.hass.config.version, 0, 92)
+ );
+ }
+
+ private get _computeUsesProtectedOptions(): boolean {
+ return (
+ this.addon.docker_api || this.addon.full_access || this.addon.host_pid
+ );
+ }
+
+ private async _startOnBootToggled(): Promise {
+ this._error = undefined;
+ const data: HassioAddonSetOptionParams = {
+ boot:
+ (this.addon as HassioAddonDetails).boot === "auto" ? "manual" : "auto",
+ };
+ try {
+ await setHassioAddonOption(this.hass, this.addon.slug, data);
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "option",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ } catch (err: any) {
+ this._error = this.hass.localize(
+ "ui.panel.config.apps.dashboard.failed_to_save",
+ {
+ error: extractApiErrorMessage(err),
+ }
+ );
+ }
+ }
+
+ private async _watchdogToggled(): Promise {
+ this._error = undefined;
+ const data: HassioAddonSetOptionParams = {
+ watchdog: !(this.addon as HassioAddonDetails).watchdog,
+ };
+ try {
+ await setHassioAddonOption(this.hass, this.addon.slug, data);
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "option",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ } catch (err: any) {
+ this._error = this.hass.localize(
+ "ui.panel.config.apps.dashboard.failed_to_save",
+ {
+ error: extractApiErrorMessage(err),
+ }
+ );
+ }
+ }
+
+ private async _autoUpdateToggled(): Promise {
+ this._error = undefined;
+ const data: HassioAddonSetOptionParams = {
+ auto_update: !(this.addon as HassioAddonDetails).auto_update,
+ };
+ try {
+ await setHassioAddonOption(this.hass, this.addon.slug, data);
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "option",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ } catch (err: any) {
+ this._error = this.hass.localize(
+ "ui.panel.config.apps.dashboard.failed_to_save",
+ {
+ error: extractApiErrorMessage(err),
+ }
+ );
+ }
+ }
+
+ private async _protectionToggled(): Promise {
+ this._error = undefined;
+ const data: HassioAddonSetSecurityParams = {
+ protected: !(this.addon as HassioAddonDetails).protected,
+ };
+ try {
+ await setHassioAddonSecurity(this.hass, this.addon.slug, data);
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "security",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ } catch (err: any) {
+ this._error = this.hass.localize(
+ "ui.panel.config.apps.dashboard.failed_to_save",
+ {
+ error: extractApiErrorMessage(err),
+ }
+ );
+ }
+ }
+
+ private async _panelToggled(): Promise {
+ this._error = undefined;
+ const data: HassioAddonSetOptionParams = {
+ ingress_panel: !(this.addon as HassioAddonDetails).ingress_panel,
+ };
+ try {
+ await setHassioAddonOption(this.hass, this.addon.slug, data);
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "option",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ } catch (err: any) {
+ this._error = this.hass.localize(
+ "ui.panel.config.apps.dashboard.failed_to_save",
+ {
+ error: extractApiErrorMessage(err),
+ }
+ );
+ }
+ }
+
+ private async _openChangelog(): Promise {
+ try {
+ const content = await fetchHassioAddonChangelog(
+ this.hass,
+ this.addon.slug
+ );
+
+ showAlertDialog(this, {
+ title: this.hass.localize("ui.panel.config.apps.dashboard.changelog"),
+ text: html``,
+ });
+ } catch (err: any) {
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.action_error.get_changelog"
+ ),
+ text: extractApiErrorMessage(err),
+ });
+ }
+ }
+
+ private _updateComplete() {
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "install",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ }
+
+ private async _installClicked(ev: CustomEvent): Promise {
+ const button = ev.currentTarget as any;
+ button.progress = true;
+
+ try {
+ await installHassioAddon(this.hass, this.addon.slug);
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "install",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ } catch (err: any) {
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.action_error.install"
+ ),
+ text: extractApiErrorMessage(err),
+ });
+ }
+ button.progress = false;
+ }
+
+ private async _stopClicked(ev: CustomEvent): Promise {
+ if (this._isSystemManaged(this.addon) && !this.controlEnabled) {
+ return;
+ }
+
+ const button = ev.currentTarget as any;
+ button.progress = true;
+
+ try {
+ await stopHassioAddon(this.hass, this.addon.slug);
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "stop",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ } catch (err: any) {
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.action_error.stop"
+ ),
+ text: extractApiErrorMessage(err),
+ });
+ }
+ button.progress = false;
+ }
+
+ private async _restartClicked(ev: CustomEvent): Promise {
+ if (this._isSystemManaged(this.addon) && !this.controlEnabled) {
+ return;
+ }
+
+ const button = ev.currentTarget as any;
+ button.progress = true;
+
+ try {
+ await restartHassioAddon(this.hass, this.addon.slug);
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "restart",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ } catch (err: any) {
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.action_error.restart"
+ ),
+ text: extractApiErrorMessage(err),
+ });
+ }
+ button.progress = false;
+ }
+
+ private async _rebuildClicked(ev: CustomEvent): Promise {
+ const button = ev.currentTarget as any;
+ button.progress = true;
+
+ try {
+ await rebuildLocalAddon(this.hass, this.addon.slug);
+ } catch (err: any) {
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.action_error.rebuild"
+ ),
+ text: extractApiErrorMessage(err),
+ });
+ }
+ button.progress = false;
+ }
+
+ private async _startClicked(ev: CustomEvent): Promise {
+ const button = ev.currentTarget as any;
+ button.progress = true;
+ try {
+ const validate = await validateHassioAddonOption(
+ this.hass,
+ this.addon.slug
+ );
+ if (!validate.valid) {
+ await showConfirmationDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.action_error.start_invalid_config"
+ ),
+ text: validate.message.split(" Got ")[0],
+ confirm: () => this._openConfiguration(),
+ confirmText: this.hass.localize(
+ "ui.panel.config.apps.dashboard.action_error.go_to_config"
+ ),
+ dismissText: this.hass.localize("ui.common.cancel"),
+ });
+ button.actionError();
+ button.progress = false;
+ return;
+ }
+ } catch (err: any) {
+ button.actionError();
+ button.progress = false;
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.action_error.validate_config"
+ ),
+ text: extractApiErrorMessage(err),
+ });
+ return;
+ }
+
+ try {
+ await startHassioAddon(this.hass, this.addon.slug);
+ this.addon = await fetchHassioAddonInfo(this.hass, this.addon.slug);
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "start",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ } catch (err: any) {
+ button.actionError();
+ button.progress = false;
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.action_error.start"
+ ),
+ text: extractApiErrorMessage(err),
+ });
+ return;
+ }
+ button.actionSuccess();
+ button.progress = false;
+ }
+
+ private _openConfiguration(): void {
+ navigate(`/config/app/${this.addon.slug}/config`);
+ }
+
+ private async _uninstallClicked(ev: CustomEvent): Promise {
+ if (this._isSystemManaged(this.addon) && !this.controlEnabled) {
+ return;
+ }
+
+ const button = ev.currentTarget as any;
+ button.progress = true;
+ let removeData = false;
+ const _removeDataToggled = (e: Event) => {
+ removeData = (e.target as HaSwitch).checked;
+ };
+
+ const confirmed = await showConfirmationDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.uninstall_dialog.title",
+ {
+ name: this.addon.name,
+ }
+ ),
+ text: html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.uninstall_dialog.remove_data"
+ )}
+
`}
+ >
+
+
+ `,
+ confirmText: this.hass.localize(
+ "ui.panel.config.apps.dashboard.uninstall_dialog.uninstall"
+ ),
+ dismissText: this.hass.localize("ui.common.cancel"),
+ destructive: true,
+ });
+
+ if (!confirmed) {
+ button.progress = false;
+ return;
+ }
+
+ this._error = undefined;
+ try {
+ await uninstallHassioAddon(this.hass, this.addon.slug, removeData);
+ const eventdata = {
+ success: true,
+ response: undefined,
+ path: "uninstall",
+ };
+ fireEvent(this, "hass-api-called", eventdata);
+ button.actionSuccess();
+ } catch (err: any) {
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dashboard.action_error.uninstall"
+ ),
+ text: extractApiErrorMessage(err),
+ });
+ button.actionError();
+ }
+ button.progress = false;
+ }
+
+ private _isSystemManaged = memoizeOne(
+ (addon: HassioAddonDetails | StoreAddonDetails) =>
+ "system_managed" in addon && addon.system_managed
+ );
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ css`
+ :host {
+ display: block;
+ }
+ ha-card {
+ display: block;
+ margin-bottom: 16px;
+ }
+ ha-card.warning {
+ background-color: var(--error-color);
+ color: white;
+ }
+ ha-card.warning .card-header {
+ color: white;
+ }
+ ha-card.warning .card-content {
+ color: white;
+ }
+ ha-card.warning ha-button {
+ --mdc-theme-primary: white !important;
+ }
+ .warning {
+ color: var(--error-color);
+ --mdc-theme-primary: var(--error-color);
+ }
+ .light-color {
+ color: var(--secondary-text-color);
+ }
+ .addon-header {
+ padding-left: var(--ha-space-2);
+ padding-inline-start: var(--ha-space-2);
+ padding-inline-end: initial;
+ font-size: var(--ha-font-size-2xl);
+ color: var(--ha-card-header-color, var(--primary-text-color));
+ }
+ .addon-version {
+ float: var(--float-end);
+ font-size: var(--ha-font-size-l);
+ vertical-align: middle;
+ }
+ .errors {
+ color: var(--error-color);
+ margin-bottom: 16px;
+ }
+ .description {
+ margin-bottom: 16px;
+ }
+ img.logo {
+ max-width: 100%;
+ max-height: 60px;
+ margin: 16px 0;
+ display: block;
+ }
+
+ ha-switch {
+ display: flex;
+ }
+ ha-svg-icon.running {
+ color: var(--success-color);
+ }
+ ha-svg-icon.stopped {
+ color: var(--error-color);
+ }
+ protection-enable ha-button {
+ --mdc-theme-primary: white;
+ }
+ .description a {
+ color: var(--primary-color);
+ }
+ .long-description {
+ direction: ltr;
+ }
+ ha-assist-chip {
+ --md-sys-color-primary: var(--text-primary-color);
+ --md-sys-color-on-surface: var(--text-primary-color);
+ --ha-assist-chip-filled-container-color: var(--primary-color);
+ }
+
+ .red {
+ --ha-assist-chip-filled-container-color: var(
+ --label-badge-red,
+ #df4c1e
+ );
+ }
+ .blue {
+ --ha-assist-chip-filled-container-color: var(
+ --label-badge-blue,
+ #039be5
+ );
+ }
+ .green {
+ --ha-assist-chip-filled-container-color: var(
+ --label-badge-green,
+ #0da035
+ );
+ }
+ .yellow {
+ --ha-assist-chip-filled-container-color: var(
+ --label-badge-yellow,
+ #f4b400
+ );
+ }
+ .capabilities {
+ margin-bottom: 16px;
+ }
+ .card-actions {
+ justify-content: space-between;
+ display: flex;
+ direction: var(--direction);
+ }
+ .changelog {
+ display: contents;
+ }
+ .changelog-link {
+ color: var(--primary-color);
+ text-decoration: underline;
+ cursor: pointer;
+ }
+ ha-markdown {
+ padding: 16px;
+ --markdown-image-background-color: transparent;
+ --markdown-image-border-radius: 0;
+ --markdown-image-min-height: auto;
+ --markdown-image-text-indent: 0;
+ --markdown-image-transition: none;
+ }
+ ha-settings-row {
+ padding: 0;
+ height: 54px;
+ width: 100%;
+ }
+ ha-settings-row > span[slot="description"] {
+ white-space: normal;
+ color: var(--secondary-text-color);
+ }
+ ha-settings-row[three-line] {
+ height: 74px;
+ }
+
+ .addon-options {
+ max-width: 90%;
+ }
+
+ .addon-container {
+ display: grid;
+ grid-auto-flow: column;
+ grid-template-columns: 60% 40%;
+ }
+
+ .addon-container > div:last-of-type {
+ align-self: end;
+ }
+
+ ha-alert ha-button {
+ --mdc-theme-primary: var(--primary-text-color);
+ }
+
+ :host > ha-alert {
+ display: block;
+ margin-bottom: 16px;
+ }
+
+ a {
+ text-decoration: none;
+ }
+
+ supervisor-app-update-available-card {
+ padding-bottom: 16px;
+ }
+
+ @media (max-width: 720px) {
+ .addon-options {
+ max-width: 100%;
+ }
+ .addon-container {
+ display: block;
+ }
+ }
+ `,
+ ];
+ }
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-app-info": SupervisorAppInfo;
+ }
+}
diff --git a/src/panels/config/apps/app-view/info/supervisor-app-system-managed.ts b/src/panels/config/apps/app-view/info/supervisor-app-system-managed.ts
new file mode 100644
index 0000000000..f6e3fb75e0
--- /dev/null
+++ b/src/panels/config/apps/app-view/info/supervisor-app-system-managed.ts
@@ -0,0 +1,65 @@
+import type { TemplateResult } from "lit";
+import { LitElement, css, html, nothing } from "lit";
+import { customElement, property } from "lit/decorators";
+import { fireEvent } from "../../../../../common/dom/fire_event";
+import "../../../../../components/ha-alert";
+import "../../../../../components/ha-button";
+import type { HomeAssistant } from "../../../../../types";
+
+@customElement("supervisor-app-system-managed")
+class SupervisorAppSystemManaged extends LitElement {
+ @property({ type: Boolean }) public narrow = false;
+
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ type: Boolean, attribute: "hide-button" }) public hideButton =
+ false;
+
+ protected render(): TemplateResult {
+ return html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.system_managed.description"
+ )}
+ ${!this.hideButton
+ ? html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dashboard.system_managed.take_control"
+ )}
+
+ `
+ : nothing}
+
+ `;
+ }
+
+ private _takeControl() {
+ fireEvent(this, "system-managed-take-control");
+ }
+
+ static styles = css`
+ ha-alert {
+ display: block;
+ margin-bottom: 16px;
+ }
+ ha-button {
+ white-space: nowrap;
+ }
+ `;
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-app-system-managed": SupervisorAppSystemManaged;
+ }
+
+ interface HASSDomEvents {
+ "system-managed-take-control": undefined;
+ }
+}
diff --git a/src/panels/config/apps/app-view/log/supervisor-app-log-tab.ts b/src/panels/config/apps/app-view/log/supervisor-app-log-tab.ts
new file mode 100644
index 0000000000..c6ae73a26a
--- /dev/null
+++ b/src/panels/config/apps/app-view/log/supervisor-app-log-tab.ts
@@ -0,0 +1,88 @@
+import {
+ css,
+ type CSSResultGroup,
+ html,
+ LitElement,
+ type TemplateResult,
+} from "lit";
+import { customElement, property, state } from "lit/decorators";
+import "../../../../../components/ha-spinner";
+import type { HassioAddonDetails } from "../../../../../data/hassio/addon";
+import { haStyle } from "../../../../../resources/styles";
+import type { HomeAssistant } from "../../../../../types";
+import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
+import "../../../logs/error-log-card";
+import "../../../../../components/search-input";
+import { extractSearchParam } from "../../../../../common/url/search-params";
+
+@customElement("supervisor-app-log-tab")
+class SupervisorAppLogDashboard extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public addon?: HassioAddonDetails;
+
+ @state() private _filter = extractSearchParam("filter") || "";
+
+ protected render(): TemplateResult {
+ if (!this.addon) {
+ return html` `;
+ }
+ return html`
+
+
+
+
+
+
+
+ `;
+ }
+
+ private async _filterChanged(ev) {
+ this._filter = ev.detail.value;
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ supervisorAppsStyle,
+ css`
+ .content {
+ margin: auto;
+ padding: var(--ha-space-2);
+ }
+ .search {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ }
+ search-input {
+ display: block;
+ --mdc-text-field-fill-color: var(--sidebar-background-color);
+ --mdc-text-field-idle-line-color: var(--divider-color);
+ }
+ @media all and (max-width: 870px) {
+ :host {
+ --error-log-card-height: calc(100vh - 304px);
+ }
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-app-log-tab": SupervisorAppLogDashboard;
+ }
+}
diff --git a/src/panels/config/apps/app-view/supervisor-app-router.ts b/src/panels/config/apps/app-view/supervisor-app-router.ts
new file mode 100644
index 0000000000..38af6549dc
--- /dev/null
+++ b/src/panels/config/apps/app-view/supervisor-app-router.ts
@@ -0,0 +1,58 @@
+import { customElement, property } from "lit/decorators";
+import type { HassioAddonDetails } from "../../../../data/hassio/addon";
+import type { StoreAddonDetails } from "../../../../data/supervisor/store";
+import type { RouterOptions } from "../../../../layouts/hass-router-page";
+import { HassRouterPage } from "../../../../layouts/hass-router-page";
+import type { HomeAssistant } from "../../../../types";
+import "./config/supervisor-app-config-tab";
+import "./documentation/supervisor-app-documentation-tab";
+// Don't codesplit the others, because it breaks the UI when pushed to a Pi
+import "./info/supervisor-app-info-tab";
+import "./log/supervisor-app-log-tab";
+
+@customElement("supervisor-app-router")
+class SupervisorAppRouter extends HassRouterPage {
+ @property({ type: Boolean }) public narrow = false;
+
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public addon!:
+ | HassioAddonDetails
+ | StoreAddonDetails;
+
+ @property({ type: Boolean, attribute: "control-enabled" })
+ public controlEnabled = false;
+
+ protected routerOptions: RouterOptions = {
+ defaultPage: "info",
+ showLoading: true,
+ routes: {
+ info: {
+ tag: "supervisor-app-info-tab",
+ },
+ documentation: {
+ tag: "supervisor-app-documentation-tab",
+ },
+ config: {
+ tag: "supervisor-app-config-tab",
+ },
+ logs: {
+ tag: "supervisor-app-log-tab",
+ },
+ },
+ };
+
+ protected updatePageEl(el) {
+ el.route = this.routeTail;
+ el.hass = this.hass;
+ el.addon = this.addon;
+ el.narrow = this.narrow;
+ el.controlEnabled = this.controlEnabled;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-app-router": SupervisorAppRouter;
+ }
+}
diff --git a/src/panels/config/apps/app-view/util/supervisor-app.ts b/src/panels/config/apps/app-view/util/supervisor-app.ts
new file mode 100644
index 0000000000..139e0c4096
--- /dev/null
+++ b/src/panels/config/apps/app-view/util/supervisor-app.ts
@@ -0,0 +1,30 @@
+import memoizeOne from "memoize-one";
+import type { HassioAddonDetails } from "../../../../../data/hassio/addon";
+import type { SupervisorArch } from "../../../../../data/supervisor/supervisor";
+
+export const addonArchIsSupported = memoizeOne(
+ (supported_archs: SupervisorArch[], addon_archs: SupervisorArch[]) =>
+ addon_archs.some((arch) => supported_archs.includes(arch))
+);
+
+export const extractChangelog = (
+ addon: HassioAddonDetails,
+ content: string
+): string => {
+ if (content.startsWith("# Changelog")) {
+ content = content.substring(12);
+ }
+ if (
+ content.includes(`# ${addon.version}`) &&
+ content.includes(`# ${addon.version_latest}`)
+ ) {
+ const newcontent = content.split(`# ${addon.version}`)[0];
+ if (newcontent.includes(`# ${addon.version_latest}`)) {
+ // Only change the content if the new version still exist
+ // if the changelog does not have the newests version on top
+ // this will not be true, and we don't modify the content
+ content = newcontent;
+ }
+ }
+ return content;
+};
diff --git a/src/panels/config/apps/components/supervisor-apps-card-content.ts b/src/panels/config/apps/components/supervisor-apps-card-content.ts
new file mode 100644
index 0000000000..7a3afd7a4c
--- /dev/null
+++ b/src/panels/config/apps/components/supervisor-apps-card-content.ts
@@ -0,0 +1,151 @@
+import { mdiHelpCircle } from "@mdi/js";
+import type { TemplateResult } from "lit";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators";
+import "../../../../components/ha-svg-icon";
+import type { HomeAssistant } from "../../../../types";
+
+@customElement("supervisor-apps-card-content")
+class SupervisorAppsCardContent extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ // eslint-disable-next-line lit/no-native-attributes
+ @property() public title!: string;
+
+ @property() public description?: string;
+
+ @property({ type: Boolean }) public available = true;
+
+ @property({ attribute: false }) public showTopbar = false;
+
+ @property({ attribute: false }) public topbarClass?: string;
+
+ @property({ attribute: false }) public iconTitle?: string;
+
+ @property({ attribute: false }) public iconClass?: string;
+
+ @property() public icon = mdiHelpCircle;
+
+ @property({ attribute: false }) public iconImage?: string;
+
+ protected render(): TemplateResult {
+ return html`
+ ${this.showTopbar
+ ? html` `
+ : ""}
+ ${this.iconImage
+ ? html`
+
+

+
+
+ `
+ : html`
+
+ `}
+
+
${this.title}
+
+ ${this.description}
+ ${
+ /* treat as available when undefined */
+ this.available === false ? " (Not available)" : ""
+ }
+
+
+ `;
+ }
+
+ static styles = css`
+ :host {
+ direction: ltr;
+ }
+
+ ha-svg-icon {
+ margin-right: var(--ha-space-6);
+ margin-left: var(--ha-space-2);
+ margin-top: var(--ha-space-3);
+ float: left;
+ color: var(--secondary-text-color);
+ }
+ ha-svg-icon.update {
+ color: var(--warning-color);
+ }
+ ha-svg-icon.running,
+ ha-svg-icon.installed {
+ color: var(--success-color);
+ }
+ ha-svg-icon.hassupdate,
+ ha-svg-icon.backup {
+ color: var(--state-icon-color);
+ }
+ ha-svg-icon.not_available {
+ color: var(--error-color);
+ }
+ .title {
+ color: var(--primary-text-color);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ .addition {
+ color: var(--secondary-text-color);
+ overflow: hidden;
+ position: relative;
+ height: 2.4em;
+ line-height: var(--ha-line-height-condensed);
+ }
+ .icon_image img {
+ max-height: 40px;
+ max-width: 40px;
+ margin-top: var(--ha-space-1);
+ margin-right: var(--ha-space-4);
+ float: left;
+ }
+ .icon_image.stopped,
+ .icon_image.not_available {
+ filter: grayscale(1);
+ }
+ .dot {
+ position: absolute;
+ background-color: var(--warning-color);
+ width: 12px;
+ height: 12px;
+ top: var(--ha-space-2);
+ right: var(--ha-space-2);
+ border-radius: var(--ha-border-radius-circle);
+ }
+ .topbar {
+ position: absolute;
+ width: 100%;
+ height: 2px;
+ top: 0;
+ left: 0;
+ border-top-left-radius: 2px;
+ border-top-right-radius: 2px;
+ }
+ .topbar.installed {
+ background-color: var(--primary-color);
+ }
+ .topbar.update {
+ background-color: var(--accent-color);
+ }
+ .topbar.unavailable {
+ background-color: var(--error-color);
+ }
+ `;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-apps-card-content": SupervisorAppsCardContent;
+ }
+}
diff --git a/src/panels/config/apps/components/supervisor-apps-filter.ts b/src/panels/config/apps/components/supervisor-apps-filter.ts
new file mode 100644
index 0000000000..690ef8ce1e
--- /dev/null
+++ b/src/panels/config/apps/components/supervisor-apps-filter.ts
@@ -0,0 +1,15 @@
+import type { IFuseOptions } from "fuse.js";
+import Fuse from "fuse.js";
+import type { StoreAddon } from "../../../../data/supervisor/store";
+
+export function filterAndSort(addons: StoreAddon[], filter: string) {
+ const options: IFuseOptions = {
+ keys: ["name", "description", "slug"],
+ isCaseSensitive: false,
+ minMatchCharLength: Math.min(filter.length, 2),
+ threshold: 0.2,
+ ignoreDiacritics: true,
+ };
+ const fuse = new Fuse(addons, options);
+ return fuse.search(filter).map((result) => result.item);
+}
diff --git a/src/panels/config/apps/dialogs/registries/dialog-registries.ts b/src/panels/config/apps/dialogs/registries/dialog-registries.ts
new file mode 100644
index 0000000000..0d3ab0bd84
--- /dev/null
+++ b/src/panels/config/apps/dialogs/registries/dialog-registries.ts
@@ -0,0 +1,262 @@
+import { mdiDelete, mdiPlus } from "@mdi/js";
+import type { CSSResultGroup, TemplateResult } from "lit";
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import "../../../../../components/ha-button";
+import { createCloseHeading } from "../../../../../components/ha-dialog";
+import "../../../../../components/ha-form/ha-form";
+import type { SchemaUnion } from "../../../../../components/ha-form/types";
+import "../../../../../components/ha-icon-button";
+import "../../../../../components/ha-settings-row";
+import "../../../../../components/ha-svg-icon";
+import { extractApiErrorMessage } from "../../../../../data/hassio/common";
+import {
+ addHassioDockerRegistry,
+ fetchHassioDockerRegistries,
+ removeHassioDockerRegistry,
+} from "../../../../../data/hassio/docker";
+import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
+import { haStyle, haStyleDialog } from "../../../../../resources/styles";
+import type { HomeAssistant } from "../../../../../types";
+
+const SCHEMA = [
+ {
+ name: "registry",
+ required: true,
+ selector: { text: {} },
+ },
+ {
+ name: "username",
+ required: true,
+ selector: { text: {} },
+ },
+ {
+ name: "password",
+ required: true,
+ selector: { text: { type: "password" } },
+ },
+] as const;
+
+@customElement("dialog-apps-registries")
+class AppsRegistriesDialog extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @state() private _registries?: {
+ registry: string;
+ username: string;
+ }[];
+
+ @state() private _input: {
+ registry?: string;
+ username?: string;
+ password?: string;
+ } = {};
+
+ @state() private _opened = false;
+
+ @state() private _addingRegistry = false;
+
+ protected render(): TemplateResult {
+ return html`
+
+ ${this._addingRegistry
+ ? html`
+
+
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dialog.registries.add_registry"
+ )}
+
+
+ `
+ : html`${this._registries?.length
+ ? this._registries.map(
+ (entry) => html`
+
+ ${entry.registry}
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dialog.registries.username"
+ )}:
+ ${entry.username}
+
+
+
+ `
+ )
+ : html`
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dialog.registries.no_registries"
+ )}
+
+ `}
+
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.dialog.registries.add_new_registry"
+ )}
+
+
`}
+
+ `;
+ }
+
+ private _computeLabel = (schema: SchemaUnion) =>
+ this.hass.localize(
+ `ui.panel.config.apps.dialog.registries.${schema.name}` as any
+ );
+
+ private _valueChanged(ev: CustomEvent) {
+ this._input = ev.detail.value;
+ }
+
+ public async showDialog(): Promise {
+ this._opened = true;
+ this._input = {};
+ await this._loadRegistries();
+ await this.updateComplete;
+ }
+
+ public closeDialog(): void {
+ this._addingRegistry = false;
+ this._opened = false;
+ this._input = {};
+ }
+
+ public focus(): void {
+ this.updateComplete.then(() =>
+ (
+ this.shadowRoot?.querySelector("[dialogInitialFocus]") as HTMLElement
+ )?.focus()
+ );
+ }
+
+ private async _loadRegistries(): Promise {
+ const registries = await fetchHassioDockerRegistries(this.hass);
+ this._registries = Object.keys(registries!.registries).map((key) => ({
+ registry: key,
+ username: registries.registries[key].username,
+ }));
+ }
+
+ private _addRegistry(): void {
+ this._addingRegistry = true;
+ }
+
+ private async _addNewRegistry(): Promise {
+ const data = {};
+ data[this._input.registry!] = {
+ username: this._input.username,
+ password: this._input.password,
+ };
+
+ try {
+ await addHassioDockerRegistry(this.hass, data);
+ await this._loadRegistries();
+ this._addingRegistry = false;
+ this._input = {};
+ } catch (err: any) {
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dialog.registries.failed_to_add"
+ ),
+ text: extractApiErrorMessage(err),
+ });
+ }
+ }
+
+ private async _removeRegistry(ev: Event): Promise {
+ const entry = (ev.currentTarget as any).entry;
+
+ try {
+ await removeHassioDockerRegistry(this.hass, entry.registry);
+ await this._loadRegistries();
+ } catch (err: any) {
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.apps.dialog.registries.failed_to_remove"
+ ),
+ text: extractApiErrorMessage(err),
+ });
+ }
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ haStyleDialog,
+ css`
+ .registry {
+ border: 1px solid var(--divider-color);
+ border-radius: var(--ha-border-radius-sm);
+ margin-top: 4px;
+ }
+ .action {
+ margin-top: 24px;
+ width: 100%;
+ display: flex;
+ justify-content: flex-end;
+ }
+ ha-icon-button {
+ color: var(--error-color);
+ margin-right: -10px;
+ margin-inline-end: -10px;
+ margin-inline-start: initial;
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "dialog-apps-registries": AppsRegistriesDialog;
+ }
+}
diff --git a/src/panels/config/apps/dialogs/registries/show-dialog-registries.ts b/src/panels/config/apps/dialogs/registries/show-dialog-registries.ts
new file mode 100644
index 0000000000..4ebba836d6
--- /dev/null
+++ b/src/panels/config/apps/dialogs/registries/show-dialog-registries.ts
@@ -0,0 +1,10 @@
+import { fireEvent } from "../../../../../common/dom/fire_event";
+import "./dialog-registries";
+
+export const showRegistriesDialog = (element: HTMLElement): void => {
+ fireEvent(element, "show-dialog", {
+ dialogTag: "dialog-apps-registries",
+ dialogImport: () => import("./dialog-registries"),
+ dialogParams: {},
+ });
+};
diff --git a/src/panels/config/apps/dialogs/repositories/dialog-repositories.ts b/src/panels/config/apps/dialogs/repositories/dialog-repositories.ts
new file mode 100644
index 0000000000..3ecc7bf916
--- /dev/null
+++ b/src/panels/config/apps/dialogs/repositories/dialog-repositories.ts
@@ -0,0 +1,281 @@
+import { mdiDelete, mdiDeleteOff, mdiPlus } from "@mdi/js";
+import type { CSSResultGroup } from "lit";
+import { css, html, LitElement, nothing } from "lit";
+import { customElement, property, query, state } from "lit/decorators";
+import memoizeOne from "memoize-one";
+import { fireEvent } from "../../../../../common/dom/fire_event";
+import { caseInsensitiveStringCompare } from "../../../../../common/string/compare";
+import "../../../../../components/ha-alert";
+import "../../../../../components/ha-button";
+import { createCloseHeading } from "../../../../../components/ha-dialog";
+import "../../../../../components/ha-icon-button";
+import "../../../../../components/ha-md-list";
+import "../../../../../components/ha-md-list-item";
+import "../../../../../components/ha-svg-icon";
+import "../../../../../components/ha-textfield";
+import type { HaTextField } from "../../../../../components/ha-textfield";
+import "../../../../../components/ha-tooltip";
+import type {
+ HassioAddonInfo,
+ HassioAddonsInfo,
+ HassioAddonRepository,
+} from "../../../../../data/hassio/addon";
+import { extractApiErrorMessage } from "../../../../../data/hassio/common";
+import {
+ addStoreRepository,
+ fetchStoreRepositories,
+ removeStoreRepository,
+} from "../../../../../data/supervisor/store";
+import { haStyle, haStyleDialog } from "../../../../../resources/styles";
+import type { HomeAssistant } from "../../../../../types";
+import type { RepositoryDialogParams } from "./show-dialog-repositories";
+
+@customElement("dialog-apps-repositories")
+class AppsRepositoriesDialog extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @query("#repository_input", true) private _optionInput?: HaTextField;
+
+ @state() private _repositories?: HassioAddonRepository[];
+
+ @state() private _dialogParams?: RepositoryDialogParams;
+
+ @state() private _addon?: HassioAddonsInfo;
+
+ @state() private _opened = false;
+
+ @state() private _processing = false;
+
+ @state() private _error?: string;
+
+ public async showDialog(dialogParams: RepositoryDialogParams): Promise {
+ this._dialogParams = dialogParams;
+ this._addon = dialogParams.addon;
+ this._opened = true;
+ await this._loadData();
+ await this.updateComplete;
+ }
+
+ public closeDialog(): void {
+ this._dialogParams?.closeCallback?.();
+ this._dialogParams = undefined;
+ this._opened = false;
+ this._error = "";
+ }
+
+ private _filteredRepositories = memoizeOne((repos: HassioAddonRepository[]) =>
+ repos
+ .filter(
+ (repo) =>
+ repo.slug !== "core" && // The core add-ons repository
+ repo.slug !== "local" && // Locally managed add-ons
+ repo.slug !== "a0d7b954" && // Home Assistant Community Add-ons
+ repo.slug !== "5c53de3b" && // The ESPHome repository
+ repo.slug !== "d5369777" // Music Assistant repository
+ )
+ .sort((a, b) =>
+ caseInsensitiveStringCompare(a.name, b.name, this.hass.locale.language)
+ )
+ );
+
+ private _filteredUsedRepositories = memoizeOne(
+ (repos: HassioAddonRepository[], addons: HassioAddonInfo[]) =>
+ repos
+ .filter((repo) =>
+ addons.some((addon) => addon.repository === repo.slug)
+ )
+ .map((repo) => repo.slug)
+ );
+
+ protected render() {
+ if (!this._addon || this._repositories === undefined) {
+ return nothing;
+ }
+ const repositories = this._filteredRepositories(this._repositories);
+ const usedRepositories = this._filteredUsedRepositories(
+ repositories,
+ this._addon.addons
+ );
+ return html`
+
+ ${this._error
+ ? html`${this._error}`
+ : ""}
+
+
+ ${this.hass.localize("ui.common.close")}
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ haStyleDialog,
+ css`
+ ha-dialog.button-left {
+ --justify-action-buttons: flex-start;
+ }
+ .form {
+ color: var(--primary-text-color);
+ }
+ .option {
+ border: 1px solid var(--divider-color);
+ border-radius: var(--ha-border-radius-sm);
+ margin-top: 4px;
+ }
+ ha-button {
+ margin-left: var(--ha-space-2);
+ margin-inline-start: var(--ha-space-2);
+ margin-inline-end: initial;
+ }
+ div.delete ha-icon-button {
+ color: var(--error-color);
+ }
+ ha-md-list-item {
+ position: relative;
+ --md-item-overflow: visible;
+ }
+ `,
+ ];
+ }
+
+ public focus() {
+ this.updateComplete.then(() =>
+ (
+ this.shadowRoot?.querySelector("[dialogInitialFocus]") as HTMLElement
+ )?.focus()
+ );
+ }
+
+ private _handleKeyAdd(ev: KeyboardEvent) {
+ ev.stopPropagation();
+ if (ev.key !== "Enter") {
+ return;
+ }
+ this._addRepository();
+ }
+
+ private async _loadData(): Promise {
+ try {
+ this._repositories = await fetchStoreRepositories(this.hass);
+
+ fireEvent(this, "apps-collection-refresh", { collection: "addon" });
+ } catch (err: any) {
+ this._error = extractApiErrorMessage(err);
+ }
+ }
+
+ private async _addRepository() {
+ const input = this._optionInput;
+ if (!input || !input.value) {
+ return;
+ }
+ this._processing = true;
+
+ try {
+ await addStoreRepository(this.hass, input.value);
+ await this._loadData();
+
+ fireEvent(this, "apps-collection-refresh", { collection: "store" });
+
+ input.value = "";
+ } catch (err: any) {
+ this._error = extractApiErrorMessage(err);
+ }
+ this._processing = false;
+ }
+
+ private async _removeRepository(ev: Event) {
+ const slug = (ev.currentTarget as any).slug;
+ try {
+ await removeStoreRepository(this.hass, slug);
+ await this._loadData();
+
+ fireEvent(this, "apps-collection-refresh", { collection: "store" });
+ } catch (err: any) {
+ this._error = extractApiErrorMessage(err);
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "dialog-apps-repositories": AppsRepositoriesDialog;
+ }
+}
diff --git a/src/panels/config/apps/dialogs/repositories/show-dialog-repositories.ts b/src/panels/config/apps/dialogs/repositories/show-dialog-repositories.ts
new file mode 100644
index 0000000000..c91d149ba5
--- /dev/null
+++ b/src/panels/config/apps/dialogs/repositories/show-dialog-repositories.ts
@@ -0,0 +1,20 @@
+import { fireEvent } from "../../../../../common/dom/fire_event";
+import type { HassioAddonsInfo } from "../../../../../data/hassio/addon";
+import "./dialog-repositories";
+
+export interface RepositoryDialogParams {
+ addon: HassioAddonsInfo;
+ url?: string;
+ closeCallback?: () => void;
+}
+
+export const showRepositoriesDialog = (
+ element: HTMLElement,
+ dialogParams: RepositoryDialogParams
+): void => {
+ fireEvent(element, "show-dialog", {
+ dialogTag: "dialog-apps-repositories",
+ dialogImport: () => import("./dialog-repositories"),
+ dialogParams,
+ });
+};
diff --git a/src/panels/config/apps/ha-config-app-dashboard.ts b/src/panels/config/apps/ha-config-app-dashboard.ts
new file mode 100644
index 0000000000..2d3fd38038
--- /dev/null
+++ b/src/panels/config/apps/ha-config-app-dashboard.ts
@@ -0,0 +1,209 @@
+import {
+ mdiCogs,
+ mdiFileDocument,
+ mdiInformationVariant,
+ mdiMathLog,
+} from "@mdi/js";
+import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import memoizeOne from "memoize-one";
+import { fireEvent } from "../../../common/dom/fire_event";
+import { extractSearchParam } from "../../../common/url/search-params";
+import type { HassioAddonDetails } from "../../../data/hassio/addon";
+import { fetchHassioAddonInfo } from "../../../data/hassio/addon";
+import { extractApiErrorMessage } from "../../../data/hassio/common";
+import "../../../layouts/hass-error-screen";
+import "../../../layouts/hass-loading-screen";
+import "../../../layouts/hass-tabs-subpage";
+import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
+import { haStyle } from "../../../resources/styles";
+import type { HomeAssistant, Route } from "../../../types";
+
+// Import app-view components
+import "./app-view/supervisor-app-router";
+
+@customElement("ha-config-app-dashboard")
+class HaConfigAppDashboard extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public route!: Route;
+
+ @property({ type: Boolean }) public narrow = false;
+
+ @state() private _addon?: HassioAddonDetails;
+
+ @state() private _error?: string;
+
+ @state() private _controlEnabled = false;
+
+ @state() private _fromStore = false;
+
+ private _computeTail = memoizeOne((route: Route) => {
+ const pathParts = route.path.split("/").filter(Boolean);
+ // Path is like //info or //config
+ const slug = pathParts[0] || "";
+ const subPath = pathParts.slice(1).join("/");
+
+ return {
+ prefix: route.prefix + "/" + slug,
+ path: subPath ? "/" + subPath : "",
+ };
+ });
+
+ protected async firstUpdated(): Promise {
+ this._fromStore = extractSearchParam("store") === "true";
+ await this._loadAddon();
+ this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev));
+ }
+
+ protected updated(changedProperties: PropertyValues) {
+ if (changedProperties.has("route") && this.route) {
+ const oldRoute = changedProperties.get("route") as Route | undefined;
+ const oldSlug = oldRoute?.path.split("/")[1];
+ const newSlug = this.route.path.split("/")[1];
+
+ if (oldSlug !== newSlug && newSlug) {
+ this._loadAddon();
+ }
+ }
+ }
+
+ protected render(): TemplateResult {
+ if (this._error) {
+ return html``;
+ }
+
+ if (!this._addon) {
+ return html``;
+ }
+
+ const addonTabs: PageNavigation[] = [
+ {
+ translationKey: "ui.panel.config.apps.panel.info",
+ path: `/config/app/${this._addon.slug}/info`,
+ iconPath: mdiInformationVariant,
+ },
+ ];
+
+ if (this._addon.documentation) {
+ addonTabs.push({
+ translationKey: "ui.panel.config.apps.panel.documentation",
+ path: `/config/app/${this._addon.slug}/documentation`,
+ iconPath: mdiFileDocument,
+ });
+ }
+
+ if (this._addon.version) {
+ addonTabs.push(
+ {
+ translationKey: "ui.panel.config.apps.panel.configuration",
+ path: `/config/app/${this._addon.slug}/config`,
+ iconPath: mdiCogs,
+ },
+ {
+ translationKey: "ui.panel.config.apps.panel.log",
+ path: `/config/app/${this._addon.slug}/logs`,
+ iconPath: mdiMathLog,
+ }
+ );
+ }
+
+ const route = this._computeTail(this.route);
+
+ return html`
+
+ ${this._addon.name}
+
+
+ `;
+ }
+
+ private async _loadAddon(): Promise {
+ const slug = this.route.path.split("/")[1];
+ if (!slug) {
+ this._error = "No addon specified";
+ return;
+ }
+
+ try {
+ this._addon = await fetchHassioAddonInfo(this.hass, slug);
+ } catch (err: any) {
+ this._error = `Error loading addon: ${extractApiErrorMessage(err)}`;
+ }
+ }
+
+ private async _apiCalled(ev): Promise {
+ if (!ev.detail.success) {
+ return;
+ }
+
+ const pathSplit: string[] = ev.detail.path?.split("/");
+
+ if (!pathSplit || pathSplit.length === 0) {
+ return;
+ }
+
+ const path: string = pathSplit[pathSplit.length - 1];
+
+ if (["uninstall", "install", "update", "start", "stop"].includes(path)) {
+ fireEvent(this, "apps-collection-refresh", {
+ collection: "addon",
+ });
+ }
+
+ if (path === "uninstall") {
+ // Navigate back to installed apps after uninstall
+ window.history.back();
+ } else {
+ // Reload addon info
+ await this._loadAddon();
+ }
+ }
+
+ private _enableControl() {
+ this._controlEnabled = true;
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ css`
+ :host {
+ color: var(--primary-text-color);
+ }
+ .content {
+ padding: var(--ha-space-6) 0 var(--ha-space-8);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-config-app-dashboard": HaConfigAppDashboard;
+ }
+}
diff --git a/src/panels/config/apps/ha-config-apps-available.ts b/src/panels/config/apps/ha-config-apps-available.ts
new file mode 100644
index 0000000000..7d2ef523df
--- /dev/null
+++ b/src/panels/config/apps/ha-config-apps-available.ts
@@ -0,0 +1,332 @@
+import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
+import { mdiDotsVertical } from "@mdi/js";
+import type { PropertyValues, TemplateResult } from "lit";
+import { css, html, LitElement, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import memoizeOne from "memoize-one";
+import { navigate } from "../../../common/navigate";
+import { extractSearchParam } from "../../../common/url/search-params";
+import "../../../components/ha-button-menu";
+import "../../../components/ha-icon-button";
+import "../../../components/ha-list-item";
+import "../../../components/search-input";
+import type {
+ HassioAddonsInfo,
+ HassioAddonRepository,
+} from "../../../data/hassio/addon";
+import {
+ fetchHassioAddonsInfo,
+ reloadHassioAddons,
+} from "../../../data/hassio/addon";
+import { extractApiErrorMessage } from "../../../data/hassio/common";
+import type {
+ StoreAddon,
+ SupervisorStore,
+} from "../../../data/supervisor/store";
+import { fetchSupervisorStore } from "../../../data/supervisor/store";
+import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
+import "../../../layouts/hass-error-screen";
+import "../../../layouts/hass-loading-screen";
+import "../../../layouts/hass-subpage";
+import type { HASSDomEvent } from "../../../common/dom/fire_event";
+import type { HomeAssistant, Route } from "../../../types";
+import { showRepositoriesDialog } from "./dialogs/repositories/show-dialog-repositories";
+import { showRegistriesDialog } from "./dialogs/registries/show-dialog-registries";
+import "./supervisor-apps-repository";
+
+const sortRepos = (a: HassioAddonRepository, b: HassioAddonRepository) => {
+ if (a.slug === "local") {
+ return -1;
+ }
+ if (b.slug === "local") {
+ return 1;
+ }
+ if (a.slug === "core") {
+ return -1;
+ }
+ if (b.slug === "core") {
+ return 1;
+ }
+ return a.name.toUpperCase() < b.name.toUpperCase() ? -1 : 1;
+};
+
+@customElement("ha-config-apps-available")
+export class HaConfigAppsAvailable extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ type: Boolean }) public narrow = false;
+
+ @property({ attribute: false }) public route!: Route;
+
+ @state() private _store?: SupervisorStore;
+
+ @state() private _addon?: HassioAddonsInfo;
+
+ @state() private _error?: string;
+
+ @state() private _filter?: string;
+
+ public connectedCallback(): void {
+ super.connectedCallback();
+ this.addEventListener(
+ "apps-collection-refresh",
+ this._handleCollectionRefresh as unknown as EventListener
+ );
+ }
+
+ public disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this.removeEventListener(
+ "apps-collection-refresh",
+ this._handleCollectionRefresh as unknown as EventListener
+ );
+ }
+
+ protected firstUpdated(changedProps: PropertyValues) {
+ super.firstUpdated(changedProps);
+ const repositoryUrl = extractSearchParam("repository_url");
+ navigate("/config/apps/available", { replace: true });
+ this._loadData().then(() => {
+ if (repositoryUrl) {
+ this._manageRepositories(repositoryUrl);
+ }
+ });
+
+ this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev));
+ }
+
+ protected render() {
+ if (this._error) {
+ return html`
+
+ `;
+ }
+
+ if (!this._store || !this._addon) {
+ return html`
+
+ `;
+ }
+
+ let repos: (TemplateResult | typeof nothing)[] = [];
+
+ if (this._store.repositories) {
+ repos = this._addonRepositories(
+ this._store.repositories,
+ this._store.addons,
+ this._filter
+ );
+ }
+
+ return html`
+
+
+
+
+ ${this.hass.localize("ui.panel.config.apps.store.check_updates")}
+
+
+ ${this.hass.localize("ui.panel.config.apps.store.repositories")}
+
+ ${this.hass.userData?.showAdvanced
+ ? html`
+ ${this.hass.localize("ui.panel.config.apps.store.registries")}
+ `
+ : ""}
+
+ ${repos.length === 0
+ ? html``
+ : html`
+
+
+
+
+ ${repos}
+ `}
+ ${!this.hass.userData?.showAdvanced
+ ? html`
+
+ `
+ : ""}
+
+ `;
+ }
+
+ private _addonRepositories = memoizeOne(
+ (
+ repositories: HassioAddonRepository[],
+ addons: StoreAddon[],
+ filter?: string
+ ) =>
+ repositories.sort(sortRepos).map((repo) => {
+ const filteredAddons = addons.filter(
+ (addon) => addon.repository === repo.slug
+ );
+
+ return filteredAddons.length !== 0
+ ? html`
+
+ `
+ : nothing;
+ })
+ );
+
+ private _handleAction(ev: CustomEvent) {
+ switch (ev.detail.index) {
+ case 0:
+ this._refreshData();
+ break;
+ case 1:
+ this._manageRepositoriesClicked();
+ break;
+ case 2:
+ this._manageRegistries();
+ break;
+ }
+ }
+
+ private async _refreshData() {
+ try {
+ await reloadHassioAddons(this.hass);
+ } catch (err) {
+ showAlertDialog(this, {
+ text: extractApiErrorMessage(err),
+ });
+ } finally {
+ this._loadData();
+ }
+ }
+
+ private _apiCalled(ev) {
+ if (ev.detail.success) {
+ this._loadData();
+ }
+ }
+
+ private _manageRepositoriesClicked() {
+ this._manageRepositories();
+ }
+
+ private _manageRepositories(url?: string) {
+ showRepositoriesDialog(this, {
+ addon: this._addon!,
+ url,
+ closeCallback: () => this._loadData(),
+ });
+ }
+
+ private _manageRegistries() {
+ showRegistriesDialog(this);
+ }
+
+ private async _loadData(): Promise {
+ try {
+ const [addon, store] = await Promise.all([
+ fetchHassioAddonsInfo(this.hass),
+ fetchSupervisorStore(this.hass),
+ ]);
+
+ this._addon = addon;
+ this._store = store;
+ } catch (err: any) {
+ this._error =
+ err.message || this.hass.localize("ui.panel.config.apps.error_loading");
+ showAlertDialog(this, {
+ title: this.hass.localize("ui.panel.config.apps.error_loading"),
+ text: this._error,
+ });
+ }
+ }
+
+ private _handleCollectionRefresh = async (
+ ev: HASSDomEvent<{ collection: "addon" | "store" }>
+ ): Promise => {
+ const { collection } = ev.detail;
+ try {
+ if (collection === "addon") {
+ this._addon = await fetchHassioAddonsInfo(this.hass);
+ } else if (collection === "store") {
+ this._store = await fetchSupervisorStore(this.hass);
+ }
+ } catch (_err: any) {
+ // Silently fail on refresh errors
+ }
+ };
+
+ private _filterChanged(e) {
+ this._filter = e.detail.value;
+ }
+
+ static styles = css`
+ :host {
+ display: block;
+ height: 100%;
+ background-color: var(--primary-background-color);
+ }
+ supervisor-apps-repository {
+ margin-top: 24px;
+ }
+ .search {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ }
+ search-input {
+ display: block;
+ --mdc-text-field-fill-color: var(--sidebar-background-color);
+ --mdc-text-field-idle-line-color: var(--divider-color);
+ }
+ .advanced {
+ padding: 12px;
+ display: flex;
+ flex-wrap: wrap;
+ color: var(--primary-text-color);
+ }
+ .advanced a {
+ margin-left: 0.5em;
+ margin-inline-start: 0.5em;
+ margin-inline-end: initial;
+ color: var(--primary-color);
+ }
+ `;
+}
+
+declare global {
+ interface HASSDomEvents {
+ "apps-collection-refresh": { collection: "addon" | "store" };
+ }
+ interface HTMLElementTagNameMap {
+ "ha-config-apps-available": HaConfigAppsAvailable;
+ }
+}
diff --git a/src/panels/config/apps/ha-config-apps-installed.ts b/src/panels/config/apps/ha-config-apps-installed.ts
new file mode 100644
index 0000000000..1bb13069bd
--- /dev/null
+++ b/src/panels/config/apps/ha-config-apps-installed.ts
@@ -0,0 +1,296 @@
+import {
+ mdiArrowUpBoldCircle,
+ mdiPuzzle,
+ mdiRefresh,
+ mdiStorePlus,
+} from "@mdi/js";
+import type { CSSResultGroup, TemplateResult } from "lit";
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import memoizeOne from "memoize-one";
+import { navigate } from "../../../common/navigate";
+import { caseInsensitiveStringCompare } from "../../../common/string/compare";
+import "../../../components/ha-card";
+import "../../../components/ha-fab";
+import "../../../components/ha-icon-button";
+import "../../../components/ha-svg-icon";
+import "../../../components/search-input";
+import type {
+ HassioAddonInfo,
+ HassioAddonsInfo,
+} from "../../../data/hassio/addon";
+import {
+ fetchHassioAddonsInfo,
+ reloadHassioAddons,
+} from "../../../data/hassio/addon";
+import { extractApiErrorMessage } from "../../../data/hassio/common";
+import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
+import "../../../layouts/hass-error-screen";
+import "../../../layouts/hass-loading-screen";
+import "../../../layouts/hass-subpage";
+import type { HomeAssistant, Route } from "../../../types";
+import "./components/supervisor-apps-card-content";
+
+@customElement("ha-config-apps-installed")
+export class HaConfigAppsInstalled extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ type: Boolean }) public narrow = false;
+
+ @property({ attribute: false }) public route!: Route;
+
+ @state() private _addonInfo?: HassioAddonsInfo;
+
+ @state() private _filter?: string;
+
+ @state() private _error?: string;
+
+ protected firstUpdated() {
+ this._loadData();
+ }
+
+ protected render(): TemplateResult {
+ if (this._error) {
+ return html`
+
+ `;
+ }
+
+ if (!this._addonInfo) {
+ return html`
+
+ `;
+ }
+
+ const addons = this._getAddons(this._addonInfo.addons, this._filter);
+
+ return html`
+
+
+
+
+
+
+
+
+ ${addons.length === 0
+ ? html`
+
+
+
+
+
+ `
+ : addons.map(
+ (addon) => html`
+
+
+
+
+
+ `
+ )}
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ private _getAddons = memoizeOne(
+ (addons: HassioAddonInfo[], filter?: string) => {
+ let filteredAddons = addons;
+ if (filter) {
+ const lowerCaseFilter = filter.toLowerCase();
+ filteredAddons = addons.filter(
+ (addon) =>
+ addon.name.toLowerCase().includes(lowerCaseFilter) ||
+ addon.description.toLowerCase().includes(lowerCaseFilter) ||
+ addon.slug.toLowerCase().includes(lowerCaseFilter)
+ );
+ }
+ return filteredAddons.sort((a, b) =>
+ caseInsensitiveStringCompare(a.name, b.name, this.hass.locale.language)
+ );
+ }
+ );
+
+ private _handleSearchChange(ev: CustomEvent) {
+ this._filter = ev.detail.value;
+ }
+
+ private async _loadData(): Promise {
+ try {
+ this._addonInfo = await fetchHassioAddonsInfo(this.hass);
+ } catch (err: any) {
+ this._error =
+ err.message || this.hass.localize("ui.panel.config.apps.error_loading");
+ }
+ }
+
+ private async _handleCheckUpdates() {
+ try {
+ await reloadHassioAddons(this.hass);
+ } catch (err) {
+ showAlertDialog(this, {
+ text: extractApiErrorMessage(err),
+ });
+ } finally {
+ this._loadData();
+ }
+ }
+
+ private _addonTapped(ev: Event): void {
+ const addon = (ev.currentTarget as any).addon as HassioAddonInfo;
+ navigate(`/config/app/${addon.slug}/info`);
+ }
+
+ private _openStore(): void {
+ navigate("/config/apps/available");
+ }
+
+ static styles: CSSResultGroup = css`
+ :host {
+ display: block;
+ height: 100%;
+ background-color: var(--primary-background-color);
+ }
+
+ ha-card {
+ cursor: pointer;
+ overflow: hidden;
+ direction: ltr;
+ }
+
+ .search {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ }
+
+ search-input {
+ display: block;
+ --mdc-text-field-fill-color: var(--sidebar-background-color);
+ --mdc-text-field-idle-line-color: var(--divider-color);
+ }
+
+ .content {
+ padding: var(--ha-space-4);
+ margin-bottom: var(--ha-space-18);
+ }
+
+ .card-group {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ grid-gap: var(--ha-space-2);
+ }
+
+ .card-content {
+ display: flex;
+ justify-content: space-between;
+ padding: var(--ha-space-4);
+ }
+
+ button.link {
+ color: var(--primary-color);
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ text-align: left;
+ text-decoration: underline;
+ cursor: pointer;
+ }
+
+ ha-fab {
+ position: fixed;
+ right: calc(var(--ha-space-4) + var(--safe-area-inset-right));
+ bottom: calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
+ inset-inline-end: calc(var(--ha-space-4) + var(--safe-area-inset-right));
+ inset-inline-start: initial;
+ z-index: 1;
+ }
+ `;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-config-apps-installed": HaConfigAppsInstalled;
+ }
+}
diff --git a/src/panels/config/apps/ha-config-apps.ts b/src/panels/config/apps/ha-config-apps.ts
new file mode 100644
index 0000000000..95f8a8003c
--- /dev/null
+++ b/src/panels/config/apps/ha-config-apps.ts
@@ -0,0 +1,45 @@
+import { customElement, property } from "lit/decorators";
+import type { RouterOptions } from "../../../layouts/hass-router-page";
+import { HassRouterPage } from "../../../layouts/hass-router-page";
+import type { HomeAssistant, Route } from "../../../types";
+
+@customElement("ha-config-apps")
+class HaConfigApps extends HassRouterPage {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ type: Boolean }) public narrow = false;
+
+ @property({ attribute: "is-wide", type: Boolean }) public isWide = false;
+
+ @property({ attribute: false }) public showAdvanced = false;
+
+ @property({ attribute: false }) public route!: Route;
+
+ protected routerOptions: RouterOptions = {
+ defaultPage: "installed",
+ routes: {
+ installed: {
+ tag: "ha-config-apps-installed",
+ load: () => import("./ha-config-apps-installed"),
+ },
+ available: {
+ tag: "ha-config-apps-available",
+ load: () => import("./ha-config-apps-available"),
+ },
+ },
+ };
+
+ protected updatePageEl(pageEl) {
+ pageEl.hass = this.hass;
+ pageEl.narrow = this.narrow;
+ pageEl.isWide = this.isWide;
+ pageEl.showAdvanced = this.showAdvanced;
+ pageEl.route = this.routeTail;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-config-apps": HaConfigApps;
+ }
+}
diff --git a/src/panels/config/apps/resources/supervisor-apps-style.ts b/src/panels/config/apps/resources/supervisor-apps-style.ts
new file mode 100644
index 0000000000..9e3d149bf5
--- /dev/null
+++ b/src/panels/config/apps/resources/supervisor-apps-style.ts
@@ -0,0 +1,55 @@
+import { css } from "lit";
+
+export const supervisorAppsStyle = css`
+ .content {
+ margin: var(--ha-space-2);
+ }
+ h1,
+ .description,
+ .card-content {
+ color: var(--primary-text-color);
+ }
+ h1 {
+ font-size: 2em;
+ margin-bottom: var(--ha-space-2);
+ font-family: var(--ha-font-family-body);
+ -webkit-font-smoothing: var(--ha-font-smoothing);
+ -moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
+ font-size: var(--ha-font-size-2xl);
+ font-weight: var(--ha-font-weight-normal);
+ line-height: var(--ha-line-height-condensed);
+ padding-left: var(--ha-space-2);
+ padding-inline-start: var(--ha-space-2);
+ padding-inline-end: initial;
+ }
+ .description {
+ margin-top: var(--ha-space-1);
+ padding-left: var(--ha-space-2);
+ padding-inline-start: var(--ha-space-2);
+ padding-inline-end: initial;
+ }
+ .card-group {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ grid-gap: var(--ha-space-2);
+ }
+ @media screen and (min-width: 640px) {
+ .card-group {
+ grid-template-columns: repeat(auto-fit, minmax(300px, 0.5fr));
+ }
+ }
+ @media screen and (min-width: 1020px) {
+ .card-group {
+ grid-template-columns: repeat(auto-fit, minmax(300px, 0.333fr));
+ }
+ }
+ @media screen and (min-width: 1300px) {
+ .card-group {
+ grid-template-columns: repeat(auto-fit, minmax(300px, 0.25fr));
+ }
+ }
+ .error {
+ color: var(--error-color);
+ margin-top: var(--ha-space-4);
+ }
+`;
diff --git a/src/panels/config/apps/supervisor-apps-repository.ts b/src/panels/config/apps/supervisor-apps-repository.ts
new file mode 100644
index 0000000000..474a7a40c2
--- /dev/null
+++ b/src/panels/config/apps/supervisor-apps-repository.ts
@@ -0,0 +1,150 @@
+import { mdiArrowUpBoldCircle, mdiPuzzle } from "@mdi/js";
+import type { CSSResultGroup, TemplateResult } from "lit";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators";
+import memoizeOne from "memoize-one";
+import { navigate } from "../../../common/navigate";
+import { caseInsensitiveStringCompare } from "../../../common/string/compare";
+import "../../../components/ha-card";
+import type { HassioAddonRepository } from "../../../data/hassio/addon";
+import type { StoreAddon } from "../../../data/supervisor/store";
+import type { HomeAssistant } from "../../../types";
+import "./components/supervisor-apps-card-content";
+import { filterAndSort } from "./components/supervisor-apps-filter";
+import { supervisorAppsStyle } from "./resources/supervisor-apps-style";
+
+@customElement("supervisor-apps-repository")
+export class SupervisorAppsRepositoryEl extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public repo!: HassioAddonRepository;
+
+ @property({ attribute: false }) public addons!: StoreAddon[];
+
+ @property() public filter!: string;
+
+ private _getAddons = memoizeOne((addons: StoreAddon[], filter?: string) => {
+ if (filter) {
+ return filterAndSort(addons, filter);
+ }
+ return addons.sort((a, b) =>
+ caseInsensitiveStringCompare(a.name, b.name, this.hass.locale.language)
+ );
+ });
+
+ protected render(): TemplateResult {
+ const repo = this.repo;
+ let _addons = this.addons;
+ if (!this.hass.userData?.showAdvanced) {
+ _addons = _addons.filter(
+ (addon) => !addon.advanced && addon.stage === "stable"
+ );
+ }
+ const addons = this._getAddons(_addons, this.filter);
+
+ if (this.filter && addons.length < 1) {
+ return html`
+
+
+ ${this.hass.localize(
+ "ui.panel.config.apps.store.no_results_found",
+ {
+ repository: repo.name,
+ }
+ )}
+
+
+ `;
+ }
+ return html`
+
+
${repo.name}
+
+ ${addons.map(
+ (addon) => html`
+
+
+
+
+
+ `
+ )}
+
+
+ `;
+ }
+
+ private _addonTapped(ev) {
+ navigate(`/config/app/${ev.currentTarget.addon.slug}/info?store=true`);
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ supervisorAppsStyle,
+ css`
+ ha-card {
+ cursor: pointer;
+ overflow: hidden;
+ }
+ .not_available {
+ opacity: 0.6;
+ }
+ a.repo {
+ color: var(--primary-text-color);
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "supervisor-apps-repository": SupervisorAppsRepositoryEl;
+ }
+}
diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts
index fb6dbd526a..b40c26d924 100644
--- a/src/panels/config/ha-panel-config.ts
+++ b/src/panels/config/ha-panel-config.ts
@@ -85,7 +85,7 @@ export const configSections: Record = {
component: "zone",
},
{
- path: "/hassio",
+ path: "/config/apps",
translationKey: "supervisor",
iconPath: mdiPuzzle,
iconColor: "#F1C447",
@@ -646,6 +646,14 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) {
load: () =>
import("./application_credentials/ha-config-application-credentials"),
},
+ apps: {
+ tag: "ha-config-apps",
+ load: () => import("./apps/ha-config-apps"),
+ },
+ app: {
+ tag: "ha-config-app-dashboard",
+ load: () => import("./apps/ha-config-app-dashboard"),
+ },
},
};
diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts
index 100ca9f438..bc40bb725d 100644
--- a/src/panels/my/ha-panel-my.ts
+++ b/src/panels/my/ha-panel-my.ts
@@ -324,6 +324,27 @@ export const getMyRedirects = (): Redirects => ({
// Moved from Supervisor panel in 2022.5
redirect: "/config/info",
},
+ supervisor_store: {
+ redirect: "/config/apps/available",
+ },
+ supervisor_addons: {
+ redirect: "/config/apps",
+ },
+ supervisor_addon: {
+ redirect: "/config/app",
+ params: {
+ addon: "string",
+ },
+ optional_params: {
+ repository_url: "url",
+ },
+ },
+ supervisor_add_addon_repository: {
+ redirect: "/config/apps/available",
+ params: {
+ repository_url: "url",
+ },
+ },
hacs_repository: {
component: "hacs",
redirect: "/hacs/_my_redirect/hacs_repository",
@@ -502,13 +523,27 @@ class HaPanelMy extends LitElement {
}
private _createRedirectUrl(): string {
- const params = this._createRedirectParams();
- return `${this._redirect!.redirect}${params}`;
+ const params = extractSearchParamsObject();
+
+ // Special case for supervisor_addon: use path-based URL
+ if (params.addon && this._redirect!.redirect === "/config/app") {
+ const addon = params.addon;
+ delete params.addon;
+ const optionalParams = this._createOptionalParams(params);
+ return `/config/app/${addon}/info${optionalParams}`;
+ }
+
+ const resultParams = this._createRedirectParams();
+ return `${this._redirect!.redirect}${resultParams}`;
}
private _createRedirectParams(): string {
const params = extractSearchParamsObject();
- if (!this._redirect!.params && !Object.keys(params).length) {
+ if (
+ !this._redirect!.params &&
+ !this._redirect!.optional_params &&
+ !Object.keys(params).length
+ ) {
return "";
}
const resultParams = {};
@@ -521,7 +556,37 @@ class HaPanelMy extends LitElement {
}
resultParams[key] = params[key];
}
- return `?${createSearchParam(resultParams)}`;
+ for (const [key, type] of Object.entries(
+ this._redirect!.optional_params || {}
+ )) {
+ if (params[key]) {
+ if (!this._checkParamType(type, params[key])) {
+ throw Error();
+ }
+ resultParams[key] = params[key];
+ }
+ }
+ return Object.keys(resultParams).length
+ ? `?${createSearchParam(resultParams)}`
+ : "";
+ }
+
+ private _createOptionalParams(params: Record): string {
+ if (!this._redirect!.optional_params) {
+ return "";
+ }
+ const resultParams = {};
+ for (const [key, type] of Object.entries(this._redirect!.optional_params)) {
+ if (params[key]) {
+ if (!this._checkParamType(type, params[key])) {
+ throw Error();
+ }
+ resultParams[key] = params[key];
+ }
+ }
+ return Object.keys(resultParams).length
+ ? `?${createSearchParam(resultParams)}`
+ : "";
}
private _checkParamType(type: ParamType, value: string) {
diff --git a/src/translations/en.json b/src/translations/en.json
index 3e1f6be108..f263cfb828 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -1,5 +1,6 @@
{
"panel": {
+ "apps": "Apps",
"energy": "Energy",
"calendar": "Calendar",
"config": "Settings",
@@ -2315,7 +2316,7 @@
"secondary": "Generate backups of your Home Assistant configuration"
},
"supervisor": {
- "main": "Add-ons",
+ "main": "Apps",
"secondary": "Run extra applications next to Home Assistant"
},
"dashboards": {
@@ -2474,6 +2475,253 @@
"gen_image_header": "Image generation tasks",
"gen_image_description": "Generate images."
},
+ "apps": {
+ "caption": "Apps",
+ "description": "Install and manage apps",
+ "error_loading": "Error loading apps",
+ "state": {
+ "update_available": "Update available",
+ "installed": "Installed",
+ "not_installed": "Not installed",
+ "not_available": "Not available"
+ },
+ "installed": {
+ "search": "Search apps",
+ "no_apps": "You don't have any apps installed yet. Click here to browse available apps.",
+ "add_app": "Install app",
+ "app_stopped": "App is stopped",
+ "app_update_available": "Update available",
+ "app_running": "App is running"
+ },
+ "store": {
+ "title": "App store",
+ "check_updates": "Check for updates",
+ "repositories": "Repositories",
+ "registries": "Registries",
+ "missing_apps": "Looking for apps? Enable advanced mode.",
+ "no_results_found": "No results found in {repository}"
+ },
+ "dialog": {
+ "repositories": {
+ "title": "Manage app repositories",
+ "add": "Add",
+ "remove": "Remove",
+ "used": "In use by installed apps",
+ "no_repositories": "You don't have any app repositories configured"
+ },
+ "registries": {
+ "title_add": "Add registry",
+ "title_manage": "Manage registries",
+ "add_registry": "Add",
+ "add_new_registry": "Add new registry",
+ "username": "Username",
+ "remove": "Remove registry",
+ "no_registries": "You don't have any registries configured",
+ "failed_to_add": "Failed to add registry",
+ "failed_to_remove": "Failed to remove registry",
+ "registry": "Registry",
+ "password": "Password"
+ }
+ },
+ "panel": {
+ "info": "Info",
+ "documentation": "Documentation",
+ "configuration": "Configuration",
+ "log": "Log"
+ },
+ "dashboard": {
+ "cpu_usage": "CPU usage",
+ "ram_usage": "RAM usage",
+ "app_running": "App is running",
+ "app_stopped": "App is stopped",
+ "current_version": "Current version: {version}",
+ "changelog": "Changelog",
+ "hostname": "Hostname",
+ "visit_addon_page": "Visit {name} page for more details.",
+ "start": "Start",
+ "stop": "Stop",
+ "restart": "Restart",
+ "rebuild": "Rebuild",
+ "uninstall": "Uninstall",
+ "install": "Install",
+ "open_web_ui": "Open Web UI",
+ "failed_to_save": "Failed to save: {error}",
+ "failed_to_reset": "Failed to reset: {error}",
+ "failed_to_restart": "Failed to restart {name}",
+ "protection_mode": {
+ "title": "Protection mode disabled!",
+ "content": "Protection mode on this app is disabled! This gives the app full access to the entire system, which is more risky for your system if the app is compromised. Only disable protection mode if you know what you are doing.",
+ "enable": "Enable"
+ },
+ "system_managed": {
+ "title": "System managed",
+ "description": "This app is managed by the system. Settings are automatically configured and changes may be overwritten.",
+ "take_control": "Take control",
+ "badge": "System managed"
+ },
+ "option": {
+ "boot": {
+ "title": "Start on boot",
+ "description": "Automatically start this app when Home Assistant starts."
+ },
+ "watchdog": {
+ "title": "Watchdog",
+ "description": "Automatically restart this app when it crashes."
+ },
+ "auto_update": {
+ "title": "Auto update",
+ "description": "Automatically update this app when a new version is available."
+ },
+ "ingress_panel": {
+ "title": "Show in sidebar",
+ "description": "Show a shortcut to this app in the sidebar."
+ },
+ "protected": {
+ "title": "Protection mode",
+ "description": "Prevent the app from getting full access to the system."
+ }
+ },
+ "capability": {
+ "stages": {
+ "experimental": "Experimental",
+ "deprecated": "Deprecated"
+ },
+ "label": {
+ "rating": "Rating",
+ "host": "Host",
+ "hardware": "Hardware access",
+ "core": "Home Assistant",
+ "docker": "Docker",
+ "host_pid": "Host PID",
+ "apparmor": "AppArmor",
+ "auth": "Auth API",
+ "ingress": "Ingress",
+ "signed": "Signed"
+ },
+ "role": {
+ "manager": "manager",
+ "default": "default",
+ "homeassistant": "homeassistant",
+ "backup": "backup",
+ "admin": "admin"
+ },
+ "stage": {
+ "title": "App stage",
+ "description": "This app has a specific stage classification."
+ },
+ "rating": {
+ "title": "Security rating",
+ "description": "This shows the security rating of the app. Higher is better."
+ },
+ "host_network": {
+ "title": "Host network",
+ "description": "This app uses the host network stack."
+ },
+ "full_access": {
+ "title": "Full hardware access",
+ "description": "This app has full access to the hardware."
+ },
+ "homeassistant_api": {
+ "title": "Home Assistant API",
+ "description": "This app has access to the Home Assistant API."
+ },
+ "hassio_api": {
+ "title": "Supervisor API",
+ "description": "This app has access to the Supervisor API."
+ },
+ "docker_api": {
+ "title": "Docker API",
+ "description": "This app has access to the Docker API."
+ },
+ "host_pid": {
+ "title": "Host PID namespace",
+ "description": "This app has access to the host PID namespace."
+ },
+ "apparmor": {
+ "title": "AppArmor",
+ "description": "This shows the AppArmor status of the app."
+ },
+ "auth_api": {
+ "title": "Auth API",
+ "description": "This app has access to the Auth API."
+ },
+ "ingress": {
+ "title": "Ingress",
+ "description": "This app supports ingress for secure access."
+ },
+ "signed": {
+ "title": "Signed",
+ "description": "This app is cryptographically signed."
+ }
+ },
+ "action_error": {
+ "install": "Failed to install app",
+ "start": "Failed to start app",
+ "stop": "Failed to stop app",
+ "restart": "Failed to restart app",
+ "rebuild": "Failed to rebuild app",
+ "uninstall": "Failed to uninstall app",
+ "get_changelog": "Failed to get changelog",
+ "start_invalid_config": "Invalid configuration",
+ "go_to_config": "Go to configuration",
+ "validate_config": "Failed to validate app configuration"
+ },
+ "uninstall_dialog": {
+ "title": "Uninstall {name}?",
+ "remove_data": "Also remove app data",
+ "uninstall": "Uninstall"
+ },
+ "restart_dialog": {
+ "title": "Restart {name}?",
+ "text": "The app needs to be restarted for the changes to take effect.",
+ "restart": "Restart"
+ },
+ "update_available": {
+ "update_name": "Update {name}",
+ "no_update": "{name} is up to date.",
+ "description": "{name} {newest_version} is available. You are running {version}.",
+ "updating": "Updating {name} to {version}...",
+ "create_backup": {
+ "addon": "Create backup before updating",
+ "addon_description": "Creates a backup of {version} before updating.",
+ "generic": "Create backup"
+ }
+ }
+ },
+ "configuration": {
+ "no_configuration": "This app has no configuration options.",
+ "options": {
+ "header": "Options",
+ "edit_in_ui": "Edit in UI",
+ "edit_in_yaml": "Edit in YAML",
+ "invalid_yaml": "Invalid YAML",
+ "show_unused_optional": "Show unused optional configuration options"
+ },
+ "network": {
+ "header": "Network",
+ "introduction": "Configure the network ports that this app uses.",
+ "show_disabled": "Show disabled ports",
+ "reset_defaults": "Reset to defaults"
+ },
+ "audio": {
+ "header": "Audio",
+ "input": "Audio input",
+ "output": "Audio output",
+ "default": "Default",
+ "failed_to_load_hardware": "Failed to fetch audio hardware",
+ "failed_to_save": "Failed to set audio device"
+ },
+ "confirm": {
+ "reset_options": {
+ "title": "Reset options?",
+ "text": "Are you sure you want to reset all options to their default values?"
+ }
+ }
+ },
+ "documentation": {
+ "get_documentation": "Failed to get documentation: {error}"
+ }
+ },
"category": {
"caption": "Categories",
"assign": {