Compare commits

..

5 Commits

Author SHA1 Message Date
Bram Kragten 156ab27cfa 20260527.4 (#52388) 2026-06-03 12:44:08 +02:00
Bram Kragten ba26e9f491 Bumped version to 20260527.4 2026-06-03 12:03:26 +02:00
Paul Bottein 8778fe8577 Restore search field autofocus in card and badge pickers (#52387) 2026-06-03 12:03:12 +02:00
Aidan Timson 6801aaea30 Fix automation building block action icon style (#52382) 2026-06-03 12:03:12 +02:00
Wendelin c3f5b6693a Landingpage download progress (#52359)
* Simplify and improve landingpage

* add core download progress

* reduce to 2 seconds

* Use round to display full integer as progress percentage

* Use find to get the job object

* Don't show progress label when progress is at 0

Before download starts, progress is at 0. At this point we may trying
to reach a server (and error out), so we aren't really in downloading
phase just yet. Simply treat 0 as "not started" and hide the progress
label until we have a real progress value.

---------

Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-06-03 12:03:10 +02:00
14 changed files with 226 additions and 131 deletions
+31
View File
@@ -13,6 +13,28 @@ export interface NetworkInfo {
supervisor_internet: boolean;
}
interface SupervisorJob {
name: string;
reference: string | null;
uuid: string;
progress: number; // float, 0100
stage: string | null;
done: boolean;
errors: {
type: string;
message: string;
stage: string | null;
}[];
created: string; // ISO datetime string
extra: Record<string, unknown> | null;
child_jobs: SupervisorJob[];
}
export interface SupervisorJobInfo {
ignore_conditions: string[];
jobs: SupervisorJob[];
}
export const ALTERNATIVE_DNS_SERVERS: {
ipv4: string[];
ipv6: string[];
@@ -57,6 +79,15 @@ export async function getSupervisorNetworkInfo(): Promise<NetworkInfo> {
return responseData?.data;
}
export async function getSupervisorJobsInfo(): Promise<
HassioResponse<SupervisorJobInfo>
> {
const responseData = await handleFetchPromise<
HassioResponse<SupervisorJobInfo>
>(fetch("/supervisor-api/jobs/info"));
return responseData;
}
export const setSupervisorNetworkDns = async (
dnsServerIndex: number,
primaryInterface: string
+58 -6
View File
@@ -2,9 +2,9 @@ import { mdiOpenInNew } from "@mdi/js";
import { css, html, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { extractSearchParam } from "../../src/common/url/search-params";
import "../../src/components/animation/ha-fade-in";
import "../../src/components/ha-alert";
import "../../src/components/ha-button";
import "../../src/components/animation/ha-fade-in";
import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
import "../../src/components/progress/ha-progress-bar";
@@ -15,6 +15,7 @@ import { haStyle } from "../../src/resources/styles";
import "./components/landing-page-logs";
import "./components/landing-page-network";
import {
getSupervisorJobsInfo,
getSupervisorNetworkInfo,
pingSupervisor,
type NetworkInfo,
@@ -24,6 +25,7 @@ import { LandingPageBaseElement } from "./landing-page-base-element";
export const ASSUME_CORE_START_SECONDS = 60;
const SCHEDULE_CORE_CHECK_SECONDS = 1;
const SCHEDULE_FETCH_NETWORK_INFO_SECONDS = 5;
const SCHEDULE_FETCH_JOBS_INFO_SECONDS = 2;
@customElement("ha-landing-page")
class HaLandingPage extends LandingPageBaseElement {
@@ -39,6 +41,8 @@ class HaLandingPage extends LandingPageBaseElement {
@state() private _coreCheckActive = false;
@state() private _progress = -1;
private _mobileApp =
extractSearchParam("redirect_uri") === "homeassistant://auth-callback";
@@ -60,7 +64,14 @@ class HaLandingPage extends LandingPageBaseElement {
${!networkIssue && !this._supervisorError
? html`
<p>${this.localize("subheader")}</p>
<ha-progress-bar indeterminate></ha-progress-bar>
<ha-progress-bar
.indeterminate=${this._progress <= 0}
.value=${this._progress > 0 ? this._progress : undefined}
.loading=${this._progress >= 0}
>${this._progress > 0
? `${Math.round(this._progress)}%`
: nothing}</ha-progress-bar
>
`
: nothing}
${networkIssue || this._networkInfoError
@@ -126,6 +137,7 @@ class HaLandingPage extends LandingPageBaseElement {
import("../../src/components/ha-language-picker");
this._fetchSupervisorInfo(true);
this._fetchSupervisorJobsInfo();
}
private _scheduleFetchSupervisorInfo() {
@@ -138,6 +150,13 @@ class HaLandingPage extends LandingPageBaseElement {
);
}
private _scheduleFetchSupervisorJobsInfo() {
setTimeout(
() => this._fetchSupervisorJobsInfo(),
SCHEDULE_FETCH_JOBS_INFO_SECONDS * 1000
);
}
private _scheduleTurnOffCoreCheck() {
setTimeout(() => {
this._coreCheckActive = false;
@@ -165,7 +184,7 @@ class HaLandingPage extends LandingPageBaseElement {
// assume supervisor update if ping fails -> don't show an error
if (!this._coreCheckActive && err.message !== "ping-failed") {
// eslint-disable-next-line no-console
console.error(err);
console.error("Failed to fetch supervisor info", err);
this._networkInfoError = true;
}
}
@@ -175,6 +194,33 @@ class HaLandingPage extends LandingPageBaseElement {
}
}
private async _fetchSupervisorJobsInfo() {
try {
const jobsInfo = await getSupervisorJobsInfo();
const coreInstallJob =
jobsInfo.result === "ok"
? jobsInfo.data.jobs.find(
(job) => job.name === "home_assistant_core_install"
)
: undefined;
if (coreInstallJob) {
this._progress = coreInstallJob.progress;
} else {
this._progress = -1;
}
} catch (err: any) {
await this._checkCoreAvailability();
if (!this._coreCheckActive) {
this._progress = -1;
// eslint-disable-next-line no-console
console.error("Failed to fetch supervisor jobs info", err);
}
}
this._scheduleFetchSupervisorJobsInfo();
}
private async _checkCoreAvailability() {
try {
const response = await fetch("/manifest.json");
@@ -222,21 +268,27 @@ class HaLandingPage extends LandingPageBaseElement {
flex-direction: column;
gap: var(--ha-space-4);
}
ha-language-picker {
min-width: 200px;
}
ha-alert p {
text-align: unset;
}
.footer ha-svg-icon {
--mdc-icon-size: var(--ha-space-5);
}
ha-language-picker {
margin-inline-start: calc(-1 * var(--ha-space-4));
}
ha-button {
margin-inline-end: calc(-1 * var(--ha-space-2));
}
ha-fade-in {
min-height: calc(100vh - 64px - 88px);
display: flex;
justify-content: center;
align-items: center;
}
ha-progress-bar {
--ha-progress-bar-track-height: 20px;
}
`,
];
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260527.3"
version = "20260527.4"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -165,6 +165,7 @@ export class HaAutomationRow extends LitElement {
::slotted([slot="leading-icon"]) {
color: var(--ha-color-on-neutral-quiet);
}
:host([building-block]) ::slotted([slot="leading-icon"].action-icon),
:host([building-block]) ::slotted(#condition-icon) {
--mdc-icon-size: var(--ha-space-5);
color: var(--white-color);
+19 -30
View File
@@ -1,39 +1,20 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { LocalizeFunc } from "../../common/translations/localize";
import { fireEvent } from "../../common/dom/fire_event";
import { customElement } from "lit/decorators";
import "../../components/ha-dialog";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import type { AppDialogParams } from "./show-app-dialog";
@customElement("app-dialog")
class DialogApp extends LitElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@state() private _open = false;
public async showDialog(params: { localize: LocalizeFunc }): Promise<void> {
this.localize = params.localize;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this.localize = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
class DialogApp extends DialogMixin<AppDialogParams>(LitElement) {
protected render() {
if (!this.localize) {
if (!this.params?.localize) {
return nothing;
}
return html`<ha-dialog
.open=${this._open}
header-title=${this.localize(
open
header-title=${this.params.localize(
"ui.panel.page-onboarding.welcome.download_app"
) || "Click here to download the app"}
@closed=${this._dialogClosed}
>
<div>
<div class="app-qr">
@@ -45,13 +26,17 @@ class DialogApp extends LitElement {
<img
loading="lazy"
src="/static/images/appstore.svg"
alt=${this.localize("ui.panel.page-onboarding.welcome.appstore")}
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.appstore"
)}
class="icon"
/>
<img
loading="lazy"
src="/static/images/qr-appstore.svg"
alt=${this.localize("ui.panel.page-onboarding.welcome.appstore")}
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.appstore"
)}
/>
</a>
<a
@@ -62,13 +47,17 @@ class DialogApp extends LitElement {
<img
loading="lazy"
src="/static/images/playstore.svg"
alt=${this.localize("ui.panel.page-onboarding.welcome.playstore")}
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.playstore"
)}
class="icon"
/>
<img
loading="lazy"
src="/static/images/qr-playstore.svg"
alt=${this.localize("ui.panel.page-onboarding.welcome.playstore")}
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.playstore"
)}
/>
</a>
</div>
+59 -75
View File
@@ -1,103 +1,87 @@
import { mdiAccountGroup, mdiOpenInNew } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import { customElement } from "lit/decorators";
import "../../components/ha-dialog";
import "../../components/ha-list";
import "../../components/ha-list-item";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-nav";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import type { CommunityDialogParams } from "./show-community-dialog";
@customElement("community-dialog")
class DialogCommunity extends LitElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@state() private _open = false;
public async showDialog(params): Promise<void> {
this.localize = params.localize;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this.localize = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
class DialogCommunity extends DialogMixin<CommunityDialogParams>(LitElement) {
protected render() {
if (!this.localize) {
if (!this.params?.localize) {
return nothing;
}
return html`<ha-dialog
.open=${this._open}
header-title=${this.localize(
open
header-title=${this.params.localize(
"ui.panel.page-onboarding.welcome.community"
)}
@closed=${this._dialogClosed}
>
<ha-list>
<a
<ha-list-nav>
<ha-list-item-button
target="_blank"
rel="noreferrer noopener"
href="https://community.home-assistant.io/"
>
<ha-list-item hasMeta graphic="icon">
<img
src="/static/icons/favicon-192x192.png"
slot="graphic"
alt="Home Assistant Logo"
/>
${this.localize("ui.panel.page-onboarding.welcome.forums")}
<ha-svg-icon slot="meta" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item>
</a>
<a
<img
src="/static/icons/favicon-192x192.png"
slot="start"
alt="Home Assistant Logo"
/>
<span slot="headline">
${this.params.localize("ui.panel.page-onboarding.welcome.forums")}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
<ha-list-item-button
target="_blank"
rel="noreferrer noopener"
href="https://newsletter.openhomefoundation.org/"
>
<ha-list-item hasMeta graphic="icon">
<img
src="/static/icons/logo_ohf.svg"
slot="graphic"
alt="Open Home Foundation Logo"
/>
${this.localize(
<img
src="/static/icons/logo_ohf.svg"
slot="start"
alt="Open Home Foundation Logo"
/>
<span slot="headline">
${this.params.localize(
"ui.panel.page-onboarding.welcome.open_home_newsletter"
)}
<ha-svg-icon slot="meta" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item>
</a>
<a
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
<ha-list-item-button
target="_blank"
rel="noreferrer noopener"
href="https://www.home-assistant.io/join-chat"
>
<ha-list-item hasMeta graphic="icon">
<img
src="/static/images/logo_discord.png"
slot="graphic"
alt="Discord Logo"
/>
${this.localize("ui.panel.page-onboarding.welcome.discord")}
<ha-svg-icon slot="meta" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item>
</a>
<a
<img
src="/static/images/logo_discord.png"
slot="start"
alt="Discord Logo"
/>
<span slot="headline">
${this.params.localize("ui.panel.page-onboarding.welcome.discord")}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
<ha-list-item-button
target="_blank"
rel="noreferrer noopener"
href="https://fosstodon.org/@homeassistant"
>
<ha-list-item hasMeta graphic="icon">
<ha-svg-icon .path=${mdiAccountGroup} slot="graphic"></ha-svg-icon>
${this.localize("ui.panel.page-onboarding.welcome.social_media")}
<ha-svg-icon slot="meta" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item>
</a>
</ha-list>
<ha-svg-icon .path=${mdiAccountGroup} slot="start"></ha-svg-icon>
<span slot="headline">
${this.params.localize(
"ui.panel.page-onboarding.welcome.social_media"
)}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
</ha-list-nav>
</ha-dialog>`;
}
@@ -105,12 +89,12 @@ class DialogCommunity extends LitElement {
ha-dialog {
--dialog-content-padding: 0;
}
ha-list-item {
height: 56px;
--mdc-list-item-meta-size: 20px;
img {
width: 32px;
height: 32px;
}
a {
text-decoration: none;
ha-svg-icon {
color: var(--ha-color-text-secondary);
}
`;
}
+6 -1
View File
@@ -3,13 +3,18 @@ import type { LocalizeFunc } from "../../common/translations/localize";
export const loadAppDialog = () => import("./app-dialog");
export interface AppDialogParams {
localize: LocalizeFunc;
}
export const showAppDialog = (
element: HTMLElement,
params: { localize: LocalizeFunc }
params: AppDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "app-dialog",
dialogImport: loadAppDialog,
dialogParams: params,
addHistory: false,
});
};
@@ -3,13 +3,18 @@ import type { LocalizeFunc } from "../../common/translations/localize";
export const loadCommunityDialog = () => import("./community-dialog");
export interface CommunityDialogParams {
localize: LocalizeFunc;
}
export const showCommunityDialog = (
element: HTMLElement,
params: { localize: LocalizeFunc }
params: CommunityDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "community-dialog",
dialogImport: loadCommunityDialog,
dialogParams: params,
addHistory: false,
});
};
@@ -2,7 +2,7 @@ import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
@@ -66,10 +66,19 @@ export class HuiBadgePicker extends LitElement {
@state() private _height?: number;
@query("ha-input-search") private _searchInput?: HaInputSearch;
private _unusedEntities?: string[];
private _usedEntities?: string[];
public async focus(): Promise<void> {
await this.updateComplete;
// Wait for the input's inner wa-input to render so focus delegation works.
await this._searchInput?.updateComplete;
this._searchInput?.focus();
}
private _filterBadges = memoizeOne(
(badgeElements: BadgeElement[], filter?: string): BadgeElement[] => {
if (!filter) {
@@ -62,19 +62,17 @@ export class HuiCardPicker extends LitElement {
@state() private _filter = "";
@query("ha-input-search") private _searchInput?: HTMLElement;
@query("ha-input-search") private _searchInput?: HaInputSearch;
private _unusedEntities?: string[];
private _usedEntities?: string[];
public async focus(): Promise<void> {
if (this._searchInput) {
this._searchInput.focus();
} else {
await this.updateComplete;
this.focus();
}
await this.updateComplete;
// Wait for the input's inner wa-input to render so focus delegation works.
await this._searchInput?.updateComplete;
this._searchInput?.focus();
}
private _filterCards = memoizeOne(
@@ -133,6 +133,7 @@ export class HuiCreateDialogCard
this._currTab === "entity"
? html`
<hui-suggestion-picker
?autofocus=${!this._narrow}
.hass=${this.hass}
.prioritizedCardTypes=${this._params.suggestedCards}
@suggestion-picked=${this._handleSuggestionPicked}
@@ -8,7 +8,7 @@ import {
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { transform } from "../../../../common/decorators/transform";
@@ -85,6 +85,15 @@ export class HuiSuggestionEntityTree extends LitElement {
@state() private _fuseIndex?: EntityFuseIndex;
@query("ha-input-search") private _searchInput?: HaInputSearch;
public async focus(): Promise<void> {
await this.updateComplete;
// Wait for the input's inner wa-input to render so focus delegation works.
await this._searchInput?.updateComplete;
this._searchInput?.focus();
}
public connectedCallback(): void {
super.connectedCallback();
this._loadDomainTranslations();
@@ -135,7 +144,7 @@ export class HuiSuggestionEntityTree extends LitElement {
}
protected render() {
if (!this.hass || !this._tree) return nothing;
if (!this.hass) return nothing;
return html`
<ha-input-search
@@ -146,11 +155,13 @@ export class HuiSuggestionEntityTree extends LitElement {
)}
@input=${this._handleFilterChange}
></ha-input-search>
${this._filter
? this._renderSearchResults()
: html`<div class="tree ha-scrollbar">
${this._renderTree(this._tree)}
</div>`}
${this._tree
? this._filter
? this._renderSearchResults()
: html`<div class="tree ha-scrollbar">
${this._renderTree(this._tree)}
</div>`
: nothing}
`;
}
@@ -1,7 +1,7 @@
import { mdiClose, mdiViewGridPlus } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
@@ -24,6 +24,7 @@ import {
import type { CardSuggestion } from "../../card-suggestions/types";
import "./hui-suggestion-card";
import "./hui-suggestion-entity-tree";
import type { HuiSuggestionEntityTree } from "./hui-suggestion-entity-tree";
@customElement("hui-suggestion-picker")
export class HuiSuggestionPicker extends LitElement {
@@ -38,6 +39,14 @@ export class HuiSuggestionPicker extends LitElement {
private _narrowMql?: MediaQueryList;
@query("hui-suggestion-entity-tree")
private _entityTree?: HuiSuggestionEntityTree;
public async focus(): Promise<void> {
await this.updateComplete;
await this._entityTree?.focus();
}
public connectedCallback(): void {
super.connectedCallback();
this._narrowMql = matchMedia("(max-width: 600px)");
+1 -1
View File
@@ -11206,7 +11206,7 @@
},
"landing-page": {
"header": "Preparing Home Assistant",
"subheader": "This may take 20 minutes or more",
"subheader": "The latest version of Home Assistant is being downloaded. This may take 20 minutes or more.",
"show_details": "Show details",
"hide_details": "Hide details",
"network_issue": {