Compare commits

..
Author SHA1 Message Date
Petar Petrov e4f54540c2 Add local disk mounts to storage settings
Support the new disk mount type from the Supervisor mount API:

- Model disk mounts in the mounts data layer. Only network mounts have a
  server and a port, so those move out of the shared base, and a disk
  mount carries the uuid and filesystem Supervisor resolves it to.
- Offer "Local disk" when adding storage, with the disk picked from the
  devices Supervisor reports as mountable. A device the host reports as
  write-protected forces the read-only option on, and is not offered for
  backups at all, as Supervisor refuses a read-only backup mount.
- Hide the option when Supervisor does not have the candidates endpoint,
  and explain it rather than error when no suitable disk is connected.
- Show the disk a mount already uses as fixed text when editing it,
  since Supervisor excludes a mounted device from the candidates.
- Describe disk mounts in the storage panel and the mount picker. Both
  built their secondary line from server, share and path, which a disk
  mount does not have, so it rendered as "undefined".
- Retitle the card to "Additional storage", as it is no longer limited
  to storage reached over the network.

Also fixes the mount picker dropping its mount type filter whenever a
usage was set, which reapplied the filter to the unfiltered list.
2026-08-14 16:12:13 +03:00
61 changed files with 1118 additions and 3098 deletions
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
category: "/language:javascript-typescript"
+8 -8
View File
@@ -1,15 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 585518,
"core": 57048,
"authorize": 552423,
"onboarding": 666818
"app": 595204,
"core": 57741,
"authorize": 576928,
"onboarding": 685964
},
"frontend-legacy": {
"app": 887384,
"core": 244482,
"authorize": 844995,
"onboarding": 1008816
"app": 861452,
"core": 258557,
"authorize": 834356,
"onboarding": 1001360
}
}
-5
View File
@@ -102,11 +102,6 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
cost_sensors: {},
solar_forecast_domains: [],
}));
hass.mockWS("energy/validate", () => ({
energy_sources: Array.from({ length: 6 }, () => []),
device_consumption: Array.from({ length: 6 }, () => []),
device_consumption_water: Array.from({ length: 2 }, () => []),
}));
hass.mockWS(
"energy/fossil_energy_consumption",
({ period }): FossilEnergyConsumption => ({
-6
View File
@@ -1,12 +1,6 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockSensor = (hass: MockHomeAssistant) => {
hass.mockWS(
"sensor/device_class_convertible_units",
({ device_class }: { device_class: string }) => ({
units: device_class === "energy" ? ["kWh"] : ["W"],
})
);
hass.mockWS("sensor/numeric_device_classes", () => ({
numeric_device_classes: [
"volume_storage",
+11 -1
View File
@@ -15,6 +15,7 @@ const ALL_FEATURES =
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.FAN_SPEED +
VacuumEntityFeature.BATTERY +
VacuumEntityFeature.STATUS +
VacuumEntityFeature.LOCATE +
VacuumEntityFeature.CLEAN_SPOT +
@@ -27,6 +28,8 @@ const ENTITIES = [
attributes: {
friendly_name: "Full featured vacuum",
supported_features: ALL_FEATURES,
battery_level: 85,
battery_icon: "mdi:battery-80",
fan_speed: "balanced",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Charged",
@@ -38,6 +41,8 @@ const ENTITIES = [
attributes: {
friendly_name: "Cleaning vacuum",
supported_features: ALL_FEATURES,
battery_level: 62,
battery_icon: "mdi:battery-60",
fan_speed: "turbo",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Cleaning bedroom",
@@ -53,7 +58,10 @@ const ENTITIES = [
VacuumEntityFeature.START +
VacuumEntityFeature.PAUSE +
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME,
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.BATTERY,
battery_level: 23,
battery_icon: "mdi:battery-20",
status: "Returning to dock",
},
},
@@ -88,6 +96,8 @@ const ENTITIES = [
attributes: {
friendly_name: "Paused vacuum",
supported_features: ALL_FEATURES,
battery_level: 45,
battery_icon: "mdi:battery-40",
fan_speed: "standard",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Paused",
+5 -4
View File
@@ -103,7 +103,7 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.0",
"hls.js": "1.6.17",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
@@ -151,8 +151,8 @@
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.2",
"@rspack/core": "2.1.10",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.8",
"@rspack/dev-server": "2.2.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -186,7 +186,7 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.11.0",
"globals": "17.9.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
@@ -223,6 +223,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.9.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
-19
View File
@@ -39,25 +39,6 @@ const formatTimeWithSecondsMem = memoizeOne(
})
);
// 9:15:24.123 PM || 21:15:24,123
export const formatTimeWithMilliseconds = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => formatTimeWithMillisecondsMem(locale, config.time_zone).format(dateObj);
const formatTimeWithMillisecondsMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
hour: useAmPm(locale) ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
hourCycle: useAmPm(locale) ? "h12" : "h23",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Tuesday 7:00 PM || Tuesday 19:00
export const formatTimeWeekday = (
dateObj: Date,
-64
View File
@@ -1,64 +0,0 @@
import {
isMoreInfoView,
type MoreInfoView,
} from "../../dialogs/more-info/more-info-view";
import type { SearchParamsSource } from "./query-params";
const ENTITY_ID_PARAM = "more-info-entity-id";
const VIEW_PARAM = "more-info-view";
export interface MoreInfoUrlData {
entityId?: string;
view?: MoreInfoView;
hash: URLSearchParams;
}
export interface CreateMoreInfoUrlData {
entityId: string;
view: MoreInfoView;
hash?: URLSearchParams;
}
export const decodeMoreInfoUrl = (
search: SearchParamsSource,
hash = ""
): MoreInfoUrlData => {
const params =
typeof search === "string"
? new URLSearchParams(search)
: search instanceof URLSearchParams
? search
: new URLSearchParams(search);
const entityId = params.get(ENTITY_ID_PARAM) || undefined;
const view = params.get(VIEW_PARAM) || undefined;
return {
entityId,
view: isMoreInfoView(view) ? view : undefined,
hash: new URLSearchParams(
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
),
};
};
export const createMoreInfoUrl = (
base: string,
data: CreateMoreInfoUrlData
): string => {
const url = new URL(base, window.location.origin);
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
url.searchParams.set(VIEW_PARAM, data.view);
if (!__DEMO__ && data.hash !== undefined) {
url.hash = data.hash.toString();
}
return `${url.pathname}${url.search}${url.hash}`;
};
export const removeMoreInfoUrl = (base: string): string => {
const url = new URL(base, window.location.origin);
url.searchParams.delete(ENTITY_ID_PARAM);
url.searchParams.delete(VIEW_PARAM);
return `${url.pathname}${url.search}${url.hash}`;
};
+15 -17
View File
@@ -117,7 +117,7 @@ export class HaDataTable extends LitElement {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Object }) public columns: DataTableColumnContainer = {};
@@ -1158,11 +1158,6 @@ export class HaDataTable extends LitElement {
/* default mdc styles, colors changed, without checkbox styles */
:host {
height: 100%;
--_cell-padding-inline: 16px;
}
:host([narrow]) {
--_cell-padding-inline: 8px;
}
.mdc-data-table__content {
font-family: var(--ha-font-family-body);
@@ -1243,7 +1238,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__cell,
.mdc-data-table__header-cell {
padding-inline: var(--_cell-padding-inline);
padding-right: 16px;
padding-left: 16px;
min-width: 150px;
align-self: center;
overflow: hidden;
@@ -1263,8 +1259,14 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--checkbox,
.mdc-data-table__cell--checkbox {
padding-inline-start: var(--_cell-padding-inline);
padding-inline-end: 0;
/* @noflip */
padding-left: 16px;
/* @noflip */
padding-right: 0;
/* @noflip */
padding-inline-start: 16px;
/* @noflip */
padding-inline-end: initial;
width: 60px;
min-width: 60px;
}
@@ -1377,7 +1379,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--overflow-menu:first-child,
.mdc-data-table__header-cell--icon-button:first-child,
.mdc-data-table__cell--icon-button:first-child {
padding-inline-start: var(--_cell-padding-inline);
padding-left: 16px;
padding-inline-start: 16px;
padding-inline-end: initial;
}
@@ -1385,7 +1388,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--overflow-menu:last-child,
.mdc-data-table__header-cell--icon-button:last-child,
.mdc-data-table__cell--icon-button:last-child {
padding-inline-end: var(--_cell-padding-inline);
padding-right: 16px;
padding-inline-end: 16px;
padding-inline-start: initial;
}
.mdc-data-table__cell--overflow-menu,
@@ -1512,17 +1516,11 @@ export class HaDataTable extends LitElement {
.center {
text-align: center;
}
.primary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.secondary {
color: var(--secondary-text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.scroller {
height: calc(100% - 57px);
-1
View File
@@ -8,7 +8,6 @@ export const datePickerStyles = css`
}
calendar-date::part(button),
calendar-range::part(button) {
color: var(--primary-text-color);
border: none;
background-color: unset;
border-radius: var(--ha-border-radius-circle);
-3
View File
@@ -34,8 +34,6 @@ export class HaButtonToggleGroup extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "full-width" })
public fullWidth = false;
@property({ type: Boolean }) public disabled = false;
@property() public variant:
"brand" | "neutral" | "success" | "warning" | "danger" = "brand";
@@ -59,7 +57,6 @@ export class HaButtonToggleGroup extends LitElement {
.value=${button.value}
@click=${this._handleClick}
.title=${button.label}
.disabled=${this.disabled}
.appearance=${this.active === button.value ? "accent" : "filled"}
>
${
+2 -3
View File
@@ -68,7 +68,6 @@ export class HaDurationInput extends LitElement {
{ label: "-", iconPath: mdiMinusThick, value: "-" },
]}
.active=${this._negative ? "-" : "+"}
.disabled=${this.disabled}
@value-changed=${this._negativeChanged}
></ha-button-toggle-group>
`
@@ -236,8 +235,8 @@ export class HaDurationInput extends LitElement {
ev.stopPropagation();
const negative = (ev.detail?.value || ev.target.value) === "-";
this._toggleNegative = negative;
if (this.data) {
const value = { ...this.data };
const value = this.data;
if (value) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
+10 -5
View File
@@ -11,6 +11,7 @@ import {
fetchSupervisorMounts,
SupervisorMountType,
SupervisorMountUsage,
supervisorMountDescription,
} from "../data/supervisor/mounts";
import type { HomeAssistant } from "../types";
import "./ha-alert";
@@ -58,9 +59,7 @@ class HaMountPicker extends LitElement {
).map((mount) => ({
value: mount.name,
label: mount.name,
secondary: `${mount.server}${mount.port ? `:${mount.port}` : ""}${
mount.type === SupervisorMountType.NFS ? mount.path : `:${mount.share}`
}`,
secondary: supervisorMountDescription(mount),
iconPath:
mount.usage === SupervisorMountUsage.MEDIA
? mdiPlayBox
@@ -108,10 +107,16 @@ class HaMountPicker extends LitElement {
private _filterMounts = memoizeOne(
(mounts: SupervisorMounts, usage: this["usage"]) => {
let filteredMounts = mounts.mounts.filter((mount) =>
[SupervisorMountType.CIFS, SupervisorMountType.NFS].includes(mount.type)
[
SupervisorMountType.CIFS,
SupervisorMountType.DISK,
SupervisorMountType.NFS,
].includes(mount.type)
);
if (usage) {
filteredMounts = mounts.mounts.filter((mount) => mount.usage === usage);
filteredMounts = filteredMounts.filter(
(mount) => mount.usage === usage
);
}
return filteredMounts.sort((mountA, mountB) => {
if (mountA.name === mounts.default_backup_mount) {
@@ -81,7 +81,6 @@ export class HaChooseSelector extends LitElement {
.required=${this.required}
@value-changed=${this._handleValueChanged}
.helper=${this.helper}
.localizeValue=${this.localizeValue}
></ha-selector>`;
}
@@ -108,12 +107,7 @@ export class HaChooseSelector extends LitElement {
: {
[this._activeChoice!]: this.value,
};
const choice = ev.detail?.value || ev.target.value;
this._activeChoice = choice;
if (choice && "constant" in this.selector.choose.choices[choice].selector) {
value[choice] =
this.selector.choose.choices[choice].selector.constant?.value;
}
this._activeChoice = ev.detail?.value || ev.target.value;
fireEvent(this, "value-changed", {
value: {
...value,
-1
View File
@@ -664,7 +664,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-1
View File
@@ -29,7 +29,6 @@ export class HaTraceLogbook extends LitElement {
.hass=${this.hass}
.entries=${this.logbookEntries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
@@ -437,7 +437,6 @@ export class HaTracePathDetails extends LitElement {
.hass=${this.hass}
.entries=${entries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
+1 -1
View File
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
): Promise<LogbookEntry[]> =>
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
export const getLogbookDataFromServer = (
const getLogbookDataFromServer = (
hass: HomeAssistant,
startDate: string,
endDate?: string,
+86 -6
View File
@@ -3,6 +3,7 @@ import type { HomeAssistant } from "../../types";
export enum SupervisorMountType {
BIND = "bind",
CIFS = "cifs",
DISK = "disk",
NFS = "nfs",
}
@@ -28,26 +29,40 @@ interface SupervisorMountBase {
name: string;
usage: SupervisorMountUsage;
type: SupervisorMountType;
server: string;
port: number;
read_only?: boolean;
}
export interface SupervisorMountResponse extends SupervisorMountBase {
state: SupervisorMountState | null;
}
export interface SupervisorNFSMount extends SupervisorMountResponse {
// Supervisor omits port when the mount uses the protocol default.
interface SupervisorNetworkMount extends SupervisorMountResponse {
server: string;
port?: number;
}
export interface SupervisorNFSMount extends SupervisorNetworkMount {
type: SupervisorMountType.NFS;
path: string;
}
export interface SupervisorCIFSMount extends SupervisorMountResponse {
export interface SupervisorCIFSMount extends SupervisorNetworkMount {
type: SupervisorMountType.CIFS;
share: string;
version?: CIFSVersion;
}
export type SupervisorMount = SupervisorNFSMount | SupervisorCIFSMount;
// A disk mount is identified by device on the way in, but Supervisor resolves
// that to a UUID and only ever reports uuid and filesystem back.
export interface SupervisorDiskMount extends SupervisorMountResponse {
type: SupervisorMountType.DISK;
uuid: string;
filesystem?: string;
}
export type SupervisorMount =
SupervisorNFSMount | SupervisorCIFSMount | SupervisorDiskMount;
export type SupervisorNFSMountRequestParams = SupervisorNFSMount;
@@ -57,14 +72,67 @@ export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMount {
version?: CIFSVersion;
}
interface SupervisorDiskMountRequestParamsBase {
name: string;
usage: SupervisorMountUsage;
type: SupervisorMountType.DISK;
read_only?: boolean;
}
// Supervisor accepts exactly one identifier: device when creating from a
// candidate, uuid when round-tripping a mount it already resolved.
export type SupervisorDiskMountRequestParams =
| (SupervisorDiskMountRequestParamsBase & { device: string; uuid?: never })
| (SupervisorDiskMountRequestParamsBase & { uuid: string; device?: never });
export type SupervisorMountRequestParams =
SupervisorNFSMountRequestParams | SupervisorCIFSMountRequestParams;
| SupervisorNFSMountRequestParams
| SupervisorCIFSMountRequestParams
| SupervisorDiskMountRequestParams;
export interface SupervisorMounts {
default_backup_mount: string | null;
mounts: SupervisorMount[];
}
// Null when UDisks2 cannot attribute the device to a drive, which also happens
// when the drive is unplugged between enumeration and lookup.
export interface SupervisorMountCandidateDrive {
vendor: string;
model: string;
serial: string;
id: string;
size: number;
connection_bus: string;
removable: boolean;
ejectable: boolean;
}
export interface SupervisorMountCandidate {
device: string;
uuid: string;
label: string;
filesystem: string;
size: number;
read_only: boolean;
drive: SupervisorMountCandidateDrive | null;
}
export interface SupervisorMountCandidates {
candidates: SupervisorMountCandidate[];
}
// Identifies a mount in a list row. A disk mount has no server, share or path,
// so it is described by what Supervisor does report for it.
export const supervisorMountDescription = (mount: SupervisorMount): string => {
if (mount.type === SupervisorMountType.DISK) {
return [mount.filesystem, mount.uuid].filter(Boolean).join(" • ");
}
return `${mount.server}${mount.port ? `:${mount.port}` : ""}${
mount.type === SupervisorMountType.NFS ? mount.path : `:${mount.share}`
}`;
};
export const fetchSupervisorMounts = async (
hass: HomeAssistant
): Promise<SupervisorMounts> =>
@@ -75,6 +143,18 @@ export const fetchSupervisorMounts = async (
timeout: null,
});
// Returns an empty list on a host without UDisks2. A Supervisor predating disk
// mounts answers 404, which callers use to hide the feature.
export const fetchSupervisorMountCandidates = async (
hass: HomeAssistant
): Promise<SupervisorMountCandidates> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/mounts/candidates`,
method: "get",
timeout: null,
});
export const createSupervisorMount = async (
hass: HomeAssistant,
data: SupervisorMountRequestParams
+2
View File
@@ -22,6 +22,7 @@ export enum VacuumEntityFeature {
STOP = 8,
RETURN_HOME = 16,
FAN_SPEED = 32,
BATTERY = 64,
STATUS = 128,
SEND_COMMAND = 256,
LOCATE = 512,
@@ -33,6 +34,7 @@ export enum VacuumEntityFeature {
}
interface VacuumEntityAttributes extends HassEntityAttributeBase {
battery_level?: number;
fan_speed?: string;
fan_speed_list?: string[];
[key: string]: any;
+2 -3
View File
@@ -566,10 +566,9 @@ export const getWeatherStateIcon = (
if (userDefinedIcon) {
return html`
<div
style=${styleMap({
"background-size": "cover",
style="background-size: cover;${styleMap({
"background-image": userDefinedIcon,
})}
})}"
></div>
`;
}
+15 -5
View File
@@ -7,11 +7,21 @@ import { isNumericEntity } from "../../data/history";
import { CONTINUOUS_DOMAINS } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
export {
isMoreInfoView,
MORE_INFO_VIEWS,
type MoreInfoView,
} from "./more-info-view";
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
export const DOMAINS_NO_INFO = ["camera", "configurator"];
/**
-9
View File
@@ -1,9 +0,0 @@
import { createContext } from "@lit/context";
export interface MoreInfoContext {
hash: URLSearchParams;
setHashParam: (key: string, value?: string) => void;
}
export const moreInfoContext =
createContext<MoreInfoContext>("more-info-context");
@@ -159,6 +159,23 @@ class MoreInfoVacuum extends LitElement {
`;
}
// Use deprecated battery_level and battery_icon attributes
if (
supportsFeature(this.stateObj, VacuumEntityFeature.BATTERY) &&
this.stateObj.attributes.battery_level
) {
return html`
<span class="battery" slot="after-time">
<span
>${Math.round(
this.stateObj.attributes.battery_level
)}${blankBeforePercent(this._i18n.locale)}%</span
>
<ha-icon .icon=${this.stateObj.attributes.battery_icon}></ha-icon>
</span>
`;
}
return nothing;
}
@@ -11,7 +11,6 @@ import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
import { formatTime } from "../../../common/datetime/format_time";
import { transform } from "../../../common/decorators/transform";
import { formatNumber } from "../../../common/number/format_number";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-relative-time";
import "../../../components/ha-spinner";
@@ -49,16 +48,11 @@ import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import { moreInfoContext, type MoreInfoContext } from "../context";
@customElement("more-info-weather")
class MoreInfoWeather extends LitElement {
@property({ attribute: false }) public stateObj?: WeatherEntity;
@state()
@consume({ context: moreInfoContext, subscribe: true })
private _moreInfoContext?: MoreInfoContext;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@@ -133,33 +127,15 @@ class MoreInfoWeather extends LitElement {
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
(changedProps.has("stateObj") ||
changedProps.has("_moreInfoContext") ||
!this._subscribed) &&
this.stateObj
) {
if ((changedProps.has("stateObj") || !this._subscribed) && this.stateObj) {
const oldState = changedProps.get("stateObj") as
WeatherEntity | undefined;
if (
oldState?.entity_id !== this.stateObj?.entity_id ||
changedProps.has("_moreInfoContext") ||
!this._subscribed
) {
const supportedForecastTypes = getSupportedForecastTypes(this.stateObj);
const requestedForecastType =
this._moreInfoContext?.hash.get("forecast");
const selectedForecastType =
supportedForecastTypes.find(
(forecastType) => forecastType === requestedForecastType
) ?? getDefaultForecastType(this.stateObj);
if (selectedForecastType !== requestedForecastType) {
this._moreInfoContext?.setHashParam("forecast", selectedForecastType);
}
if (this._forecastType !== selectedForecastType || !this._subscribed) {
this._forecastType = selectedForecastType;
this._subscribeForecastEvents();
}
this._forecastType = getDefaultForecastType(this.stateObj);
this._subscribeForecastEvents();
}
} else if (changedProps.has("_forecastType")) {
this._subscribeForecastEvents();
@@ -533,17 +509,8 @@ class MoreInfoWeather extends LitElement {
`;
}
private _handleForecastTypeChanged(
ev: HASSDomEvent<{ name: ModernForecastType }>
): void {
if (
!this.stateObj ||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
) {
return;
}
private _handleForecastTypeChanged(ev: CustomEvent): void {
this._forecastType = ev.detail.name;
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
}
static get styles(): CSSResultGroup {
+2 -62
View File
@@ -17,7 +17,6 @@ import {
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { provide } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -44,10 +43,8 @@ import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-reques
import {
getHistoryState,
navigate,
replaceCurrentUrl,
updateHistoryState,
} from "../../common/navigate";
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -88,7 +85,6 @@ import {
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
type MoreInfoView,
} from "./const";
import { moreInfoContext, type MoreInfoContext } from "./context";
import "./controls/more-info-default";
import type { FavoritesDialogContext } from "./favorites";
import { getFavoritesDialogHandler } from "./favorites";
@@ -106,9 +102,6 @@ export interface MoreInfoDialogParams {
tab?: MoreInfoView;
large?: boolean;
data?: Record<string, any>;
hash?: URLSearchParams;
fromUrl?: boolean;
returnUrl?: string;
parentElement?: LitElement;
}
@@ -154,12 +147,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _data?: Record<string, any>;
@provide({ context: moreInfoContext })
@state()
private _moreInfoContext: MoreInfoContext = this._createMoreInfoContext();
private _returnUrl?: string;
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
@state() private _initialView: MoreInfoView = DEFAULT_VIEW;
@@ -194,8 +181,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const view = params.view || params.tab || DEFAULT_VIEW;
this._data = params.data;
this._moreInfoContext = this._createMoreInfoContext(params.hash);
this._returnUrl = params.returnUrl;
this._currView = view;
this._initialView = view;
this._childViewStack = [];
@@ -231,9 +216,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _dialogClosed() {
if (this._returnUrl) {
replaceCurrentUrl(this._returnUrl);
}
this._entityId = undefined;
this._parentEntityIds = [];
this._entry = undefined;
@@ -242,8 +224,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._initialView = DEFAULT_VIEW;
this._currView = DEFAULT_VIEW;
this._childViewStack = [];
this._moreInfoContext = this._createMoreInfoContext();
this._returnUrl = undefined;
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
@@ -291,10 +271,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
return entity?.device_id ?? null;
}
private _setView(view: MoreInfoView, preserveHash = false) {
if (view !== this._currView && !preserveHash) {
this._moreInfoContext = this._createMoreInfoContext();
}
private _setView(view: MoreInfoView) {
updateHistoryState({
dialogParams: {
...getHistoryState()?.dialogParams,
@@ -302,38 +279,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
},
});
this._currView = view;
this._syncUrl();
}
private _syncUrl() {
if (!this._returnUrl || !this._entityId) {
return;
}
replaceCurrentUrl(
createMoreInfoUrl(this._returnUrl, {
entityId: this._entityId,
view: this._currView,
hash: this._moreInfoContext.hash,
})
);
}
private _createMoreInfoContext(hash?: URLSearchParams): MoreInfoContext {
return {
hash: new URLSearchParams(hash),
setHashParam: (key, value) => this._setHashParam(key, value),
};
}
private _setHashParam(key: string, value?: string) {
const hash = new URLSearchParams(this._moreInfoContext.hash);
if (value) {
hash.set(key, value);
} else {
hash.delete(key);
}
this._moreInfoContext = this._createMoreInfoContext(hash);
this._syncUrl();
}
private _goBack() {
@@ -361,9 +306,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
if (this._parentEntityIds.length > 0) {
this._entityId = this._parentEntityIds.pop();
this._currView = DEFAULT_VIEW;
this._moreInfoContext = this._createMoreInfoContext();
this._loadEntityRegistryEntry();
this._syncUrl();
}
}
@@ -1064,22 +1007,19 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
if (entityId === this._entityId) {
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._infoEditMode = false;
this._detailsYamlMode = false;
this._setView(view, true);
this._setView(view);
return;
}
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
this._entityId = entityId;
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._currView = view === "details" ? view : DEFAULT_VIEW;
this._initialView = view;
this._infoEditMode = false;
this._detailsYamlMode = false;
this._childViewStack = [];
this._loadEntityRegistryEntry();
this._syncUrl();
}
private _enableEscapeKeyClose = () => {
-15
View File
@@ -1,15 +0,0 @@
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
+5
View File
@@ -74,6 +74,11 @@ export class MockVacuumEntity extends MockBaseEntity {
stateAttrs.fan_speed = attrs.fan_speed ?? null;
}
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.BATTERY)) {
stateAttrs.battery_level = attrs.battery_level ?? null;
stateAttrs.battery_icon = attrs.battery_icon ?? null;
}
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.STATUS)) {
stateAttrs.status = attrs.status ?? null;
}
-40
View File
@@ -6,16 +6,12 @@ import { storage } from "../common/decorators/storage";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
import { extractSearchParamsObject } from "../common/url/search-params";
import { afterNextRender } from "../common/util/render-status";
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 { showMoreInfoDialog } from "../dialogs/more-info/show-ha-more-info-dialog";
import "../resources/custom-card-support";
import { HassElement } from "../state/hass-element";
import QuickBarMixin from "../state/quick-bar-mixin";
@@ -148,7 +144,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// the shadow root. Appending the dialog before that render would let it
// tear the freshly-added dialog straight back out of the DOM.
this.checkHttpPendingConfig();
this._restoreMoreInfoFromUrl();
}
}
@@ -183,12 +178,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// Handle history changes
window.addEventListener("popstate", () => updateRoute());
// Restore a more-info dialog deep-linked in the current URL, if any.
window.addEventListener("location-changed", () =>
this._restoreMoreInfoFromUrl()
);
window.addEventListener("popstate", () => this._restoreMoreInfoFromUrl());
// Handle clicking on links
window.addEventListener("click", (ev) => {
const href = isNavigationClick(ev);
@@ -308,35 +297,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
});
}
private _restoreMoreInfoFromUrl() {
// Only restore once the main UI is rendered so the dialog has access to
// the loaded entities.
if (this.render !== this.renderHass) {
return;
}
const searchParams = extractSearchParamsObject();
if (!searchParams["more-info-entity-id"]) {
return;
}
const { entityId, view, hash } = decodeMoreInfoUrl(
window.location.search,
window.location.hash
);
if (!entityId) {
return;
}
// Wait for the next render to ensure the view is fully loaded
// because the more info dialog is closed when the url changes
afterNextRender(() => {
showMoreInfoDialog(this, {
entityId,
view,
hash,
fromUrl: true,
});
});
}
protected async checkDataBaseMigration() {
if (__DEMO__) {
this._databaseMigration = false;
@@ -2,12 +2,10 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { consume } from "@lit/context";
import {
mdiCloseThick,
mdiCog,
mdiContentDuplicate,
mdiDelete,
mdiDotsVertical,
mdiExclamationThick,
mdiHelpCircleOutline,
mdiInformationOutline,
mdiMenuDown,
@@ -131,28 +129,6 @@ import {
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
import { showNewAutomationDialog } from "./show-dialog-new-automation";
const renderIconBadge = (path: string, color: string) => html`
<div
style=${styleMap({
position: "absolute",
top: "-5px",
insetInlineEnd: "-7px",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "18px",
height: "18px",
borderRadius: "50%",
backgroundColor: color,
boxShadow: "0 0 0 2px var(--data-table-background-color)",
color: "var(--data-table-background-color)",
"--mdc-icon-size": "12px",
})}
>
<ha-svg-icon style="margin: 0;" .path=${path}></ha-svg-icon>
</div>
`;
type AutomationItem = AutomationEntity & {
name: string;
area: string | undefined;
@@ -327,11 +303,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
localize: LocalizeFunc,
entitiesToCheck?: any[]
): DataTableColumnContainer<AutomationItem> => {
const triggeredAtColumn = getTriggeredAtTableColumn<AutomationItem>(
localize,
this.hass
);
const columns: DataTableColumnContainer<AutomationItem> = {
icon: {
title: "",
@@ -339,34 +310,18 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
type: "icon",
moveable: false,
showNarrow: true,
template: (automation) => {
const unavailable = automation.state === UNAVAILABLE;
const disabled = automation.state === "off";
return html`<div
style="position: relative; display: inline-flex; width: 24px; height: 24px;"
>
<ha-state-icon
.stateObj=${automation}
.stateValue=${unavailable || disabled ? "on" : undefined}
style=${styleMap({
display: "flex",
margin: "0",
color: unavailable
template: (automation) =>
html`<ha-state-icon
.stateObj=${automation}
style=${styleMap({
color:
automation.state === UNAVAILABLE
? "var(--error-color)"
: disabled
? "var(--disabled-color)"
: automation.state === "on"
? "var(--state-active-color)"
: "unset",
})}
></ha-state-icon>
${
unavailable
? renderIconBadge(mdiExclamationThick, "var(--error-color)")
: disabled
? renderIconBadge(mdiCloseThick, "var(--disabled-color)")
: nothing
}
</div>`;
},
})}
></ha-state-icon>`,
},
entity_id: getEntityIdHiddenTableColumn(),
name: {
@@ -387,33 +342,23 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
area: getAreaTableColumn(localize),
category: getCategoryTableColumn(localize),
labels: getLabelsTableColumn(),
last_triggered: {
...triggeredAtColumn,
template: (automation) =>
narrow && automation.state === "off"
? nothing
: triggeredAtColumn.template!(automation),
},
last_triggered: getTriggeredAtTableColumn(localize, this.hass),
formatted_state: {
minWidth: "82px",
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) =>
narrow
? automation.state === "off"
? localize("ui.panel.config.automation.picker.disabled")
: nothing
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -1,4 +1,4 @@
import { mdiAccount, mdiAccountPlus, mdiPencil } from "@mdi/js";
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -19,7 +19,6 @@ import type { PersonMutableParams } from "../../../data/person";
import type { User } from "../../../data/user";
import {
deleteUser,
fetchUsers,
SYSTEM_GROUP_ID_ADMIN,
SYSTEM_GROUP_ID_USER,
updateUser,
@@ -37,8 +36,6 @@ import { documentationUrl } from "../../../util/documentation-url";
import { showAddUserDialog } from "../users/show-dialog-add-user";
import { showAdminChangePasswordDialog } from "../users/show-dialog-admin-change-password";
import type { PersonDetailDialogParams } from "./show-dialog-person-detail";
import { showListItemsDialog } from "../../../dialogs/dialog-list-items/show-list-items-dialog";
import { computeDomain } from "../../../common/entity/compute_domain";
const includeDomains = ["device_tracker"];
@@ -88,8 +85,6 @@ class DialogPersonDetail
@state() private _open = false;
private _linkedExistingUser = false;
private _deviceTrackersAvailable = memoizeOne((hass) =>
Object.keys(hass.states).some(
(entityId) =>
@@ -145,7 +140,7 @@ class DialogPersonDetail
private _dialogClosed() {
// If we do not have a person ID yet (= person creation dialog was just cancelled), but
// we already created a user ID for it, delete it now to not have it "free floating".
if (!this._personExists && this._userId && !this._linkedExistingUser) {
if (!this._personExists && this._userId) {
const callback = this._params?.refreshUsers;
deleteUser(this.hass, this._userId).then(() => {
callback?.();
@@ -431,71 +426,26 @@ class DialogPersonDetail
this._updateDirtyState(this._currentState());
}
private async _linkUser(user: User, newUser: boolean) {
this._linkedExistingUser = !newUser;
if (this._params!.entry && this._params!.updateEntry) {
await this._params!.updateEntry({ user_id: user.id });
}
if (newUser) {
this._params?.refreshUsers?.();
}
this._user = user;
this._userId = user.id;
this._isAdmin = user.group_ids.includes(SYSTEM_GROUP_ID_ADMIN);
this._localOnly = user.local_only;
this._updateDirtyState(this._currentState());
}
private async _allowLoginChanged(ev): Promise<void> {
const target = ev.target;
if (target.checked) {
target.checked = false;
const users = await fetchUsers(this.hass);
const currentLinkedUsers = new Set(
Object.values(this.hass.states)
.filter(
(s) =>
computeDomain(s.entity_id) === "person" && s.attributes.user_id
)
.map((s) => s.attributes.user_id)
);
const eligibleUsers = users.filter(
(u) =>
!currentLinkedUsers.has(u.id) && !u.system_generated && u.username
);
const addUserDialog = () =>
showAddUserDialog(this, {
userAddedCallback: async (user?: User) => {
if (user) {
target.checked = true;
this._linkUser(user, true);
showAddUserDialog(this, {
userAddedCallback: async (user?: User) => {
if (user) {
target.checked = true;
if (this._params!.entry && this._params!.updateEntry) {
await this._params!.updateEntry({ user_id: user.id });
}
},
name: this._name,
});
if (eligibleUsers.length === 0) {
addUserDialog();
return;
}
showListItemsDialog(this, {
title: this.hass.localize("ui.panel.config.person.detail.select_user"),
items: [
{
iconPath: mdiAccountPlus,
label: this.hass.localize(
"ui.panel.config.person.detail.create_new_user"
),
action: addUserDialog,
},
...eligibleUsers.map((user) => ({
iconPath: mdiAccount,
label: `${user.name} (${user.username})`,
action: () => this._linkUser(user, false),
})),
],
this._params?.refreshUsers?.();
this._user = user;
this._userId = user.id;
this._isAdmin = user.group_ids.includes(SYSTEM_GROUP_ID_ADMIN);
this._localOnly = user.local_only;
this._updateDirtyState(this._currentState());
}
},
name: this._name,
});
} else if (this._userId) {
if (
+203 -44
View File
@@ -14,28 +14,92 @@ import type { SchemaUnion } from "../../../components/ha-form/types";
import "../../../components/ha-icon-button";
import "../../../components/ha-dialog";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { SupervisorMountRequestParams } from "../../../data/supervisor/mounts";
import type {
SupervisorMountCandidate,
SupervisorMountRequestParams,
} from "../../../data/supervisor/mounts";
import {
createSupervisorMount,
fetchSupervisorMountCandidates,
removeSupervisorMount,
SupervisorMountType,
SupervisorMountUsage,
updateSupervisorMount,
} from "../../../data/supervisor/mounts";
import { bytesToString } from "../../../util/bytes-to-string";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { MountViewDialogParams } from "./show-dialog-view-mount";
// Describes a device by the drive it belongs to, falling back to what UDisks2
// did report: an unattributed device has no drive, and an unformatted-label
// partition has no label.
const mountCandidateLabel = (candidate: SupervisorMountCandidate): string => {
const drive = [candidate.drive?.vendor, candidate.drive?.model]
.filter(Boolean)
.join(" ");
const identity = candidate.label || candidate.device;
const size = bytesToString(candidate.size);
return drive ? `${drive}${identity}, ${size}` : `${identity}, ${size}`;
};
const mountSchema = memoizeOne(
(
localize: LocalizeFunc,
existing?: boolean,
mountType?: SupervisorMountType,
showCIFSVersion?: boolean
) =>
[
showCIFSVersion?: boolean,
showDisk?: boolean,
candidates?: SupervisorMountCandidate[],
diskIdentity?: string,
readOnlyForced?: boolean,
allowBackupUsage = true
) => {
// Supervisor rejects a read-only mount used for backups, so a device that
// can only be mounted read-only is not offered for one.
const usageOptions: [string, string][] = allowBackupUsage
? [
[
SupervisorMountUsage.BACKUP,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.backup"
),
],
]
: [];
usageOptions.push(
[
SupervisorMountUsage.MEDIA,
localize("ui.panel.config.storage.network_mounts.mount_usage.media"),
],
[
SupervisorMountUsage.SHARE,
localize("ui.panel.config.storage.network_mounts.mount_usage.share"),
]
);
const typeOptions: [string, string][] = [
[
SupervisorMountType.CIFS,
localize("ui.panel.config.storage.network_mounts.mount_type.cifs"),
],
[
SupervisorMountType.NFS,
localize("ui.panel.config.storage.network_mounts.mount_type.nfs"),
],
];
// Hidden on a Supervisor that does not support disk mounts, but always
// offered when editing one that already exists.
if (showDisk || mountType === SupervisorMountType.DISK) {
typeOptions.push([
SupervisorMountType.DISK,
localize("ui.panel.config.storage.network_mounts.mount_type.disk"),
]);
}
return [
{
name: "name",
required: true,
@@ -46,57 +110,34 @@ const mountSchema = memoizeOne(
name: "usage",
required: true,
type: "select",
options: [
[
SupervisorMountUsage.BACKUP,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.backup"
),
],
[
SupervisorMountUsage.MEDIA,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.media"
),
],
[
SupervisorMountUsage.SHARE,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.share"
),
],
] as const,
},
{
name: "server",
required: true,
selector: { text: {} },
options: usageOptions,
},
{
name: "type",
required: true,
type: "select",
options: [
[
SupervisorMountType.CIFS,
localize("ui.panel.config.storage.network_mounts.mount_type.cifs"),
],
[
SupervisorMountType.NFS,
localize("ui.panel.config.storage.network_mounts.mount_type.nfs"),
],
],
options: typeOptions,
},
...(mountType === "nfs"
...(mountType === SupervisorMountType.NFS
? ([
{
name: "server",
required: true,
selector: { text: {} },
},
{
name: "path",
required: true,
selector: { text: {} },
},
] as const)
: mountType === "cifs"
: mountType === SupervisorMountType.CIFS
? ([
{
name: "server",
required: true,
selector: { text: {} },
},
...(showCIFSVersion
? ([
{
@@ -148,8 +189,44 @@ const mountSchema = memoizeOne(
selector: { text: { type: "password" } },
},
] as const)
: ([] as const)),
] as const
: mountType === SupervisorMountType.DISK
? existing
? // Supervisor excludes a mounted device from the candidates, so
// an existing mount can only show what it resolved to.
([
{
name: "device_identity",
type: "constant",
value: diskIdentity,
},
{
name: "read_only",
selector: { boolean: {} },
},
] as const)
: ([
{
name: "device",
required: true,
selector: {
select: {
options: (candidates ?? []).map((candidate) => ({
value: candidate.device,
label: mountCandidateLabel(candidate),
})),
mode: "dropdown",
},
},
},
{
name: "read_only",
disabled: readOnlyForced,
selector: { boolean: {} },
},
] as const)
: ([] as const)),
] as const;
}
);
@customElement("dialog-mount-view")
@@ -172,6 +249,12 @@ class ViewMountDialog extends DirtyStateProviderMixin<
@state() private _showCIFSVersion?: boolean;
@state() private _candidates?: SupervisorMountCandidate[];
@state() private _diskSupported = false;
@state() private _diskIdentity?: string;
@state() private _reloadMounts?: () => void;
@state() private _open = false;
@@ -190,13 +273,36 @@ class ViewMountDialog extends DirtyStateProviderMixin<
) {
this._showCIFSVersion = true;
}
if (dialogParams.mount?.type === SupervisorMountType.DISK) {
this._diskIdentity = [
dialogParams.mount.filesystem,
dialogParams.mount.uuid,
]
.filter(Boolean)
.join(" • ");
}
this._initDirtyTracking({ type: "deep" }, this._data ?? {});
this._loadCandidates();
}
public closeDialog(): void {
this._open = false;
}
private async _loadCandidates(): Promise<void> {
try {
const { candidates } = await fetchSupervisorMountCandidates(this.hass);
this._candidates = candidates;
this._diskSupported = true;
} catch (_err: any) {
// A Supervisor predating disk mounts answers 404. Any other failure
// leaves us unable to offer a device either, so in both cases the option
// is hidden rather than shown as broken.
this._candidates = [];
this._diskSupported = false;
}
}
private _dialogClosed(): void {
this._data = undefined;
this._waiting = undefined;
@@ -205,6 +311,9 @@ class ViewMountDialog extends DirtyStateProviderMixin<
this._validationWarning = undefined;
this._existing = undefined;
this._showCIFSVersion = undefined;
this._candidates = undefined;
this._diskSupported = false;
this._diskIdentity = undefined;
this._reloadMounts = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -249,6 +358,15 @@ class ViewMountDialog extends DirtyStateProviderMixin<
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${
this._showNoCandidates
? html`<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.storage.network_mounts.no_disk_candidates"
)}
</ha-alert>`
: nothing
}
<ha-form
autofocus
.data=${this._data}
@@ -256,7 +374,12 @@ class ViewMountDialog extends DirtyStateProviderMixin<
this.hass.localize,
this._existing,
this._data?.type,
this._showCIFSVersion
this._showCIFSVersion,
this._diskSupported,
this._candidates,
this._diskIdentity,
this._readOnlyForced,
this._allowBackupUsage
)}
.error=${this._validationError}
.warning=${this._validationWarning}
@@ -308,6 +431,33 @@ class ViewMountDialog extends DirtyStateProviderMixin<
`;
}
// The device the mount already uses is excluded from candidates, so an empty
// list is only worth mentioning while creating one.
private get _showNoCandidates(): boolean {
return (
!this._existing &&
this._data?.type === SupervisorMountType.DISK &&
this._candidates?.length === 0
);
}
private get _readOnlyForced(): boolean {
if (this._existing || this._data?.type !== SupervisorMountType.DISK) {
return false;
}
const { device } = this._data;
return !!this._candidates?.find((candidate) => candidate.device === device)
?.read_only;
}
// Backup usage is impossible for a read-only mount, whether the device forced
// that or the user chose it.
private get _allowBackupUsage(): boolean {
return !(
this._data?.type === SupervisorMountType.DISK && this._data.read_only
);
}
private _computeLabelCallback = (
// @ts-ignore
schema: SchemaUnion<ReturnType<typeof mountSchema>>
@@ -353,6 +503,15 @@ class ViewMountDialog extends DirtyStateProviderMixin<
) {
this._validationWarning.version = "not_recomeded_cifs_version";
}
// A device the host reports as read-only cannot be mounted writable.
if (this._readOnlyForced) {
this._data!.read_only = true;
}
// Picking such a device while backup was selected leaves a combination
// Supervisor refuses, so drop the usage and make the user choose again.
if (!this._allowBackupUsage && this._data?.usage === "backup") {
delete (this._data as Partial<SupervisorMountRequestParams>).usage;
}
this._updateDirtyState(this._data ?? {});
}
@@ -37,6 +37,7 @@ import {
SupervisorMountUsage,
fetchSupervisorMounts,
reloadSupervisorMount,
supervisorMountDescription,
} from "../../../data/supervisor/mounts";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
@@ -74,7 +75,11 @@ class HaConfigSectionStorage extends LitElement {
return nothing;
}
const validMounts = this._mountsInfo?.mounts.filter((mount) =>
[SupervisorMountType.CIFS, SupervisorMountType.NFS].includes(mount.type)
[
SupervisorMountType.CIFS,
SupervisorMountType.DISK,
SupervisorMountType.NFS,
].includes(mount.type)
);
const isHAOS = this._hostInfo?.features.includes("haos");
return html`
@@ -193,13 +198,7 @@ class HaConfigSectionStorage extends LitElement {
${mount.name}
</span>
<span slot="secondary">
${mount.server}${
mount.port ? `:${mount.port}` : nothing
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
${supervisorMountDescription(mount)}
</span>
${
mount.state !== SupervisorMountState.ACTIVE
@@ -20,10 +20,9 @@ type ReloadableDomain = Exclude<
"heading" | "introduction" | "reload"
>;
interface TranslatedReloadableItem {
domain: ReloadableDomain | "frontend";
interface TranslatedReloadableDomain {
domain: ReloadableDomain;
name: string;
service?: string;
}
@customElement("tools-yaml-config")
@@ -38,7 +37,7 @@ export class ToolsYamlConfig extends LitElement {
@state() private _validating = false;
@state() private _reloadableItems: TranslatedReloadableItem[] = [];
@state() private _reloadableDomains: TranslatedReloadableDomain[] = [];
@state() private _validateResult?: CheckConfigResult;
@@ -55,10 +54,10 @@ export class ToolsYamlConfig extends LitElement {
oldHass.config.components !== this.hass.config.components ||
oldHass.localize !== this.hass.localize)
) {
this._reloadableItems = (
this._reloadableDomains = (
componentsWithService(this.hass, "reload") as ReloadableDomain[]
)
.map<TranslatedReloadableItem>((domain) => ({
.map((domain) => ({
domain,
name:
this.hass.localize(
@@ -69,15 +68,6 @@ export class ToolsYamlConfig extends LitElement {
{ domain: domainToName(this.hass.localize, domain) }
),
}))
.concat([
{
domain: "frontend",
service: "reload_themes",
name: this.hass.localize(
`ui.panel.config.tools.tabs.yaml.section.reloading.themes`
),
},
])
.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
@@ -200,12 +190,12 @@ export class ToolsYamlConfig extends LitElement {
)}
</ha-call-service-button>
</div>
${this._reloadableItems.map(
${this._reloadableDomains.map(
(reloadable) => html`
<div class="card-actions">
<ha-call-service-button
.domain=${reloadable.domain}
.service=${reloadable.service || "reload"}
service="reload"
>${reloadable.name}
</ha-call-service-button>
</div>
-406
View File
@@ -1,406 +0,0 @@
import { mdiPuzzle } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-adaptive-dialog";
import "../../components/ha-alert";
import "../../components/ha-icon";
import "../../components/ha-icon-next";
import "../../components/ha-relative-time";
import "../../components/ha-spinner";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-base";
import "../../components/item/ha-list-item-button";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import { fetchDateWS } from "../../data/history";
import type { LogbookEntry } from "../../data/logbook";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-chain";
import type { LogbookChain } from "./logbook-chain-resolver";
import { resolveLogbookChain } from "./logbook-chain-resolver";
import type { LogbookItem } from "./logbook-entry-model";
import { computeLogbookItem } from "./logbook-entry-model";
import { renderLogbookGlyph, transitionArrow } from "./logbook-entry-templates";
import type { LogbookDetailDialogParams } from "./show-dialog-logbook-detail";
@customElement("dialog-logbook-detail")
class DialogLogbookDetail
extends LitElement
implements HassDialog<LogbookDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LogbookDetailDialogParams;
@state() private _open = false;
@state() private _chain?: LogbookChain;
@state() private _previousState?: string;
@state() private _error = false;
public showDialog(params: LogbookDetailDialogParams): void {
this._params = params;
this._open = true;
this._chain = undefined;
this._previousState = undefined;
this._error = false;
if (
params.entry.context_event_type === "call_service" &&
params.entry.context_domain
) {
this.hass.loadBackendTranslation("services", params.entry.context_domain);
}
this._loadDetails();
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._chain = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
// Both fetches resolve into a single render so the dialog reflows once.
private async _loadDetails() {
const { entry } = this._params!;
const [{ chain, errored }, previousState] = await Promise.all([
this._fetchChain(entry),
this._fetchPreviousState(entry),
]);
if (this._params?.entry !== entry) {
return;
}
this._error = errored;
this._chain = chain;
this._previousState = previousState;
}
private async _fetchChain(
entry: LogbookEntry
): Promise<{ chain: LogbookChain; errored: boolean }> {
const { userIdToName, systemUserIds } = this._params!;
const options = { userIdToName, systemUserIds };
const resolveWithoutFetch = () =>
resolveLogbookChain(this.hass, entry, options, async () => []);
try {
const chain = isComponentLoaded(this.hass.config, "logbook")
? await resolveLogbookChain(this.hass, entry, options)
: await resolveWithoutFetch();
return { chain, errored: false };
} catch {
return { chain: await resolveWithoutFetch(), errored: true };
}
}
// The feed the row was clicked in can be filtered or partially loaded, so
// the state active just before the entry is resolved from history instead.
private async _fetchPreviousState(
entry: LogbookEntry
): Promise<string | undefined> {
if (
!entry.entity_id ||
entry.state === undefined ||
!isComponentLoaded(this.hass.config, "history")
) {
return undefined;
}
const end = new Date(entry.when * 1000);
const start = new Date(end.getTime() - 1);
try {
const states = await fetchDateWS(this.hass, start, end, [
entry.entity_id,
]);
return states[entry.entity_id]?.[0]?.s;
} catch {
// The row is still useful without an old state.
return undefined;
}
}
protected render() {
if (!this._params) {
return nothing;
}
const { entry } = this._params;
const item = computeLogbookItem(this.hass, entry);
return html`
<ha-adaptive-dialog
.open=${this._open}
header-title=${this.hass.localize("ui.dialogs.logbook_detail.title")}
@closed=${this._dialogClosed}
@hass-more-info=${this._moreInfoOpened}
>
<div class="content">
${this._renderFacts(item, entry)} ${this._renderWhatHappened(entry)}
</div>
</ha-adaptive-dialog>
`;
}
private _renderFacts(item: LogbookItem, entry: LogbookEntry) {
const stateObj = entry.entity_id
? this.hass.states[entry.entity_id]
: undefined;
const transition = this._transitionValues(item, entry, stateObj);
const when = this._entryDate(item.when);
return html`
<ha-grouped-list>
${this._renderSubjectRow(item, entry)}
${
transition
? html`
<ha-list-item-value
.label=${this.hass.localize(
"ui.dialogs.logbook_detail.state"
)}
>
${
transition.oldState
? html`<span class="old-state"
>${transition.oldState}</span
><span class="arrow"
>${transitionArrow(this.hass)}</span
>`
: nothing
}<span class="new-state">${transition.newState}</span>
</ha-list-item-value>
`
: item.value
? html`
<ha-list-item-value
.label=${this.hass.localize(
"ui.dialogs.logbook_detail.event"
)}
>
${item.value.text}
</ha-list-item-value>
`
: nothing
}
<ha-list-item-value
class="time-value"
.label=${this.hass.localize("ui.dialogs.logbook_detail.time")}
>
${formatDateTimeWithSeconds(when, this.hass.locale, this.hass.config)}
<span class="sub">
<ha-relative-time .datetime=${when} capitalize></ha-relative-time>
</span>
</ha-list-item-value>
</ha-grouped-list>
`;
}
// Mirrors the target picker: icon, name, area ▸ device.
private _renderSubjectRow(item: LogbookItem, entry: LogbookEntry) {
const icon = html`<span class="subject-icon" slot="start">
${this._renderSubjectIcon(item, entry)}
</span>`;
if (!entry.entity_id || !(entry.entity_id in this.hass.states)) {
return html`
<ha-list-item-base
.headline=${item.name}
.supportingText=${item.context}
>
${icon}
</ha-list-item-base>
`;
}
return html`
<ha-list-item-button
.headline=${item.name}
.supportingText=${item.context}
.entityId=${entry.entity_id}
@click=${this._entityClicked}
>
${icon}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
}
// The feed draws brand rows as a brands image, which stays blank for the
// integrations that have none. An integration domain resolves to no domain
// icon either, so fall back to the chain's own integration glyph.
private _renderSubjectIcon(item: LogbookItem, entry: LogbookEntry) {
if (item.glyph.type !== "brand") {
return renderLogbookGlyph(this.hass, entry, item.glyph);
}
return item.glyph.icon
? html`<ha-icon .icon=${item.glyph.icon}></ha-icon>`
: html`<ha-svg-icon .path=${mdiPuzzle}></ha-svg-icon>`;
}
private _entityClicked(ev: Event) {
const target = ev.currentTarget as HTMLElement & { entityId: string };
fireEvent(target, "hass-more-info", { entityId: target.entityId });
}
private _renderWhatHappened(entry: LogbookEntry) {
return html`
${
this._error
? html`<ha-alert alert-type="warning">
${this.hass.localize("ui.components.logbook.retrieval_error")}
</ha-alert>`
: nothing
}
<ha-grouped-list
.header=${this.hass.localize("ui.dialogs.logbook_detail.what_happened")}
>
<div class="chain-area">
${
this._chain === undefined
? html`<div class="loading"><ha-spinner></ha-spinner></div>`
: html`<ha-logbook-chain
.hass=${this.hass}
.chain=${this._chain}
.subject=${entry}
.traceContexts=${this._params?.traceContexts ?? {}}
></ha-logbook-chain>`
}
</div>
</ha-grouped-list>
`;
}
private _entryDate = memoizeOne((when: number) => new Date(when));
private _transitionValues(
item: LogbookItem,
entry: LogbookEntry,
stateObj?: HassEntity
): { oldState?: string; newState: string } | undefined {
if (item.category !== "entity" || entry.state === undefined) {
return undefined;
}
const newState = stateObj
? this.hass.formatEntityState(stateObj, entry.state)
: entry.state;
const previousState = this._previousState;
const oldState =
previousState !== undefined && previousState !== entry.state
? stateObj
? this.hass.formatEntityState(stateObj, previousState)
: previousState
: undefined;
return { oldState, newState };
}
private _moreInfoOpened() {
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
.content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
ha-list-item-button,
ha-list-item-base {
--ha-row-item-gap: var(--ha-space-3);
--ha-row-item-min-height: 56px;
}
/* Match the weight the chain gives its own row names. */
ha-list-item-button::part(headline),
ha-list-item-base::part(headline) {
font-weight: var(--ha-font-weight-medium);
}
.subject-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
color: var(--secondary-text-color);
--state-icon-color: var(--secondary-text-color);
}
.subject-icon state-badge {
width: 32px;
height: 32px;
margin: 0;
}
ha-icon-next {
color: var(--secondary-text-color);
}
.sub {
display: block;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.old-state {
color: var(--secondary-text-color);
}
.arrow {
color: var(--disabled-color);
padding: 0 4px;
}
.new-state {
font-weight: var(--ha-font-weight-medium);
}
.time-value {
font-variant-numeric: tabular-nums;
}
ha-relative-time {
display: contents;
}
/* minmax(0, 1fr) lets the chain shrink: a grid item defaults to its
min-content width, which a long automation name blows past. */
.chain-area {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
/* Held at two chain rows, the typical chain height, so the swap from
spinner to content barely moves the dialog. The resolved content
sizes itself — a lone "no cause" line must not sit in a tall box. */
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 114px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-logbook-detail": DialogLogbookDetail;
}
}
-510
View File
@@ -1,510 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { mdiStateMachine } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatTimeWithMilliseconds } from "../../common/datetime/format_time";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import { navigate } from "../../common/navigate";
import "../../components/ha-icon-next";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import { computeServiceLabel } from "../../data/compute-service-info";
import type { LogbookEntry } from "../../data/logbook";
import { createHistoricState, localizeTriggerSource } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { LogbookChain } from "./logbook-chain-resolver";
import type { LogbookCause } from "./logbook-entry-model";
import {
computeLogbookItem,
computeTraceLink,
entityDisplay,
isRunRow,
isSameLogbookEntry,
nodeColor,
} from "./logbook-entry-model";
import {
renderLogbookCauseIcon,
renderLogbookGlyph,
} from "./logbook-entry-templates";
@customElement("ha-logbook-chain")
class HaLogbookChain extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public chain!: LogbookChain;
@property({ attribute: false }) public subject!: LogbookEntry;
@property({ attribute: false }) public traceContexts: TraceContexts = {};
protected render() {
const { rows, runRow, origins, syntheticRun, triggerRow } = this.chain;
if (!origins.length && !runRow && !syntheticRun && rows.length <= 1) {
return html`
<p class="no-cause">
${this.hass.localize("ui.dialogs.logbook_detail.no_known_cause")}
</p>
`;
}
// The chain is the subject's cause path: the runs recorded before its
// rows (user → automation → script → entity). Sibling effects and runs
// recorded after the subject are not causes and stay out.
const isSubjectRow = (row: LogbookEntry) =>
this.subject.entity_id
? row.entity_id === this.subject.entity_id
: isSameLogbookEntry(row, this.subject);
const firstSubjectIndex = rows.findIndex(isSubjectRow);
const visibleRows = rows.filter(
(row, index) =>
isSubjectRow(row) ||
(isRunRow(row) &&
(firstSubjectIndex === -1 || index < firstSubjectIndex))
);
return html`
<div class="chain">
${origins.map((origin) =>
this._renderOriginNode(
origin,
runRow ?? this.subject,
origin.type === "state" ? triggerRow : undefined
)
)}
${syntheticRun ? this._renderSyntheticRunNode(syntheticRun) : nothing}
${visibleRows.map((row) =>
isRunRow(row) ? this._renderRunNode(row) : this._renderEffectNode(row)
)}
</div>
`;
}
// A div rather than a button: a run row nests the trace link inside it.
private _renderRow(entityId: string | undefined, content: TemplateResult) {
if (!entityId || !(entityId in this.hass.states)) {
return html`<div class="chain-row">${content}</div>`;
}
return html`
<div
class="chain-row clickable"
role="button"
tabindex="0"
.entityId=${entityId}
@click=${this._entityClicked}
@keydown=${this._rowKeydown}
>
${content}
<ha-icon-next class="chevron"></ha-icon-next>
</div>
`;
}
private _renderTraceLink(traceLink?: string) {
if (!traceLink) {
return nothing;
}
return html`<a
class="trace-link"
href=${traceLink}
@click=${this._traceClicked}
>${this.hass.localize("ui.components.logbook.view_trace")}</a
>`;
}
private _renderSyntheticRunNode(cause: LogbookCause) {
const mechanism = this.subject.context_source
? localizeTriggerSource(this.hass.localize, this.subject.context_source)
: this.hass.localize(
cause.type === "script"
? "ui.components.logbook.script_ran"
: "ui.components.logbook.automation_triggered"
);
const traceLink = computeTraceLink(
this.traceContexts,
this.subject.context_id
);
return this._renderRow(
cause.entityId,
html`
<span class="chain-node run">${renderLogbookCauseIcon(cause)}</span>
<span class="chain-content">
<span class="name">${cause.name}</span>
${
mechanism
? html`<span class="chain-secondary">${mechanism}</span>`
: nothing
}
${this._renderTraceLink(traceLink)}
</span>
`
);
}
private _renderOriginNode(
origin: LogbookCause,
originRow: LogbookEntry,
triggerRow?: LogbookEntry
) {
const name =
origin.name ||
this.hass.localize("ui.components.logbook.cause.scheduled");
const isState = origin.type === "state";
const triggerState = isState
? (triggerRow?.state ?? originRow.context_state)
: undefined;
const stateObj = origin.entityId
? this.hass.states[origin.entityId]
: undefined;
const triggerValue = triggerState
? stateObj
? this.hass.formatEntityState(stateObj, triggerState)
: triggerState
: undefined;
const secondary = isState
? origin.entityId
? entityDisplay(this.hass, origin.entityId).secondary
: undefined
: this._actionUsedText(originRow);
const isAvatar = origin.type === "user" && !origin.systemUser;
const historicStateObj =
stateObj && triggerState
? createHistoricState(stateObj, triggerState)
: stateObj;
const color =
isState && historicStateObj
? nodeColor("entity", historicStateObj)
: undefined;
return this._renderRow(
origin.entityId,
html`
<span
class="chain-node ${isAvatar ? "avatar" : ""}"
style=${color ? styleMap({ "--node-color": color }) : nothing}
>
${this._renderOriginIcon(origin, historicStateObj)}
</span>
<span class="chain-content">
<span class="name">${name}</span>
${
secondary
? html`<span class="chain-secondary">${secondary}</span>`
: nothing
}
</span>
${
triggerValue
? html`<span class="chain-trailing">
<span class="trailing-state">${triggerValue}</span>
${
triggerRow
? html`<span class="trailing-time"
>${this._formatTimeWithMs(triggerRow.when * 1000)}</span
>`
: nothing
}
</span>`
: nothing
}
`
);
}
private _actionUsedText(originRow: LogbookEntry) {
if (
originRow.context_event_type === "call_service" &&
originRow.context_domain &&
originRow.context_service
) {
return this.hass.localize("ui.dialogs.logbook_detail.action_used", {
name: computeServiceLabel(
this.hass.localize,
this.hass.services,
`${originRow.context_domain}.${originRow.context_service}`
),
});
}
return "";
}
private _renderOriginIcon(origin: LogbookCause, stateObj?: HassEntity) {
if (origin.type === "state") {
return stateObj
? html`<ha-state-icon .stateObj=${stateObj}></ha-state-icon>`
: html`<ha-svg-icon .path=${mdiStateMachine}></ha-svg-icon>`;
}
return renderLogbookCauseIcon(origin);
}
private _formatTimeWithMs(when: number) {
return formatTimeWithMilliseconds(
new Date(when),
this.hass.locale,
this.hass.config
);
}
private _renderRunNode(row: LogbookEntry) {
const item = computeLogbookItem(this.hass, row);
const time = this._formatTimeWithMs(item.when);
const traceLink = computeTraceLink(this.traceContexts, row.context_id);
return this._renderRow(
row.entity_id,
html`
<span class="chain-node run">
${renderLogbookGlyph(this.hass, row, item.glyph)}
</span>
<span class="chain-content">
<span class="name">${item.name}</span>
${this._renderTraceLink(traceLink)}
</span>
<span class="chain-trailing">
${
item.value
? html`<span class="trailing-state">${item.value.text}</span>`
: nothing
}
<span class="trailing-time">${time}</span>
</span>
`
);
}
private _renderEffectNode(row: LogbookEntry) {
const item = computeLogbookItem(this.hass, row);
const stateObj = row.entity_id
? this.hass.states[row.entity_id]
: undefined;
const historicStateObj = stateObj
? createHistoricState(stateObj, row.state)
: undefined;
const color = nodeColor(item.category, historicStateObj);
const time = this._formatTimeWithMs(item.when);
return this._renderRow(
row.entity_id,
html`
<span
class="chain-node"
style=${color ? styleMap({ "--node-color": color }) : nothing}
>
${renderLogbookGlyph(this.hass, row, item.glyph)}
</span>
<span class="chain-content">
<span class="name">${item.name}</span>
${
item.context
? html`<span class="chain-secondary">${item.context}</span>`
: nothing
}
</span>
<span class="chain-trailing">
${
item.value
? html`<span class="trailing-state">${item.value.text}</span>`
: nothing
}
<span class="trailing-time">${time}</span>
</span>
`
);
}
private _entityClicked(ev: Event) {
const target = ev.currentTarget as HTMLElement & { entityId: string };
fireEvent(target, "hass-more-info", { entityId: target.entityId });
}
private _rowKeydown(ev: KeyboardEvent) {
if (ev.key !== "Enter" && ev.key !== " ") {
return;
}
if (ev.target !== ev.currentTarget) {
return;
}
ev.preventDefault();
this._entityClicked(ev);
}
private _traceClicked(ev: MouseEvent) {
// The row underneath opens the more info; the link owns its own click.
ev.stopPropagation();
const href = isNavigationClick(ev);
if (!href) {
return;
}
// navigate() closes the dialogs above this chain.
navigate(href);
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
}
/* The enclosing ha-grouped-list owns the frame; the rows only have to
line up with its own items. */
.chain-row {
position: relative;
display: flex;
align-items: center;
gap: var(--ha-space-3);
width: 100%;
min-height: 56px;
padding: var(--ha-space-2)
var(--ha-row-item-padding-inline, var(--ha-space-3));
box-sizing: border-box;
}
.chain-row.clickable {
cursor: pointer;
}
.chain-row.clickable:hover {
background-color: rgba(var(--rgb-primary-text-color), 0.04);
}
.chain-row.clickable:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
/* Caret between rows: the chain reads top-down, cause to effects.
Centered on the node: row padding + half the 32px node - half the
caret. */
.chain-row + .chain-row::before {
content: "";
position: absolute;
top: -3px;
inset-inline-start: calc(
var(--ha-row-item-padding-inline, var(--ha-space-3)) + 11px
);
border-inline-start: 5px solid transparent;
border-inline-end: 5px solid transparent;
border-top: 6px solid var(--divider-color);
}
.chain-node {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: var(--ha-border-radius-circle);
background-color: var(--card-background-color);
color: var(--node-color, var(--secondary-text-color));
--mdc-icon-size: 18px;
}
.chain-node state-badge {
margin: 0;
color: inherit;
}
.chain-node::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background-color: currentColor;
opacity: 0.15;
}
.chain-node > * {
position: relative;
}
.chain-node.run {
color: var(
--logbook-category-automation-color,
var(--light-blue-color)
);
border-radius: var(--ha-border-radius-md);
}
.chain-node.avatar::before {
display: none;
}
.chain-node.avatar ha-user-badge {
width: 32px;
height: 32px;
font-size: 14px;
}
.chain-content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.chain-content .name {
font-weight: var(--ha-font-weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chain-secondary {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.trace-link {
align-self: flex-start;
color: var(--primary-color);
font-size: var(--ha-font-size-s);
text-decoration: none;
}
.trace-link:hover {
text-decoration: underline;
}
.chain-trailing {
text-align: end;
flex-shrink: 0;
}
.trailing-state {
display: block;
}
.trailing-time {
display: block;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-variant-numeric: tabular-nums;
}
.chevron {
flex-shrink: 0;
color: var(--secondary-text-color);
}
.no-cause {
margin: 0;
padding: var(--ha-space-3)
var(--ha-row-item-padding-inline, var(--ha-space-3));
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-logbook-chain": HaLogbookChain;
}
}
+380 -154
View File
@@ -1,50 +1,58 @@
import { mdiCast, mdiCloud, mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeTimelineColor } from "../../components/chart/timeline-color";
import { computeDomain } from "../../common/entity/compute_domain";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { useAmPm } from "../../common/datetime/use_am_pm";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-icon-next";
import { navigate } from "../../common/navigate";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/entity/state-badge";
import "../../components/ha-relative-time";
import "../../components/ha-domain-icon";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/ha-tooltip";
import "../../components/user/ha-user-badge";
import { UNAVAILABLE } from "../../data/entity/entity";
import type { LogbookEntry } from "../../data/logbook";
import { haStyle } from "../../resources/styles";
import type { TraceContexts } from "../../data/trace";
import type { User } from "../../data/user";
import { buttonLinkStyle, haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type {
LogbookCause,
LogbookCauseType,
LogbookGlyph,
LogbookItem,
LogbookNameDetail,
LogbookValue,
} from "./logbook-entry-model";
import { computeLogbookItem, nodeColor } from "./logbook-entry-model";
import {
renderLogbookCauseIcon,
renderLogbookGlyph,
transitionArrow,
} from "./logbook-entry-templates";
computeLogbookItem,
nodeColor,
TRIGGER_DOMAINS,
} from "./logbook-entry-model";
type EntryLayout = "timeline" | "list" | "inline";
interface LogbookRenderItem extends LogbookItem {
renderedTime: string;
traceLink: string | undefined;
renderedTime: TemplateResult | string;
renderedValue: TemplateResult | string;
}
export interface LogbookEntrySelectedDetail {
item: LogbookEntry;
}
declare global {
interface HASSDomEvents {
"logbook-entry-selected": LogbookEntrySelectedDetail;
}
}
// Names are the fixed system user names set by core (cloud/cast integrations).
const SYSTEM_USER_ICONS: Record<string, string> = {
"Home Assistant Cloud": mdiCloud,
"Home Assistant Cast": mdiCast,
};
@customElement("ha-logbook-entry")
class HaLogbookEntry extends LitElement {
@@ -57,7 +65,7 @@ class HaLogbookEntry extends LitElement {
@property({ attribute: false }) public systemUserIds = new Set<string>();
@property({ type: Boolean, attribute: "no-detail" }) public noDetail = false;
@property({ attribute: false }) public traceContexts: TraceContexts = {};
@property({ type: Boolean }) public narrow = false;
@@ -72,6 +80,8 @@ class HaLogbookEntry extends LitElement {
@property({ type: Boolean, attribute: false }) public lastOfDay = false;
@property({ type: Boolean, attribute: false }) public showRelative = false;
@property({ type: Boolean, attribute: "show-cause" }) public showCause =
false;
@@ -89,28 +99,37 @@ class HaLogbookEntry extends LitElement {
systemUserIds: this.systemUserIds,
});
const traceContext =
entry.domain &&
TRIGGER_DOMAINS.includes(entry.domain) &&
entry.context_id &&
entry.context_id in this.traceContexts
? this.traceContexts[entry.context_id]
: undefined;
const traceLink = traceContext
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
: undefined;
const hideName = this.nameDetail === "none";
const layout: EntryLayout =
!this.narrow && !this.noIcon ? "timeline" : hideName ? "inline" : "list";
const node = layout === "timeline" ? "icon" : "dot";
const renderedTime = formatTimeWithSeconds(
new Date(item.when),
this.hass.locale,
this.hass.config
);
const when = new Date(item.when);
const renderedTime = this.showRelative
? html`<ha-relative-time
.datetime=${when}
format="short"
></ha-relative-time>`
: formatTimeWithSeconds(when, this.hass.locale, this.hass.config);
const ctx: LogbookRenderItem = {
...item,
traceLink,
renderedTime,
renderedValue: this._renderValue(item.value, seenEntityIds),
renderedValue: this._renderValue(item.value, seenEntityIds, !!traceLink),
};
const clickable = !this.noDetail;
// On wide screens the hover state carries the affordance; a chevron would
// sit far away from the text it belongs to.
const chevron = clickable && layout !== "timeline";
return html`
<div
class="entry ${classMap({
@@ -119,16 +138,17 @@ class HaLogbookEntry extends LitElement {
"last-of-day": this.lastOfDay,
[`category-${ctx.category}`]: true,
"time-am-pm": useAmPm(this.hass.locale),
clickable,
})}"
role=${ifDefined(clickable ? "button" : undefined)}
tabindex=${ifDefined(clickable ? "0" : undefined)}
@click=${this._rowClicked}
@keydown=${this._rowKeydown}
>
${
layout === "timeline"
? html`<div class="time">
? html`<div
class="time"
role="button"
tabindex="0"
@click=${this._toggleTime}
@keydown=${this._timeKeydown}
>
<span class="time-content">${renderedTime}</span>
</div>`
: nothing
@@ -141,73 +161,109 @@ class HaLogbookEntry extends LitElement {
>
${this._renderNode(ctx, layout)}
</div>
<div class="body">
<div class="content">
${
layout === "timeline"
? this._renderTimeline(ctx)
: layout === "list"
? this._renderList(ctx)
: this._renderInline(ctx)
}
</div>
${chevron ? html`<ha-icon-next class="chevron"></ha-icon-next>` : nothing}
<div class="content">
${
layout === "timeline"
? this._renderTimeline(ctx)
: layout === "list"
? this._renderList(ctx)
: this._renderInline(ctx)
}
</div>
</div>
`;
}
private _rowClicked() {
if (this.noDetail) {
return;
}
fireEvent(this, "logbook-entry-selected", { item: this.item });
private _toggleTime(e: Event) {
e.stopPropagation();
fireEvent(this, "logbook-toggle-time" as any);
}
private _rowKeydown(ev: KeyboardEvent) {
if (ev.key !== "Enter" && ev.key !== " ") {
return;
private _timeKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fireEvent(this, "logbook-toggle-time" as any);
}
if (ev.target !== ev.currentTarget) {
}
private _handleTraceClick(ev: MouseEvent) {
// Let modified clicks open in a new tab; otherwise route in-app.
if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey) {
return;
}
ev.preventDefault();
this._rowClicked();
navigate((ev.currentTarget as HTMLAnchorElement).getAttribute("href")!);
fireEvent(this, "closed");
}
private _entityClicked(ev: Event) {
const entityId = (ev.currentTarget as any).entityId;
if (!entityId) return;
ev.preventDefault();
ev.stopPropagation();
fireEvent(this, "hass-more-info", { entityId });
}
private _renderTimeChip(renderedTime: TemplateResult | string) {
return html`<span
class="time-chip"
role="button"
tabindex="0"
@click=${this._toggleTime}
@keydown=${this._timeKeydown}
>${renderedTime}</span
>`;
}
private _renderTrailing(
cause: LogbookCause | undefined,
renderedTime: string
traceLink: string | undefined,
renderedTime: TemplateResult | string
) {
return html`<span class="trailing">
${cause ? this._renderCauseBadge(cause) : nothing}
<span class="time-chip">${renderedTime}</span>
${
cause
? html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
<span class="cause-badge" id="cause-badge"
>${this._renderCauseIcon(cause)}</span
>`
: nothing
}
${traceLink ? this._renderTraceLink(traceLink) : nothing}
${this._renderTimeChip(renderedTime)}
</span>`;
}
private _renderCauseBadge(cause: LogbookCause) {
return html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
<span class="cause-badge" id="cause-badge"
>${renderLogbookCauseIcon(cause)}</span
>`;
private _renderTraceLink(traceLink: string) {
return html`<a
class="trace-link"
href=${traceLink}
@click=${this._handleTraceClick}
>${this.hass.localize("ui.components.logbook.view_trace")}</a
>`;
}
private _renderTimeline(ctx: LogbookRenderItem) {
const hideName = this.nameDetail === "none";
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const valueIsState = ctx.value?.type === "state";
const causePhrase = ctx.cause
? this._renderCausePhrase(ctx.cause)
: undefined;
return html`
<div class="primary">
<span class="primary-text"
>${
!hideName
? html`<span class="subject"
>${this._entityName(ctx.entityId, ctx.name)}</span
>${this._renderEntity(ctx.entityId, ctx.name)}</span
>${
ctx.renderedValue
? valueIsState
? html`<span class="arrow"
>${transitionArrow(this.hass)}</span
>`
? html`<span class="arrow">${rtl ? "←" : "→"}</span>`
: " "
: nothing
}`
@@ -223,9 +279,15 @@ class HaLogbookEntry extends LitElement {
: nothing
}
${
ctx.cause
causePhrase || ctx.traceLink
? html`<div class="secondary">
${this._renderCauseLine(ctx.cause)}
${
causePhrase
? html`<span class="cause-phrase">${causePhrase}</span>`
: nothing
}
${causePhrase && ctx.traceLink ? html`·` : nothing}
${ctx.traceLink ? this._renderTraceLink(ctx.traceLink) : nothing}
</div>`
: nothing
}
@@ -233,11 +295,16 @@ class HaLogbookEntry extends LitElement {
}
private _renderList(ctx: LogbookRenderItem) {
const cause = ctx.cause;
const showThirdLine = this.showCause && cause;
const cause =
this.showCause || ctx.category === "entity" ? ctx.cause : undefined;
const trailingTrace = this.showCause ? undefined : ctx.traceLink;
const thirdLineTrace = this.showCause ? ctx.traceLink : undefined;
const showThirdLine = this.showCause && (cause || thirdLineTrace);
return html`
<div class="primary">
<span class="subject">${this._entityName(ctx.entityId, ctx.name)}</span>
<span class="subject"
>${this._renderEntity(ctx.entityId, ctx.name)}</span
>
<span class="value" title=${ctx.value?.text ?? ""}
>${ctx.renderedValue}</span
>
@@ -246,18 +313,27 @@ class HaLogbookEntry extends LitElement {
<span class="secondary-text">${ctx.context ?? nothing}</span>
${this._renderTrailing(
showThirdLine ? undefined : cause,
trailingTrace,
ctx.renderedTime
)}
</div>
${
showThirdLine
? html`<div class="secondary">${this._renderCauseLine(cause)}</div>`
? html`<div class="secondary">
${this._renderListCauseLine(cause, thirdLineTrace)}
</div>`
: nothing
}
`;
}
private _renderCauseLine(cause: LogbookCause) {
private _renderListCauseLine(
cause: LogbookCause | undefined,
traceLink: string | undefined
) {
if (!cause) {
return traceLink ? this._renderTraceLink(traceLink) : nothing;
}
const { localize } = this.hass;
if (cause.entityId) {
const prefixMap: Partial<Record<LogbookCauseType, string>> = {
@@ -272,67 +348,115 @@ class HaLogbookEntry extends LitElement {
}),
};
const prefix = prefixMap[cause.type];
return html`<span class="cause-line">
${prefix ?? nothing}<span class="cause-entity">${cause.name}</span>
</span>`;
return html`
${prefix ? html`<span class="cause-prefix">${prefix}</span>` : nothing}
<button
class="link cause-entity"
@click=${this._entityClicked}
.entityId=${cause.entityId}
>
${cause.name}
</button>
${traceLink ? this._renderTraceLink(traceLink) : nothing}
`;
}
return html`<span class="cause-line"
>${this._renderCausePhrase(cause)}</span
>`;
return html`
<span class="secondary-text">${this._renderCausePhrase(cause)}</span>
${traceLink ? this._renderTraceLink(traceLink) : nothing}
`;
}
private _renderInline(ctx: LogbookRenderItem) {
return html`
<div class="primary">
<span class="primary-text">${ctx.renderedValue}</span>
${this._renderTrailing(ctx.cause, ctx.renderedTime)}
${this._renderTrailing(
ctx.category === "entity" ? ctx.cause : undefined,
ctx.traceLink,
ctx.renderedTime
)}
</div>
`;
}
private _renderValue(
value: LogbookValue | undefined,
seenEntityIds: string[]
seenEntityIds: string[],
noLink: boolean
): TemplateResult | string {
if (!value) {
return "";
}
return value.type === "message"
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds)
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds, noLink)
: value.text;
}
private _entityName(
private _renderEntity(
entityId: string | undefined,
entityName: string | undefined
entityName: string | undefined,
noLink?: boolean
) {
return (
const hasState = entityId && entityId in this.hass.states;
const displayName =
entityName ||
(entityId && entityId in this.hass.states
(hasState
? this.hass.states[entityId].attributes.friendly_name || entityId
: entityId)
);
: entityId);
if (!hasState) {
return displayName;
}
return noLink
? displayName
: html`<button
class="link"
@click=${this._entityClicked}
.entityId=${entityId}
>
${displayName}
</button>`;
}
private _renderCausePhrase(cause: LogbookCause): string {
private _renderCausePhrase(cause: LogbookCause): TemplateResult | string {
const { localize } = this.hass;
const nameEl = cause.entityId
? html`<button
class="link"
@click=${this._entityClicked}
.entityId=${cause.entityId}
>
${cause.name}
</button>`
: cause.name;
switch (cause.type) {
case "user":
return localize("ui.components.logbook.cause.by", {
name: cause.name,
});
case "automation":
return localize("ui.components.logbook.cause.by_automation", {
name: cause.name,
});
return cause.entityId
? html`${localize("ui.components.logbook.cause.by_automation", {
name: "",
})}${nameEl}`
: localize("ui.components.logbook.cause.by_automation", {
name: cause.name,
});
case "script":
return localize("ui.components.logbook.cause.by_script", {
name: cause.name,
});
return cause.entityId
? html`${localize("ui.components.logbook.cause.by_script", {
name: "",
})}${nameEl}`
: localize("ui.components.logbook.cause.by_script", {
name: cause.name,
});
case "state":
return localize("ui.components.logbook.cause.by_state_change", {
name: cause.name,
});
return cause.entityId
? html`${localize("ui.components.logbook.cause.by_state_change", {
name: "",
})}${nameEl}`
: localize("ui.components.logbook.cause.by_state_change", {
name: cause.name,
});
case "scheduled":
return localize("ui.components.logbook.cause.scheduled");
case "homeassistant":
@@ -346,9 +470,54 @@ class HaLogbookEntry extends LitElement {
}
}
private _renderCauseIcon(cause: LogbookCause) {
if (cause.type === "user") {
const systemIcon = cause.systemUser
? SYSTEM_USER_ICONS[cause.name]
: undefined;
if (systemIcon) {
return html`<ha-svg-icon
class="cause-icon"
.path=${systemIcon}
></ha-svg-icon>`;
}
return html`<ha-user-badge
class="cause-icon cause-avatar"
.user=${{ id: cause.userId!, name: cause.name } as User}
></ha-user-badge>`;
}
if (cause.type === "automation") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiRobot}
></ha-svg-icon>`;
}
if (cause.type === "script") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiScriptText}
></ha-svg-icon>`;
}
if (cause.type === "state") {
return nothing;
}
if (cause.brandDomain) {
return html`<ha-domain-icon
class="cause-icon"
.domain=${cause.brandDomain}
brand-fallback
></ha-domain-icon>`;
}
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiPuzzle}
></ha-svg-icon>`;
}
private _formatMessageWithPossibleEntity(
message: string,
seenEntities: string[]
seenEntities: string[],
noLink?: boolean
) {
if (message.indexOf(".") !== -1) {
const messageParts = message.split(" ");
@@ -362,9 +531,10 @@ class HaLogbookEntry extends LitElement {
const messageEnd = messageParts.splice(i);
messageEnd.shift();
return html`${messageParts.join(" ")}
${this._entityName(
${this._renderEntity(
entityId,
this.hass.states[entityId].attributes.friendly_name
this.hass.states[entityId].attributes.friendly_name,
noLink
)}
${messageEnd.join(" ")}`;
}
@@ -401,14 +571,53 @@ class HaLogbookEntry extends LitElement {
const unavailable =
item.glyph.type === "state" && item.glyph.stateObj.state === UNAVAILABLE;
return html`<div class="node-glyph" style=${style}>
${renderLogbookGlyph(this.hass, this.item, item.glyph)}
${this._renderGlyph(item.glyph)}
${unavailable ? html`<span class="node-badge"></span>` : nothing}
</div>`;
}
private _renderGlyph(glyph: LogbookGlyph) {
if (glyph.type === "automation") {
return html`<ha-svg-icon
.path=${glyph.script ? mdiScriptText : mdiRobot}
></ha-svg-icon>`;
}
if (glyph.type === "state") {
return html`<ha-state-icon
.stateObj=${glyph.stateObj}
.icon=${glyph.icon}
></ha-state-icon>`;
}
return html`<state-badge
.overrideIcon=${glyph.icon}
.overrideImage=${this._brandImage(glyph.domain)}
.stateColor=${false}
></state-badge>`;
}
private _brandImage(domain?: string): string | undefined {
if (
!domain ||
this.item.icon ||
this.item.state ||
!isComponentLoaded(this.hass.config, domain)
) {
return undefined;
}
return brandsUrl(
{
domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
}
static get styles(): CSSResultGroup {
return [
haStyle,
buttonLinkStyle,
css`
:host {
display: block;
@@ -463,20 +672,6 @@ class HaLogbookEntry extends LitElement {
);
}
.entry.clickable {
cursor: pointer;
user-select: none;
}
.entry.clickable:hover {
background-color: rgba(var(--rgb-primary-text-color), 0.04);
}
.entry.clickable:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
.time {
display: flex;
flex-direction: column;
@@ -486,6 +681,12 @@ class HaLogbookEntry extends LitElement {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
overflow: hidden;
cursor: pointer;
user-select: none;
}
.time:hover {
opacity: 0.75;
}
.time-content {
@@ -630,33 +831,19 @@ class HaLogbookEntry extends LitElement {
box-sizing: border-box;
}
/* Discreet divider between rows, aligned to the content so it does
not cross the rail. Suppressed on the last row of each day. */
.body {
display: flex;
align-items: stretch;
min-width: 0;
border-bottom: 1px solid var(--divider-color);
}
.entry.last-of-day .body {
border-bottom: none;
}
.chevron {
align-self: center;
flex-shrink: 0;
margin-inline-start: var(--ha-space-2);
color: var(--secondary-text-color);
}
.content {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
min-width: 0;
gap: 2px;
/* Discreet divider between rows, aligned to the content so it does
not cross the rail. Suppressed on the last row of each day. */
border-bottom: 1px solid var(--divider-color);
}
.entry.last-of-day .content {
border-bottom: none;
}
/* List/inline: fixed padding instead of justify-content:center so
@@ -687,13 +874,6 @@ class HaLogbookEntry extends LitElement {
color: var(--primary-text-color);
}
/* Inline is the only layout carrying the trailing block in .primary,
and an icon badge has no baseline of its own — it would ride 5px
above the text and miss the chevron's centerline. */
.entry.layout-inline .primary {
align-items: center;
}
.entry.layout-inline .primary-text:first-letter {
text-transform: capitalize;
}
@@ -715,6 +895,14 @@ class HaLogbookEntry extends LitElement {
font-weight: var(--ha-font-weight-medium);
}
.primary > .subject button.link {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.subject {
font-weight: var(--ha-font-weight-medium);
}
@@ -763,20 +951,33 @@ class HaLogbookEntry extends LitElement {
align-items: center;
}
ha-relative-time {
display: contents;
}
.time-chip {
flex-shrink: 0;
min-width: 4.5em;
text-align: end;
line-height: 1;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
font-variant-numeric: tabular-nums;
cursor: pointer;
user-select: none;
}
.time-chip {
min-width: 4.5em;
}
.entry.time-am-pm .time-chip {
min-width: 6em;
}
.time-chip:hover {
opacity: 0.75;
}
.cause-icon {
flex-shrink: 0;
--mdc-icon-size: var(--cause-icon-size);
@@ -790,19 +991,44 @@ class HaLogbookEntry extends LitElement {
font-size: 9px;
}
/* The cause reads as one sentence on one line, truncating as a whole. */
.cause-line {
flex: 1;
min-width: 0;
.cause-prefix {
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cause-entity {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
}
/* The trace link sits after the cause; it never shrinks, so a long
cause truncates instead. */
.trace-link {
flex-shrink: 0;
color: var(--primary-color);
text-decoration: none;
}
.trace-link:hover {
text-decoration: underline;
}
/* Entity names read as the subject, not a wall of blue links — the
colored node is the scan anchor. */
button.link {
color: var(--primary-text-color);
font-weight: var(--ha-font-weight-medium);
text-decoration: none;
}
button.link:hover {
text-decoration: underline;
}
`,
];
}
+14 -31
View File
@@ -2,11 +2,10 @@ import type { VisibilityChangedEvent } from "@lit-labs/virtualizer";
import memoizeOne from "memoize-one";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
import { customElement, eventOptions, property, state } from "lit/decorators";
import { formatDate } from "../../common/datetime/format_date";
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import type { LogbookEntry } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
@@ -14,14 +13,13 @@ import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-entry";
import type { LogbookEntrySelectedDetail } from "./ha-logbook-entry";
import type { LogbookNameDetail } from "./logbook-entry-model";
import { sameDay } from "./logbook-entry-model";
import { showLogbookDetailDialog } from "./show-dialog-logbook-detail";
declare global {
interface HASSDomEvents {
"hass-logbook-live": { enable: boolean };
"logbook-toggle-time": undefined;
}
}
@@ -34,7 +32,6 @@ class HaLogbookRenderer extends LitElement {
@property({ attribute: false }) public systemUserIds = new Set<string>();
// Not rendered by rows; read at click time and handed to the detail dialog.
@property({ attribute: false }) public traceContexts: TraceContexts = {};
@property({ attribute: false }) public entries: LogbookEntry[] = [];
@@ -52,14 +49,14 @@ class HaLogbookRenderer extends LitElement {
@property({ type: Boolean, attribute: "show-cause" }) public showCause =
false;
@property({ type: Boolean, attribute: "no-detail" }) public noDetail = false;
@property({ type: String, attribute: "name-detail" })
public nameDetail?: LogbookNameDetail;
// @ts-ignore
@restoreScroll(".container") private _savedScrollPos?: number;
@state() private _showRelative = false;
protected willUpdate(changedProps: PropertyValues<this>) {
if (
(!this.hasUpdated && this.virtualize) ||
@@ -80,9 +77,8 @@ class HaLogbookRenderer extends LitElement {
return (
changedProps.has("entries") ||
changedProps.has("noDetail") ||
changedProps.has("userIdToName") ||
changedProps.has("systemUserIds") ||
changedProps.has("traceContexts") ||
changedProps.has("_showRelative" as never) ||
languageChanged
);
}
@@ -100,7 +96,7 @@ class HaLogbookRenderer extends LitElement {
<div
class="container ha-scrollbar"
@scroll=${this._saveScrollPos}
@logbook-entry-selected=${this._handleEntrySelected}
@logbook-toggle-time=${this._handleToggleTime}
>
${
this.virtualize
@@ -109,12 +105,7 @@ class HaLogbookRenderer extends LitElement {
scroller
class="ha-scrollbar"
.items=${this.entries}
.renderItem=${
this._getRenderRow(
this.userIdToName,
this.systemUserIds
) as any
}
.renderItem=${this._getRenderRow(this._showRelative) as any}
>
</lit-virtualizer>`
: this.entries.map((item, index) => this._renderItem(item, index))
@@ -123,12 +114,9 @@ class HaLogbookRenderer extends LitElement {
`;
}
// Memoized on every input the rows render from, so the virtualizer sees a
// new renderItem and refreshes already-rendered rows when one changes.
private _getRenderRow = memoizeOne(
(_userIdToName: Record<string, string>, _systemUserIds: Set<string>) =>
(item: LogbookEntry, index: number) =>
this._renderItem(item, index)
(_showRelative: boolean) => (item: LogbookEntry, index: number) =>
this._renderItem(item, index)
);
private _renderItem = (item: LogbookEntry, index: number) => {
@@ -153,27 +141,22 @@ class HaLogbookRenderer extends LitElement {
.item=${item}
.userIdToName=${this.userIdToName}
.systemUserIds=${this.systemUserIds}
.traceContexts=${this.traceContexts}
.narrow=${this.narrow}
.noIcon=${this.noIcon}
.graphColor=${this.graphColor}
.nameDetail=${this.nameDetail}
.firstOfDay=${firstOfDay}
.lastOfDay=${lastOfDay}
.showRelative=${this._showRelative}
.showCause=${this.showCause}
.noDetail=${this.noDetail}
></ha-logbook-entry>
</div>
`;
};
private _handleEntrySelected(ev: HASSDomEvent<LogbookEntrySelectedDetail>) {
ev.stopPropagation();
showLogbookDetailDialog(this, {
entry: ev.detail.item,
traceContexts: this.traceContexts,
userIdToName: this.userIdToName,
systemUserIds: this.systemUserIds,
});
private _handleToggleTime() {
this._showRelative = !this._showRelative;
}
private _formatDateHeader(date: Date): string {
@@ -1,184 +0,0 @@
import type { LogbookEntry } from "../../data/logbook";
import { getLogbookDataFromServer } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
import type { LogbookCause } from "./logbook-entry-model";
import {
computeContextCause,
computeLogbookCause,
computeUserCause,
isRunCause,
isRunRow,
isSameLogbookEntry,
} from "./logbook-entry-model";
// No run start is available and a delayed script can start long before the
// clicked entry.
const LOOKBACK_MS = 24 * 60 * 60 * 1000;
const WHEN_EPSILON = 0.001;
const MAX_RUN_CANDIDATES = 3;
export interface LogbookChain {
rows: LogbookEntry[];
runRow?: LogbookEntry;
// Causes shown above the run, topmost first.
origins: LogbookCause[];
// Stands in for the run row when it could not be fetched.
syntheticRun?: LogbookCause;
// The state change that fired a state trigger in `origins`.
triggerRow?: LogbookEntry;
}
export type LogbookFetcher = (
startDate: string,
endDate?: string,
entityIds?: string[],
contextId?: string
) => Promise<LogbookEntry[]>;
interface ResolveOptions {
userIdToName?: Record<string, string>;
systemUserIds?: Set<string>;
}
const lookbackIso = (when: number) =>
new Date(when * 1000 - LOOKBACK_MS).toISOString();
const justAfterIso = (when: number) =>
new Date(when * 1000 + 1000).toISOString();
// Effect rows never carry a context_id, run rows do: the logbook processor
// only emits it on rows described by the automation/script platforms. This
// candidate discovery + verification is a workaround for that gap; once core
// emits context_id on described rows too, the fast path by entry.context_id
// covers every click and this can be deleted.
const resolveRowsByContextEntity = async (
entry: LogbookEntry,
fetchEvents: LogbookFetcher
): Promise<LogbookEntry[]> => {
const contextEntityId = entry.context_entity_id!;
const runs = (
await fetchEvents(lookbackIso(entry.when), justAfterIso(entry.when), [
contextEntityId,
])
)
.filter((row) => row.context_id && row.when <= entry.when + WHEN_EPSILON)
.sort((a, b) => b.when - a.when)
.slice(0, MAX_RUN_CANDIDATES);
for (const run of runs) {
// eslint-disable-next-line no-await-in-loop
const rows = await fetchEvents(
lookbackIso(entry.when),
undefined,
undefined,
run.context_id
);
if (rows.some((row) => isSameLogbookEntry(row, entry))) {
return rows;
}
}
return [];
};
const resolveTriggerRow = async (
entityId: string,
beforeWhen: number,
fetchEvents: LogbookFetcher
): Promise<LogbookEntry | undefined> => {
const rows = await fetchEvents(
lookbackIso(beforeWhen),
justAfterIso(beforeWhen),
[entityId]
);
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (row.state !== undefined && row.when <= beforeWhen + WHEN_EPSILON) {
return row;
}
}
return undefined;
};
export const resolveLogbookChain = async (
hass: HomeAssistant,
entry: LogbookEntry,
options: ResolveOptions = {},
fetchEvents: LogbookFetcher = (startDate, endDate, entityIds, contextId) =>
getLogbookDataFromServer(hass, startDate, endDate, entityIds, contextId)
): Promise<LogbookChain> => {
const userIdToName = options.userIdToName ?? {};
const { systemUserIds } = options;
let rows: LogbookEntry[] = [];
if (entry.context_id) {
rows = await fetchEvents(
lookbackIso(entry.when),
undefined,
undefined,
entry.context_id
);
} else if (entry.context_entity_id) {
rows = await resolveRowsByContextEntity(entry, fetchEvents);
}
if (!rows.length) {
rows = [entry];
}
let runRow = rows.find(isRunRow);
if (runRow && runRow !== entry && isSameLogbookEntry(entry, runRow)) {
// The clicked feed copy carries the call_service description that the
// fetched copy of the run row never has.
rows = rows.map((row) => (row === runRow ? entry : row));
runRow = entry;
}
const origins: LogbookCause[] = [];
let syntheticRun: LogbookCause | undefined;
if (runRow) {
const runCause = computeLogbookCause(
hass,
runRow,
userIdToName,
systemUserIds
);
if (runCause?.type !== "user" && !isSameLogbookEntry(entry, runRow)) {
// The run row is its own context origin and comes back without the
// user its effects carry: read the user from the clicked entry.
const userCause = computeUserCause(entry, userIdToName, systemUserIds);
if (userCause) {
origins.push(userCause);
}
}
if (runCause) {
origins.push(runCause);
}
} else {
const userCause = computeUserCause(entry, userIdToName, systemUserIds);
const contextCause = computeContextCause(hass, entry);
if (isRunCause(contextCause)) {
syntheticRun = contextCause;
if (userCause) {
origins.push(userCause);
}
} else {
const cause = userCause ?? contextCause;
if (cause) {
origins.push(cause);
}
}
}
const stateOrigin = origins.find(
(cause) => cause.type === "state" && cause.entityId
);
const triggerRow = stateOrigin
? await resolveTriggerRow(
stateOrigin.entityId!,
(runRow ?? entry).when,
fetchEvents
)
: undefined;
return { rows, runRow, origins, syntheticRun, triggerRow };
};
+21 -68
View File
@@ -12,14 +12,13 @@ import {
localizeStateMessage,
parseTriggerSource,
} from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
import type { HomeAssistant } from "../../types";
export type LogbookEntryCategory = "entity" | "automation" | "integration";
const TRIGGER_DOMAINS = ["automation", "script"];
export const TRIGGER_DOMAINS = ["automation", "script"];
const stripEntityId = (message: string, entityId?: string) =>
export const stripEntityId = (message: string, entityId?: string) =>
entityId ? message.replace(entityId, " ") : message;
export const classifyLogbookEntry = (
@@ -36,10 +35,6 @@ export const classifyLogbookEntry = (
return "integration";
};
// A run row is the acting automation or script; every other row is an effect.
export const isRunRow = (item: LogbookEntry): boolean =>
classifyLogbookEntry(item) === "automation";
// How much naming detail an entity row shows, from least to most. The value is
// the broadest part shown: `none` (name hidden), `entity`, `device` (device ▸
// entity), `area` (area ▸ device ▸ entity).
@@ -101,31 +96,12 @@ export const entityDisplay = (
return { primary, secondary };
};
const hasContext = (item: LogbookEntry) =>
export const hasContext = (item: LogbookEntry) =>
item.context_event_type || item.context_state || item.context_message;
export const sameDay = (a?: LogbookEntry, b?: LogbookEntry) =>
!!a?.when && !!b?.when && isSameDay(a.when * 1000, b.when * 1000);
export const isSameLogbookEntry = (a: LogbookEntry, b: LogbookEntry) =>
a.when === b.when &&
a.entity_id === b.entity_id &&
a.state === b.state &&
a.message === b.message &&
a.name === b.name;
// Every entry of a run shares the run's context id, so effect rows resolve
// to their cause's trace too.
export const computeTraceLink = (
traceContexts: TraceContexts,
contextId?: string
): string | undefined => {
const traceContext = contextId ? traceContexts[contextId] : undefined;
return traceContext
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
: undefined;
};
// Unavailable is flagged with an orange badge by the row, not a color change.
export const nodeColor = (
category: LogbookEntryCategory,
@@ -155,7 +131,8 @@ export interface LogbookCause {
brandDomain?: string;
}
export const computeUserCause = (
export const computeLogbookCause = (
hass: HomeAssistant,
item: LogbookEntry,
userIdToName: Record<string, string>,
systemUserIds?: Set<string>
@@ -163,21 +140,15 @@ export const computeUserCause = (
const userName = item.context_user_id
? userIdToName[item.context_user_id]
: undefined;
if (!userName) {
return undefined;
if (userName) {
return {
type: "user",
name: userName,
userId: item.context_user_id,
systemUser: systemUserIds?.has(item.context_user_id!),
};
}
return {
type: "user",
name: userName,
userId: item.context_user_id,
systemUser: systemUserIds?.has(item.context_user_id!),
};
};
export const computeContextCause = (
hass: HomeAssistant,
item: LogbookEntry
): LogbookCause | undefined => {
if (
item.context_event_type === "automation_triggered" ||
item.context_event_type === "script_started"
@@ -268,18 +239,6 @@ export const computeContextCause = (
return undefined;
};
export const computeLogbookCause = (
hass: HomeAssistant,
item: LogbookEntry,
userIdToName: Record<string, string>,
systemUserIds?: Set<string>
): LogbookCause | undefined =>
computeUserCause(item, userIdToName, systemUserIds) ??
computeContextCause(hass, item);
export const isRunCause = (cause?: LogbookCause): boolean =>
cause?.type === "automation" || cause?.type === "script";
export type LogbookGlyph =
| { type: "state"; stateObj: HassEntity; icon?: string }
| { type: "automation"; script: boolean }
@@ -325,11 +284,10 @@ const computeLogbookValue = (
type: "state",
};
}
// Core sends run rows (carrying the automation/script entity) with a raw
// English message; use our own label. Domain-only entries (e.g. logbook.log)
// keep their custom message.
const isAutomationRun =
item.entity_id && domain && TRIGGER_DOMAINS.includes(domain);
domain &&
TRIGGER_DOMAINS.includes(domain) &&
(item.source || hasContext(item) || !!item.context_user_id);
if (isAutomationRun) {
return {
text: hass.localize(
@@ -390,13 +348,6 @@ export const computeLogbookItem = (
? entityDisplay(hass, entry.entity_id, opts.nameDetail)
: undefined;
const userCause = computeUserCause(
entry,
opts.userIdToName ?? {},
opts.systemUserIds
);
const contextCause = computeContextCause(hass, entry);
return {
category,
glyph: computeLogbookGlyph(entry, category, historicStateObj, domain),
@@ -404,10 +355,12 @@ export const computeLogbookItem = (
name: display?.primary ?? entry.name,
context: display?.secondary,
value: computeLogbookValue(hass, entry, domain, historicStateObj),
// A row shows the run over the user who started it; the dialog shows both.
cause: isRunCause(contextCause)
? contextCause
: (userCause ?? contextCause),
cause: computeLogbookCause(
hass,
entry,
opts.userIdToName ?? {},
opts.systemUserIds
),
when: entry.when * 1000,
};
};
@@ -1,137 +0,0 @@
import {
mdiCast,
mdiClockOutline,
mdiCloud,
mdiPuzzle,
mdiRobot,
mdiScriptText,
mdiStateMachine,
} from "@mdi/js";
import { html } from "lit";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/entity/state-badge";
import "../../components/ha-domain-icon";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/user/ha-user-badge";
import { mdiHomeAssistant } from "../../resources/home-assistant-logo-svg";
import type { LogbookEntry } from "../../data/logbook";
import type { User } from "../../data/user";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { LogbookCause, LogbookGlyph } from "./logbook-entry-model";
// Names are the fixed system user names set by core (cloud/cast integrations).
const SYSTEM_USER_ICONS: Record<string, string> = {
"Home Assistant Cloud": mdiCloud,
"Home Assistant Cast": mdiCast,
};
export const renderLogbookCauseIcon = (cause: LogbookCause) => {
if (cause.type === "user") {
const systemIcon = cause.systemUser
? SYSTEM_USER_ICONS[cause.name]
: undefined;
if (systemIcon) {
return html`<ha-svg-icon
class="cause-icon"
.path=${systemIcon}
></ha-svg-icon>`;
}
return html`<ha-user-badge
class="cause-icon cause-avatar"
.user=${{ id: cause.userId!, name: cause.name } as User}
></ha-user-badge>`;
}
if (cause.type === "automation") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiRobot}
></ha-svg-icon>`;
}
if (cause.type === "script") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiScriptText}
></ha-svg-icon>`;
}
if (cause.type === "state") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiStateMachine}
></ha-svg-icon>`;
}
if (cause.type === "scheduled") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiClockOutline}
></ha-svg-icon>`;
}
if (cause.type === "homeassistant") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiHomeAssistant}
></ha-svg-icon>`;
}
if (cause.brandDomain) {
return html`<ha-domain-icon
class="cause-icon"
.domain=${cause.brandDomain}
brand-fallback
></ha-domain-icon>`;
}
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiPuzzle}
></ha-svg-icon>`;
};
const brandImage = (
hass: HomeAssistant,
entry: LogbookEntry,
domain?: string
): string | undefined => {
if (
!domain ||
entry.icon ||
entry.state ||
!isComponentLoaded(hass.config, domain)
) {
return undefined;
}
return brandsUrl(
{
domain,
type: "icon",
darkOptimized: hass.themes?.darkMode,
},
hass.auth.data.hassUrl
);
};
export const transitionArrow = (hass: HomeAssistant) =>
computeRTL(hass.language, hass.translationMetadata.translations) ? "←" : "→";
export const renderLogbookGlyph = (
hass: HomeAssistant,
entry: LogbookEntry,
glyph: LogbookGlyph
) => {
if (glyph.type === "automation") {
return html`<ha-svg-icon
.path=${glyph.script ? mdiScriptText : mdiRobot}
></ha-svg-icon>`;
}
if (glyph.type === "state") {
return html`<ha-state-icon
.stateObj=${glyph.stateObj}
.icon=${glyph.icon}
></ha-state-icon>`;
}
return html`<state-badge
.overrideIcon=${glyph.icon}
.overrideImage=${brandImage(hass, entry, glyph.domain)}
.stateColor=${false}
></state-badge>`;
};
@@ -1,23 +0,0 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { LogbookEntry } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
export interface LogbookDetailDialogParams {
entry: LogbookEntry;
traceContexts?: TraceContexts;
userIdToName?: Record<string, string>;
systemUserIds?: Set<string>;
}
export const loadLogbookDetailDialog = () => import("./dialog-logbook-detail");
export const showLogbookDetailDialog = (
element: HTMLElement,
params: LogbookDetailDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-logbook-detail",
dialogImport: loadLogbookDetailDialog,
dialogParams: params,
});
};
@@ -271,10 +271,6 @@ export class HuiEnergyUsageGraphCard
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
if (!this.isConnected) {
return;
}
const datasets: BarSeriesOption[] = [];
let yMin = Infinity;
@@ -175,10 +175,6 @@ export class HuiPowerSourcesGraphCard
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
if (!this.isConnected) {
return;
}
const result = generatePowerSourcesGraphData({
localize: this.hass.localize,
states: this.hass.states,
@@ -36,7 +36,7 @@ import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction, hasAnyAction } from "../common/has-action";
import { hasAction } from "../common/has-action";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import type {
@@ -300,7 +300,6 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
return html`
<ha-card
class=${classMap({
"no-action": !hasAnyAction(this._config!),
[this._sizeController.value?.height]: Boolean(
this._sizeController.value
),
@@ -731,10 +730,6 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
padding: 16px 0;
}
ha-card.no-action {
cursor: default;
}
.content {
display: flex;
flex-wrap: nowrap;
+3 -12
View File
@@ -15,22 +15,13 @@ const _loadLovelaceResource = (
hass.auth.data.hassUrl
).toString();
const logLoadError = (err: unknown) => {
// eslint-disable-next-line no-console
console.error(
`Failed to load Lovelace resource ${normalizedUrl} (type: ${resource.type}). Check that the URL is correct and the file exists.`,
err
);
};
switch (resource.type) {
case "css": {
if (normalizedUrl in CSS_CACHE) {
return CSS_CACHE[normalizedUrl];
}
// Catch before caching, so a cache hit cannot log the failure again
const loadTask = loadCSS(normalizedUrl).catch(logLoadError);
const loadTask = loadCSS(normalizedUrl);
CSS_CACHE[normalizedUrl] = loadTask;
return loadTask;
}
@@ -40,13 +31,13 @@ const _loadLovelaceResource = (
return JS_CACHE[normalizedUrl];
}
const loadTask = loadJS(normalizedUrl).catch(logLoadError);
const loadTask = loadJS(normalizedUrl);
JS_CACHE[normalizedUrl] = loadTask;
return loadTask;
}
case "module":
return loadModule(normalizedUrl).catch(logLoadError);
return loadModule(normalizedUrl);
default:
// eslint-disable-next-line
+17
View File
@@ -70,6 +70,8 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../dialogs/generic/show-dialog-box";
import { isMoreInfoView } from "../../dialogs/more-info/const";
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { haStyle } from "../../resources/styles";
@@ -721,6 +723,21 @@ class HUIRoot extends LitElement {
} else if (searchParams.conversation === "1") {
this._clearParam("conversation");
this._showVoiceCommandDialog();
} else if (searchParams["more-info-entity-id"]) {
const entityId = searchParams["more-info-entity-id"];
const view = searchParams["more-info-view"];
this._clearParam("more-info-entity-id");
if (view) {
this._clearParam("more-info-view");
}
// Wait for the next render to ensure the view is fully loaded
// because the more info dialog is closed when the url changes
afterNextRender(() => {
showMoreInfoDialog(this, {
entityId,
view: isMoreInfoView(view) ? view : undefined,
});
});
}
}
+2 -26
View File
@@ -1,12 +1,6 @@
import type { PropertyValues } from "lit";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { computeDomain } from "../common/entity/compute_domain";
import { replaceCurrentUrl } from "../common/navigate";
import {
createMoreInfoUrl,
removeMoreInfoUrl,
} from "../common/url/more-info-query-params";
import { showDialog } from "../dialogs/make-dialog-manager";
import type { MoreInfoDialogParams } from "../dialogs/more-info/ha-more-info-dialog";
import type { Constructor } from "../types";
@@ -34,19 +28,12 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
private async _handleMoreInfo(
ev: HASSDomEvent<HASSDomEvents["hass-more-info"]>
) {
const view = ev.detail.view || ev.detail.tab || "info";
const currentUrl = `${mainWindow.location.pathname}${mainWindow.location.search}${mainWindow.location.hash}`;
const returnUrl = ev.detail.fromUrl
? removeMoreInfoUrl(currentUrl)
: currentUrl;
replaceCurrentUrl(returnUrl);
const shown = await showDialog(
showDialog(
this,
"ha-more-info-dialog",
{
entityId: ev.detail.entityId,
view,
view: ev.detail.view || ev.detail.tab,
large:
ev.detail.large ??
(ev.detail.entityId
@@ -55,20 +42,9 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
)
: false),
data: ev.detail.data,
hash: ev.detail.hash,
returnUrl,
},
() => import("../dialogs/more-info/ha-more-info-dialog"),
ev.detail.parentElement
);
if (shown && ev.detail.entityId) {
replaceCurrentUrl(
createMoreInfoUrl(returnUrl, {
entityId: ev.detail.entityId,
view,
hash: ev.detail.hash,
})
);
}
}
};
+27 -24
View File
@@ -2054,15 +2054,6 @@
"description": "Description",
"required_error_msg": "[%key:ui::panel::config::zone::detail::required_error_msg%]"
},
"logbook_detail": {
"title": "Activity details",
"state": "State",
"event": "Event",
"time": "Time",
"what_happened": "What happened",
"no_known_cause": "No cause was recorded for this activity.",
"action_used": "Action used: {name}"
},
"voice-settings": {
"expose_header": "Expose",
"aliases_header": "Aliases",
@@ -3454,7 +3445,7 @@
"agents": {
"cloud_agent_description": "Note: It stores only the most recent backup, regardless of your retention settings, with a maximum size of 5 GB.",
"cloud_agent_no_subcription": "You currently do not have an active Home Assistant Cloud subscription.",
"network_mount_agent_description": "Network storage",
"network_mount_agent_description": "Additional storage",
"unavailable_agents": "Unavailable locations",
"no_agents": "No locations configured",
"encryption_turned_off": "Encryption turned off",
@@ -3739,7 +3730,7 @@
"no_location": "No location selected",
"no_location_description": "You have to select at least one location to create a backup.",
"more_locations": "Explore more locations",
"manage_network_storage": "Manage network storage",
"manage_network_storage": "Manage storage",
"ha_cloud_backup": "{home_assistant_cloud} backup",
"ha_cloud_description": "Stores an encrypted backup offsite. This is ideal in case your local backup becomes inaccessible. The cloud stores one backup with a maximum size of 5 GB, and older backups are automatically deleted."
},
@@ -7041,8 +7032,6 @@
"confirm_delete_user_text": "The user account for ''{name}'' will be permanently deleted. You can still track the user, but the person will no longer be able to log in.",
"allow_login": "Allow login",
"allow_login_description": "Allow access using username and password.",
"select_user": "Select user account",
"create_new_user": "Create a new username",
"username": "[%key:ui::panel::config::users::editor::username%]",
"password": "[%key:ui::panel::config::users::editor::password%]",
"admin": "[%key:ui::panel::config::users::editor::admin%]",
@@ -8649,7 +8638,7 @@
"join_chat": "Chat",
"join_blog": "Blog",
"join_newsletter": "Newsletter",
"media_storage": "You can add network storage to your Home Assistant instance in the {storage} panel."
"media_storage": "You can add additional storage to your Home Assistant instance in the {storage} panel."
},
"analytics": {
"caption": "Analytics",
@@ -8912,15 +8901,16 @@
"move": "Move"
},
"network_mounts": {
"title": "Network storage",
"add_title": "Add network storage",
"update_title": "Update network storage",
"no_mounts": "No connected network storage",
"title": "Additional storage",
"add_title": "Add storage",
"update_title": "Update storage",
"no_mounts": "No additional storage configured",
"no_disk_candidates": "No suitable disks were found. Connect a disk with a supported file system, then reopen this dialog.",
"documentation": "Documentation",
"not_supported": {
"title": "The operating system does not support network storage",
"supervised": "Network storage is not supported on this host",
"os": "To use network storage you need to run at least Home Assistant OS {version}",
"title": "The operating system does not support additional storage",
"supervised": "Additional storage is not supported on this host",
"os": "To use additional storage you need to run at least Home Assistant OS {version}",
"navigate_to_updates": "Go to updates"
},
"mount_usage": {
@@ -8930,7 +8920,8 @@
},
"mount_type": {
"nfs": "Network File System (NFS)",
"cifs": "Samba/Windows (CIFS)"
"cifs": "Samba/Windows (CIFS)",
"disk": "Local disk"
},
"cifs_versions": {
"auto": "Auto (2.1+)",
@@ -8958,13 +8949,25 @@
"description": "This is the path of the remote share on your storage server"
},
"type": {
"title": "Protocol",
"description": "This determines how to communicate with the storage server"
"title": "Type",
"description": "This determines the kind of storage you want to add"
},
"usage": {
"title": "Usage",
"description": "This determines how the share is intended to be used"
},
"device": {
"title": "Disk",
"description": "This is the disk connected to your system that you want to use"
},
"device_identity": {
"title": "Disk",
"description": "This is the disk this storage was set up with, which cannot be changed"
},
"read_only": {
"title": "Read-only",
"description": "Home Assistant will not be able to write to this storage. Backup storage cannot be read-only"
},
"version": {
"title": "Samba/Windows (CIFS) version",
"description": "This chooses the version of the protocol to use"
-53
View File
@@ -3,7 +3,6 @@ import { expect, describe, it } from "vitest";
import {
formatTime,
formatTimeWithSeconds,
formatTimeWithMilliseconds,
formatTimeWeekday,
formatTime24h,
} from "../../../src/common/datetime/format_time";
@@ -86,58 +85,6 @@ describe("formatTimeWithSeconds", () => {
});
});
describe("formatTimeWithMilliseconds", () => {
const dateObj = new Date(2017, 10, 18, 23, 12, 13, 400);
it("Formats English times with milliseconds", () => {
expect(
formatTimeWithMilliseconds(
dateObj,
{
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.am_pm,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
},
demoConfig
)
).toBe("11:12:13.400 PM");
expect(
formatTimeWithMilliseconds(
dateObj,
{
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.twenty_four,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
},
demoConfig
)
).toBe("23:12:13.400");
});
it("Uses the locale decimal separator", () => {
expect(
formatTimeWithMilliseconds(
dateObj,
{
language: "fr",
number_format: NumberFormat.language,
time_format: TimeFormat.twenty_four,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
},
demoConfig
)
).toBe("23:12:13,400");
});
});
describe("formatTimeWeekday", () => {
const dateObj = new Date(2017, 10, 18, 23, 12, 13, 1400);
-69
View File
@@ -14,11 +14,6 @@ import {
serviceTargetFromQueryParams,
type QueryParamConfig,
} from "../../../src/common/url/query-params";
import {
createMoreInfoUrl,
decodeMoreInfoUrl,
removeMoreInfoUrl,
} from "../../../src/common/url/more-info-query-params";
import {
createTodoQueryString,
decodeTodoQueryParams,
@@ -245,67 +240,3 @@ describe("todo query params", () => {
expect(encoded).toBe("add_item=true&entity_id=todo.tasks");
});
});
describe("more-info query params", () => {
it("decodes the entity, view, and named hash params", () => {
const params = decodeMoreInfoUrl(
"?more-info-entity-id=weather.home&more-info-view=info",
"#forecast=hourly"
);
expect(params.entityId).toBe("weather.home");
expect(params.view).toBe("info");
expect(params.hash.get("forecast")).toBe("hourly");
});
it("ignores invalid views", () => {
expect(
decodeMoreInfoUrl(
"?more-info-entity-id=weather.home&more-info-view=forecast"
).view
).toBeUndefined();
});
it("creates a link without dropping unrelated query params", () => {
expect(
createMoreInfoUrl("/lovelace/home?theme=dark", {
entityId: "weather.home",
view: "info",
hash: new URLSearchParams({ forecast: "hourly" }),
})
).toBe(
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#forecast=hourly"
);
});
it("removes more-info query params but preserves other hash state", () => {
expect(
removeMoreInfoUrl(
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#forecast=hourly"
)
).toBe("/lovelace/home?theme=dark#forecast=hourly");
});
it("preserves an unrelated hash when creating a more-info url without a dialog hash", () => {
expect(
createMoreInfoUrl("/lovelace/home?theme=dark#some-anchor", {
entityId: "light.kitchen",
view: "info",
})
).toBe(
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info#some-anchor"
);
});
it("clears an existing hash when an empty dialog hash is explicitly supplied", () => {
expect(
createMoreInfoUrl("/lovelace/home?theme=dark#some-anchor", {
entityId: "light.kitchen",
view: "info",
hash: new URLSearchParams(),
})
).toBe(
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info"
);
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import type {
SupervisorCIFSMount,
SupervisorDiskMount,
SupervisorNFSMount,
} from "../../src/data/supervisor/mounts";
import {
SupervisorMountState,
SupervisorMountType,
SupervisorMountUsage,
supervisorMountDescription,
} from "../../src/data/supervisor/mounts";
const nfsMount = (
overrides: Partial<SupervisorNFSMount> = {}
): SupervisorNFSMount => ({
name: "my_nfs",
type: SupervisorMountType.NFS,
usage: SupervisorMountUsage.MEDIA,
state: SupervisorMountState.ACTIVE,
server: "nas.local",
path: "/export/media",
...overrides,
});
const cifsMount = (
overrides: Partial<SupervisorCIFSMount> = {}
): SupervisorCIFSMount => ({
name: "my_share",
type: SupervisorMountType.CIFS,
usage: SupervisorMountUsage.MEDIA,
state: SupervisorMountState.ACTIVE,
server: "nas.local",
share: "media",
...overrides,
});
const diskMount = (
overrides: Partial<SupervisorDiskMount> = {}
): SupervisorDiskMount => ({
name: "media_disk",
type: SupervisorMountType.DISK,
usage: SupervisorMountUsage.MEDIA,
state: SupervisorMountState.ACTIVE,
uuid: "e3f1a2b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b",
filesystem: "ext4",
...overrides,
});
describe("supervisorMountDescription", () => {
it("describes an NFS mount by server and path", () => {
expect(supervisorMountDescription(nfsMount())).toBe(
"nas.local/export/media"
);
});
it("describes a CIFS mount by server and share", () => {
expect(supervisorMountDescription(cifsMount())).toBe("nas.local:media");
});
it("includes the port only when Supervisor reports one", () => {
expect(supervisorMountDescription(nfsMount({ port: 2049 }))).toBe(
"nas.local:2049/export/media"
);
expect(supervisorMountDescription(nfsMount({ port: undefined }))).toBe(
"nas.local/export/media"
);
});
it("describes a disk mount by filesystem and uuid", () => {
expect(supervisorMountDescription(diskMount())).toBe(
"ext4 • e3f1a2b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b"
);
});
it("omits the filesystem when Supervisor has not resolved one", () => {
expect(
supervisorMountDescription(diskMount({ filesystem: undefined }))
).toBe("e3f1a2b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b");
});
it("never describes a disk mount using network fields", () => {
// A disk mount has no server, share or path, which previously rendered as
// "undefined" in the storage panel and the mount picker.
expect(supervisorMountDescription(diskMount())).not.toContain("undefined");
});
});
+1 -138
View File
@@ -211,7 +211,7 @@ test("keeps the launch screen until initial panel content renders", async ({
name: "generated dashboard",
path: "/?scenario=delayed-generated-dashboard#/climate",
loadingSelector: "#ha-launch-screen",
readySelector: "hui-card",
readySelector: "hui-view",
resolvers: ["resolveGeneratedDashboard"],
},
{
@@ -279,85 +279,6 @@ test.describe("Lovelace dashboard", () => {
});
});
test.describe("Energy dashboard", () => {
test("returns to Energy after repeatedly opening the device dialog", async ({
page,
}) => {
const errors = trackPageErrors(page);
await goToPanel(
page,
"/?historyBack=1&backPath=%2Flovelace#/energy/overview"
);
const energyRoot = page.locator("ha-panel-energy hui-root");
await expect(energyRoot).toBeAttached({ timeout: PANEL_TIMEOUT });
const editDashboard = energyRoot.getByRole("button", {
name: /^Edit dashboard\b/,
});
const dashboardMenu = energyRoot.getByRole("button", {
name: "Open dashboard menu",
});
await expect(editDashboard.or(dashboardMenu)).toBeVisible({
timeout: QUICK_TIMEOUT,
});
if (await editDashboard.isVisible()) {
await editDashboard.click();
} else {
await dashboardMenu.click();
await page.getByRole("menuitem", { name: /^Edit dashboard\b/ }).click();
}
await expect(page.locator("ha-config-energy")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
const backLink = page
.locator("ha-config-energy hass-tabs-subpage")
.getByRole("link", { name: "Back" });
await expect(backLink).toHaveAttribute(
"href",
"/config/lovelace/dashboards"
);
const addDevice = page
.locator("ha-energy-device-settings")
.locator("ha-button")
.first();
const openAndCancelDeviceDialog = async () => {
await addDevice.click();
const dialog = page.locator("dialog-energy-device-settings");
const cancel = dialog.locator("ha-dialog-footer ha-button").first();
await expect(cancel).toBeVisible({ timeout: QUICK_TIMEOUT });
await cancel.click();
await expect(cancel).toBeHidden({ timeout: QUICK_TIMEOUT });
};
await openAndCancelDeviceDialog();
await openAndCancelDeviceDialog();
await openAndCancelDeviceDialog();
await backLink.click();
await expect
.poll(
() =>
page.evaluate(() => ({
hash: window.location.hash,
search: window.location.search,
})),
{ timeout: PANEL_TIMEOUT }
)
.toEqual({
hash: "#/energy/overview",
search: "?historyBack=1&backPath=%2Flovelace",
});
await expect(page.locator("ha-panel-energy")).toBeAttached();
await expect(
energyRoot.getByRole("link", { name: "Back" })
).toHaveAttribute("href", "/lovelace");
expectNoPageErrors(errors);
});
});
// ---------------------------------------------------------------------------
// More-info dialog (light)
// ---------------------------------------------------------------------------
@@ -406,64 +327,6 @@ test.describe("Light more-info dialog", () => {
}
});
test.describe("Weather more-info deep link", () => {
test("opens and synchronizes the selected forecast", async ({ page }) => {
await goToPanel(
page,
"/?scenario=weather-more-info&more-info-entity-id=weather.test_weather&more-info-view=info#/lovelace"
);
const dialog = page.locator("ha-more-info-dialog");
const weather = dialog.locator("more-info-weather");
await expect(weather).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(page).toHaveURL(
/more-info-entity-id=weather\.test_weather&more-info-view=info/
);
await expect(
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
).toBeAttached();
await page.locator("ha-test").evaluate((el) => {
el.dispatchEvent(
new CustomEvent("hass-more-info", {
detail: {
entityId: "weather.test_weather",
hash: new URLSearchParams({ forecast: "hourly" }),
},
bubbles: true,
composed: true,
})
);
});
await expect(
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Hourly" })
).toBeAttached();
await dialog.getByRole("button", { name: "History" }).click();
await expect(page).toHaveURL(/more-info-view=history/);
await dialog.getByRole("button", { name: "Back" }).click();
await expect(
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
).toBeAttached();
await weather
.locator("ha-tab-group-tab")
.filter({ hasText: "Daily" })
.click();
await expect(
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
).toBeAttached();
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await expect(page).not.toHaveURL(/more-info-entity-id/);
});
});
// ---------------------------------------------------------------------------
// Theming
// ---------------------------------------------------------------------------
-2
View File
@@ -1,2 +0,0 @@
// HomeAssistantAppEl registers this path; keep it inert in E2E tests.
void 0;
-1
View File
@@ -1,2 +1 @@
import "../../../../src/resources/append-ha-style";
import "./ha-test";
+9 -5
View File
@@ -247,12 +247,16 @@ export class HaTest extends HomeAssistantAppEl {
window.__mockHass = hass;
// SPA navigation
window.addEventListener("click", (e) => {
const href = isNavigationClick(e);
if (href) {
document.body.addEventListener(
"click",
(e) => {
const href = isNavigationClick(e);
if (!href) return;
e.preventDefault();
navigate(href);
}
});
},
{ capture: true }
);
this.hassConnected();
}
-50
View File
@@ -5,10 +5,6 @@ import type {
} from "../../../../../src/data/entity/entity_registry";
import type { LovelaceRawConfig } from "../../../../../src/data/lovelace/config/types";
import type { MediaPlayerItem } from "../../../../../src/data/media-player";
import {
WeatherEntityFeature,
type ForecastEvent,
} from "../../../../../src/data/weather";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
@@ -99,51 +95,6 @@ const lightMoreInfoScenario: Scenario = async (hass) => {
hass.mockWS("config/entity_registry/get", () => registryEntry);
};
const weatherMoreInfoScenario: Scenario = (hass) => {
hass.addEntities([
{
entity_id: "weather.test_weather",
state: "sunny",
attributes: {
friendly_name: "Test Weather",
supported_features:
WeatherEntityFeature.FORECAST_DAILY +
WeatherEntityFeature.FORECAST_HOURLY,
precipitation_unit: "mm",
pressure_unit: "hPa",
temperature: 20,
temperature_unit: "°C",
visibility_unit: "km",
wind_speed_unit: "km/h",
},
},
]);
hass.mockWS("weather/subscribe_forecast", (message, _hass, onChange) => {
onChange?.({
type: message.forecast_type,
forecast: [
{
datetime: "2026-08-13T10:00:00Z",
temperature: 20,
condition: "sunny",
},
{
datetime: "2026-08-13T11:00:00Z",
temperature: 21,
condition: "sunny",
},
{
datetime: "2026-08-13T12:00:00Z",
temperature: 22,
condition: "sunny",
},
],
} satisfies ForecastEvent);
return () => undefined;
});
};
const quickSearchAssistScenario: Scenario = async (hass) => {
const pipeline: AssistPipeline = {
id: "test-pipeline",
@@ -302,7 +253,6 @@ export const scenarios: Record<string, Scenario> = {
"delayed-media-browse": delayedMediaBrowseScenario,
"delayed-media-browse-error": delayedMediaBrowseErrorScenario,
"light-more-info": lightMoreInfoScenario,
"weather-more-info": weatherMoreInfoScenario,
"quick-search-assist": quickSearchAssistScenario,
"delayed-lovelace": delayedLovelaceScenario,
};
@@ -1,319 +0,0 @@
import { describe, expect, it } from "vitest";
import type { LogbookEntry } from "../../../src/data/logbook";
import type { LogbookFetcher } from "../../../src/panels/logbook/logbook-chain-resolver";
import { resolveLogbookChain } from "../../../src/panels/logbook/logbook-chain-resolver";
import type { HomeAssistant } from "../../../src/types";
const hass = {
language: "en",
translationMetadata: { translations: {} },
states: {},
entities: {},
devices: {},
areas: {},
floors: {},
localize: () => "",
} as unknown as HomeAssistant;
const USERS = { user_1: "Alice" };
const entry = (partial: Partial<LogbookEntry>): LogbookEntry => ({
when: 0,
name: "",
...partial,
});
const runRow = (when: number, contextId: string): LogbookEntry =>
entry({
when,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
source: "state of binary_sensor.motion",
context_id: contextId,
});
const effectRow = (when: number): LogbookEntry =>
entry({
when,
name: "Ceiling light",
entity_id: "light.ceiling",
state: "on",
context_event_type: "automation_triggered",
context_name: "Wake up",
context_entity_id: "automation.wake_up",
context_source: "state of binary_sensor.motion",
});
interface FetchCall {
entityIds?: string[];
contextId?: string;
}
const makeFetcher = (
handler: (entityIds?: string[], contextId?: string) => LogbookEntry[]
): { fetch: LogbookFetcher; calls: FetchCall[] } => {
const calls: FetchCall[] = [];
return {
calls,
fetch: async (_start, _end, entityIds, contextId) => {
calls.push({ entityIds, contextId });
return handler(entityIds, contextId);
},
};
};
describe("resolveLogbookChain", () => {
it("fetches by context id when the entry has one", async () => {
const run = runRow(10, "ctx_run");
const effect = { ...effectRow(11), context_id: "ctx_run" };
const { fetch, calls } = makeFetcher((entityIds, contextId) => {
if (contextId === "ctx_run") {
return [run, effect];
}
if (entityIds?.includes("binary_sensor.motion")) {
return [
entry({ when: 9.8, entity_id: "binary_sensor.motion", state: "on" }),
];
}
return [];
});
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
// One context fetch + one trigger fetch: no candidate discovery.
expect(calls).toHaveLength(2);
expect(calls[0].contextId).toBe("ctx_run");
expect(chain.runRow).toBe(run);
expect(chain.rows).toEqual([run, effect]);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("state");
expect(chain.origins[0].entityId).toBe("binary_sensor.motion");
expect(chain.syntheticRun).toBeUndefined();
expect(chain.triggerRow?.when).toBe(9.8);
});
it("resolves the run through the context entity and verifies candidates", async () => {
const effect = effectRow(11);
const otherRun = runRow(10.9, "ctx_other");
const goodRun = runRow(10, "ctx_good");
const { fetch, calls } = makeFetcher((entityIds, contextId) => {
if (entityIds?.includes("automation.wake_up")) {
return [goodRun, otherRun];
}
if (contextId === "ctx_other") {
// The closest run does not contain the clicked entry.
return [
otherRun,
entry({ when: 11, entity_id: "light.desk", state: "on" }),
];
}
if (contextId === "ctx_good") {
return [goodRun, effect];
}
return [];
});
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
expect(calls[0].entityIds).toEqual(["automation.wake_up"]);
expect(calls[1].contextId).toBe("ctx_other");
expect(calls[2].contextId).toBe("ctx_good");
expect(chain.runRow?.context_id).toBe("ctx_good");
expect(chain.rows).toEqual([goodRun, effect]);
});
it("falls back to a synthetic run when no candidate matches", async () => {
const effect = effectRow(11);
const { fetch } = makeFetcher((entityIds) =>
entityIds?.includes("automation.wake_up")
? [runRow(10, "ctx_unrelated")]
: []
);
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
expect(chain.runRow).toBeUndefined();
expect(chain.rows).toEqual([effect]);
expect(chain.syntheticRun?.type).toBe("automation");
expect(chain.origins).toEqual([]);
});
it("keeps a direct user action as the only origin", async () => {
const direct = entry({
when: 5,
entity_id: "light.ceiling",
state: "on",
context_user_id: "user_1",
context_event_type: "call_service",
context_domain: "light",
context_service: "turn_on",
});
const { fetch, calls } = makeFetcher(() => []);
const chain = await resolveLogbookChain(
hass,
direct,
{ userIdToName: USERS },
fetch
);
expect(calls).toHaveLength(0);
expect(chain.rows).toEqual([direct]);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("user");
expect(chain.origins[0].name).toBe("Alice");
expect(chain.syntheticRun).toBeUndefined();
});
it("picks the last trigger state before the run, even minutes earlier", async () => {
const run = runRow(600, "ctx_run");
const effect = { ...effectRow(601), context_id: "ctx_run" };
const { fetch } = makeFetcher((entityIds, contextId) => {
if (contextId === "ctx_run") {
return [run, effect];
}
if (entityIds?.includes("binary_sensor.motion")) {
return [
entry({ when: 100, entity_id: "binary_sensor.motion", state: "on" }),
entry({ when: 480, entity_id: "binary_sensor.motion", state: "off" }),
entry({ when: 640, entity_id: "binary_sensor.motion", state: "on" }),
];
}
return [];
});
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
expect(chain.triggerRow?.when).toBe(480);
expect(chain.triggerRow?.state).toBe("off");
});
it("prefers the clicked copy of the run row over the fetched one", async () => {
const clicked = entry({
when: 10,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
context_user_id: "user_1",
context_event_type: "call_service",
context_domain: "automation",
context_service: "trigger",
});
const fetched = entry({
when: 10,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
});
const effect = { ...effectRow(11), context_id: "ctx_run" };
const { fetch } = makeFetcher((_entityIds, contextId) =>
contextId === "ctx_run" ? [fetched, effect] : []
);
const chain = await resolveLogbookChain(
hass,
clicked,
{ userIdToName: USERS },
fetch
);
expect(chain.runRow).toBe(clicked);
expect(chain.rows).toEqual([clicked, effect]);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("user");
expect(chain.origins[0].name).toBe("Alice");
});
it("surfaces the integration that triggered the run", async () => {
const clicked = entry({
when: 20,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
context_event_type: "call_service",
context_domain: "homekit",
context_service: "turn_on",
});
const fetched = entry({
when: 20,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
});
const effect = { ...effectRow(21), context_id: "ctx_run" };
const { fetch } = makeFetcher((_entityIds, contextId) =>
contextId === "ctx_run" ? [fetched, effect] : []
);
const chain = await resolveLogbookChain(hass, clicked, {}, fetch);
expect(chain.runRow).toBe(clicked);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("integration");
expect(chain.origins[0].brandDomain).toBe("homekit");
});
it("keeps the user above a run row that does not carry one", async () => {
const fetched = entry({
when: 10,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
});
const effect = {
...effectRow(11),
context_id: "ctx_run",
context_user_id: "user_1",
};
const { fetch } = makeFetcher((_entityIds, contextId) =>
contextId === "ctx_run" ? [fetched, effect] : []
);
const chain = await resolveLogbookChain(
hass,
effect,
{ userIdToName: USERS },
fetch
);
expect(chain.runRow).toBe(fetched);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("user");
expect(chain.origins[0].name).toBe("Alice");
});
it("does not stack the user twice when the run row resolves it", async () => {
const fetched = entry({
when: 10,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
context_user_id: "user_1",
});
const effect = {
...effectRow(11),
context_id: "ctx_run",
context_user_id: "user_1",
};
const { fetch } = makeFetcher((_entityIds, contextId) =>
contextId === "ctx_run" ? [fetched, effect] : []
);
const chain = await resolveLogbookChain(
hass,
effect,
{ userIdToName: USERS },
fetch
);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("user");
});
});
+1 -144
View File
@@ -1,12 +1,10 @@
import { describe, expect, it } from "vitest";
import {
computeLogbookItem,
computeTraceLink,
classifyLogbookEntry,
entityDisplay,
computeLogbookCause,
computeLogbookGlyph,
isSameLogbookEntry,
} from "../../../src/panels/logbook/logbook-entry-model";
import type { LogbookEntry } from "../../../src/data/logbook";
import type { HomeAssistant } from "../../../src/types";
@@ -243,7 +241,7 @@ describe("computeLogbookCause", () => {
const cause = computeLogbookCause(
hass,
entry({ context_user_id: "person_1" }),
{ person_1: "Alice" },
{ person_1: "Paul" },
new Set(["cloud_user"])
);
expect(cause?.type).toBe("user");
@@ -405,144 +403,3 @@ describe("computeLogbookItem", () => {
expect(model.value).toEqual({ text: "Ran", type: "state" });
});
});
describe("isSameLogbookEntry", () => {
const a = entry({
when: 1.234567,
entity_id: "light.x",
state: "on",
name: "Light",
});
it("matches an identical entry", () => {
expect(isSameLogbookEntry(a, { ...a })).toBe(true);
});
it("rejects an entry differing by any field", () => {
expect(isSameLogbookEntry(a, { ...a, when: 1.234568 })).toBe(false);
expect(isSameLogbookEntry(a, { ...a, entity_id: "light.y" })).toBe(false);
expect(isSameLogbookEntry(a, { ...a, state: "off" })).toBe(false);
expect(isSameLogbookEntry(a, { ...a, name: "Other" })).toBe(false);
expect(isSameLogbookEntry(a, { ...a, message: "turned on" })).toBe(false);
});
it("matches entity-less entries on name/message/when", () => {
const b = entry({ when: 2, name: "HACS", message: "2 updates available" });
expect(isSameLogbookEntry(b, { ...b })).toBe(true);
expect(isSameLogbookEntry(b, { ...b, message: "other" })).toBe(false);
});
});
describe("computeTraceLink", () => {
const traceContexts = {
ctx_1: { run_id: "run_9", domain: "automation", item_id: "auto_1" },
};
it("builds the trace URL for a known context", () => {
expect(computeTraceLink(traceContexts, "ctx_1")).toBe(
"/config/automation/trace/auto_1?run_id=run_9"
);
});
it("returns undefined for an unknown or missing context", () => {
expect(computeTraceLink(traceContexts, "ctx_2")).toBeUndefined();
expect(computeTraceLink(traceContexts, undefined)).toBeUndefined();
expect(computeTraceLink({}, "ctx_1")).toBeUndefined();
});
});
describe("computeLogbookItem cause", () => {
const hass = baseHass({ localize: (() => "") as HomeAssistant["localize"] });
const users = { user_1: "Alice" };
it("prefers the automation over the user who ran it", () => {
const model = computeLogbookItem(
hass,
entry({
entity_id: "light.ceiling",
state: "on",
context_user_id: "user_1",
context_event_type: "automation_triggered",
context_entity_id: "automation.wake_up",
context_name: "Wake up",
}),
{ userIdToName: users }
);
expect(model.cause?.type).toBe("automation");
expect(model.cause?.name).toBe("Wake up");
});
it("keeps the user for a direct action call", () => {
const model = computeLogbookItem(
hass,
entry({
entity_id: "light.ceiling",
state: "on",
context_user_id: "user_1",
context_event_type: "call_service",
context_domain: "light",
context_service: "turn_on",
}),
{ userIdToName: users }
);
expect(model.cause?.type).toBe("user");
expect(model.cause?.name).toBe("Alice");
});
it("falls back to the context cause without a user", () => {
const model = computeLogbookItem(
hass,
entry({
entity_id: "light.ceiling",
state: "on",
context_event_type: "automation_triggered",
context_name: "Wake up",
}),
{ userIdToName: users }
);
expect(model.cause?.type).toBe("automation");
});
it("leaves the cause empty without any context", () => {
const model = computeLogbookItem(
hass,
entry({ entity_id: "light.ceiling", state: "on" }),
{ userIdToName: users }
);
expect(model.cause).toBeUndefined();
});
});
describe("computeLogbookItem run rows", () => {
it("uses the run label instead of the raw backend message", () => {
const hass = baseHass({
localize: ((key: string) => key) as HomeAssistant["localize"],
});
const model = computeLogbookItem(
hass,
entry({
entity_id: "automation.wake_up",
domain: "automation",
name: "Wake up",
message: "triggered",
}),
{}
);
expect(model.value).toEqual({
text: "ui.components.logbook.automation_triggered",
type: "state",
});
});
it("keeps the custom message of a domain-only entry (logbook.log)", () => {
const hass = baseHass({
localize: ((key: string) => key) as HomeAssistant["localize"],
});
const model = computeLogbookItem(
hass,
entry({ domain: "script", name: "Backup", message: "Backup finished" }),
{}
);
expect(model.value).toEqual({ text: "Backup finished", type: "message" });
});
});
@@ -1,58 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadJS, loadModule } from "../../../../src/common/dom/load_resource";
import { loadLovelaceResourcesAndWait } from "../../../../src/panels/lovelace/common/load-resources";
import type { HomeAssistant } from "../../../../src/types";
vi.mock("../../../../src/common/dom/load_resource", () => ({
loadCSS: vi.fn(),
loadJS: vi.fn(),
loadModule: vi.fn(),
}));
const hass = {
auth: { data: { hassUrl: "http://localhost:8123" } },
} as unknown as HomeAssistant;
describe("loadLovelaceResourcesAndWait", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("logs an error naming the resource that failed to load", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
const loadError = new Error("load failed");
vi.mocked(loadModule).mockImplementation(() => Promise.reject(loadError));
await loadLovelaceResourcesAndWait(
[{ id: "1", url: "/local/missing.js", type: "module" }],
hass
);
expect(consoleError).toHaveBeenCalledTimes(1);
expect(consoleError.mock.calls[0][0]).toContain(
"http://localhost:8123/local/missing.js"
);
expect(consoleError.mock.calls[0][0]).toContain("module");
// the rejection reason is forwarded, so any detail it carries is not lost
expect(consoleError.mock.calls[0][1]).toBe(loadError);
});
it("logs only once for a cached resource that failed to load", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
vi.mocked(loadJS).mockImplementation((url) => Promise.reject(url));
const resources = [
{ id: "2", url: "/local/broken.js", type: "js" as const },
];
await loadLovelaceResourcesAndWait(resources, hass);
await loadLovelaceResourcesAndWait(resources, hass);
expect(loadJS).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledTimes(1);
});
});
+113 -133
View File
@@ -4648,22 +4648,22 @@ __metadata:
languageName: node
linkType: hard
"@rsdoctor/client@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/client@npm:1.6.2"
checksum: 10/7b57cf3a9c6e3dc1b64b0f07969330579fd0e84963843cb3517b7c1160bc9b2fa08e709eaf835139d8a7e8c32977af8aae050d100df80daed44371ced8272642
"@rsdoctor/client@npm:1.6.1":
version: 1.6.1
resolution: "@rsdoctor/client@npm:1.6.1"
checksum: 10/aefc3a378e3ecdc9f03c21272dfb06d4eb68ca5395d83edb0a44fd816ea96b45e894de8c862e605fcb02611d306014ffef4ca09723945cf75c9a33b7062d5d4b
languageName: node
linkType: hard
"@rsdoctor/core@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/core@npm:1.6.2"
"@rsdoctor/core@npm:1.6.1":
version: 1.6.1
resolution: "@rsdoctor/core@npm:1.6.1"
dependencies:
"@rsbuild/plugin-check-syntax": "npm:^1.6.1"
"@rsdoctor/graph": "npm:1.6.2"
"@rsdoctor/sdk": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/utils": "npm:1.6.2"
"@rsdoctor/graph": "npm:1.6.1"
"@rsdoctor/sdk": "npm:1.6.1"
"@rsdoctor/types": "npm:1.6.1"
"@rsdoctor/utils": "npm:1.6.1"
"@rspack/resolver": "npm:^0.2.8"
browserslist-load-config: "npm:^1.0.2"
es-toolkit: "npm:^1.49.0"
@@ -4671,60 +4671,60 @@ __metadata:
fs-extra: "npm:^11.1.1"
semver: "npm:^7.8.5"
source-map: "npm:^0.7.6"
checksum: 10/803bb41042b2af07b096f2dde6383d516bb22b62705aeb9693959788e8a63df80e6d25514f1d92639a267e46e73732689abf5ba2c25679fbd80da9dd126f7ed9
checksum: 10/de5708e15e1efd67a23d753ed906e573ca6a6857428dbdfea0aff2f85f1ed3f631ff34e23bf40e955855ed878ed548c073c620a4edbd12959c20a3f9ff9c1cfd
languageName: node
linkType: hard
"@rsdoctor/graph@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/graph@npm:1.6.2"
"@rsdoctor/graph@npm:1.6.1":
version: 1.6.1
resolution: "@rsdoctor/graph@npm:1.6.1"
dependencies:
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/utils": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.1"
"@rsdoctor/utils": "npm:1.6.1"
es-toolkit: "npm:^1.49.0"
path-browserify: "npm:1.0.1"
source-map: "npm:^0.7.6"
checksum: 10/f28aaedc529a6cff782f1813faa6d19d10f76ab8f529bbe2899385b5659d247f0543149e2fcd700264a00e9568605a85fde04cbf634e821f24e9bf239eb1b009
checksum: 10/065c485f11939143021c12a1acff47a2539c22e9a9d5f8501c906578213ca16b71a35d08bc0a83035529d42a7906fc098b6e3c536d895cd13ee74b5340a39e40
languageName: node
linkType: hard
"@rsdoctor/rspack-plugin@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/rspack-plugin@npm:1.6.2"
"@rsdoctor/rspack-plugin@npm:1.6.1":
version: 1.6.1
resolution: "@rsdoctor/rspack-plugin@npm:1.6.1"
dependencies:
"@rsdoctor/core": "npm:1.6.2"
"@rsdoctor/graph": "npm:1.6.2"
"@rsdoctor/sdk": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/utils": "npm:1.6.2"
"@rsdoctor/core": "npm:1.6.1"
"@rsdoctor/graph": "npm:1.6.1"
"@rsdoctor/sdk": "npm:1.6.1"
"@rsdoctor/types": "npm:1.6.1"
"@rsdoctor/utils": "npm:1.6.1"
peerDependencies:
"@rspack/core": "*"
peerDependenciesMeta:
"@rspack/core":
optional: true
checksum: 10/dec9f0424d14ec7ed03cd6cbc5e0b11cd74c29be12c3f79466bf8da0f317a4bdba858a58daf64e4a7c16d20a1d58433b1d2c038b62ff771c1d045bca3d71c3ad
checksum: 10/8908f4a3c2b46fdaba2c6d39dd535623ddcc411fb50d358de949142a5628a512827f5206a93ae975bd93e5de6b72d5cd71f63463c65a37ba718727567187ce8e
languageName: node
linkType: hard
"@rsdoctor/sdk@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/sdk@npm:1.6.2"
"@rsdoctor/sdk@npm:1.6.1":
version: 1.6.1
resolution: "@rsdoctor/sdk@npm:1.6.1"
dependencies:
"@rsdoctor/client": "npm:1.6.2"
"@rsdoctor/graph": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/utils": "npm:1.6.2"
"@rsdoctor/client": "npm:1.6.1"
"@rsdoctor/graph": "npm:1.6.1"
"@rsdoctor/types": "npm:1.6.1"
"@rsdoctor/utils": "npm:1.6.1"
launch-editor: "npm:^2.13.2"
safer-buffer: "npm:2.1.2"
socket.io: "npm:4.8.1"
tapable: "npm:2.3.3"
checksum: 10/89d5850145b490bbf3f43486176ebdd1f9eb67cdbe96a07549bb0b400ab76d85f3b6d5667be7cf8304434281f0db74fbaaab3360228eb1650f0a8ce640101208
checksum: 10/d667d62d730bed7606bcf1ed2063fb7597024e31d9108c3ba9729313764866407c216499ccb3c7102f239d7d82463e510f2b2ec71270af6a40039cbe6f876d23
languageName: node
linkType: hard
"@rsdoctor/types@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/types@npm:1.6.2"
"@rsdoctor/types@npm:1.6.1":
version: 1.6.1
resolution: "@rsdoctor/types@npm:1.6.1"
dependencies:
"@types/connect": "npm:3.4.38"
"@types/estree": "npm:1.0.5"
@@ -4738,16 +4738,16 @@ __metadata:
optional: true
webpack:
optional: true
checksum: 10/39328739b31f7df46b663a9f6b5ac8c1e290c6b7ce0f68d9b45a423b89a1a296d32fbee0c26f555fc40d60b356a3605909f7bbe19c77d9c6bca3603d6928aef2
checksum: 10/1c4ae6c1aa6fa525d65ab78396954d86358880959ee3fdb68247c2f3215a35e4cb2aab3836cb82f6c208c2c3834cfe0dc8bf575ba59fd8e2ff89ffc92322fcad
languageName: node
linkType: hard
"@rsdoctor/utils@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/utils@npm:1.6.2"
"@rsdoctor/utils@npm:1.6.1":
version: 1.6.1
resolution: "@rsdoctor/utils@npm:1.6.1"
dependencies:
"@babel/code-frame": "npm:7.26.2"
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.1"
"@types/estree": "npm:1.0.5"
acorn: "npm:^8.10.0"
acorn-import-attributes: "npm:^1.9.5"
@@ -4761,83 +4761,69 @@ __metadata:
picocolors: "npm:^1.1.1"
rslog: "npm:^2.1.2"
strip-ansi: "npm:^7.2.0"
checksum: 10/fb6a147e452756018e93789e07dbe015354fc2690699755ef411748a393c7db86fdff2614cdf9ce07822434abcac3eef362aea8c63cbca9fe3f96c958fde56dc
checksum: 10/11f4e9136dd849fa05b0ab56ab0de3be3de43880d530dcbfb1835780dccad9976636f6a743852893948ae053e71fb7cd6144cac3ffac27929a87710e3981c048
languageName: node
linkType: hard
"@rspack/binding-darwin-arm64@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-darwin-arm64@npm:2.1.10"
"@rspack/binding-darwin-arm64@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-darwin-arm64@npm:2.1.8"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-darwin-x64@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-darwin-x64@npm:2.1.10"
"@rspack/binding-darwin-x64@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-darwin-x64@npm:2.1.8"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-gnu@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.10"
"@rspack/binding-linux-arm64-gnu@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.8"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-musl@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.10"
"@rspack/binding-linux-arm64-musl@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.8"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-ppc64-gnu@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-linux-ppc64-gnu@npm:2.1.10"
conditions: os=linux & cpu=ppc64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-riscv64-gnu@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.10"
"@rspack/binding-linux-riscv64-gnu@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.8"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-riscv64-musl@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.10"
"@rspack/binding-linux-riscv64-musl@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.8"
conditions: os=linux & cpu=riscv64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-s390x-gnu@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-linux-s390x-gnu@npm:2.1.10"
conditions: os=linux & cpu=s390x & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-x64-gnu@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.10"
"@rspack/binding-linux-x64-gnu@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.8"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-x64-musl@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.10"
"@rspack/binding-linux-x64-musl@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.8"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-wasm32-wasi@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.10"
"@rspack/binding-wasm32-wasi@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.8"
dependencies:
"@emnapi/core": "npm:1.11.3"
"@emnapi/runtime": "npm:1.11.3"
@@ -4846,45 +4832,43 @@ __metadata:
languageName: node
linkType: hard
"@rspack/binding-win32-arm64-msvc@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.10"
"@rspack/binding-win32-arm64-msvc@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.8"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-win32-ia32-msvc@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.10"
"@rspack/binding-win32-ia32-msvc@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.8"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rspack/binding-win32-x64-msvc@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.10"
"@rspack/binding-win32-x64-msvc@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.8"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@rspack/binding@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/binding@npm:2.1.10"
"@rspack/binding@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding@npm:2.1.8"
dependencies:
"@rspack/binding-darwin-arm64": "npm:2.1.10"
"@rspack/binding-darwin-x64": "npm:2.1.10"
"@rspack/binding-linux-arm64-gnu": "npm:2.1.10"
"@rspack/binding-linux-arm64-musl": "npm:2.1.10"
"@rspack/binding-linux-ppc64-gnu": "npm:2.1.10"
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.10"
"@rspack/binding-linux-riscv64-musl": "npm:2.1.10"
"@rspack/binding-linux-s390x-gnu": "npm:2.1.10"
"@rspack/binding-linux-x64-gnu": "npm:2.1.10"
"@rspack/binding-linux-x64-musl": "npm:2.1.10"
"@rspack/binding-wasm32-wasi": "npm:2.1.10"
"@rspack/binding-win32-arm64-msvc": "npm:2.1.10"
"@rspack/binding-win32-ia32-msvc": "npm:2.1.10"
"@rspack/binding-win32-x64-msvc": "npm:2.1.10"
"@rspack/binding-darwin-arm64": "npm:2.1.8"
"@rspack/binding-darwin-x64": "npm:2.1.8"
"@rspack/binding-linux-arm64-gnu": "npm:2.1.8"
"@rspack/binding-linux-arm64-musl": "npm:2.1.8"
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.8"
"@rspack/binding-linux-riscv64-musl": "npm:2.1.8"
"@rspack/binding-linux-x64-gnu": "npm:2.1.8"
"@rspack/binding-linux-x64-musl": "npm:2.1.8"
"@rspack/binding-wasm32-wasi": "npm:2.1.8"
"@rspack/binding-win32-arm64-msvc": "npm:2.1.8"
"@rspack/binding-win32-ia32-msvc": "npm:2.1.8"
"@rspack/binding-win32-x64-msvc": "npm:2.1.8"
dependenciesMeta:
"@rspack/binding-darwin-arm64":
optional: true
@@ -4894,14 +4878,10 @@ __metadata:
optional: true
"@rspack/binding-linux-arm64-musl":
optional: true
"@rspack/binding-linux-ppc64-gnu":
optional: true
"@rspack/binding-linux-riscv64-gnu":
optional: true
"@rspack/binding-linux-riscv64-musl":
optional: true
"@rspack/binding-linux-s390x-gnu":
optional: true
"@rspack/binding-linux-x64-gnu":
optional: true
"@rspack/binding-linux-x64-musl":
@@ -4914,15 +4894,15 @@ __metadata:
optional: true
"@rspack/binding-win32-x64-msvc":
optional: true
checksum: 10/b15410b23aac7bf934d271a17994fc13dd10ddf65eeaa20a84dedb2b2baf753cd411c4b56e971728c3ee054be5bafd46238e7fc9721ae883a0a8a5c5e121bd66
checksum: 10/74c73ac543059fa62365557ca949ef7a84e1cebc42fe51a343126c875d90f557b35a52b33bacd9498d9231e8fdb8ab13907988e1fd39131ffbd56becb4260785
languageName: node
linkType: hard
"@rspack/core@npm:2.1.10":
version: 2.1.10
resolution: "@rspack/core@npm:2.1.10"
"@rspack/core@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/core@npm:2.1.8"
dependencies:
"@rspack/binding": "npm:2.1.10"
"@rspack/binding": "npm:2.1.8"
peerDependencies:
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
"@swc/helpers": ^0.5.23
@@ -4931,7 +4911,7 @@ __metadata:
optional: true
"@swc/helpers":
optional: true
checksum: 10/bf1e7183aa5e507668cc3233562a74d2d61ddbfb7279748ae9569094f43e041ced5893fe7191de3e2d44306cc017c6db0c0c4d823bc2fd9c6fbc53fbb18a8f22
checksum: 10/c818257225a24feb22af81eb3ed7d02d4b74d6e6cb70689c8cbcd34e3facfcfe833dfe4078e0a35277e926f431cae9fd7f2101c3944b396a72baa7cf3ce99d19
languageName: node
linkType: hard
@@ -9698,10 +9678,10 @@ __metadata:
languageName: node
linkType: hard
"globals@npm:17.11.0":
version: 17.11.0
resolution: "globals@npm:17.11.0"
checksum: 10/9cff5b8099b585862ab0d31e8b93dc1c3e7b59239dc58b59447a38709715751df8794ee0d28f434f8e421b01aee3cc10051e2455b5c967d960e79f882e68aa0f
"globals@npm:17.9.0":
version: 17.9.0
resolution: "globals@npm:17.9.0"
checksum: 10/8619386d449e8cbb81e242eced8bca0a42e5c3633a3a35996b8b1618beb17ff9fb33bfe703d60d83d4b7f6f8a996dede4faf02f9e966e50ff895e0cf6122ae15
languageName: node
linkType: hard
@@ -9888,10 +9868,10 @@ __metadata:
languageName: node
linkType: hard
"hls.js@npm:1.7.0":
version: 1.7.0
resolution: "hls.js@npm:1.7.0"
checksum: 10/8bb97ba86b72a3be057bd0036b01df83adfe7dc10c76a5e177b24bae3f380e19d4180e061d8140b3b826e10c9f61a7b230e10019dcb81619ab2fc41e4d292ed9
"hls.js@npm:1.6.17":
version: 1.6.17
resolution: "hls.js@npm:1.6.17"
checksum: 10/345d6c98f7f32f25f1924d34d4177f37aa9ae10c95fe6518c4ca10b295acbf174fbeba113696fbce673a1ee90461b530e101dd2b2f4d958af82577706d6e24c2
languageName: node
linkType: hard
@@ -9955,8 +9935,8 @@ __metadata:
"@octokit/rest": "npm:22.0.1"
"@playwright/test": "npm:1.62.1"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.6.2"
"@rspack/core": "npm:2.1.10"
"@rsdoctor/rspack-plugin": "npm:1.6.1"
"@rspack/core": "npm:2.1.8"
"@rspack/dev-server": "npm:2.2.0"
"@swc/helpers": "npm:0.5.23"
"@thomasloven/round-slider": "npm:0.6.0"
@@ -10012,11 +9992,11 @@ __metadata:
fuse.js: "npm:7.5.0"
generate-license-file: "npm:4.2.1"
glob: "npm:13.0.6"
globals: "npm:17.11.0"
globals: "npm:17.9.0"
gulp: "npm:5.0.1"
gulp-json-transform: "npm:0.5.0"
gulp-rename: "npm:2.1.0"
hls.js: "npm:1.7.0"
hls.js: "npm:1.6.17"
home-assistant-js-websocket: "npm:9.6.0"
html-minifier-terser: "npm:7.2.0"
husky: "npm:9.1.7"