mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-06 08:22:08 +00:00
Compare commits
3 Commits
dev
...
port-8123-confirm
| Author | SHA1 | Date | |
|---|---|---|---|
| 05588d2dc8 | |||
| 6ff09c95e4 | |||
| d99526ff60 |
@@ -80,7 +80,7 @@ class HaInputMulti extends LitElement {
|
||||
<div class="items">
|
||||
${repeat(
|
||||
this._items,
|
||||
(item, index) => `${item}-${index}`,
|
||||
(_item, index) => index,
|
||||
(item, index) => {
|
||||
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
|
||||
return html`
|
||||
@@ -128,7 +128,7 @@ class HaInputMulti extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<div class="layout horizontal">
|
||||
<div class="layout horizontal add-row">
|
||||
<ha-button
|
||||
size="small"
|
||||
appearance="filled"
|
||||
@@ -217,6 +217,9 @@ class HaInputMulti extends LitElement {
|
||||
margin-bottom: 8px;
|
||||
--ha-input-padding-bottom: 0;
|
||||
}
|
||||
.add-row:has(+ ha-input-helper-text) {
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
ha-icon-button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface HttpConfig {
|
||||
server_host?: string[];
|
||||
server_port?: number;
|
||||
ssl_certificate?: string;
|
||||
ssl_peer_certificate?: string;
|
||||
ssl_key?: string;
|
||||
cors_allowed_origins?: string[];
|
||||
use_x_forwarded_for?: boolean;
|
||||
trusted_proxies?: string[];
|
||||
use_x_frame_options?: boolean;
|
||||
ip_ban_enabled?: boolean;
|
||||
login_attempts_threshold?: number;
|
||||
ssl_profile?: "modern" | "intermediate";
|
||||
}
|
||||
|
||||
export interface HttpConfigState {
|
||||
stable: HttpConfig;
|
||||
pending: HttpConfig | null;
|
||||
}
|
||||
|
||||
export interface SaveHttpConfigResult {
|
||||
restart: boolean;
|
||||
}
|
||||
|
||||
export const fetchHttpConfig = (hass: HomeAssistant) =>
|
||||
hass.callWS<HttpConfigState>({ type: "http/config" });
|
||||
|
||||
export const saveHttpConfig = (
|
||||
hass: HomeAssistant,
|
||||
config: HttpConfig | null
|
||||
) =>
|
||||
hass.callWS<SaveHttpConfigResult>({
|
||||
type: "http/config/configure",
|
||||
config,
|
||||
});
|
||||
|
||||
export const promoteHttpConfig = (hass: HomeAssistant) =>
|
||||
hass.callWS<undefined>({ type: "http/config/promote" });
|
||||
@@ -0,0 +1,229 @@
|
||||
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog";
|
||||
import type { HttpConfig } from "../../data/http";
|
||||
import { promoteHttpConfig, saveHttpConfig } from "../../data/http";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../make-dialog-manager";
|
||||
import type { HttpPendingConfigDialogParams } from "./show-dialog-http-pending-config";
|
||||
|
||||
const HTTP_FIELDS: (keyof HttpConfig)[] = [
|
||||
"server_port",
|
||||
"server_host",
|
||||
"ssl_certificate",
|
||||
"ssl_key",
|
||||
"ssl_peer_certificate",
|
||||
"ssl_profile",
|
||||
"cors_allowed_origins",
|
||||
"use_x_forwarded_for",
|
||||
"trusted_proxies",
|
||||
"use_x_frame_options",
|
||||
"ip_ban_enabled",
|
||||
"login_attempts_threshold",
|
||||
];
|
||||
|
||||
@customElement("dialog-http-pending-config")
|
||||
export class DialogHttpPendingConfig
|
||||
extends LitElement
|
||||
implements HassDialog<HttpPendingConfigDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: HttpPendingConfigDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _busy: "confirm" | "revert" | undefined;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
public showDialog(params: HttpPendingConfigDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._busy = undefined;
|
||||
this._error = undefined;
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._busy = undefined;
|
||||
this._error = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private get _changedFields(): (keyof HttpConfig)[] {
|
||||
if (!this._params?.state.pending) {
|
||||
return [];
|
||||
}
|
||||
const { stable, pending } = this._params.state;
|
||||
return HTTP_FIELDS.filter(
|
||||
(key) => JSON.stringify(stable[key]) !== JSON.stringify(pending[key])
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const changes = this._changedFields;
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
.headerTitle=${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.title"
|
||||
)}
|
||||
prevent-scrim-close
|
||||
width="medium"
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<span slot="headerNavigationIcon"></span>
|
||||
<div class="content">
|
||||
<p>
|
||||
${this.hass.localize("ui.dialogs.http_pending_config.description")}
|
||||
</p>
|
||||
${changes.length
|
||||
? html`
|
||||
<p class="changes-label">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.changes_label"
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
${changes.map(
|
||||
(key) => html`
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${key}` as any
|
||||
)}
|
||||
</li>
|
||||
`
|
||||
)}
|
||||
</ul>
|
||||
`
|
||||
: nothing}
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
.loading=${this._busy === "revert"}
|
||||
.disabled=${this._busy === "confirm"}
|
||||
@click=${this._revert}
|
||||
>
|
||||
${this.hass.localize("ui.dialogs.http_pending_config.revert")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
.loading=${this._busy === "confirm"}
|
||||
.disabled=${this._busy === "revert"}
|
||||
@click=${this._confirm}
|
||||
>
|
||||
${this.hass.localize("ui.dialogs.http_pending_config.confirm")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _confirm(): Promise<void> {
|
||||
if (this._busy) {
|
||||
return;
|
||||
}
|
||||
this._busy = "confirm";
|
||||
this._error = undefined;
|
||||
try {
|
||||
await promoteHttpConfig(this.hass);
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
} catch (err: any) {
|
||||
this._error = this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.confirm_error",
|
||||
{ error: err.message ?? "" }
|
||||
);
|
||||
this._busy = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async _revert(): Promise<void> {
|
||||
if (this._busy || !this._params) {
|
||||
return;
|
||||
}
|
||||
this._busy = "revert";
|
||||
this._error = undefined;
|
||||
try {
|
||||
await saveHttpConfig(this.hass, null);
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
} catch (err: any) {
|
||||
// The restart triggered by clearing pending may cut the WS connection
|
||||
// before we get a reply. The disconnected overlay takes over from here.
|
||||
if (err?.error?.code === ERR_CONNECTION_LOST) {
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
return;
|
||||
}
|
||||
this._error = this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.revert_error",
|
||||
{ error: err.message ?? "" }
|
||||
);
|
||||
this._busy = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _notifyResolved(): void {
|
||||
this._params?.onResolved?.();
|
||||
// The form on Settings > System > Network may be mounted and showing
|
||||
// stale state; let it know to refetch.
|
||||
window.dispatchEvent(new Event("http-config-resolved"));
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleDialog,
|
||||
css`
|
||||
.content {
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
p {
|
||||
margin: 0 0 var(--ha-space-4) 0;
|
||||
}
|
||||
.changes-label {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
ul {
|
||||
margin: 0 0 var(--ha-space-4) 0;
|
||||
padding-left: var(--ha-space-6);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
li {
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-http-pending-config": DialogHttpPendingConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HttpConfigState } from "../../data/http";
|
||||
|
||||
export interface HttpPendingConfigDialogParams {
|
||||
state: HttpConfigState;
|
||||
onResolved?: () => void;
|
||||
}
|
||||
|
||||
export const loadHttpPendingConfigDialog = () =>
|
||||
import("./dialog-http-pending-config");
|
||||
|
||||
export const showHttpPendingConfigDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: HttpPendingConfigDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-http-pending-config",
|
||||
dialogImport: loadHttpPendingConfigDialog,
|
||||
dialogParams,
|
||||
addHistory: false,
|
||||
});
|
||||
};
|
||||
@@ -5,9 +5,12 @@ import { customElement, state } from "lit/decorators";
|
||||
import { storage } from "../common/decorators/storage";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { navigate } from "../common/navigate";
|
||||
import { fetchHttpConfig } from "../data/http";
|
||||
import type { HttpConfigState } from "../data/http";
|
||||
import type { WindowWithPreloads } from "../data/preloads";
|
||||
import type { RecorderInfo } from "../data/recorder";
|
||||
import { getRecorderInfo } from "../data/recorder";
|
||||
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
|
||||
import "../resources/custom-card-support";
|
||||
import { HassElement } from "../state/hass-element";
|
||||
import QuickBarMixin from "../state/quick-bar-mixin";
|
||||
@@ -39,6 +42,8 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
|
||||
@state() private _databaseMigration?: boolean;
|
||||
|
||||
private _httpPendingDialogOpen = false;
|
||||
|
||||
private _panelUrl: string;
|
||||
|
||||
@storage({ key: "ha-version", state: false, subscribe: false })
|
||||
@@ -70,14 +75,24 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (
|
||||
this._databaseMigration === undefined &&
|
||||
changedProps.has("hass") &&
|
||||
this.hass?.config &&
|
||||
changedProps.get("hass")?.config !== this.hass?.config
|
||||
oldHass?.config !== this.hass.config
|
||||
) {
|
||||
this.checkDataBaseMigration();
|
||||
}
|
||||
// Wait for `hass.user` to populate so the admin guard can run; it arrives
|
||||
// asynchronously after `hass.config`.
|
||||
if (
|
||||
changedProps.has("hass") &&
|
||||
this.hass?.user &&
|
||||
oldHass?.user !== this.hass.user
|
||||
) {
|
||||
this.checkHttpPendingConfig();
|
||||
}
|
||||
}
|
||||
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
@@ -208,6 +223,32 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
}
|
||||
}
|
||||
|
||||
protected async checkHttpPendingConfig() {
|
||||
if (__DEMO__ || this._httpPendingDialogOpen) {
|
||||
return;
|
||||
}
|
||||
if (!this.hass?.user?.is_admin) {
|
||||
return;
|
||||
}
|
||||
let httpConfig: HttpConfigState;
|
||||
try {
|
||||
httpConfig = await fetchHttpConfig(this.hass);
|
||||
} catch (_err) {
|
||||
// The check re-runs on the next reconnect; ignore transient failures.
|
||||
return;
|
||||
}
|
||||
if (!httpConfig.pending || this._httpPendingDialogOpen) {
|
||||
return;
|
||||
}
|
||||
this._httpPendingDialogOpen = true;
|
||||
showHttpPendingConfigDialog(this, {
|
||||
state: httpConfig,
|
||||
onResolved: () => {
|
||||
this._httpPendingDialogOpen = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected async checkDataBaseMigration() {
|
||||
if (__DEMO__) {
|
||||
this._databaseMigration = false;
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-form/ha-form";
|
||||
import type { HaForm } from "../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../components/ha-form/types";
|
||||
import { fetchHttpConfig, saveHttpConfig } from "../../../data/http";
|
||||
import type { HttpConfig } from "../../../data/http";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
const SCHEMA = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "server_port",
|
||||
required: true,
|
||||
selector: { number: { min: 1, max: 65535, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "server_host",
|
||||
selector: { text: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "ssl",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.ssl"),
|
||||
schema: [
|
||||
{
|
||||
name: "ssl_certificate",
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "ssl_key",
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "ssl_peer_certificate",
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "ssl_profile",
|
||||
selector: {
|
||||
select: {
|
||||
options: [
|
||||
{
|
||||
value: "modern",
|
||||
label: localize(
|
||||
"ui.panel.config.network.http.ssl_profile_modern"
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "intermediate",
|
||||
label: localize(
|
||||
"ui.panel.config.network.http.ssl_profile_intermediate"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "reverse_proxy",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.reverse_proxy"),
|
||||
schema: [
|
||||
{
|
||||
name: "use_x_forwarded_for",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "trusted_proxies",
|
||||
selector: { text: { multiple: true } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "ip_banning",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.ip_banning"),
|
||||
schema: [
|
||||
{
|
||||
name: "ip_ban_enabled",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "login_attempts_threshold",
|
||||
required: true,
|
||||
selector: { number: { min: -1, max: 1000, mode: "box" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "advanced",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.advanced"),
|
||||
schema: [
|
||||
{
|
||||
name: "cors_allowed_origins",
|
||||
selector: { text: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "use_x_frame_options",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const
|
||||
);
|
||||
|
||||
@customElement("ha-config-http-form")
|
||||
class HaConfigHttpForm extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _stable?: HttpConfig;
|
||||
|
||||
@state() private _config?: HttpConfig;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _fieldErrors: Record<string, string> = {};
|
||||
|
||||
@state() private _saving = false;
|
||||
|
||||
@state() private _showNoChanges = false;
|
||||
|
||||
@query("ha-form") private _form?: HaForm;
|
||||
|
||||
@query("ha-alert") private _firstAlert?: HTMLElement;
|
||||
|
||||
private _onConfigResolved = () => this._fetchConfig();
|
||||
|
||||
protected override firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._fetchConfig();
|
||||
}
|
||||
|
||||
public override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("http-config-resolved", this._onConfigResolved);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("http-config-resolved", this._onConfigResolved);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._stable && !this._error) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const schema = SCHEMA(this.hass.localize);
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize("ui.panel.config.network.http.caption")}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p class="description">
|
||||
${this.hass.localize("ui.panel.config.network.http.description")}
|
||||
</p>
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
${this._showNoChanges
|
||||
? html`
|
||||
<ha-alert alert-type="success">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.save_no_changes"
|
||||
)}
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing}
|
||||
${this._config
|
||||
? html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${schema}
|
||||
.error=${this._fieldErrors}
|
||||
.disabled=${this._saving}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
${this._config
|
||||
? html`
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
@click=${this._save}
|
||||
.disabled=${this._saving}
|
||||
.loading=${this._saving}
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.network.http.save")}
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _fetchConfig(): Promise<void> {
|
||||
try {
|
||||
// Pending is exclusively handled by the global confirm/revert dialog, so
|
||||
// the form only ever displays stable.
|
||||
const { stable } = await fetchHttpConfig(this.hass);
|
||||
this._stable = stable;
|
||||
this._config = { ...stable };
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private _computeLabel = (
|
||||
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
|
||||
): string => {
|
||||
if ("type" in schema && schema.type === "expandable") {
|
||||
// Expandable sections render their own title; never label them.
|
||||
return "";
|
||||
}
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${schema.name}` as any
|
||||
);
|
||||
};
|
||||
|
||||
private _computeHelper = (
|
||||
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
|
||||
): string => {
|
||||
if ("type" in schema && schema.type === "expandable") {
|
||||
return "";
|
||||
}
|
||||
return (
|
||||
this.hass.localize(
|
||||
`ui.panel.config.network.http.helpers.${schema.name}` as any
|
||||
) || ""
|
||||
);
|
||||
};
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
this._config = ev.detail.value;
|
||||
this._error = undefined;
|
||||
this._fieldErrors = {};
|
||||
this._showNoChanges = false;
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._config || !this._stable) {
|
||||
return;
|
||||
}
|
||||
if (this._form && !this._form.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (JSON.stringify(this._stable) === JSON.stringify(this._config)) {
|
||||
this._showNoChanges = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.network.http.save_confirm.title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.network.http.save_confirm.text"
|
||||
),
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.network.http.save_confirm.confirm"
|
||||
),
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._saving = true;
|
||||
this._error = undefined;
|
||||
this._fieldErrors = {};
|
||||
this._showNoChanges = false;
|
||||
try {
|
||||
const result = await saveHttpConfig(this.hass, this._config);
|
||||
if (!result.restart) {
|
||||
this._showNoChanges = true;
|
||||
}
|
||||
// restart === true: a restart is in flight. The reply usually races with
|
||||
// the connection drop; if we do reach this branch, the disconnected
|
||||
// overlay will appear in moments. Leave the form as is.
|
||||
} catch (err: any) {
|
||||
// The restart kills the WS connection before the ack — that's expected.
|
||||
if (
|
||||
err?.error?.code === ERR_CONNECTION_LOST ||
|
||||
err === ERR_CONNECTION_LOST
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// voluptuous formats errors as "<message> @ data['<field>']".
|
||||
// If a field is identified, mark it inline; otherwise show a card-level
|
||||
// alert.
|
||||
const fieldMatch = err.message?.match(/\bdata\['([^']+)'\]/);
|
||||
if (fieldMatch) {
|
||||
this._fieldErrors = { [fieldMatch[1]]: err.message };
|
||||
} else {
|
||||
this._error = err.message;
|
||||
}
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
await this.updateComplete;
|
||||
await this._form?.updateComplete;
|
||||
// Inline field errors render inside ha-form's shadow root, so fall back to
|
||||
// it when no top-level alert is present.
|
||||
const target =
|
||||
this._firstAlert ??
|
||||
this._form?.shadowRoot?.querySelector<HTMLElement>("ha-alert");
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.description {
|
||||
margin-top: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-http-form": HaConfigHttpForm;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import "../../../components/ha-md-list";
|
||||
import "../../../components/ha-md-list-item";
|
||||
import "../../../components/ha-icon-next";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import "./ha-config-http-form";
|
||||
import "./ha-config-network";
|
||||
import "./ha-config-url-form";
|
||||
import "./supervisor-hostname";
|
||||
@@ -40,6 +41,7 @@ class HaConfigSectionNetwork extends LitElement {
|
||||
<supervisor-network .hass=${this.hass}></supervisor-network>`
|
||||
: ""}
|
||||
<ha-config-url-form .hass=${this.hass}></ha-config-url-form>
|
||||
<ha-config-http-form .hass=${this.hass}></ha-config-http-form>
|
||||
<ha-config-network .hass=${this.hass}></ha-config-network>
|
||||
${NETWORK_BROWSERS.some((component) =>
|
||||
isComponentLoaded(this.hass.config, component)
|
||||
@@ -88,6 +90,7 @@ class HaConfigSectionNetwork extends LitElement {
|
||||
supervisor-hostname,
|
||||
supervisor-network,
|
||||
ha-config-url-form,
|
||||
ha-config-http-form,
|
||||
ha-config-network,
|
||||
.discovery-card {
|
||||
display: block;
|
||||
|
||||
@@ -352,6 +352,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
|
||||
}
|
||||
this._updateHass({ config });
|
||||
this.checkDataBaseMigration();
|
||||
this.checkHttpPendingConfig();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ export class HassBaseEl extends LitElement {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
protected checkDataBaseMigration() {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
protected checkHttpPendingConfig() {}
|
||||
|
||||
protected hassChanged(hass, _oldHass) {
|
||||
this.__provideHass.forEach((el) => {
|
||||
(el as any).hass = hass;
|
||||
|
||||
@@ -1523,6 +1523,15 @@
|
||||
"title": "Safe mode",
|
||||
"text": "Home Assistant is running in safe mode, custom integrations and community frontend modules are not available. Restart Home Assistant to exit safe mode."
|
||||
},
|
||||
"http_pending_config": {
|
||||
"title": "Confirm new network configuration",
|
||||
"description": "Home Assistant has restarted with new HTTP server settings. Confirm to keep them, or revert to undo the change.",
|
||||
"changes_label": "Changed settings:",
|
||||
"confirm": "Confirm",
|
||||
"revert": "Revert",
|
||||
"confirm_error": "Failed to confirm the new configuration: {error}",
|
||||
"revert_error": "Failed to revert the configuration: {error}"
|
||||
},
|
||||
"quick-bar": {
|
||||
"commands_title": "Commands",
|
||||
"navigate_title": "Navigate",
|
||||
@@ -8285,6 +8294,53 @@
|
||||
"zeroconf": "Zeroconf browser",
|
||||
"zeroconf_info": "Show services discovered using mDNS. Does not include services unknown to Home Assistant."
|
||||
},
|
||||
"http": {
|
||||
"caption": "HTTP server",
|
||||
"description": "Configure how Home Assistant serves its web interface. Saving restarts Home Assistant.",
|
||||
"save": "Save",
|
||||
"save_no_changes": "Nothing changed — no restart needed.",
|
||||
"save_confirm": {
|
||||
"title": "Restart required",
|
||||
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
|
||||
"confirm": "Save and restart"
|
||||
},
|
||||
"ssl_profile_modern": "Modern",
|
||||
"ssl_profile_intermediate": "Intermediate",
|
||||
"sections": {
|
||||
"ssl": "SSL/TLS",
|
||||
"reverse_proxy": "Reverse proxy",
|
||||
"ip_banning": "IP banning",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"fields": {
|
||||
"server_port": "Server port",
|
||||
"server_host": "Listen addresses",
|
||||
"ssl_certificate": "SSL certificate path",
|
||||
"ssl_key": "SSL key path",
|
||||
"ssl_peer_certificate": "SSL peer certificate path",
|
||||
"ssl_profile": "SSL profile",
|
||||
"cors_allowed_origins": "CORS allowed origins",
|
||||
"use_x_forwarded_for": "Trust X-Forwarded-For",
|
||||
"trusted_proxies": "Trusted proxies",
|
||||
"use_x_frame_options": "Send X-Frame-Options",
|
||||
"ip_ban_enabled": "Enable IP banning",
|
||||
"login_attempts_threshold": "Login attempts before ban"
|
||||
},
|
||||
"helpers": {
|
||||
"server_port": "The port Home Assistant listens on. Default is 8123.",
|
||||
"server_host": "IP addresses to bind to. Leave empty to listen on all interfaces.",
|
||||
"ssl_certificate": "Absolute path to your TLS certificate (for example, /ssl/fullchain.pem).",
|
||||
"ssl_key": "Absolute path to your TLS private key (for example, /ssl/privkey.pem).",
|
||||
"ssl_peer_certificate": "Absolute path to a client certificate Home Assistant should require for secure connections.",
|
||||
"ssl_profile": "Mozilla SSL profile. Use 'Intermediate' only if integrations have SSL handshake issues.",
|
||||
"cors_allowed_origins": "Origins that may make cross-origin requests. Include the scheme, for example https://example.com.",
|
||||
"use_x_forwarded_for": "Trust the X-Forwarded-For header behind a reverse proxy. Requires the trusted proxies list below.",
|
||||
"trusted_proxies": "Reverse-proxy IP addresses or CIDR networks allowed to set X-Forwarded-For. Use a network address, not a host.",
|
||||
"use_x_frame_options": "Send the X-Frame-Options header to help prevent clickjacking.",
|
||||
"ip_ban_enabled": "Automatically ban IP addresses after repeated failed logins.",
|
||||
"login_attempts_threshold": "Failed login attempts before an IP is banned. Set to -1 to disable automatic bans."
|
||||
}
|
||||
},
|
||||
"network_adapter": "Network adapter",
|
||||
"network_adapter_info": "Configure which network adapters integrations will use. A restart is required for these settings to apply.",
|
||||
"ip_information": "IP information",
|
||||
|
||||
Reference in New Issue
Block a user