Compare commits

...

4 Commits

Author SHA1 Message Date
Petar Petrov 94bc9d8567 Update src/panels/config/network/ha-config-http-form.ts
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-05-12 08:39:01 +03:00
Petar Petrov 18268c67dc Surface fetch errors and validate before saving in HTTP config form 2026-05-11 14:50:20 +03:00
Petar Petrov 75a2331c87 Add HTTP server settings to the network panel 2026-05-11 14:06:52 +03:00
Petar Petrov 40fa954542 Fix focus loss in ha-input-multi when items change 2026-05-11 14:06:27 +03:00
5 changed files with 343 additions and 1 deletions
+1 -1
View File
@@ -78,7 +78,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`
+29
View File
@@ -0,0 +1,29 @@
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";
}
interface HttpConfigResponse {
config: HttpConfig;
}
export const fetchHttpConfig = (hass: HomeAssistant) =>
hass.callWS<HttpConfigResponse>({ type: "http/config/get" });
export const saveHttpConfig = (hass: HomeAssistant, config: HttpConfig) =>
hass.callWS<HttpConfigResponse>({
type: "http/config/update",
config,
});
@@ -0,0 +1,272 @@
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 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 { SchemaUnion } from "../../../components/ha-form/types";
import { fetchHttpConfig, saveHttpConfig } from "../../../data/http";
import type { HttpConfig } from "../../../data/http";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
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_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: "cors_allowed_origins",
selector: { text: { multiple: true } },
},
{
name: "use_x_forwarded_for",
selector: { boolean: {} },
},
{
name: "trusted_proxies",
selector: { text: { multiple: true } },
},
{
name: "use_x_frame_options",
selector: { boolean: {} },
},
{
name: "ip_ban_enabled",
selector: { boolean: {} },
},
{
name: "login_attempts_threshold",
required: true,
selector: { number: { min: -1, max: 1000, mode: "box" } },
},
] as const
);
@customElement("ha-config-http-form")
class HaConfigHttpForm extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: HttpConfig;
@state() private _error?: string;
@state() private _fieldErrors: Record<string, string> = {};
@state() private _saving = false;
@state() private _saved = false;
protected override firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._fetchConfig();
}
protected render() {
if (!this._config && !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._saved
? html`
<ha-alert
alert-type="info"
.title=${this.hass.localize(
"ui.panel.config.network.http.restart_required_title"
)}
>
${this.hass.localize(
"ui.panel.config.network.http.restart_required_description"
)}
<ha-button slot="action" @click=${this._restart}>
${this.hass.localize(
"ui.panel.config.network.http.restart"
)}
</ha-button>
</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}>
${this.hass.localize("ui.panel.config.network.http.save")}
</ha-button>
</div>
`
: nothing}
</ha-card>
`;
}
private async _fetchConfig(): Promise<void> {
try {
const result = await fetchHttpConfig(this.hass);
this._config = result.config;
} catch (err: any) {
this._error = err.message;
}
}
private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
): string =>
this.hass.localize(`ui.panel.config.network.http.fields.${schema.name}`);
private _computeHelper = (
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
): string =>
this.hass.localize(`ui.panel.config.network.http.helpers.${schema.name}`) ||
"";
private _valueChanged(ev: CustomEvent): void {
this._config = ev.detail.value;
this._saved = false;
this._error = undefined;
this._fieldErrors = {};
}
private async _save(): Promise<void> {
if (!this._config) {
return;
}
const form = this.renderRoot.querySelector("ha-form");
if (form && !form.reportValidity()) {
return;
}
this._saving = true;
this._error = undefined;
this._fieldErrors = {};
try {
const result = await saveHttpConfig(this.hass, this._config);
this._config = result.config;
this._saved = true;
} catch (err: any) {
// 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;
const haForm = this.renderRoot.querySelector("ha-form");
await haForm?.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.renderRoot.querySelector<HTMLElement>("ha-alert") ??
haForm?.shadowRoot?.querySelector<HTMLElement>("ha-alert");
target?.scrollIntoView({ behavior: "smooth", block: "center" });
}
private _restart(): void {
showRestartDialog(this);
}
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;
+38
View File
@@ -8251,6 +8251,44 @@
"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. Changes take effect after a restart.",
"save": "Save",
"restart": "Restart",
"restart_required_title": "Restart required",
"restart_required_description": "Restart Home Assistant to apply the new HTTP settings.",
"ssl_profile_modern": "Modern",
"ssl_profile_intermediate": "Intermediate",
"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",