mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-17 11:59:34 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4f54540c2 |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"}
|
||||
>
|
||||
${
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// HomeAssistantAppEl registers this path; keep it inert in E2E tests.
|
||||
void 0;
|
||||
@@ -1,2 +1 @@
|
||||
import "../../../../src/resources/append-ha-style";
|
||||
import "./ha-test";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user