mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-25 14:33:23 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f56e4d155c | ||
|
|
2e28098b73 | ||
|
|
a5a5ac6a1c |
@@ -118,8 +118,7 @@ For user-facing changes, establish the existing design context as part of fronte
|
||||
- Before reviewing a pull request, read its existing comments, reviews, and threads, including their status, resolver, and Copilot resolution reason when available.
|
||||
- Prioritise substantive human feedback, especially from authors marked `MEMBER`, and validate agent-generated feedback against the code and repository guidance.
|
||||
- Do not duplicate unresolved findings as new inline comments; reference any that still need action in the review summary. Treat resolved feedback as closed only when the resolution reason or surrounding discussion supports that outcome; otherwise validate it against the current code before suppressing it. Respect **Won't fix** and **Incorrect** reasons.
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests that `ha-frontend-testing` calls for first.
|
||||
- Do not ask for new tests on visual components. If the visuals clearly changed and the PR has no screenshots or videos, suggest adding them instead.
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
|
||||
@@ -51,10 +51,10 @@ Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so on
|
||||
|
||||
## When To Add Tests
|
||||
|
||||
- Write tests for code that computes something: data processing, utilities, config validation, and strategies.
|
||||
- Do not write rendering tests. This includes views, panels, and components whose text, styles, slots, or option defaults are checked, or that only put context and helper data into a template.
|
||||
- Do not try to cover every scenario, especially for behaviour that changes often.
|
||||
- If you are not sure a test is useful, describe it and what it would catch, and let the user decide.
|
||||
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
|
||||
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
|
||||
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
|
||||
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
|
||||
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export const GITHUB_CORE_ISSUES_URL =
|
||||
"https://github.com/home-assistant/core/issues";
|
||||
|
||||
export const GITHUB_FRONTEND_ISSUES_URL =
|
||||
"https://github.com/home-assistant/frontend/issues";
|
||||
@@ -123,8 +123,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@property({ type: Array }) public data: DataTableRowData[] = [];
|
||||
|
||||
@property({ type: Boolean }) public loading = false;
|
||||
|
||||
@property({ type: Boolean }) public selectable = false;
|
||||
|
||||
@property({ type: Boolean }) public clickable = false;
|
||||
@@ -167,9 +165,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@state() private _filteredData?: DataTableRowData[];
|
||||
|
||||
// Row count of the data that _filteredData was computed from
|
||||
@state() private _filteredDataSourceLength = 0;
|
||||
|
||||
@state() private _headerHeight = 0;
|
||||
|
||||
@query("slot[name='header']") private _header!: HTMLSlotElement;
|
||||
@@ -528,9 +523,7 @@ export class HaDataTable extends LitElement {
|
||||
role="cell"
|
||||
>
|
||||
${
|
||||
this.loading ||
|
||||
!this._filteredData ||
|
||||
(this.data.length && !this._filteredDataSourceLength)
|
||||
!this._filteredData
|
||||
? this._i18n?.localize?.("ui.common.loading") ||
|
||||
"Loading"
|
||||
: this.data.length
|
||||
@@ -728,11 +721,10 @@ export class HaDataTable extends LitElement {
|
||||
!this._lastUpdate ||
|
||||
(timeBetweenUpdate > 500 && timeBetweenRequest < 500);
|
||||
|
||||
const sourceData = this.data;
|
||||
let filteredData = sourceData;
|
||||
let filteredData = this.data;
|
||||
if (this._filter) {
|
||||
filteredData = await this._memFilterData(
|
||||
sourceData,
|
||||
this.data,
|
||||
this._sortColumns,
|
||||
this._filter.trim()
|
||||
);
|
||||
@@ -768,13 +760,8 @@ export class HaDataTable extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (startTime < this._lastUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastUpdate = startTime;
|
||||
this._filteredData = data;
|
||||
this._filteredDataSourceLength = sourceData.length;
|
||||
}
|
||||
|
||||
private _groupData = memoizeOne(
|
||||
|
||||
@@ -166,8 +166,8 @@ export class HaIcon extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const iconPromise = fetch(`${__STATIC_PATH__}mdi/${chunk}.json`).then(
|
||||
(response) => response.json()
|
||||
const iconPromise = fetch(`/static/mdi/${chunk}.json`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
chunks[chunk] = iconPromise;
|
||||
this._setPath(iconPromise, iconName, requestedIcon);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
narrowViewportContext,
|
||||
uiContext,
|
||||
@@ -33,10 +32,6 @@ class HaMenuButton extends LitElement {
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
private _ui?: ContextType<typeof uiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config?: ContextType<typeof configContext>;
|
||||
|
||||
@state() private _hasNotifications = false;
|
||||
|
||||
@state() private _show = false;
|
||||
@@ -87,8 +82,7 @@ class HaMenuButton extends LitElement {
|
||||
if (
|
||||
!changedProps.has("_narrow") &&
|
||||
!changedProps.has("_ui") &&
|
||||
!changedProps.has("_connection") &&
|
||||
!changedProps.has("_config")
|
||||
!changedProps.has("_connection")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -100,7 +94,6 @@ class HaMenuButton extends LitElement {
|
||||
|
||||
const showButton =
|
||||
this._ui?.kioskMode === false &&
|
||||
this._config?.auth.external?.config.hasSidebar !== true &&
|
||||
(this._narrow || this._ui.dockedSidebar === "always_hidden");
|
||||
|
||||
this._show = showButton || this._alwaysVisible;
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaSkeleton } from "./ha-skeleton";
|
||||
|
||||
/**
|
||||
* Placeholder for an icon. Follows `ha-svg-icon`'s `--mdc-icon-size`
|
||||
* (24px by default), including inherited size overrides.
|
||||
*/
|
||||
@customElement("ha-skeleton-icon")
|
||||
export class HaSkeletonIcon extends HaSkeleton {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
super.styles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
flex: none;
|
||||
width: var(--mdc-icon-size, 24px);
|
||||
height: var(--mdc-icon-size, 24px);
|
||||
min-height: 0;
|
||||
--ha-skeleton-border-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton-icon": HaSkeletonIcon;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaSkeleton } from "./ha-skeleton";
|
||||
|
||||
/**
|
||||
* Placeholder for a line of text, matching the tile secondary text skeleton.
|
||||
* Its height follows the surrounding font size. Set `width` in CSS to fit the
|
||||
* expected text.
|
||||
*
|
||||
* @cssprop --ha-skeleton-text-width - The width of the placeholder. Defaults to `140px`.
|
||||
*/
|
||||
@customElement("ha-skeleton-text")
|
||||
export class HaSkeletonText extends HaSkeleton {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
super.styles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
width: var(--ha-skeleton-text-width, 140px);
|
||||
max-width: 100%;
|
||||
height: 1em;
|
||||
min-height: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton-text": HaSkeletonText;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import WaSkeleton from "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* Placeholder shown while content loads. Sized by its container unless a
|
||||
* more specific variant such as `ha-skeleton-text` or `ha-skeleton-icon` is used.
|
||||
*
|
||||
* @cssprop --ha-skeleton-color - The fill color. Defaults to `var(--ha-color-fill-neutral-normal-resting)`.
|
||||
* @cssprop --ha-skeleton-sheen-color - The sheen color when `effect="sheen"`. Defaults to `var(--ha-color-fill-neutral-loud-resting)`.
|
||||
* @cssprop --ha-skeleton-border-radius - The corner radius. Defaults to `var(--ha-border-radius-sm)`.
|
||||
*/
|
||||
@customElement("ha-skeleton")
|
||||
export class HaSkeleton extends WaSkeleton {
|
||||
override effect: WaSkeleton["effect"] = "pulse";
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
WaSkeleton.styles,
|
||||
css`
|
||||
:host {
|
||||
--color: var(
|
||||
--ha-skeleton-color,
|
||||
var(--ha-color-fill-neutral-normal-resting)
|
||||
);
|
||||
--sheen-color: var(
|
||||
--ha-skeleton-sheen-color,
|
||||
var(--ha-color-fill-neutral-loud-resting)
|
||||
);
|
||||
--wa-border-radius-pill: var(
|
||||
--ha-skeleton-border-radius,
|
||||
var(--ha-border-radius-sm)
|
||||
);
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
:host {
|
||||
--color: GrayText;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:host([effect="pulse"]) .indicator,
|
||||
:host([effect="sheen"]) .indicator {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton": HaSkeleton;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import "../skeleton/ha-skeleton-text";
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@@ -49,7 +49,7 @@ export class HaTileInfo extends LitElement {
|
||||
${
|
||||
this.secondaryLoading
|
||||
? html`<div class="secondary">
|
||||
<ha-skeleton-text></ha-skeleton-text>
|
||||
<wa-skeleton class="placeholder" effect="pulse"></wa-skeleton>
|
||||
</div>`
|
||||
: html`<slot name="secondary" class="secondary">
|
||||
<span>${this.secondary}</span>
|
||||
@@ -150,6 +150,14 @@ export class HaTileInfo extends LitElement {
|
||||
letter-spacing: var(--tile-info-secondary-letter-spacing);
|
||||
color: var(--tile-info-secondary-color);
|
||||
}
|
||||
.placeholder {
|
||||
width: 140px;
|
||||
max-width: 100%;
|
||||
height: var(--tile-info-secondary-font-size);
|
||||
--wa-border-radius-pill: var(--ha-border-radius-sm);
|
||||
--color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import type { PropertyValues } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
@@ -74,6 +75,9 @@ export class HatScriptGraph extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public selected?: string;
|
||||
|
||||
/** Accessible name per node path, from `buildTraceLabels`. */
|
||||
@property({ attribute: false }) public labels?: Record<string, string>;
|
||||
|
||||
@query("hat-graph-node[active], hat-graph-branch[active]")
|
||||
private _activeNode?: HTMLElement;
|
||||
|
||||
@@ -106,6 +110,9 @@ export class HatScriptGraph extends LitElement {
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${node.disabled}
|
||||
.error=${node.error}
|
||||
role="img"
|
||||
aria-label=${ifDefined(this.labels?.[path])}
|
||||
aria-current=${ifDefined(this.selected === path || undefined)}
|
||||
tabindex=${hasTrace ? "0" : "-1"}
|
||||
>
|
||||
<ha-trigger-icon
|
||||
@@ -153,6 +160,10 @@ export class HatScriptGraph extends LitElement {
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${node.disabled}
|
||||
role="group"
|
||||
aria-label=${ifDefined(this.labels?.[path])}
|
||||
aria-current=${ifDefined(this.selected === path || undefined)}
|
||||
aria-disabled=${ifDefined(node.disabled || undefined)}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
@@ -164,6 +175,7 @@ export class HatScriptGraph extends LitElement {
|
||||
.error=${node.error}
|
||||
slot="head"
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
></hat-graph-node>
|
||||
|
||||
${node.branches.slice(0, -1).map(
|
||||
@@ -187,6 +199,9 @@ export class HatScriptGraph extends LitElement {
|
||||
?track=${branch.hasTrace}
|
||||
?active=${this.selected === branch.path}
|
||||
.notEnabled=${branch.disabled}
|
||||
role="img"
|
||||
aria-label=${ifDefined(this.labels?.[branch.path])}
|
||||
aria-current=${ifDefined(this.selected === branch.path || undefined)}
|
||||
></hat-graph-node>
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
@@ -196,7 +211,10 @@ export class HatScriptGraph extends LitElement {
|
||||
?track=${defaultBranch.hasTrace}
|
||||
?unfinished=${defaultBranch.unfinished}
|
||||
>
|
||||
<hat-graph-spacer ?track=${defaultBranch.hasTrace}></hat-graph-spacer>
|
||||
<hat-graph-spacer
|
||||
aria-hidden="true"
|
||||
?track=${defaultBranch.hasTrace}
|
||||
></hat-graph-spacer>
|
||||
${defaultBranch.children.map((action) =>
|
||||
this._renderActionNode(action)
|
||||
)}
|
||||
@@ -215,6 +233,10 @@ export class HatScriptGraph extends LitElement {
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${node.disabled}
|
||||
role="group"
|
||||
aria-label=${ifDefined(this.labels?.[path])}
|
||||
aria-current=${ifDefined(this.selected === path || undefined)}
|
||||
aria-disabled=${ifDefined(node.disabled || undefined)}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
@@ -226,6 +248,7 @@ export class HatScriptGraph extends LitElement {
|
||||
.error=${node.error}
|
||||
slot="head"
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
></hat-graph-node>
|
||||
${
|
||||
config.else
|
||||
@@ -240,12 +263,14 @@ export class HatScriptGraph extends LitElement {
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${elseBranch.disabled}
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
></hat-graph-node
|
||||
>${elseBranch.children.map((action) =>
|
||||
this._renderActionNode(action)
|
||||
)}
|
||||
</div>`
|
||||
: html`<hat-graph-spacer
|
||||
aria-hidden="true"
|
||||
?track=${elseBranch.hasTrace}
|
||||
></hat-graph-spacer>`
|
||||
}
|
||||
@@ -260,6 +285,7 @@ export class HatScriptGraph extends LitElement {
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${thenBranch.disabled}
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
></hat-graph-node>
|
||||
${thenBranch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
@@ -281,6 +307,10 @@ export class HatScriptGraph extends LitElement {
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${model.disabled}
|
||||
role="group"
|
||||
aria-label=${ifDefined(this.labels?.[path])}
|
||||
aria-current=${ifDefined(this.selected === path || undefined)}
|
||||
aria-disabled=${ifDefined(model.disabled || undefined)}
|
||||
tabindex=${hasTrace ? "0" : "-1"}
|
||||
short
|
||||
>
|
||||
@@ -293,6 +323,7 @@ export class HatScriptGraph extends LitElement {
|
||||
.error=${model.error}
|
||||
?building-block=${CONDITION_BUILDING_BLOCKS.includes(condition)}
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ha-condition-icon
|
||||
slot="icon"
|
||||
@@ -308,6 +339,7 @@ export class HatScriptGraph extends LitElement {
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiClose}
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
?track=${failed}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${model.disabled}
|
||||
@@ -329,6 +361,10 @@ export class HatScriptGraph extends LitElement {
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${model.disabled}
|
||||
role="group"
|
||||
aria-label=${ifDefined(this.labels?.[path])}
|
||||
aria-current=${ifDefined(this.selected === path || undefined)}
|
||||
aria-disabled=${ifDefined(model.disabled || undefined)}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
@@ -341,6 +377,7 @@ export class HatScriptGraph extends LitElement {
|
||||
.badge=${model.badge}
|
||||
slot="head"
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
></hat-graph-node>
|
||||
<div
|
||||
class="repeat-sequence"
|
||||
@@ -372,6 +409,9 @@ export class HatScriptGraph extends LitElement {
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${model.disabled}
|
||||
.error=${model.error}
|
||||
role="img"
|
||||
aria-label=${ifDefined(this.labels?.[path])}
|
||||
aria-current=${ifDefined(this.selected === path || undefined)}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
>
|
||||
${
|
||||
@@ -405,6 +445,9 @@ export class HatScriptGraph extends LitElement {
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${model.disabled}
|
||||
.error=${model.error}
|
||||
role="img"
|
||||
aria-label=${ifDefined(this.labels?.[path])}
|
||||
aria-current=${ifDefined(this.selected === path || undefined)}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
@@ -423,6 +466,10 @@ export class HatScriptGraph extends LitElement {
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${model.disabled}
|
||||
role="group"
|
||||
aria-label=${ifDefined(this.labels?.[path])}
|
||||
aria-current=${ifDefined(this.selected === path || undefined)}
|
||||
aria-disabled=${ifDefined(model.disabled || undefined)}
|
||||
>
|
||||
<div
|
||||
class="graph-container"
|
||||
@@ -439,6 +486,7 @@ export class HatScriptGraph extends LitElement {
|
||||
.error=${model.error}
|
||||
slot="head"
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
></hat-graph-node>
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
@@ -458,6 +506,10 @@ export class HatScriptGraph extends LitElement {
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${model.disabled}
|
||||
role="group"
|
||||
aria-label=${ifDefined(this.labels?.[path])}
|
||||
aria-current=${ifDefined(this.selected === path || undefined)}
|
||||
aria-disabled=${ifDefined(model.disabled || undefined)}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
@@ -469,6 +521,7 @@ export class HatScriptGraph extends LitElement {
|
||||
.error=${model.error}
|
||||
slot="head"
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
></hat-graph-node>
|
||||
${model.branches.map(
|
||||
(branch) =>
|
||||
@@ -496,6 +549,9 @@ export class HatScriptGraph extends LitElement {
|
||||
?active=${this.selected === path}
|
||||
.error=${model.error}
|
||||
.notEnabled=${model.disabled}
|
||||
role="img"
|
||||
aria-label=${ifDefined(this.labels?.[path])}
|
||||
aria-current=${ifDefined(this.selected === path || undefined)}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
+2
-14
@@ -1,11 +1,6 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { GITHUB_CORE_ISSUES_URL } from "../common/url/github";
|
||||
import {
|
||||
createQueryString,
|
||||
type QueryParamConfig,
|
||||
} from "../common/url/query-params";
|
||||
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -113,19 +108,12 @@ const sanitizeManifest = <T extends IntegrationManifest | undefined>(
|
||||
} as T)
|
||||
: manifest;
|
||||
|
||||
const integrationIssuesQueryParams = {
|
||||
string: ["q"],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export const integrationIssuesUrl = (
|
||||
domain: string,
|
||||
manifest: IntegrationManifest
|
||||
) =>
|
||||
sanitizeHttpUrl(manifest.issue_tracker) ||
|
||||
`${GITHUB_CORE_ISSUES_URL}?${createQueryString(
|
||||
{ q: `is:issue is:open label:"integration: ${domain}"` },
|
||||
integrationIssuesQueryParams
|
||||
)}`;
|
||||
`https://github.com/home-assistant/core/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
|
||||
|
||||
export const domainToName = (
|
||||
localize: LocalizeFunc,
|
||||
@@ -167,7 +155,7 @@ export const fetchIntegrationManifestsCollection = async (
|
||||
};
|
||||
|
||||
export const fetchIntegrationManifest = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
hass: HomeAssistant,
|
||||
integration: string
|
||||
) =>
|
||||
hass
|
||||
|
||||
@@ -81,7 +81,7 @@ type SystemHealthEvent =
|
||||
| SystemHealthEventFinish;
|
||||
|
||||
export const subscribeSystemHealthInfo = (
|
||||
hass: Pick<HomeAssistant, "connection">,
|
||||
hass: HomeAssistant,
|
||||
callback: (info: SystemHealthInfo | undefined) => void
|
||||
) => {
|
||||
let data = {};
|
||||
|
||||
@@ -5,7 +5,7 @@ export type SystemLogLevel =
|
||||
|
||||
export interface LoggedError {
|
||||
name: string;
|
||||
message: [string, ...string[]];
|
||||
message: [string];
|
||||
level: SystemLogLevel;
|
||||
source: [string, number];
|
||||
exception: string;
|
||||
@@ -41,10 +41,6 @@ export const getLoggedErrorIntegration = (item: LoggedError) => {
|
||||
return item.source[0].split("/")[2];
|
||||
}
|
||||
|
||||
if (item.source[0].startsWith("components/")) {
|
||||
return item.source[0].split("/")[1];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import {
|
||||
GITHUB_CORE_ISSUES_URL,
|
||||
GITHUB_FRONTEND_ISSUES_URL,
|
||||
} from "../common/url/github";
|
||||
import {
|
||||
createQueryString,
|
||||
type QueryParamConfig,
|
||||
} from "../common/url/query-params";
|
||||
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
|
||||
import { documentationUrlForVersion } from "../util/documentation-url";
|
||||
import type { IntegrationManifest } from "./integration";
|
||||
import type { LoggedError } from "./system_log";
|
||||
import {
|
||||
getLoggedErrorIntegration,
|
||||
isCustomIntegrationError,
|
||||
} from "./system_log";
|
||||
|
||||
const frontendIssueQueryParams = {
|
||||
string: ["template", "core_version", "javascript_errors"],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
const coreIssueQueryParams = {
|
||||
string: [
|
||||
"template",
|
||||
"version",
|
||||
"installation_type",
|
||||
"integration_name",
|
||||
"integration_link",
|
||||
"logs",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
const coreIssueTemplateUrl = `${GITHUB_CORE_ISSUES_URL}/new?${createQueryString(
|
||||
{ template: "bug_report.yml" },
|
||||
coreIssueQueryParams
|
||||
)}`;
|
||||
|
||||
export const systemLogReportUrl = (
|
||||
item: LoggedError,
|
||||
coreVersion: string,
|
||||
manifest?: IntegrationManifest | null,
|
||||
installationType?: string
|
||||
): string => {
|
||||
const log = [
|
||||
item.name,
|
||||
item.source.join(":"),
|
||||
...item.message,
|
||||
item.exception,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
const includeLogs = new URLSearchParams({ log }).toString().length <= 6000;
|
||||
|
||||
if (/^frontend\.js(?:_dev)?(?:\.|$)/.test(item.name)) {
|
||||
return `${GITHUB_FRONTEND_ISSUES_URL}/new?${createQueryString(
|
||||
{
|
||||
template: "bug_report.yml",
|
||||
core_version: coreVersion,
|
||||
javascript_errors: includeLogs ? log : undefined,
|
||||
},
|
||||
frontendIssueQueryParams
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (getLoggedErrorIntegration(item) && !manifest) {
|
||||
return coreIssueTemplateUrl;
|
||||
}
|
||||
|
||||
if (isCustomIntegrationError(item) || manifest?.is_built_in === false) {
|
||||
return sanitizeHttpUrl(manifest?.issue_tracker) || coreIssueTemplateUrl;
|
||||
}
|
||||
|
||||
return `${GITHUB_CORE_ISSUES_URL}/new?${createQueryString(
|
||||
{
|
||||
template: "bug_report.yml",
|
||||
version: coreVersion,
|
||||
installation_type: installationType,
|
||||
integration_name: manifest?.name,
|
||||
integration_link: manifest
|
||||
? documentationUrlForVersion(
|
||||
coreVersion,
|
||||
`/integrations/${encodeURIComponent(manifest.domain)}/`
|
||||
)
|
||||
: undefined,
|
||||
logs: includeLogs ? log : undefined,
|
||||
},
|
||||
coreIssueQueryParams
|
||||
)}`;
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { Condition, Trigger } from "./automation";
|
||||
import { describeCondition, describeTrigger } from "./automation_i18n";
|
||||
import type { EntityRegistryEntry } from "./entity/entity_registry";
|
||||
import type { DomainManifestLookup } from "./integration";
|
||||
import { describeAction } from "./script_i18n";
|
||||
import type { TraceExtended } from "./trace";
|
||||
import { TraceTree } from "./trace-tree";
|
||||
import type { TraceActionNode, TraceBranch, TraceNode } from "./trace-tree";
|
||||
|
||||
const STATE_KEY = "ui.panel.config.automation.trace.graph.state";
|
||||
|
||||
/** Shared so an absent registry does not break the callers' memoization. */
|
||||
const NO_ENTITY_REGISTRY: EntityRegistryEntry[] = [];
|
||||
|
||||
interface LabelContext {
|
||||
hass: HomeAssistant;
|
||||
entityRegistry: EntityRegistryEntry[];
|
||||
manifests?: DomainManifestLookup;
|
||||
}
|
||||
|
||||
type StatefulNode = Pick<TraceNode, "disabled" | "error" | "track"> &
|
||||
Partial<Pick<TraceNode, "notTriggered" | "condition">>;
|
||||
|
||||
/**
|
||||
* Whether a condition passed is drawn as the tracked path, and failing as a
|
||||
* cross on a node that is aria-hidden, so the outcome needs saying. A repeated
|
||||
* condition can have done both across its evaluations.
|
||||
*/
|
||||
const conditionOutcome = (
|
||||
condition: NonNullable<TraceNode["condition"]>
|
||||
): "passed" | "failed" | "passed_and_failed" | undefined => {
|
||||
if (condition.passed && condition.failed) {
|
||||
return "passed_and_failed";
|
||||
}
|
||||
if (condition.passed) {
|
||||
return "passed";
|
||||
}
|
||||
if (condition.failed) {
|
||||
return "failed";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** The graph shows the run outcome only visually, so the label says it. */
|
||||
const withState = (
|
||||
{ hass }: LabelContext,
|
||||
description: string,
|
||||
node: StatefulNode,
|
||||
badge?: number
|
||||
): string => {
|
||||
const outcome = node.condition && conditionOutcome(node.condition);
|
||||
let state: string;
|
||||
if (node.disabled) {
|
||||
state = hass.localize(`${STATE_KEY}.disabled`);
|
||||
} else if (node.error) {
|
||||
state = hass.localize(`${STATE_KEY}.error`);
|
||||
} else if (node.notTriggered) {
|
||||
state = hass.localize(`${STATE_KEY}.not_triggered`);
|
||||
} else if (outcome) {
|
||||
state = hass.localize(`${STATE_KEY}.${outcome}`);
|
||||
} else if (node.track && badge) {
|
||||
state = hass.localize(`${STATE_KEY}.repeated`, { count: badge });
|
||||
} else if (node.track) {
|
||||
state = hass.localize(`${STATE_KEY}.executed`);
|
||||
} else {
|
||||
state = hass.localize(`${STATE_KEY}.not_executed`);
|
||||
}
|
||||
return hass.localize("ui.panel.config.automation.trace.graph.node_label", {
|
||||
description,
|
||||
state,
|
||||
});
|
||||
};
|
||||
|
||||
/** Named by its first condition, as `ha-automation-option-row` does. */
|
||||
const optionLabel = (ctx: LabelContext, branch: TraceBranch): string => {
|
||||
const conditions = branch.option?.conditions
|
||||
? ensureArray<Condition | string>(branch.option.conditions)
|
||||
: undefined;
|
||||
let description: string;
|
||||
if (!conditions || conditions.length === 0) {
|
||||
description = ctx.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.type.choose.no_conditions"
|
||||
);
|
||||
} else if (typeof conditions[0] === "string") {
|
||||
description = conditions[0];
|
||||
} else {
|
||||
description = describeCondition(
|
||||
conditions[0],
|
||||
ctx.hass,
|
||||
ctx.entityRegistry
|
||||
);
|
||||
}
|
||||
if (conditions && conditions.length > 1) {
|
||||
description += ctx.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.type.choose.option_description_additional",
|
||||
{ numberOfAdditionalConditions: conditions.length - 1 }
|
||||
);
|
||||
}
|
||||
return withState(ctx, description, {
|
||||
disabled: branch.disabled,
|
||||
error: false,
|
||||
track: branch.hasTrace,
|
||||
});
|
||||
};
|
||||
|
||||
const addAction = (
|
||||
ctx: LabelContext,
|
||||
labels: Record<string, string>,
|
||||
node: TraceActionNode
|
||||
): void => {
|
||||
labels[node.path] = withState(
|
||||
ctx,
|
||||
// The tree's `actionType` is a wider union than describeAction accepts;
|
||||
// it derives the type from the config anyway.
|
||||
describeAction(
|
||||
ctx.hass,
|
||||
ctx.entityRegistry,
|
||||
node.config,
|
||||
undefined,
|
||||
undefined,
|
||||
ctx.manifests
|
||||
),
|
||||
node,
|
||||
node.badge
|
||||
);
|
||||
for (const branch of node.branches) {
|
||||
// Only `choose` branches carry an option, which gets a node of its own.
|
||||
if (branch.option) {
|
||||
labels[branch.path] = optionLabel(ctx, branch);
|
||||
}
|
||||
for (const child of branch.children) {
|
||||
addAction(ctx, labels, child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Accessible names for every step of a trace, keyed by node path. Called from
|
||||
* the trace panels, which own `hass`; memoize on everything but `hass`, which
|
||||
* is replaced on every state update.
|
||||
*/
|
||||
export const buildTraceLabels = (
|
||||
trace: TraceExtended,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[] | undefined,
|
||||
manifests?: DomainManifestLookup
|
||||
): Record<string, string> => {
|
||||
const entities = entityRegistry ?? NO_ENTITY_REGISTRY;
|
||||
const ctx: LabelContext = { hass, entityRegistry: entities, manifests };
|
||||
const tree = new TraceTree(trace);
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
tree.triggers?.forEach((node: TraceNode<Trigger>) => {
|
||||
labels[node.path] = withState(
|
||||
ctx,
|
||||
describeTrigger(node.config, hass, entities),
|
||||
node
|
||||
);
|
||||
});
|
||||
tree.conditions.forEach((node: TraceNode<Condition>) => {
|
||||
labels[node.path] = withState(
|
||||
ctx,
|
||||
describeCondition(node.config, hass, entities),
|
||||
node
|
||||
);
|
||||
});
|
||||
tree.actions.forEach((node) => addAction(ctx, labels, node));
|
||||
tree.sequence.forEach((node) => addAction(ctx, labels, node));
|
||||
|
||||
return labels;
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import "../../../../components/skeleton/ha-skeleton";
|
||||
import "../../../../components/skeleton/ha-skeleton-text";
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -113,14 +112,14 @@ export class HaMoreInfoUpdateBackup extends LitElement {
|
||||
${
|
||||
!createBackupTexts
|
||||
? html`<ha-fade-in slot="headline" .delay=${500}
|
||||
><ha-skeleton-text></ha-skeleton-text
|
||||
><wa-skeleton effect="sheen"></wa-skeleton
|
||||
></ha-fade-in>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._createBackupLoading
|
||||
? html`<ha-fade-in class="skeleton-end" slot="end" .delay=${500}
|
||||
><ha-skeleton></ha-skeleton
|
||||
><wa-skeleton effect="sheen"></wa-skeleton
|
||||
></ha-fade-in>`
|
||||
: html`<ha-switch
|
||||
slot="end"
|
||||
@@ -308,7 +307,6 @@ export class HaMoreInfoUpdateBackup extends LitElement {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
--ha-skeleton-border-radius: var(--ha-border-radius-pill);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -154,12 +154,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
*/
|
||||
@property({ type: Boolean }) public empty = false;
|
||||
|
||||
/**
|
||||
* Show a loading state instead of the empty message until data is ready.
|
||||
* @type {Boolean}
|
||||
*/
|
||||
@property({ type: Boolean }) public loading = false;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
/**
|
||||
@@ -498,7 +492,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.empty && !this.loading
|
||||
this.empty
|
||||
? html`<div class="center">
|
||||
<slot name="empty">${this.noDataText}</slot>
|
||||
</div>`
|
||||
@@ -520,7 +514,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
.narrow=${this.narrow}
|
||||
.columns=${this.columns}
|
||||
.data=${this.data}
|
||||
.loading=${this.loading}
|
||||
.noDataText=${this.noDataText}
|
||||
.filter=${this.filter}
|
||||
.selectable=${this._selectMode}
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import "../components/skeleton/ha-skeleton-text";
|
||||
|
||||
/** Placeholder shown in place of a text while onboarding translations load. */
|
||||
export const renderSkeleton = (variant: string): TemplateResult =>
|
||||
html`<ha-skeleton-text class="skeleton ${variant}"></ha-skeleton-text>`;
|
||||
html`<wa-skeleton effect="sheen" class="skeleton ${variant}"></wa-skeleton>`;
|
||||
|
||||
export const skeletonStyles = css`
|
||||
.skeleton {
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
--color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
.skeleton.title {
|
||||
--ha-skeleton-text-width: 200px;
|
||||
width: 200px;
|
||||
}
|
||||
.skeleton.line {
|
||||
--ha-skeleton-text-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.skeleton.headline {
|
||||
--ha-skeleton-text-width: 40%;
|
||||
width: 40%;
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
.skeleton.chip {
|
||||
--ha-skeleton-text-width: 80px;
|
||||
width: 80px;
|
||||
}
|
||||
.skeleton.button {
|
||||
--ha-skeleton-text-width: 120px;
|
||||
width: 120px;
|
||||
}
|
||||
.skeleton.label {
|
||||
--ha-skeleton-text-width: 80px;
|
||||
width: 80px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -123,8 +123,6 @@ const RATING_ICON = {
|
||||
8: mdiNumeric8,
|
||||
};
|
||||
|
||||
const MAX_RATING = 8;
|
||||
|
||||
const POLL_INTERVAL_SECONDS = 5;
|
||||
|
||||
@customElement("supervisor-app-info")
|
||||
@@ -1092,8 +1090,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
`ui.panel.config.apps.dashboard.capability.${id}.title` as LocalizeKeys
|
||||
),
|
||||
text: this.i18n.localize(
|
||||
`ui.panel.config.apps.dashboard.capability.${id}.description`,
|
||||
{ max: MAX_RATING }
|
||||
`ui.panel.config.apps.dashboard.capability.${id}.description`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ class DialogAreaAddTo extends LitElement {
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0 0 var(--ha-space-6);
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import {
|
||||
fireEvent,
|
||||
@@ -38,7 +39,13 @@ import type {
|
||||
NodeInfo,
|
||||
} from "../../../components/trace/hat-script-graph";
|
||||
import type { AutomationEntity } from "../../../data/automation";
|
||||
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
|
||||
import {
|
||||
fireRelatedContext,
|
||||
fullEntitiesContext,
|
||||
manifestsContext,
|
||||
} from "../../../data/context";
|
||||
import type { DomainManifestLookup } from "../../../data/integration";
|
||||
import { buildTraceLabels } from "../../../data/trace-labels";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { LogbookEntry } from "../../../data/logbook";
|
||||
import { getLogbookDataForContext } from "../../../data/logbook";
|
||||
@@ -76,6 +83,9 @@ export class HaAutomationTrace extends LitElement {
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state()
|
||||
@consume({ context: manifestsContext, subscribe: true })
|
||||
_manifests?: DomainManifestLookup;
|
||||
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityRegistry?: EntityRegistryEntry[];
|
||||
|
||||
@@ -97,6 +107,17 @@ export class HaAutomationTrace extends LitElement {
|
||||
|
||||
@query("hat-script-graph") private _graph?: HatScriptGraph;
|
||||
|
||||
/**
|
||||
* `hass` is replaced on every state update, so comparing it would rebuild
|
||||
* every label on every state event. The run already happened, so only the
|
||||
* trace and the registries the descriptions read can change the result.
|
||||
*/
|
||||
private _traceLabels = memoizeOne(
|
||||
buildTraceLabels,
|
||||
([trace, , entities, manifests], [pTrace, , pEntities, pManifests]) =>
|
||||
trace === pTrace && entities === pEntities && manifests === pManifests
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const stateObj = this._entityId
|
||||
? this.hass.states[this._entityId]
|
||||
@@ -277,6 +298,16 @@ export class HaAutomationTrace extends LitElement {
|
||||
<hat-script-graph
|
||||
.trace=${this._trace}
|
||||
.selected=${this._selected?.path}
|
||||
.labels=${
|
||||
this._trace
|
||||
? this._traceLabels(
|
||||
this._trace,
|
||||
this.hass,
|
||||
this._entityRegistry,
|
||||
this._manifests
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
@graph-node-selected=${this._pickNode}
|
||||
></hat-script-graph>
|
||||
</div>
|
||||
|
||||
@@ -222,7 +222,7 @@ export class DialogDeviceAddTo extends LitElement {
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0 0 var(--ha-space-6);
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -168,6 +168,10 @@ export class HaDeviceLinkedDevicesCard extends LitElement {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
color: var(--secondary-text-color);
|
||||
padding-bottom: var(--ha-space-2);
|
||||
|
||||
@@ -35,12 +35,9 @@ class HaConfigDevices extends HassRouterPage {
|
||||
|
||||
@state() private _manifests: IntegrationManifest[] = [];
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._loadData();
|
||||
}
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._loadData();
|
||||
}
|
||||
|
||||
protected updatePageEl(pageEl) {
|
||||
@@ -58,14 +55,8 @@ class HaConfigDevices extends HassRouterPage {
|
||||
}
|
||||
|
||||
private async _loadData() {
|
||||
await Promise.all([
|
||||
getConfigEntries(this.hass).then((configEntries) => {
|
||||
this._configEntries = configEntries;
|
||||
}),
|
||||
fetchIntegrationManifests(this.hass).then((manifests) => {
|
||||
this._manifests = manifests;
|
||||
}),
|
||||
]);
|
||||
this._configEntries = await getConfigEntries(this.hass);
|
||||
this._manifests = await fetchIntegrationManifests(this.hass);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@ import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdown";
|
||||
import "../../../../../components/ha-dropdown-item";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-list-item";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import { getSignedPath } from "../../../../../data/auth";
|
||||
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../../../data/diagnostics";
|
||||
import type { OTBRInfo, OTBRInfoDict } from "../../../../../data/otbr";
|
||||
|
||||
@@ -108,8 +108,6 @@ export class HaConfigLabels extends LitElement {
|
||||
|
||||
@state() private _labels: LabelRegistryEntry[] = [];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
@@ -260,7 +258,6 @@ export class HaConfigLabels extends LitElement {
|
||||
.tabs=${configSections.areas}
|
||||
.columns=${this._columns(this.hass.localize, this.narrow)}
|
||||
.data=${this._data(this._labels)}
|
||||
.loading=${this._loading}
|
||||
.noDataText=${this.hass.localize("ui.panel.config.labels.no_labels")}
|
||||
has-fab
|
||||
.initialSorting=${this._activeSorting}
|
||||
@@ -324,11 +321,7 @@ export class HaConfigLabels extends LitElement {
|
||||
}
|
||||
|
||||
private async _fetchLabels() {
|
||||
try {
|
||||
this._labels = await fetchLabelRegistry(this.hass.connection);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
this._labels = await fetchLabelRegistry(this.hass.connection);
|
||||
}
|
||||
|
||||
private _addLabel() {
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
import "../../../components/skeleton/ha-skeleton-text";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiContentCopy } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import {
|
||||
GITHUB_CORE_ISSUES_URL,
|
||||
GITHUB_FRONTEND_ISSUES_URL,
|
||||
} from "../../../common/url/github";
|
||||
import { copyToClipboard } from "../../../common/util/copy-clipboard";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-dialog";
|
||||
import {
|
||||
apiContext,
|
||||
configContext,
|
||||
connectionContext,
|
||||
internationalizationContext,
|
||||
} from "../../../data/context";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import {
|
||||
domainToName,
|
||||
@@ -32,13 +18,9 @@ import {
|
||||
getLoggedErrorIntegration,
|
||||
isCustomIntegrationError,
|
||||
} from "../../../data/system_log";
|
||||
import { systemLogReportUrl } from "../../../data/system_log_report";
|
||||
import { subscribeSystemHealthInfo } from "../../../data/system_health";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import {
|
||||
DOCUMENTATION_URL,
|
||||
documentationUrl,
|
||||
} from "../../../util/documentation-url";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import type { SystemLogDetailDialogParams } from "./show-dialog-system-log-detail";
|
||||
import { formatSystemLogTime } from "./util";
|
||||
@@ -54,36 +36,16 @@ const isOfficialDocumentationUrl = (url: string): boolean => {
|
||||
|
||||
@customElement("dialog-system-log-detail")
|
||||
class DialogSystemLogDetail extends LitElement {
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config!: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: ContextType<typeof connectionContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: SystemLogDetailDialogParams;
|
||||
|
||||
@state() private _manifest?: IntegrationManifest | null;
|
||||
|
||||
@state() private _installationType?: string;
|
||||
@state() private _manifest?: IntegrationManifest;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@query(".contents") private _contents?: HTMLElement;
|
||||
|
||||
private _reportUrl = memoizeOne(systemLogReportUrl);
|
||||
|
||||
private _fetchingInstallationType = false;
|
||||
|
||||
public async showDialog(params: SystemLogDetailDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._manifest = undefined;
|
||||
@@ -102,27 +64,12 @@ class DialogSystemLogDetail extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (
|
||||
(!changedProps.has("_params") && !changedProps.has("_manifest")) ||
|
||||
!this._params
|
||||
) {
|
||||
if (!changedProps.has("_params") || !this._params) {
|
||||
return;
|
||||
}
|
||||
|
||||
const integration = getLoggedErrorIntegration(this._params.item);
|
||||
|
||||
if (changedProps.has("_params") && integration) {
|
||||
this._fetchManifest(integration, this._params);
|
||||
}
|
||||
|
||||
if (
|
||||
!/^frontend\.js(?:_dev)?(?:\.|$)/.test(this._params.item.name) &&
|
||||
!isCustomIntegrationError(this._params.item) &&
|
||||
(!integration || this._manifest?.is_built_in) &&
|
||||
isComponentLoaded(this._config.config, "system_health")
|
||||
) {
|
||||
this._fetchInstallationType();
|
||||
if (integration) {
|
||||
this._fetchManifest(integration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,24 +81,6 @@ class DialogSystemLogDetail extends LitElement {
|
||||
|
||||
const integration = getLoggedErrorIntegration(item);
|
||||
|
||||
const reportUrl = this._reportUrl(
|
||||
item,
|
||||
this._connection.connection.haVersion,
|
||||
this._manifest,
|
||||
this._installationType
|
||||
);
|
||||
|
||||
const reportTarget = reportUrl.startsWith(`${GITHUB_CORE_ISSUES_URL}/`)
|
||||
? "core"
|
||||
: reportUrl.startsWith(`${GITHUB_FRONTEND_ISSUES_URL}/`)
|
||||
? "frontend"
|
||||
: "custom";
|
||||
|
||||
const reportMessage =
|
||||
this.isCustomIntegration && reportTarget === "core"
|
||||
? "custom_fallback"
|
||||
: reportTarget;
|
||||
|
||||
const showDocumentation =
|
||||
this._manifest &&
|
||||
(this._manifest.is_built_in ||
|
||||
@@ -160,12 +89,12 @@ class DialogSystemLogDetail extends LitElement {
|
||||
!isOfficialDocumentationUrl(this._manifest.documentation)));
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this._config, `/integrations/${this._manifest.domain}`)
|
||||
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
const title = this._i18n.localize("ui.panel.config.logs.details", {
|
||||
const title = this.hass.localize("ui.panel.config.logs.details", {
|
||||
level: html`<span class=${item.level}
|
||||
>${this._i18n.localize(`ui.panel.config.logs.level.${item.level}`)}</span
|
||||
>${this.hass.localize(`ui.panel.config.logs.level.${item.level}`)}</span
|
||||
>`,
|
||||
});
|
||||
|
||||
@@ -178,82 +107,34 @@ class DialogSystemLogDetail extends LitElement {
|
||||
<span slot="headerTitle">${title}</span>
|
||||
<ha-icon-button
|
||||
id="copy"
|
||||
autofocus
|
||||
@click=${this._copyLog}
|
||||
slot="headerActionItems"
|
||||
.label=${this._i18n.localize("ui.panel.config.logs.copy")}
|
||||
.label=${this.hass.localize("ui.panel.config.logs.copy")}
|
||||
.path=${mdiContentCopy}
|
||||
></ha-icon-button>
|
||||
${
|
||||
integration &&
|
||||
this._manifest === undefined &&
|
||||
reportTarget !== "frontend"
|
||||
? html`<ha-alert
|
||||
alert-type=${this.isCustomIntegration ? "warning" : "info"}
|
||||
>
|
||||
<ha-skeleton-text></ha-skeleton-text>
|
||||
</ha-alert>`
|
||||
: html`<ha-alert
|
||||
alert-type=${this.isCustomIntegration ? "warning" : "info"}
|
||||
>
|
||||
<p>
|
||||
${this._i18n.localize(
|
||||
`ui.panel.config.logs.detail.report_issue.${reportMessage}.introduction`,
|
||||
{
|
||||
integration:
|
||||
this._manifest?.name ??
|
||||
(integration
|
||||
? domainToName(this._i18n.localize, integration)
|
||||
: new URL(reportUrl).hostname),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this._i18n.localize(
|
||||
`ui.panel.config.logs.detail.report_issue.${reportMessage}.report`,
|
||||
{
|
||||
report_link: html`<a
|
||||
href=${reportUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>${this._i18n.localize(`ui.panel.config.logs.detail.report_issue.${reportTarget}.link_text`)}</a
|
||||
>`,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
${
|
||||
reportMessage !== "custom"
|
||||
? html`<p>
|
||||
${this._i18n.localize(
|
||||
`ui.panel.config.logs.detail.report_issue.${reportMessage}.guidance`,
|
||||
{
|
||||
guide_link: html`<a
|
||||
href=${`${DOCUMENTATION_URL}/help/reporting_issues/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>${this._i18n.localize("ui.panel.config.logs.detail.report_issue.guide_link_text")}</a
|
||||
>`,
|
||||
}
|
||||
)}
|
||||
</p>`
|
||||
: nothing
|
||||
}
|
||||
this.isCustomIntegration
|
||||
? html`<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.logs.error_from_custom_integration"
|
||||
)}
|
||||
</ha-alert>`
|
||||
: ""
|
||||
}
|
||||
<div class="contents">
|
||||
<div class="contents" tabindex="-1" autofocus>
|
||||
<p>
|
||||
${this._i18n.localize("ui.panel.config.logs.detail.logger")}:
|
||||
${this.hass.localize("ui.panel.config.logs.detail.logger")}:
|
||||
${item.name}<br />
|
||||
${this._i18n.localize("ui.panel.config.logs.detail.source")}:
|
||||
${this.hass.localize("ui.panel.config.logs.detail.source")}:
|
||||
${item.source.join(":")}
|
||||
${
|
||||
integration
|
||||
? html`
|
||||
<br />
|
||||
${this._i18n.localize(
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.logs.detail.integration"
|
||||
)}:
|
||||
${domainToName(this._i18n.localize, integration)}
|
||||
${domainToName(this.hass.localize, integration)}
|
||||
${
|
||||
!this._manifest ||
|
||||
// Can happen with custom integrations
|
||||
@@ -265,7 +146,7 @@ class DialogSystemLogDetail extends LitElement {
|
||||
href=${documentationLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this._i18n.localize(
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.logs.detail.documentation"
|
||||
)}</a
|
||||
>${
|
||||
@@ -279,7 +160,7 @@ class DialogSystemLogDetail extends LitElement {
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this._i18n.localize(
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.logs.detail.issues"
|
||||
)}</a
|
||||
>`
|
||||
@@ -294,15 +175,15 @@ class DialogSystemLogDetail extends LitElement {
|
||||
${
|
||||
item.count > 0
|
||||
? html`
|
||||
${this._i18n.localize(
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.logs.detail.first_occurred"
|
||||
)}:
|
||||
${formatSystemLogTime(
|
||||
item.first_occurred,
|
||||
this._i18n.locale,
|
||||
this._config.config
|
||||
this.hass!.locale,
|
||||
this.hass!.config
|
||||
)}
|
||||
(${this._i18n.localize(
|
||||
(${this.hass.localize(
|
||||
"ui.panel.config.logs.detail.number_of_occurrences",
|
||||
{
|
||||
count: item.count,
|
||||
@@ -311,11 +192,11 @@ class DialogSystemLogDetail extends LitElement {
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${this._i18n.localize("ui.panel.config.logs.detail.last_logged")}:
|
||||
${this.hass.localize("ui.panel.config.logs.detail.last_logged")}:
|
||||
${formatSystemLogTime(
|
||||
item.timestamp,
|
||||
this._i18n.locale,
|
||||
this._config.config
|
||||
this.hass!.locale,
|
||||
this.hass!.config
|
||||
)}
|
||||
</p>
|
||||
${
|
||||
@@ -339,43 +220,12 @@ class DialogSystemLogDetail extends LitElement {
|
||||
: isCustomIntegrationError(this._params!.item);
|
||||
}
|
||||
|
||||
private async _fetchManifest(
|
||||
integration: string,
|
||||
params: SystemLogDetailDialogParams
|
||||
) {
|
||||
let manifest: IntegrationManifest | null;
|
||||
private async _fetchManifest(integration: string) {
|
||||
try {
|
||||
manifest = await fetchIntegrationManifest(this._api, integration);
|
||||
} catch {
|
||||
// Ignore if loading manifest fails. Probably bad JSON in manifest.
|
||||
manifest = null;
|
||||
this._manifest = await fetchIntegrationManifest(this.hass, integration);
|
||||
} catch (_err: any) {
|
||||
// Ignore if loading manifest fails. Probably bad JSON in manifest
|
||||
}
|
||||
|
||||
if (this._params === params && this._open) {
|
||||
this._manifest = manifest;
|
||||
}
|
||||
}
|
||||
|
||||
private _fetchInstallationType() {
|
||||
if (this._installationType || this._fetchingInstallationType) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._fetchingInstallationType = true;
|
||||
|
||||
const subscription = subscribeSystemHealthInfo(this._connection, (info) => {
|
||||
this._fetchingInstallationType = false;
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._installationType = info.homeassistant?.info.installation_type;
|
||||
|
||||
subscription.then((unsub) => unsub?.());
|
||||
}).catch(() => {
|
||||
// The report remains usable without system health information.
|
||||
this._fetchingInstallationType = false;
|
||||
});
|
||||
}
|
||||
|
||||
private async _copyLog(): Promise<void> {
|
||||
@@ -385,7 +235,7 @@ class DialogSystemLogDetail extends LitElement {
|
||||
|
||||
if (this.isCustomIntegration) {
|
||||
text =
|
||||
this._i18n.localize(
|
||||
this.hass.localize(
|
||||
"ui.panel.config.logs.error_from_custom_integration"
|
||||
) +
|
||||
"\n\n" +
|
||||
@@ -394,7 +244,7 @@ class DialogSystemLogDetail extends LitElement {
|
||||
|
||||
await copyToClipboard(text);
|
||||
showToast(this, {
|
||||
message: this._i18n.localize("ui.common.copied_clipboard"),
|
||||
message: this.hass.localize("ui.common.copied_clipboard"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -402,6 +252,10 @@ class DialogSystemLogDetail extends LitElement {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
@@ -414,35 +268,10 @@ class DialogSystemLogDetail extends LitElement {
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-inline: calc(-1 * var(--ha-space-3));
|
||||
margin-block-end: var(--ha-space-4);
|
||||
}
|
||||
ha-alert p {
|
||||
margin: 0;
|
||||
}
|
||||
ha-alert p + p {
|
||||
margin-block-start: var(--ha-space-2);
|
||||
}
|
||||
ha-skeleton-text {
|
||||
--ha-skeleton-text-width: 320px;
|
||||
}
|
||||
@supports (color: color-mix(in srgb, black, transparent)) {
|
||||
ha-alert[alert-type="info"] ha-skeleton-text {
|
||||
--ha-skeleton-color: color-mix(
|
||||
in srgb,
|
||||
var(--info-color) 24%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
ha-alert[alert-type="warning"] ha-skeleton-text {
|
||||
--ha-skeleton-color: color-mix(
|
||||
in srgb,
|
||||
var(--warning-color) 24%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
margin: -4px 0;
|
||||
}
|
||||
.contents {
|
||||
padding: 16px;
|
||||
outline: none;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import {
|
||||
fireEvent,
|
||||
@@ -36,7 +37,13 @@ import type {
|
||||
HatScriptGraph,
|
||||
NodeInfo,
|
||||
} from "../../../components/trace/hat-script-graph";
|
||||
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
|
||||
import {
|
||||
fireRelatedContext,
|
||||
fullEntitiesContext,
|
||||
manifestsContext,
|
||||
} from "../../../data/context";
|
||||
import type { DomainManifestLookup } from "../../../data/integration";
|
||||
import { buildTraceLabels } from "../../../data/trace-labels";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { LogbookEntry } from "../../../data/logbook";
|
||||
import { getLogbookDataForContext } from "../../../data/logbook";
|
||||
@@ -72,6 +79,9 @@ export class HaScriptTrace extends LitElement {
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state()
|
||||
@consume({ context: manifestsContext, subscribe: true })
|
||||
_manifests?: DomainManifestLookup;
|
||||
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityRegistry?: EntityRegistryEntry[];
|
||||
|
||||
@@ -93,6 +103,17 @@ export class HaScriptTrace extends LitElement {
|
||||
|
||||
@query("hat-script-graph") private _graph?: HatScriptGraph;
|
||||
|
||||
/**
|
||||
* `hass` is replaced on every state update, so comparing it would rebuild
|
||||
* every label on every state event. The run already happened, so only the
|
||||
* trace and the registries the descriptions read can change the result.
|
||||
*/
|
||||
private _traceLabels = memoizeOne(
|
||||
buildTraceLabels,
|
||||
([trace, , entities, manifests], [pTrace, , pEntities, pManifests]) =>
|
||||
trace === pTrace && entities === pEntities && manifests === pManifests
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const stateObj = this._entityId
|
||||
? this.hass.states[this._entityId]
|
||||
@@ -257,6 +278,16 @@ export class HaScriptTrace extends LitElement {
|
||||
<hat-script-graph
|
||||
.trace=${this._trace}
|
||||
.selected=${this._selected?.path}
|
||||
.labels=${
|
||||
this._trace
|
||||
? this._traceLabels(
|
||||
this._trace,
|
||||
this.hass,
|
||||
this._entityRegistry,
|
||||
this._manifests
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
@graph-node-selected=${this._pickNode}
|
||||
></hat-script-graph>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { clearStatistics, getStatisticLabel } from "../../../../data/recorder";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
|
||||
import type { DialogStatisticsFixParams } from "./show-dialog-statistics-fix";
|
||||
|
||||
@customElement("dialog-statistics-fix")
|
||||
|
||||
@@ -13,14 +13,7 @@ import {
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import {
|
||||
css,
|
||||
type CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
nothing,
|
||||
type PropertyValues,
|
||||
} from "lit";
|
||||
import { css, type CSSResultGroup, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type {
|
||||
@@ -74,7 +67,7 @@ import { getAreaTableColumn } from "../../common/data-table-columns";
|
||||
import { KeyboardShortcutMixin } from "../../../../mixins/keyboard-shortcut-mixin";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistantRegistries } from "../../../../types";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { showConfirmationDialog } from "../../../lovelace/custom-card-helpers";
|
||||
import { fixStatisticsIssue } from "./fix-statistics";
|
||||
import { showStatisticsAdjustSumDialog } from "./show-dialog-statistics-adjust-sum";
|
||||
|
||||
@@ -115,8 +108,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
@state() private _data: StatisticData[] = [] as StatisticsMetaData[];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@state() private filter = "";
|
||||
|
||||
@state() private _selected: string[] = [];
|
||||
@@ -155,12 +146,8 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
@query("ha-input-search") private _searchInput!: HaInputSearch;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._validateStatistics();
|
||||
}
|
||||
protected firstUpdated() {
|
||||
this._validateStatistics();
|
||||
}
|
||||
|
||||
private _displayData = memoizeOne(
|
||||
@@ -567,7 +554,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
<ha-data-table
|
||||
.narrow=${this.narrow}
|
||||
.loading=${this._loading}
|
||||
.columns=${columns}
|
||||
.data=${this._displayData(
|
||||
this._data,
|
||||
@@ -736,42 +722,38 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
private async _validateStatistics() {
|
||||
try {
|
||||
const [statisticIds, issues] = await Promise.all([
|
||||
getStatisticIds(this._api),
|
||||
validateStatistics(this._api),
|
||||
]);
|
||||
const [statisticIds, issues] = await Promise.all([
|
||||
getStatisticIds(this._api),
|
||||
validateStatistics(this._api),
|
||||
]);
|
||||
|
||||
updateStatisticsIssues(this._api);
|
||||
updateStatisticsIssues(this._api);
|
||||
|
||||
const statsIds = new Set();
|
||||
const statsIds = new Set();
|
||||
|
||||
this._data = statisticIds.map((statistic) => {
|
||||
statsIds.add(statistic.statistic_id);
|
||||
return {
|
||||
...statistic,
|
||||
state: this._states[statistic.statistic_id],
|
||||
issues: issues[statistic.statistic_id],
|
||||
};
|
||||
});
|
||||
this._data = statisticIds.map((statistic) => {
|
||||
statsIds.add(statistic.statistic_id);
|
||||
return {
|
||||
...statistic,
|
||||
state: this._states[statistic.statistic_id],
|
||||
issues: issues[statistic.statistic_id],
|
||||
};
|
||||
});
|
||||
|
||||
Object.keys(issues).forEach((statisticId) => {
|
||||
if (!statsIds.has(statisticId)) {
|
||||
this._data.push({
|
||||
statistic_id: statisticId,
|
||||
statistics_unit_of_measurement: "",
|
||||
source: "",
|
||||
state: this._states[statisticId],
|
||||
issues: issues[statisticId],
|
||||
mean_type: StatisticMeanType.NONE,
|
||||
has_sum: false,
|
||||
unit_class: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
Object.keys(issues).forEach((statisticId) => {
|
||||
if (!statsIds.has(statisticId)) {
|
||||
this._data.push({
|
||||
statistic_id: statisticId,
|
||||
statistics_unit_of_measurement: "",
|
||||
source: "",
|
||||
state: this._states[statisticId],
|
||||
issues: issues[statisticId],
|
||||
mean_type: StatisticMeanType.NONE,
|
||||
has_sum: false,
|
||||
unit_class: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _clearSelected = async () => {
|
||||
|
||||
@@ -340,9 +340,6 @@ class HaRefreshTokens extends LitElement {
|
||||
ha-list-item-base {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
[slot="supporting-text"] {
|
||||
white-space: normal;
|
||||
}
|
||||
ha-icon-button {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export const loadVirtualizer = async () => {
|
||||
// The default flow layout is otherwise only fetched once there are items.
|
||||
await Promise.all([
|
||||
import("@lit-labs/virtualizer"),
|
||||
import("@lit-labs/virtualizer/layouts/flow.js"),
|
||||
]);
|
||||
await import("@lit-labs/virtualizer");
|
||||
};
|
||||
|
||||
+16
-27
@@ -3143,7 +3143,7 @@
|
||||
},
|
||||
"rating": {
|
||||
"title": "Security rating",
|
||||
"description": "This shows the security rating of the app on a scale from 1 to {max}. Higher is better."
|
||||
"description": "This shows the security rating of the app. Higher is better."
|
||||
},
|
||||
"host_network": {
|
||||
"title": "Host network",
|
||||
@@ -4719,7 +4719,7 @@
|
||||
"debug": "DEBUG"
|
||||
},
|
||||
"custom_integration": "custom integration",
|
||||
"error_from_custom_integration": "This log entry is from a custom integration.",
|
||||
"error_from_custom_integration": "This error originated from a custom integration.",
|
||||
"show_full_logs": "Show raw logs",
|
||||
"show_condensed_logs": "Show condensed logs",
|
||||
"select_number_of_lines": "Select number of lines to download",
|
||||
@@ -4745,31 +4745,6 @@
|
||||
"startups_ago": "{boot} startups ago",
|
||||
"detail": {
|
||||
"logger": "Logger",
|
||||
"report_issue": {
|
||||
"frontend": {
|
||||
"introduction": "This log entry was reported by a browser (or the Home Assistant app).",
|
||||
"report": "If you think it relates to Home Assistant’s user interface, {report_link}.",
|
||||
"guidance": "Core, custom integrations, or custom Frontend modules may also be involved. See the {guide_link} for further guidance.",
|
||||
"link_text": "report it on the Frontend repository"
|
||||
},
|
||||
"core": {
|
||||
"introduction": "This log entry was reported by Home Assistant Core.",
|
||||
"report": "If you think it indicates a bug, {report_link}.",
|
||||
"guidance": "See the {guide_link} for further guidance.",
|
||||
"link_text": "report it on the Core repository"
|
||||
},
|
||||
"custom": {
|
||||
"introduction": "This log entry is from {integration}, a custom integration.",
|
||||
"report": "If you think it indicates a bug, you can {report_link}.",
|
||||
"link_text": "report it on its issue tracker"
|
||||
},
|
||||
"custom_fallback": {
|
||||
"introduction": "This log entry is from {integration}, a custom integration. Its issue tracker is unavailable.",
|
||||
"report": "If you think it indicates a Home Assistant Core bug, {report_link}.",
|
||||
"guidance": "See the {guide_link} for further guidance."
|
||||
},
|
||||
"guide_link_text": "reporting guide"
|
||||
},
|
||||
"source": "Source",
|
||||
"integration": "[%key:ui::components::related-items::integration%]",
|
||||
"documentation": "documentation",
|
||||
@@ -6215,6 +6190,20 @@
|
||||
"no_traces_found": "No traces found",
|
||||
"trace_no_longer_available": "Chosen trace is no longer available",
|
||||
"enter_downloaded_trace": "Enter downloaded trace",
|
||||
"graph": {
|
||||
"node_label": "{description}, {state}",
|
||||
"state": {
|
||||
"executed": "executed",
|
||||
"not_executed": "not executed",
|
||||
"passed": "passed",
|
||||
"failed": "failed",
|
||||
"passed_and_failed": "passed and failed",
|
||||
"not_triggered": "did not trigger",
|
||||
"error": "error",
|
||||
"disabled": "disabled",
|
||||
"repeated": "repeated {count} times"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"details": "Step details",
|
||||
"timeline": "Trace timeline",
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import type { HomeAssistantConfig } from "../types";
|
||||
|
||||
const DOCUMENTATION_DOMAIN = "home-assistant.io";
|
||||
|
||||
export const DOCUMENTATION_URL = `https://www.${DOCUMENTATION_DOMAIN}`;
|
||||
|
||||
export const documentationUrl = (
|
||||
{ config }: Pick<HomeAssistantConfig, "config">,
|
||||
path: string
|
||||
) => documentationUrlForVersion(config.version, path);
|
||||
|
||||
export const documentationUrlForVersion = (version: string, path: string) =>
|
||||
export const documentationUrl = (hass: HomeAssistantConfig, path: string) =>
|
||||
`https://${
|
||||
version.includes("b") ? "rc" : version.includes("dev") ? "next" : "www"
|
||||
}.${DOCUMENTATION_DOMAIN}${path}`;
|
||||
hass.config.version.includes("b")
|
||||
? "rc"
|
||||
: hass.config.version.includes("dev")
|
||||
? "next"
|
||||
: "www"
|
||||
}.home-assistant.io${path}`;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import type { AutomationTraceExtended } from "../../src/data/trace";
|
||||
import { buildTraceLabels } from "../../src/data/trace-labels";
|
||||
|
||||
// These labels are the only thing a screen reader gets from the graph, which
|
||||
// is pure SVG. The run outcome in particular is otherwise conveyed only by
|
||||
// colour, dashes and badges.
|
||||
const timestamp = "2026-09-17T00:00:00Z";
|
||||
|
||||
/** `localize` echoes key and placeholders, so assertions skip English copy. */
|
||||
const hassStub = () =>
|
||||
({
|
||||
localize: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}(${Object.values(params).join("|")})` : key,
|
||||
locale: { language: "en" },
|
||||
states: {},
|
||||
services: {},
|
||||
entities: {},
|
||||
config: {},
|
||||
formatEntityState: () => "",
|
||||
formatEntityAttributeValue: () => "",
|
||||
}) as unknown as HomeAssistant;
|
||||
|
||||
const createTrace = (
|
||||
config: Record<string, unknown>,
|
||||
trace: AutomationTraceExtended["trace"] = {}
|
||||
): AutomationTraceExtended =>
|
||||
({
|
||||
domain: "automation",
|
||||
item_id: "test",
|
||||
run_id: "test",
|
||||
state: "stopped",
|
||||
script_execution: "finished",
|
||||
last_step: null,
|
||||
timestamp: { start: timestamp, finish: timestamp },
|
||||
context: { id: "test", user_id: null },
|
||||
trigger: "manual",
|
||||
config: {
|
||||
alias: "Test",
|
||||
triggers: [],
|
||||
conditions: [],
|
||||
actions: [],
|
||||
...config,
|
||||
},
|
||||
trace,
|
||||
}) as AutomationTraceExtended;
|
||||
|
||||
const build = (trace: AutomationTraceExtended) =>
|
||||
buildTraceLabels(trace, hassStub(), undefined);
|
||||
|
||||
const step = (path: string, extra: Record<string, unknown> = {}) => ({
|
||||
[path]: [{ path, timestamp, ...extra }],
|
||||
});
|
||||
|
||||
describe("buildTraceLabels", () => {
|
||||
it("says a step ran", () => {
|
||||
const labels = build(
|
||||
createTrace({ actions: [{ delay: 1 }] }, step("action/0") as never)
|
||||
);
|
||||
expect(labels["action/0"]).toContain("state.executed");
|
||||
});
|
||||
|
||||
it("says a step the run never reached did not run", () => {
|
||||
const labels = build(createTrace({ actions: [{ delay: 1 }] }));
|
||||
expect(labels["action/0"]).toContain("state.not_executed");
|
||||
});
|
||||
|
||||
it("prefers disabled over not executed", () => {
|
||||
// A disabled step is never tracked either, so the order of the checks is
|
||||
// what decides which of the two a user hears.
|
||||
const labels = build(
|
||||
createTrace({ actions: [{ delay: 1, enabled: false }] })
|
||||
);
|
||||
expect(labels["action/0"]).toContain("state.disabled");
|
||||
expect(labels["action/0"]).not.toContain("not_executed");
|
||||
});
|
||||
|
||||
it("prefers an error over the tracked state", () => {
|
||||
const labels = build(
|
||||
createTrace(
|
||||
{ actions: [{ delay: 1 }] },
|
||||
step("action/0", { error: "boom" }) as never
|
||||
)
|
||||
);
|
||||
expect(labels["action/0"]).toContain("state.error");
|
||||
});
|
||||
|
||||
it("counts the iterations of a repeat", () => {
|
||||
const labels = build(
|
||||
createTrace(
|
||||
{ actions: [{ repeat: { count: 2, sequence: [{ delay: 1 }] } }] },
|
||||
{
|
||||
// Core records the repeat step itself as well as each iteration.
|
||||
"action/0": [{ path: "action/0", timestamp }],
|
||||
"action/0/repeat/sequence/0": [
|
||||
{ path: "action/0/repeat/sequence/0", timestamp },
|
||||
{ path: "action/0/repeat/sequence/0", timestamp },
|
||||
],
|
||||
} as never
|
||||
)
|
||||
);
|
||||
expect(labels["action/0"]).toContain("state.repeated");
|
||||
});
|
||||
|
||||
describe("conditions", () => {
|
||||
// A condition is tracked whether it passed or failed, so reading `track`
|
||||
// alone would report both as "executed" and hide the outcome that matters.
|
||||
const conditionTrace = (result: boolean) =>
|
||||
createTrace(
|
||||
{ conditions: [{ condition: "state", entity_id: "light.kitchen" }] },
|
||||
{
|
||||
"condition/0": [
|
||||
{ path: "condition/0", timestamp, result: { result } },
|
||||
],
|
||||
} as never
|
||||
);
|
||||
|
||||
it("says a condition passed", () => {
|
||||
expect(build(conditionTrace(true))["condition/0"]).toContain(
|
||||
"state.passed"
|
||||
);
|
||||
});
|
||||
|
||||
it("says a condition failed rather than merely executed", () => {
|
||||
const label = build(conditionTrace(false))["condition/0"];
|
||||
expect(label).toContain("state.failed");
|
||||
expect(label).not.toContain("state.executed");
|
||||
});
|
||||
|
||||
it("reports both outcomes when a repeated condition did each", () => {
|
||||
const labels = build(
|
||||
createTrace(
|
||||
{ conditions: [{ condition: "state", entity_id: "light.kitchen" }] },
|
||||
{
|
||||
"condition/0": [
|
||||
{ path: "condition/0", timestamp, result: { result: true } },
|
||||
{ path: "condition/0", timestamp, result: { result: false } },
|
||||
],
|
||||
} as never
|
||||
)
|
||||
);
|
||||
expect(labels["condition/0"]).toContain("state.passed_and_failed");
|
||||
});
|
||||
|
||||
it("says an unevaluated condition did not run", () => {
|
||||
const labels = build(
|
||||
createTrace({
|
||||
conditions: [{ condition: "state", entity_id: "light.kitchen" }],
|
||||
})
|
||||
);
|
||||
expect(labels["condition/0"]).toContain("state.not_executed");
|
||||
});
|
||||
});
|
||||
|
||||
it("names every node of a nested config, down to the nested steps", () => {
|
||||
const labels = build(
|
||||
createTrace({
|
||||
triggers: [{ trigger: "state", entity_id: "light.kitchen" }],
|
||||
conditions: [{ condition: "state", entity_id: "light.kitchen" }],
|
||||
actions: [
|
||||
{
|
||||
choose: [
|
||||
{ conditions: [], sequence: [{ delay: 1 }] },
|
||||
{ conditions: [], sequence: [{ delay: 2 }] },
|
||||
],
|
||||
},
|
||||
{ if: [], then: [{ delay: 1 }], else: [{ delay: 2 }] },
|
||||
{ parallel: [{ sequence: [{ delay: 1 }] }] },
|
||||
{ repeat: { count: 2, sequence: [{ delay: 1 }] } },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
for (const path of [
|
||||
"trigger/0",
|
||||
"condition/0",
|
||||
// The building blocks themselves...
|
||||
"action/0",
|
||||
"action/0/choose/0",
|
||||
"action/0/choose/1",
|
||||
"action/1",
|
||||
"action/2",
|
||||
"action/3",
|
||||
// ...and the steps nested inside each branch, which only the recursion
|
||||
// in `addAction` reaches.
|
||||
"action/0/choose/0/sequence/0",
|
||||
"action/0/choose/1/sequence/0",
|
||||
"action/1/then/0",
|
||||
"action/1/else/0",
|
||||
"action/2/parallel/0/sequence/0",
|
||||
"action/3/repeat/sequence/0",
|
||||
]) {
|
||||
expect(labels[path], path).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("works without an entity registry, which loads lazily", () => {
|
||||
const labels = build(
|
||||
createTrace({
|
||||
triggers: [{ trigger: "state", entity_id: "light.kitchen" }],
|
||||
})
|
||||
);
|
||||
expect(labels["trigger/0"]).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,6 @@
|
||||
* yarn test:e2e:app
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { load } from "js-yaml";
|
||||
import {
|
||||
appSidebar,
|
||||
appSidebarConfig,
|
||||
@@ -17,7 +15,6 @@ import {
|
||||
ensureAppSidebarPanelVisible,
|
||||
goToPanel,
|
||||
openMoreInfoDialog,
|
||||
openSystemLogDetail,
|
||||
} from "./app/src/helpers";
|
||||
import {
|
||||
expectNoPageErrors,
|
||||
@@ -166,221 +163,6 @@ test.describe("Quick search", () => {
|
||||
|
||||
defineRouteSmokeTests(appRouteSmokeGroups);
|
||||
|
||||
test.describe("System log reporting", () => {
|
||||
let errors: ReturnType<typeof trackPageErrors>;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors = trackPageErrors(page);
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: async (text: string) => {
|
||||
document.body.dataset.copiedReport = text;
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
expectNoPageErrors(errors);
|
||||
});
|
||||
|
||||
test("opens the frontend form directly with the selected log details", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const requests: string[] = [];
|
||||
await context.route("https://github.com/**", async (route) => {
|
||||
requests.push(route.request().url());
|
||||
await route.fulfill({
|
||||
contentType: "text/html",
|
||||
body: "Issue form fixture",
|
||||
});
|
||||
});
|
||||
|
||||
const dialog = await openSystemLogDetail(
|
||||
page,
|
||||
"TypeError: Report fixture failed"
|
||||
);
|
||||
|
||||
const reportLink = dialog.getByRole("link", {
|
||||
name: "report it on the Frontend repository",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
|
||||
await expect(
|
||||
dialog.getByRole("link", { name: "reporting guide", exact: true })
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://www.home-assistant.io/help/reporting_issues/"
|
||||
);
|
||||
|
||||
const popup = context.waitForEvent("page");
|
||||
await reportLink.click();
|
||||
await (await popup).waitForLoadState();
|
||||
expect(requests).toHaveLength(1);
|
||||
const submitted = new URL(requests[0]);
|
||||
expect(submitted.pathname).toBe("/home-assistant/frontend/issues/new");
|
||||
expect(Object.fromEntries(submitted.searchParams)).toEqual({
|
||||
template: "bug_report.yml",
|
||||
core_version: expect.any(String),
|
||||
javascript_errors:
|
||||
"frontend.js.modern.202609180\n\ncomponents/system_log/__init__.py:350\n\nUncaught error from Firefox 140.0 on Linux\nTypeError: Report fixture failed\nrender@src/example.ts:10:2\n\nAnother occurrence: café & ? # %\n```",
|
||||
});
|
||||
|
||||
const form = load(
|
||||
readFileSync(".github/ISSUE_TEMPLATE/bug_report.yml", "utf8")
|
||||
);
|
||||
|
||||
for (const id of ["core_version", "javascript_errors"]) {
|
||||
expect(form).toEqual(
|
||||
expect.objectContaining({
|
||||
body: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id,
|
||||
type: expect.stringMatching(/^(input|textarea)$/),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await expect(dialog.locator(".contents")).toBeVisible();
|
||||
});
|
||||
|
||||
for (const { message, destination, params } of [
|
||||
...[
|
||||
"Built-in integration error",
|
||||
"Error with relative integration source",
|
||||
].map((logMessage) => ({
|
||||
message: logMessage,
|
||||
destination: "https://github.com/home-assistant/core/issues/new",
|
||||
params: {
|
||||
template: "bug_report.yml",
|
||||
version: expect.any(String),
|
||||
installation_type: "Home Assistant OS",
|
||||
logs: expect.stringContaining("ValueError:"),
|
||||
integration_name: "Philips Hue",
|
||||
integration_link: "https://www.home-assistant.io/integrations/hue/",
|
||||
},
|
||||
})),
|
||||
{
|
||||
message: "General Core error",
|
||||
destination: "https://github.com/home-assistant/core/issues/new",
|
||||
params: {
|
||||
template: "bug_report.yml",
|
||||
version: expect.any(String),
|
||||
installation_type: "Home Assistant OS",
|
||||
logs: expect.stringContaining("RuntimeError: Test error"),
|
||||
},
|
||||
},
|
||||
...["Custom integration error", "Custom override error"].map(
|
||||
(logMessage) => ({
|
||||
message: logMessage,
|
||||
destination: "https://example.com/issues",
|
||||
params: { project: "example" },
|
||||
})
|
||||
),
|
||||
]) {
|
||||
test(`routes ${message} to its issue tracker`, async ({ page }) => {
|
||||
const dialog = await openSystemLogDetail(page, message);
|
||||
|
||||
const link = dialog.getByRole("link", {
|
||||
name: /^report /i,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await expect(link).toHaveAttribute("target", "_blank");
|
||||
|
||||
await expect(async () => {
|
||||
const url = new URL((await link.getAttribute("href")) ?? "");
|
||||
|
||||
expect(`${url.origin}${url.pathname}`).toBe(destination);
|
||||
expect(Object.fromEntries(url.searchParams)).toEqual(params);
|
||||
|
||||
if ("logs" in params) {
|
||||
expect(url.searchParams.get("logs")).toContain(message);
|
||||
}
|
||||
}).toPass({ timeout: QUICK_TIMEOUT });
|
||||
});
|
||||
}
|
||||
|
||||
for (const message of [
|
||||
"Missing tracker error",
|
||||
"Unsafe tracker error",
|
||||
"Failed manifest error",
|
||||
]) {
|
||||
test(`falls back to the base template for ${message}`, async ({ page }) => {
|
||||
const dialog = await openSystemLogDetail(page, message);
|
||||
await expect(
|
||||
dialog.getByRole("link", {
|
||||
name: /^report /i,
|
||||
exact: true,
|
||||
})
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/home-assistant/core/issues/new?template=bug_report.yml"
|
||||
);
|
||||
await dialog.locator("#copy").click();
|
||||
await expect(page.locator("body")).toHaveAttribute(
|
||||
"data-copied-report",
|
||||
new RegExp(message)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("ignores a manifest response after selecting another entry", async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToPanel(page, "/?scenario=system-log-reporting#/config/logs");
|
||||
await page
|
||||
.locator("system-log-card ha-list-item")
|
||||
.filter({ hasText: "Delayed manifest error" })
|
||||
.click();
|
||||
const detail = page.locator("dialog-system-log-detail");
|
||||
await expect(
|
||||
detail.getByRole("link", {
|
||||
name: /^report /i,
|
||||
exact: true,
|
||||
})
|
||||
).not.toBeAttached();
|
||||
await detail.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(detail.locator("ha-dialog")).not.toBeAttached();
|
||||
await page
|
||||
.locator("system-log-card ha-list-item")
|
||||
.filter({ hasText: "Built-in integration error" })
|
||||
.click();
|
||||
await page.evaluate(() => window.resolveReportManifest?.());
|
||||
await expect(
|
||||
detail.getByRole("link", {
|
||||
name: /^report /i,
|
||||
exact: true,
|
||||
})
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
/\/home-assistant\/core\/issues\/new\?.*integration_name=Philips\+Hue/
|
||||
);
|
||||
});
|
||||
|
||||
test("waits for the manifest before offering a report destination", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openSystemLogDetail(page, "Delayed manifest error");
|
||||
const link = dialog.getByRole("link", { name: /^report /i, exact: true });
|
||||
|
||||
await expect(link).not.toBeAttached();
|
||||
await page.evaluate(() => window.resolveReportManifest?.());
|
||||
await expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://example.com/delayed/issues"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the launch screen until initial panel content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -27,19 +27,6 @@ export async function goToPanel(page: Page, path: string) {
|
||||
]);
|
||||
}
|
||||
|
||||
export async function openSystemLogDetail(page: Page, message: string) {
|
||||
await goToPanel(page, "/?scenario=system-log-reporting#/config/logs");
|
||||
await page
|
||||
.locator("system-log-card ha-list-item")
|
||||
.filter({ hasText: message })
|
||||
.click();
|
||||
const dialog = page.locator("dialog-system-log-detail");
|
||||
|
||||
await expect(dialog.locator(".contents")).toContainText(message);
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
// The hass-more-info event is one-shot: if it lands before the shell's
|
||||
// listener is attached it is silently dropped. Re-dispatching is idempotent
|
||||
// (showDialog just resets the dialog to the requested view), so poll the
|
||||
|
||||
@@ -264,8 +264,7 @@ export async function openOnboarding(page: Page, baseURL: string) {
|
||||
|
||||
export async function createOwner(page: Page) {
|
||||
await page
|
||||
.locator("onboarding-welcome")
|
||||
.getByRole("button", { name: "Create my smart home", exact: true })
|
||||
.locator("onboarding-welcome ha-button.start")
|
||||
.click({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
const inputs = page.locator("onboarding-create-user ha-input >> input");
|
||||
|
||||
@@ -17,15 +17,6 @@ import {
|
||||
type ForecastEvent,
|
||||
} from "../../../../../src/data/weather";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import type { LoggedError } from "../../../../../src/data/system_log";
|
||||
import type { IntegrationManifest } from "../../../../../src/data/integration";
|
||||
import { manifest } from "../../../../../demo/src/stubs/manifest";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
resolveReportManifest?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
|
||||
|
||||
@@ -371,127 +362,6 @@ const delayedMediaBrowseErrorScenario: Scenario = (hass) => {
|
||||
hass.mockWS("media_source/browse_media", () => browsePromise);
|
||||
};
|
||||
|
||||
const systemLogReportingScenario: Scenario = async (hass) => {
|
||||
await hass.loadFragmentTranslation("config");
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...hass.config.components, "system_health"],
|
||||
},
|
||||
});
|
||||
|
||||
const entries: Pick<
|
||||
LoggedError,
|
||||
"name" | "message" | "source" | "exception"
|
||||
>[] = [
|
||||
{
|
||||
name: "frontend.js.modern.202609180",
|
||||
message: [
|
||||
"Uncaught error from Firefox 140.0 on Linux\nTypeError: Report fixture failed\nrender@src/example.ts:10:2",
|
||||
"Another occurrence: café & ? # %\n```",
|
||||
],
|
||||
source: ["components/system_log/__init__.py", 350],
|
||||
exception: "",
|
||||
},
|
||||
{
|
||||
name: "homeassistant.components.hue",
|
||||
message: ["Built-in integration error"],
|
||||
source: ["components/hue/light.py", 20],
|
||||
exception:
|
||||
"Traceback (most recent call last):\nValueError: Invalid light",
|
||||
},
|
||||
{
|
||||
name: "homeassistant.core",
|
||||
message: ["General Core error"],
|
||||
source: ["core.py", 50],
|
||||
exception: "RuntimeError: Test error",
|
||||
},
|
||||
{
|
||||
name: "third_party_library",
|
||||
message: ["Error with relative integration source"],
|
||||
source: ["components/hue/light.py", 21],
|
||||
exception: "ValueError: Invalid response",
|
||||
},
|
||||
{
|
||||
name: "custom_components.example",
|
||||
message: ["Custom integration error"],
|
||||
source: ["custom_components/example/sensor.py", 30],
|
||||
exception: "ValueError: Custom fixture",
|
||||
},
|
||||
...[
|
||||
{ domain: "no_tracker", message: "Missing tracker error" },
|
||||
{ domain: "unsafe_tracker", message: "Unsafe tracker error" },
|
||||
{ domain: "delayed_manifest", message: "Delayed manifest error" },
|
||||
].map(
|
||||
({
|
||||
domain,
|
||||
message,
|
||||
}): Pick<LoggedError, "name" | "message" | "source" | "exception"> => ({
|
||||
name: `custom_components.${domain}`,
|
||||
message: [message],
|
||||
source: [`custom_components/${domain}/sensor.py`, 30],
|
||||
exception: "",
|
||||
})
|
||||
),
|
||||
{
|
||||
name: "homeassistant.components.overridden",
|
||||
message: ["Custom override error"],
|
||||
source: ["components/overridden/sensor.py", 30],
|
||||
exception: "",
|
||||
},
|
||||
{
|
||||
name: "homeassistant.components.failed_manifest",
|
||||
message: ["Failed manifest error"],
|
||||
source: ["components/failed_manifest/sensor.py", 30],
|
||||
exception: "",
|
||||
},
|
||||
];
|
||||
|
||||
hass.mockWS("system_log/list", () =>
|
||||
entries.map((entry): LoggedError => ({
|
||||
...entry,
|
||||
level: "error",
|
||||
count: 1,
|
||||
timestamp: 1789760000,
|
||||
first_occurred: 1789760000,
|
||||
}))
|
||||
);
|
||||
hass.mockWS("manifest/get", ({ integration }: { integration: string }) => {
|
||||
switch (integration) {
|
||||
case "failed_manifest":
|
||||
return Promise.reject(new Error("Manifest unavailable"));
|
||||
case "delayed_manifest":
|
||||
return new Promise<IntegrationManifest>((resolve) => {
|
||||
window.resolveReportManifest = () =>
|
||||
resolve(
|
||||
manifest(integration, "Delayed integration", {
|
||||
is_built_in: false,
|
||||
issue_tracker: "https://example.com/delayed/issues",
|
||||
})
|
||||
);
|
||||
});
|
||||
case "example":
|
||||
case "overridden":
|
||||
return manifest(integration, "Custom example", {
|
||||
is_built_in: false,
|
||||
issue_tracker: "https://example.com/issues?project=example",
|
||||
});
|
||||
case "no_tracker":
|
||||
return manifest(integration, "Missing tracker", { is_built_in: false });
|
||||
case "unsafe_tracker":
|
||||
return manifest(integration, "Unsafe tracker", {
|
||||
is_built_in: false,
|
||||
issue_tracker: "data:text/html,unsafe",
|
||||
});
|
||||
default:
|
||||
return manifest(
|
||||
integration,
|
||||
integration === "hue" ? "Philips Hue" : integration
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const scenarios: Record<string, Scenario> = {
|
||||
@@ -511,5 +381,4 @@ export const scenarios: Record<string, Scenario> = {
|
||||
"weather-more-info": weatherMoreInfoScenario,
|
||||
"quick-search-assist": quickSearchAssistScenario,
|
||||
"delayed-lovelace": delayedLovelaceScenario,
|
||||
"system-log-reporting": systemLogReportingScenario,
|
||||
};
|
||||
|
||||
@@ -11443,13 +11443,13 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:^4.1.0":
|
||||
version: 4.3.2
|
||||
resolution: "js-yaml@npm:4.3.2"
|
||||
version: 4.3.1
|
||||
resolution: "js-yaml@npm:4.3.1"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.js
|
||||
checksum: 10/05c44b9c73e4901d92703b155e76518df64bf01ac62e4c036b47de4b391e19b72e32656e8954d51b436307f08cc9d0c0d4ec617d061cf2f65fffee9f3114bee7
|
||||
checksum: 10/2ce71b5d632abbd77da80447bf860e8a0264e54bffe94840984887d58b023761495b523727547904517a6107a1ef189854b361e0fc44995ee13a84f222d7bd42
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user