mirror of
https://github.com/home-assistant/frontend.git
synced 2026-02-15 07:25:54 +00:00
Add apps panel as built-in panel (#28245)
* Add apps panel as built-in panel * Fix missing translation * Address cursor comment * Another cursor fix * One more cursor fix * Address PR review comments: localize error messages, fix deprecated method, use spacing tokens (#28246) * Initial plan * Address PR review comments: localize error messages, replace substr with substring, use spacing tokens Co-authored-by: balloob <1444314+balloob@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: balloob <1444314+balloob@users.noreply.github.com> * Cursor fix * Cursor: use willUpdate * prettier * Cursor: fix translation placeholder swap * Apply suggestions from code review Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com> * Cursor: clearTimeout * Cursor: fix race condition * Update src/translations/en.json Co-authored-by: Norbert Rittel <norbert@rittel.de> * Apply spacing tokens --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: balloob <1444314+balloob@users.noreply.github.com> Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com> Co-authored-by: Norbert Rittel <norbert@rittel.de>
This commit is contained in:
@@ -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`<ha-settings-row>
|
||||
<span slot="heading"> ${this.description} </span>
|
||||
<div slot="description" .title=${this.tooltip ?? ""}>
|
||||
<span class="value"> ${roundedValue} % </span>
|
||||
<ha-bar
|
||||
class=${classMap({
|
||||
"target-warning": roundedValue > 50,
|
||||
"target-critical": roundedValue > 85,
|
||||
})}
|
||||
.value=${this.value}
|
||||
></ha-bar>
|
||||
</div>
|
||||
</ha-settings-row>`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.apps.dashboard.update_available.update_name",
|
||||
{
|
||||
name: this.addon.name,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: ""}
|
||||
${this.addon.version === this.addon.version_latest
|
||||
? html`<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.dashboard.update_available.no_update",
|
||||
{
|
||||
name: this.addon.name,
|
||||
}
|
||||
)}
|
||||
</p>`
|
||||
: !this._updating
|
||||
? html`
|
||||
${this._changelogContent
|
||||
? html`
|
||||
<ha-faded>
|
||||
<ha-markdown .content=${this._changelogContent}>
|
||||
</ha-markdown>
|
||||
</ha-faded>
|
||||
`
|
||||
: nothing}
|
||||
<div class="versions">
|
||||
<p>
|
||||
${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,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
${createBackupTexts
|
||||
? html`
|
||||
<hr />
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${createBackupTexts.title}
|
||||
</span>
|
||||
|
||||
${createBackupTexts.description
|
||||
? html`
|
||||
<span slot="supporting-text">
|
||||
${createBackupTexts.description}
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
<ha-switch
|
||||
slot="end"
|
||||
id="create-backup"
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
`
|
||||
: nothing}
|
||||
`
|
||||
: html`<ha-spinner
|
||||
aria-label="Updating"
|
||||
size="large"
|
||||
></ha-spinner>
|
||||
<p class="progress-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.dashboard.update_available.updating",
|
||||
{
|
||||
name: this.addon.name,
|
||||
version: this.addon.version_latest,
|
||||
}
|
||||
)}
|
||||
</p>`}
|
||||
</div>
|
||||
${this.addon.version !== this.addon.version_latest && !this._updating
|
||||
? html`
|
||||
<div class="card-actions">
|
||||
<span></span>
|
||||
<ha-progress-button @click=${this._update}>
|
||||
${this.hass.localize("ui.common.update")}
|
||||
</ha-progress-button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
216
src/panels/config/apps/app-view/config/supervisor-app-audio.ts
Normal file
216
src/panels/config/apps/app-view/config/supervisor-app-audio.ts
Normal file
@@ -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`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.audio.header"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
${this._inputDevices &&
|
||||
html`<ha-select
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.audio.input"
|
||||
)}
|
||||
@selected=${this._setInputDevice}
|
||||
@closed=${stopPropagation}
|
||||
fixedMenuPosition
|
||||
naturalMenuWidth
|
||||
.value=${this._selectedInput!}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
${this._inputDevices.map(
|
||||
(item) => html`
|
||||
<ha-list-item .value=${item.device || ""}>
|
||||
${item.name}
|
||||
</ha-list-item>
|
||||
`
|
||||
)}
|
||||
</ha-select>`}
|
||||
${this._outputDevices &&
|
||||
html`<ha-select
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.audio.output"
|
||||
)}
|
||||
@selected=${this._setOutputDevice}
|
||||
@closed=${stopPropagation}
|
||||
fixedMenuPosition
|
||||
naturalMenuWidth
|
||||
.value=${this._selectedOutput!}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
${this._outputDevices.map(
|
||||
(item) => html`
|
||||
<ha-list-item .value=${item.device || ""}
|
||||
>${item.name}</ha-list-item
|
||||
>
|
||||
`
|
||||
)}
|
||||
</ha-select>`}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-progress-button
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._saveSettings}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-progress-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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`<ha-spinner></ha-spinner>`;
|
||||
}
|
||||
const hasConfiguration =
|
||||
(this.addon.options && Object.keys(this.addon.options).length) ||
|
||||
(this.addon.schema && Object.keys(this.addon.schema).length);
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
${this.addon.system_managed &&
|
||||
(hasConfiguration || this.addon.network || this.addon.audio)
|
||||
? html`
|
||||
<supervisor-app-system-managed
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.hideButton=${this.controlEnabled}
|
||||
></supervisor-app-system-managed>
|
||||
`
|
||||
: nothing}
|
||||
${hasConfiguration || this.addon.network || this.addon.audio
|
||||
? html`
|
||||
${hasConfiguration
|
||||
? html`
|
||||
<supervisor-app-config
|
||||
.hass=${this.hass}
|
||||
.addon=${this.addon}
|
||||
.disabled=${this.addon.system_managed &&
|
||||
!this.controlEnabled}
|
||||
></supervisor-app-config>
|
||||
`
|
||||
: nothing}
|
||||
${this.addon.network
|
||||
? html`
|
||||
<supervisor-app-network
|
||||
.hass=${this.hass}
|
||||
.addon=${this.addon}
|
||||
.disabled=${this.addon.system_managed &&
|
||||
!this.controlEnabled}
|
||||
></supervisor-app-network>
|
||||
`
|
||||
: nothing}
|
||||
${this.addon.audio
|
||||
? html`
|
||||
<supervisor-app-audio
|
||||
.hass=${this.hass}
|
||||
.addon=${this.addon}
|
||||
.disabled=${this.addon.system_managed &&
|
||||
!this.controlEnabled}
|
||||
></supervisor-app-audio>
|
||||
`
|
||||
: nothing}
|
||||
`
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.no_configuration"
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
518
src/panels/config/apps/app-view/config/supervisor-app-config.ts
Normal file
518
src/panels/config/apps/app-view/config/supervisor-app-config.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
@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<ObjectSelector["object"]>["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<string, unknown>, 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`
|
||||
<h1>${this.addon.name}</h1>
|
||||
<ha-card outlined>
|
||||
<div class="header">
|
||||
<h2>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.options.header"
|
||||
)}
|
||||
</h2>
|
||||
<div class="card-menu">
|
||||
<ha-button-menu @action=${this._handleAction}>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
slot="trigger"
|
||||
></ha-icon-button>
|
||||
<ha-list-item .disabled=${!this._canShowSchema || this.disabled}>
|
||||
${this._yamlMode
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.options.edit_in_ui"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.options.edit_in_yaml"
|
||||
)}
|
||||
</ha-list-item>
|
||||
<ha-list-item
|
||||
class=${!this.disabled ? "warning" : ""}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
${this.hass.localize("ui.common.reset_defaults")}
|
||||
</ha-list-item>
|
||||
</ha-button-menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
${showForm
|
||||
? html`<ha-form
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.data=${this._options!}
|
||||
@value-changed=${this._configChanged}
|
||||
.computeLabel=${this.computeLabel}
|
||||
.computeHelper=${this.computeHelper}
|
||||
.schema=${this._convertSchema(
|
||||
this._showOptional
|
||||
? this.addon.schema!
|
||||
: this._filteredSchema(
|
||||
this.addon.options,
|
||||
this.addon.schema!
|
||||
)
|
||||
)}
|
||||
></ha-form>`
|
||||
: html`<ha-yaml-editor
|
||||
@value-changed=${this._configChanged}
|
||||
.yamlSchema=${ADDON_YAML_SCHEMA}
|
||||
></ha-yaml-editor>`}
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: ""}
|
||||
${!this._yamlMode ||
|
||||
(this._canShowSchema && this.addon.schema) ||
|
||||
this._valid
|
||||
? ""
|
||||
: html`
|
||||
<ha-alert alert-type="error">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.options.invalid_yaml"
|
||||
)}
|
||||
</ha-alert>
|
||||
`}
|
||||
</div>
|
||||
${hasHiddenOptions
|
||||
? html`<ha-formfield
|
||||
class="show-additional"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.options.show_unused_optional"
|
||||
)}
|
||||
>
|
||||
<ha-switch
|
||||
@change=${this._toggleOptional}
|
||||
.checked=${this._showOptional}
|
||||
>
|
||||
</ha-switch>
|
||||
</ha-formfield>`
|
||||
: ""}
|
||||
<div class="card-actions right">
|
||||
<ha-progress-button
|
||||
@click=${this._saveTapped}
|
||||
.disabled=${this.disabled ||
|
||||
!this._configHasChanged ||
|
||||
!this._valid}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-progress-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
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<ActionDetail>) {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (this.disabled || !this._configHasChanged || !this._valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = ev.currentTarget as any;
|
||||
const options: Record<string, unknown> = 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;
|
||||
}
|
||||
}
|
||||
266
src/panels/config/apps/app-view/config/supervisor-app-network.ts
Normal file
266
src/panels/config/apps/app-view/config/supervisor-app-network.ts
Normal file
@@ -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<string, any>;
|
||||
|
||||
protected render() {
|
||||
if (!this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const hasHiddenOptions = Object.keys(this._config).find(
|
||||
(entry) => this._config![entry] === null
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.header"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.introduction"
|
||||
)}
|
||||
</p>
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
|
||||
<ha-form
|
||||
.disabled=${this.disabled}
|
||||
.data=${this._config}
|
||||
@value-changed=${this._configChanged}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
.schema=${this._createSchema(
|
||||
this._config,
|
||||
this._showOptional,
|
||||
this.hass.userData?.showAdvanced || false
|
||||
)}
|
||||
></ha-form>
|
||||
</div>
|
||||
${hasHiddenOptions
|
||||
? html`<ha-formfield
|
||||
class="show-optional"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.show_disabled"
|
||||
)}
|
||||
>
|
||||
<ha-switch
|
||||
@change=${this._toggleOptional}
|
||||
.checked=${this._showOptional}
|
||||
>
|
||||
</ha-switch>
|
||||
</ha-formfield>`
|
||||
: nothing}
|
||||
<div class="card-actions">
|
||||
<ha-progress-button
|
||||
variant="danger"
|
||||
appearance="plain"
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._resetTapped}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.reset_defaults"
|
||||
)}
|
||||
</ha-progress-button>
|
||||
<ha-progress-button
|
||||
@click=${this._saveTapped}
|
||||
.disabled=${!this._configHasChanged || this.disabled}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-progress-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("addon")) {
|
||||
this._setNetworkConfig();
|
||||
}
|
||||
}
|
||||
|
||||
private _createSchema = memoizeOne(
|
||||
(
|
||||
config: Record<string, number>,
|
||||
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<void> {
|
||||
this._configHasChanged = true;
|
||||
this._config = ev.detail.value;
|
||||
}
|
||||
|
||||
private async _resetTapped(ev: CustomEvent): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> => {
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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<void> {
|
||||
super.connectedCallback();
|
||||
await this._loadData();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this.addon) {
|
||||
return html`<ha-spinner></ha-spinner>`;
|
||||
}
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-card outlined>
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: ""}
|
||||
<div class="card-content">
|
||||
${this._content
|
||||
? html`<ha-markdown
|
||||
.content=${this._content}
|
||||
lazy-images
|
||||
></ha-markdown>`
|
||||
: html`<hass-loading-screen no-toolbar></hass-loading-screen>`}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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`<ha-spinner></ha-spinner>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<supervisor-app-info
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
.hass=${this.hass}
|
||||
.addon=${this.addon}
|
||||
.controlEnabled=${this.controlEnabled}
|
||||
></supervisor-app-info>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
1472
src/panels/config/apps/app-view/info/supervisor-app-info.ts
Normal file
1472
src/panels/config/apps/app-view/info/supervisor-app-info.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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`
|
||||
<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.apps.dashboard.system_managed.title"
|
||||
)}
|
||||
.narrow=${this.narrow}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.dashboard.system_managed.description"
|
||||
)}
|
||||
${!this.hideButton
|
||||
? html`
|
||||
<ha-button slot="action" @click=${this._takeControl}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.dashboard.system_managed.take_control"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing}
|
||||
</ha-alert>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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` <ha-spinner></ha-spinner> `;
|
||||
}
|
||||
return html`
|
||||
<div class="search">
|
||||
<search-input
|
||||
@value-changed=${this._filterChanged}
|
||||
.hass=${this.hass}
|
||||
.filter=${this._filter}
|
||||
.label=${this.hass.localize("ui.panel.config.logs.search")}
|
||||
></search-input>
|
||||
</div>
|
||||
<div class="content">
|
||||
<error-log-card
|
||||
.hass=${this.hass}
|
||||
.header=${this.addon.name}
|
||||
.provider=${this.addon.slug}
|
||||
.filter=${this._filter}
|
||||
>
|
||||
</error-log-card>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
58
src/panels/config/apps/app-view/supervisor-app-router.ts
Normal file
58
src/panels/config/apps/app-view/supervisor-app-router.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
30
src/panels/config/apps/app-view/util/supervisor-app.ts
Normal file
30
src/panels/config/apps/app-view/util/supervisor-app.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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` <div class="topbar ${this.topbarClass}"></div> `
|
||||
: ""}
|
||||
${this.iconImage
|
||||
? html`
|
||||
<div class="icon_image ${this.iconClass}">
|
||||
<img
|
||||
src=${this.iconImage}
|
||||
.title=${this.iconTitle}
|
||||
alt=${this.iconTitle ?? ""}
|
||||
/>
|
||||
<div></div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<ha-svg-icon
|
||||
class=${this.iconClass!}
|
||||
.path=${this.icon}
|
||||
.title=${this.iconTitle}
|
||||
></ha-svg-icon>
|
||||
`}
|
||||
<div>
|
||||
<div class="title">${this.title}</div>
|
||||
<div class="addition">
|
||||
${this.description}
|
||||
${
|
||||
/* treat as available when undefined */
|
||||
this.available === false ? " (Not available)" : ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
15
src/panels/config/apps/components/supervisor-apps-filter.ts
Normal file
15
src/panels/config/apps/components/supervisor-apps-filter.ts
Normal file
@@ -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<StoreAddon> = {
|
||||
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);
|
||||
}
|
||||
262
src/panels/config/apps/dialogs/registries/dialog-registries.ts
Normal file
262
src/panels/config/apps/dialogs/registries/dialog-registries.ts
Normal file
@@ -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`
|
||||
<ha-dialog
|
||||
.open=${this._opened}
|
||||
@closed=${this.closeDialog}
|
||||
scrimClickAction
|
||||
escapeKeyAction
|
||||
hideActions
|
||||
.heading=${createCloseHeading(
|
||||
this.hass,
|
||||
this._addingRegistry
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.apps.dialog.registries.title_add"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.apps.dialog.registries.title_manage"
|
||||
)
|
||||
)}
|
||||
>
|
||||
${this._addingRegistry
|
||||
? html`
|
||||
<ha-form
|
||||
.data=${this._input}
|
||||
.schema=${SCHEMA}
|
||||
@value-changed=${this._valueChanged}
|
||||
.computeLabel=${this._computeLabel}
|
||||
dialogInitialFocus
|
||||
></ha-form>
|
||||
<div class="action">
|
||||
<ha-button
|
||||
?disabled=${Boolean(
|
||||
!this._input.registry ||
|
||||
!this._input.username ||
|
||||
!this._input.password
|
||||
)}
|
||||
@click=${this._addNewRegistry}
|
||||
appearance="filled"
|
||||
size="small"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.dialog.registries.add_registry"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
: html`${this._registries?.length
|
||||
? this._registries.map(
|
||||
(entry) => html`
|
||||
<ha-settings-row class="registry">
|
||||
<span slot="heading"> ${entry.registry} </span>
|
||||
<span slot="description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.dialog.registries.username"
|
||||
)}:
|
||||
${entry.username}
|
||||
</span>
|
||||
<ha-icon-button
|
||||
.entry=${entry}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.apps.dialog.registries.remove"
|
||||
)}
|
||||
.path=${mdiDelete}
|
||||
@click=${this._removeRegistry}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>
|
||||
`
|
||||
)
|
||||
: html`
|
||||
<ha-alert>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.dialog.registries.no_registries"
|
||||
)}
|
||||
</ha-alert>
|
||||
`}
|
||||
<div class="action">
|
||||
<ha-button
|
||||
@click=${this._addRegistry}
|
||||
dialogInitialFocus
|
||||
appearance="filled"
|
||||
size="small"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.dialog.registries.add_new_registry"
|
||||
)}
|
||||
</ha-button>
|
||||
</div> `}
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _computeLabel = (schema: SchemaUnion<typeof SCHEMA>) =>
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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: {},
|
||||
});
|
||||
};
|
||||
@@ -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<void> {
|
||||
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`
|
||||
<ha-dialog
|
||||
.open=${this._opened}
|
||||
@closed=${this.closeDialog}
|
||||
scrimClickAction
|
||||
escapeKeyAction
|
||||
.heading=${createCloseHeading(
|
||||
this.hass,
|
||||
this.hass.localize("ui.panel.config.apps.dialog.repositories.title")
|
||||
)}
|
||||
>
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: ""}
|
||||
<div class="form">
|
||||
<ha-md-list>
|
||||
${repositories.length
|
||||
? repositories.map(
|
||||
(repo) => html`
|
||||
<ha-md-list-item class="option">
|
||||
${repo.name}
|
||||
<div slot="supporting-text">
|
||||
<div>${repo.maintainer}</div>
|
||||
<div>${repo.url}</div>
|
||||
</div>
|
||||
<ha-tooltip
|
||||
.for="icon-button-${repo.slug}"
|
||||
class="delete"
|
||||
slot="end"
|
||||
>
|
||||
${this.hass.localize(
|
||||
usedRepositories.includes(repo.slug)
|
||||
? "ui.panel.config.apps.dialog.repositories.used"
|
||||
: "ui.panel.config.apps.dialog.repositories.remove"
|
||||
)}
|
||||
</ha-tooltip>
|
||||
<div .id="icon-button-${repo.slug}">
|
||||
<ha-icon-button
|
||||
.disabled=${usedRepositories.includes(repo.slug)}
|
||||
.slug=${repo.slug}
|
||||
.path=${usedRepositories.includes(repo.slug)
|
||||
? mdiDeleteOff
|
||||
: mdiDelete}
|
||||
@click=${this._removeRepository}
|
||||
>
|
||||
</ha-icon-button>
|
||||
</div>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
)
|
||||
: html`<ha-md-list-item
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.apps.dialog.repositories.no_repositories"
|
||||
)}</ha-md-list-item
|
||||
>`}
|
||||
</ha-md-list>
|
||||
<div class="layout horizontal bottom">
|
||||
<ha-textfield
|
||||
class="flex-auto"
|
||||
id="repository_input"
|
||||
.value=${this._dialogParams?.url || ""}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.apps.dialog.repositories.add"
|
||||
)}
|
||||
@keydown=${this._handleKeyAdd}
|
||||
dialogInitialFocus
|
||||
></ha-textfield>
|
||||
<ha-button
|
||||
.loading=${this._processing}
|
||||
@click=${this._addRepository}
|
||||
appearance="filled"
|
||||
size="small"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.dialog.repositories.add"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
<ha-button slot="primaryAction" @click=${this.closeDialog}>
|
||||
${this.hass.localize("ui.common.close")}
|
||||
</ha-button>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
209
src/panels/config/apps/ha-config-app-dashboard.ts
Normal file
209
src/panels/config/apps/ha-config-app-dashboard.ts
Normal file
@@ -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 /<slug>/info or /<slug>/config
|
||||
const slug = pathParts[0] || "";
|
||||
const subPath = pathParts.slice(1).join("/");
|
||||
|
||||
return {
|
||||
prefix: route.prefix + "/" + slug,
|
||||
path: subPath ? "/" + subPath : "",
|
||||
};
|
||||
});
|
||||
|
||||
protected async firstUpdated(): Promise<void> {
|
||||
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`<hass-error-screen
|
||||
.hass=${this.hass}
|
||||
.error=${this._error}
|
||||
></hass-error-screen>`;
|
||||
}
|
||||
|
||||
if (!this._addon) {
|
||||
return html`<hass-loading-screen
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
></hass-loading-screen>`;
|
||||
}
|
||||
|
||||
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`
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${route}
|
||||
.tabs=${addonTabs}
|
||||
back-path=${this._fromStore ? "/config/apps/available" : "/config/apps"}
|
||||
>
|
||||
<span slot="header">${this._addon.name}</span>
|
||||
<supervisor-app-router
|
||||
.route=${route}
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
.addon=${this._addon}
|
||||
.controlEnabled=${this._controlEnabled}
|
||||
@system-managed-take-control=${this._enableControl}
|
||||
></supervisor-app-router>
|
||||
</hass-tabs-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _loadAddon(): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
332
src/panels/config/apps/ha-config-apps-available.ts
Normal file
332
src/panels/config/apps/ha-config-apps-available.ts
Normal file
@@ -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`
|
||||
<hass-error-screen
|
||||
.hass=${this.hass}
|
||||
.error=${this._error}
|
||||
></hass-error-screen>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this._store || !this._addon) {
|
||||
return html`
|
||||
<hass-loading-screen
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
></hass-loading-screen>
|
||||
`;
|
||||
}
|
||||
|
||||
let repos: (TemplateResult | typeof nothing)[] = [];
|
||||
|
||||
if (this._store.repositories) {
|
||||
repos = this._addonRepositories(
|
||||
this._store.repositories,
|
||||
this._store.addons,
|
||||
this._filter
|
||||
);
|
||||
}
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
back-path="/config/apps"
|
||||
.header=${this.hass.localize("ui.panel.config.apps.store.title")}
|
||||
>
|
||||
<ha-button-menu slot="toolbar-icon" @action=${this._handleAction}>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
slot="trigger"
|
||||
></ha-icon-button>
|
||||
<ha-list-item>
|
||||
${this.hass.localize("ui.panel.config.apps.store.check_updates")}
|
||||
</ha-list-item>
|
||||
<ha-list-item>
|
||||
${this.hass.localize("ui.panel.config.apps.store.repositories")}
|
||||
</ha-list-item>
|
||||
${this.hass.userData?.showAdvanced
|
||||
? html`<ha-list-item>
|
||||
${this.hass.localize("ui.panel.config.apps.store.registries")}
|
||||
</ha-list-item>`
|
||||
: ""}
|
||||
</ha-button-menu>
|
||||
${repos.length === 0
|
||||
? html`<hass-loading-screen no-toolbar></hass-loading-screen>`
|
||||
: html`
|
||||
<div class="search">
|
||||
<search-input
|
||||
.hass=${this.hass}
|
||||
.filter=${this._filter}
|
||||
@value-changed=${this._filterChanged}
|
||||
></search-input>
|
||||
</div>
|
||||
|
||||
${repos}
|
||||
`}
|
||||
${!this.hass.userData?.showAdvanced
|
||||
? html`
|
||||
<div class="advanced">
|
||||
<a href="/profile" target="_top">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.store.missing_apps"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<supervisor-apps-repository
|
||||
.hass=${this.hass}
|
||||
.repo=${repo}
|
||||
.addons=${filteredAddons}
|
||||
.filter=${filter!}
|
||||
></supervisor-apps-repository>
|
||||
`
|
||||
: nothing;
|
||||
})
|
||||
);
|
||||
|
||||
private _handleAction(ev: CustomEvent<ActionDetail>) {
|
||||
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<void> {
|
||||
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<void> => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
296
src/panels/config/apps/ha-config-apps-installed.ts
Normal file
296
src/panels/config/apps/ha-config-apps-installed.ts
Normal file
@@ -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`
|
||||
<hass-error-screen
|
||||
.hass=${this.hass}
|
||||
.error=${this._error}
|
||||
></hass-error-screen>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this._addonInfo) {
|
||||
return html`
|
||||
<hass-loading-screen
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
></hass-loading-screen>
|
||||
`;
|
||||
}
|
||||
|
||||
const addons = this._getAddons(this._addonInfo.addons, this._filter);
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize("ui.panel.config.apps.caption")}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="toolbar-icon"
|
||||
@click=${this._handleCheckUpdates}
|
||||
.path=${mdiRefresh}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.apps.store.check_updates"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
<div class="search">
|
||||
<search-input
|
||||
.hass=${this.hass}
|
||||
suffix
|
||||
.filter=${this._filter}
|
||||
@value-changed=${this._handleSearchChange}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.apps.installed.search"
|
||||
)}
|
||||
>
|
||||
</search-input>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card-group">
|
||||
${addons.length === 0
|
||||
? html`
|
||||
<ha-card outlined>
|
||||
<div class="card-content">
|
||||
<button class="link" @click=${this._openStore}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.installed.no_apps"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
: addons.map(
|
||||
(addon) => html`
|
||||
<ha-card
|
||||
outlined
|
||||
.addon=${addon}
|
||||
@click=${this._addonTapped}
|
||||
>
|
||||
<div class="card-content">
|
||||
<supervisor-apps-card-content
|
||||
.hass=${this.hass}
|
||||
.title=${addon.name}
|
||||
.description=${addon.description}
|
||||
available
|
||||
.showTopbar=${addon.update_available}
|
||||
topbarClass="update"
|
||||
.icon=${addon.update_available
|
||||
? mdiArrowUpBoldCircle
|
||||
: mdiPuzzle}
|
||||
.iconTitle=${addon.state !== "started"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.apps.installed.app_stopped"
|
||||
)
|
||||
: addon.update_available
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.apps.installed.app_update_available"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.apps.installed.app_running"
|
||||
)}
|
||||
.iconClass=${addon.update_available
|
||||
? addon.state === "started"
|
||||
? "update"
|
||||
: "update stopped"
|
||||
: addon.state === "started"
|
||||
? "running"
|
||||
: "stopped"}
|
||||
.iconImage=${addon.icon
|
||||
? `/api/hassio/addons/${addon.slug}/icon`
|
||||
: undefined}
|
||||
></supervisor-apps-card-content>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/config/apps/available">
|
||||
<ha-fab
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.apps.installed.add_app"
|
||||
)}
|
||||
extended
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiStorePlus}></ha-svg-icon>
|
||||
</ha-fab>
|
||||
</a>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
45
src/panels/config/apps/ha-config-apps.ts
Normal file
45
src/panels/config/apps/ha-config-apps.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
55
src/panels/config/apps/resources/supervisor-apps-style.ts
Normal file
55
src/panels/config/apps/resources/supervisor-apps-style.ts
Normal file
@@ -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);
|
||||
}
|
||||
`;
|
||||
150
src/panels/config/apps/supervisor-apps-repository.ts
Normal file
150
src/panels/config/apps/supervisor-apps-repository.ts
Normal file
@@ -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`
|
||||
<div class="content">
|
||||
<p class="description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.store.no_results_found",
|
||||
{
|
||||
repository: repo.name,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div class="content">
|
||||
<h1>${repo.name}</h1>
|
||||
<div class="card-group">
|
||||
${addons.map(
|
||||
(addon) => html`
|
||||
<ha-card
|
||||
outlined
|
||||
.addon=${addon}
|
||||
class=${addon.available ? "" : "not_available"}
|
||||
@click=${this._addonTapped}
|
||||
>
|
||||
<div class="card-content">
|
||||
<supervisor-apps-card-content
|
||||
.hass=${this.hass}
|
||||
.title=${addon.name}
|
||||
.description=${addon.description}
|
||||
.available=${addon.available}
|
||||
.icon=${addon.installed && addon.update_available
|
||||
? mdiArrowUpBoldCircle
|
||||
: mdiPuzzle}
|
||||
.iconTitle=${addon.installed
|
||||
? addon.update_available
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.apps.state.update_available"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.apps.state.installed"
|
||||
)
|
||||
: addon.available
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.apps.state.not_installed"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.apps.state.not_available"
|
||||
)}
|
||||
.iconClass=${addon.installed
|
||||
? addon.update_available
|
||||
? "update"
|
||||
: "installed"
|
||||
: !addon.available
|
||||
? "not_available"
|
||||
: ""}
|
||||
.iconImage=${addon.icon
|
||||
? `/api/hassio/addons/${addon.slug}/icon`
|
||||
: undefined}
|
||||
.showTopbar=${addon.installed || !addon.available}
|
||||
.topbarClass=${addon.installed
|
||||
? addon.update_available
|
||||
? "update"
|
||||
: "installed"
|
||||
: !addon.available
|
||||
? "unavailable"
|
||||
: ""}
|
||||
></supervisor-apps-card-content>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
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"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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, string>): 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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user