Compare commits

..
Author SHA1 Message Date
WendelinandClaude Opus 5.5 af4959b57a Use WA accessible-label for controls without a visible label (#55104)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-20 12:06:22 +00:00
CarcaBotandCarcaBot 00ef6bf8b3 Filter long media browser folders locally when the source cannot search (#54969)
* Filter long media browser folders locally when the source cannot search

Media sources that do not implement search, such as a TV's channel list,
offered no way to narrow a long folder: the Sony Bravia integration lists
over two hundred channels and the only option was to scroll. Folders with
ten or more items from such a source now get a filter field that narrows
the already loaded items by title, ignoring case and accents, with its own
empty state when nothing matches. Sources that can search keep the
existing search row unchanged.

* Reset scroll on filter input, count accepted children, rename guard

Typing a filter while scrolled down left lit-virtualizer rendering its old
range against the shorter list, which threw in grid view and landed on the
last matches instead of the first. Scroll both the virtualizer and the
content to the top on every filter input.

Count children after the accept filter so a picker does not show the
filter on a folder that displays fewer than FILTER_MIN_CHILDREN items.

Rename isBrowsable to allowsSearchOrFilter and fix the comments.

---------

Co-authored-by: CarcaBot <[email protected]>
2026-10-20 11:32:02 +00:00
Aidan Timson b94a4f947c Show context around log search matches and open app update logs filtered to the app (#55097)
* Show context and expandable hidden lines in log search

* Open Supervisor logs filtered to the app after a failed update

* Keep hidden line buttons and focus as new log lines arrive

* Relabel log buttons and check a snapshot for dialog no-match

* Mute context line text instead of fading coloured output

* Keep the logs dialog's log area visible on short screens
2026-10-20 14:21:54 +03:00
35 changed files with 563 additions and 379 deletions

No files matched your search

@@ -3,7 +3,7 @@ import type {
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import { mainWindow } from "../dom/get_main_window";
import { replaceCurrentUrl, type NavigateOptions } from "../navigate";
import { replaceCurrentUrl } from "../navigate";
import { constructUrlCurrentPath } from "../url/construct-url";
import { currentPath } from "../url/current-path";
import {
@@ -30,7 +30,7 @@ export interface QueryParamsControllerOptions {
* shared, bookmarked and reloaded. Writes replace the current history entry,
* so Back leaves the page instead of undoing each change, and only touch the
* config's params. The controller only acts while the URL is still the
* page the host was connected on, or the default page its router opened.
* page the host was connected on.
*/
export class QueryParamsController<
C extends QueryParamConfig,
@@ -120,16 +120,8 @@ export class QueryParamsController<
return this._options.enabled() && currentPath() === this._path;
}
private _navigated = (ev?: Event & { detail?: NavigateOptions }) => {
private _navigated = () => {
this._cancelPending();
// A router opening its default page, like /config/devices going to
// /config/devices/dashboard, replaces the URL after the host connected
const path = currentPath();
if (ev?.detail?.replace && path.startsWith(`${this._path}/`)) {
this._path = path;
}
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
-99
View File
@@ -1,99 +0,0 @@
import type {
DataTableFilter,
DataTableFilters,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// The filters of the devices table. Search, sorting and grouping are kept by
// the data table, see data-table-query-params.
export const devicesFilterQueryParamConfig = {
list: [
"status",
"floor",
"area",
"domain",
"label",
"config_entry",
"sub_entry",
],
} as const satisfies QueryParamConfig;
export type DevicesFilterQueryParams = QueryParamValues<
typeof devicesFilterQueryParamConfig
>;
const list = (filter: DataTableFilter | undefined): string[] | undefined =>
Array.isArray(filter?.value) ? filter.value : undefined;
const floorAreas = (
filter: DataTableFilter | undefined
): Record<string, string[]> | undefined =>
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
export const devicesFilterQueryParams = (
filters: DataTableFilters
): DevicesFilterQueryParams => ({
status: list(filters["ha-filter-states"]),
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
domain: list(filters["ha-filter-integrations"]),
label: list(filters["ha-filter-labels"]),
config_entry: list(filters.config_entry),
sub_entry: list(filters.sub_entry),
});
// What a link says to show, undefined when it has no table params so what was
// picked before stays. A link replaces the search and the filters, ones left
// behind would hide part of what it shows. The table applies its sorting and
// grouping. The filters work out their items from the values.
export interface DevicesLink {
search: string;
filters: DataTableFilters;
}
export const devicesLinkFromUrl = (
searchParams: SearchParamsSource
): DevicesLink | undefined => {
if (
!hasQueryParams(searchParams, devicesFilterQueryParamConfig) &&
!hasQueryParams(searchParams, dataTableQueryParamConfig)
) {
return undefined;
}
const params = decodeQueryParams(searchParams, devicesFilterQueryParamConfig);
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof DevicesFilterQueryParams): string[] =>
params[key]?.filter(Boolean) ?? [];
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
value,
items: undefined,
});
const floors = values("floor");
const areas = values("area");
return {
search: search ?? "",
filters: {
"ha-filter-states": filter(values("status")),
"ha-filter-floor-areas": filter(
floors.length || areas.length ? { floors, areas } : undefined
),
"ha-filter-integrations": filter(values("domain")),
"ha-filter-labels": filter(values("label")),
config_entry: filter(values("config_entry")),
sub_entry: filter(values("sub_entry")),
},
};
};
+20 -3
View File
@@ -5,6 +5,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../../common/decorators/consume";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { STATES_OFF } from "../../common/const";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { apiContext, formattersContext } from "../../data/context";
@@ -13,6 +14,7 @@ import { forwardHaptic } from "../../data/haptics";
import "../ha-icon-button";
import "../ha-switch";
import { getToggleAction } from "../../common/entity/get_toggle_action";
import type { LocalizeFunc } from "../../common/translations/localize";
const isOn = (stateObj?: HassEntity) =>
stateObj !== undefined &&
@@ -37,6 +39,10 @@ export class HaEntityToggle extends LitElement {
@consume({ context: formattersContext, subscribe: true })
private _formatters?: ContextType<typeof formattersContext>;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public stateObj?: HassEntity;
@property() public label?: string;
@@ -49,6 +55,14 @@ export class HaEntityToggle extends LitElement {
}
const name = this._formatters?.formatEntityName(this.stateObj) ?? "";
const turnOnLabel = this._localize(
"ui.components.entity.entity-toggle.turn_on",
{ name }
);
const turnOffLabel = this._localize(
"ui.components.entity.entity-toggle.turn_off",
{ name }
);
if (
this.stateObj.attributes.assumed_state ||
@@ -56,7 +70,7 @@ export class HaEntityToggle extends LitElement {
) {
return html`
<ha-icon-button
.label=${`Turn ${name} off`}
.label=${turnOffLabel}
.path=${mdiFlashOff}
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._turnOff}
@@ -65,7 +79,7 @@ export class HaEntityToggle extends LitElement {
}
></ha-icon-button>
<ha-icon-button
.label=${`Turn ${name} on`}
.label=${turnOnLabel}
.path=${mdiFlash}
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._turnOn}
@@ -75,7 +89,10 @@ export class HaEntityToggle extends LitElement {
}
return html`<ha-switch
aria-label=${`Toggle ${name} ${this._isOn ? "off" : "on"}`}
.accessibleLabel=${this._localize(
"ui.components.entity.entity-toggle.toggle",
{ name }
)}
.checked=${this._isOn}
.disabled=${this.stateObj.state === UNAVAILABLE}
@change=${this._toggleChanged}
+176 -8
View File
@@ -1,3 +1,4 @@
import { mdiUnfoldMoreHorizontal } from "@mdi/js";
import {
css,
html,
@@ -13,6 +14,10 @@ import {
state as litState,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "./ha-button";
import "./ha-svg-icon";
const LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/;
interface State {
bold: boolean;
@@ -30,12 +35,27 @@ export class HaAnsiToHtml extends LitElement {
@property({ type: Boolean, attribute: "wrap-disabled" }) public wrapDisabled =
false;
// Lines to show before and after each line that matches the filter.
@property({ type: Number, attribute: "context-lines" }) public contextLines =
0;
// Text for the button that shows a run of hidden lines between matches.
@property({ attribute: false }) public expandLabel?: (
count: number
) => string;
@query("pre") private _pre?: HTMLPreElement;
@queryAll("div") private _divs!: NodeListOf<HTMLDivElement>;
@litState() private _filter = "";
// Hidden lines the user chose to show while a filter is set.
private _expandedLines = new WeakSet<HTMLDivElement>();
// The hidden lines each expand row shows when clicked.
private _expandGaps = new WeakMap<HTMLElement, HTMLDivElement[]>();
protected render(): TemplateResult {
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
}
@@ -57,6 +77,15 @@ export class HaAnsiToHtml extends LitElement {
white-space: pre-wrap;
overflow-wrap: break-word;
}
.context {
color: var(--secondary-text-color);
}
.expand {
display: block;
margin: var(--ha-space-1) 0;
font-family: var(--ha-font-family-body);
white-space: normal;
}
.bold {
font-weight: var(--ha-font-weight-bold);
}
@@ -119,8 +148,7 @@ export class HaAnsiToHtml extends LitElement {
}
::highlight(search-results) {
background-color: var(--primary-color);
color: var(--text-primary-color);
background-color: var(--ha-color-fill-primary-normal-resting);
}
`;
@@ -322,12 +350,16 @@ export class HaAnsiToHtml extends LitElement {
* @returns true if there are lines to display
*/
filterLines(filter: string): boolean {
if (filter !== this._filter) {
this._expandedLines = new WeakSet();
}
this._filter = filter;
const lines = this._divs;
let numberOfFoundLines = 0;
if (!filter) {
lines.forEach((line) => {
line.style.display = "";
line.classList.remove("context");
});
numberOfFoundLines = lines.length;
if (CSS.highlights) {
@@ -335,9 +367,59 @@ export class HaAnsiToHtml extends LitElement {
}
} else {
const highlightRanges: Range[] = [];
lines.forEach((line) => {
if (!line.textContent?.toLowerCase().includes(filter.toLowerCase())) {
const lineList = Array.from(lines);
const matches = lineList.map(
(line) =>
line.textContent?.toLowerCase().includes(filter.toLowerCase()) ??
false
);
// A line without a timestamp continues the entry above it, such as a
// build's output, so a matching entry shows all of its lines.
let entryStart = -1;
const entryOf = lineList.map((line, lineIndex) => {
if (LINE_TIMESTAMP.test(line.textContent ?? "")) {
entryStart = lineIndex;
}
return entryStart === -1 ? lineIndex : entryStart;
});
const matchedEntries = new Set(
entryOf.filter((_entry, lineIndex) => matches[lineIndex])
);
const inMatch = entryOf.map(
(entry, lineIndex) =>
matches[lineIndex] ||
(this.contextLines > 0 && matchedEntries.has(entry))
);
// Like a diff, also show the lines around each match.
const visible = inMatch.map(
(_match, lineIndex) =>
this._expandedLines.has(lineList[lineIndex]) ||
inMatch
.slice(
Math.max(0, lineIndex - this.contextLines),
lineIndex + this.contextLines + 1
)
.some(Boolean)
);
lineList.forEach((line, lineIndex) => {
line.classList.toggle(
"context",
visible[lineIndex] && !inMatch[lineIndex]
);
if (!visible[lineIndex]) {
line.style.display = "none";
} else if (!matches[lineIndex]) {
line.style.display = "";
} else {
line.style.display = "";
numberOfFoundLines++;
@@ -367,18 +449,104 @@ export class HaAnsiToHtml extends LitElement {
if (CSS.highlights) {
CSS.highlights.set("search-results", new Highlight(...highlightRanges));
}
if (this.contextLines && numberOfFoundLines) {
this._updateExpandButtons(lineList, visible);
return true;
}
}
this._updateExpandButtons([], []);
return !!numberOfFoundLines;
}
// Filtering runs again for every new log line, so rows for unchanged gaps
// are kept rather than recreated, which would drop keyboard focus.
private _updateExpandButtons(lines: HTMLDivElement[], visible: boolean[]) {
const rows = new Map<Element | null, HTMLSpanElement>();
this._pre?.querySelectorAll(".expand").forEach((row) => {
if (row instanceof HTMLSpanElement) {
rows.set(row.nextElementSibling, row);
}
});
let hidden: HTMLDivElement[] = [];
lines.forEach((line, lineIndex) => {
if (!visible[lineIndex]) {
hidden.push(line);
}
if (
hidden.length &&
(visible[lineIndex] || lineIndex === lines.length - 1)
) {
const label = this.expandLabel?.(hidden.length) ?? `${hidden.length}`;
let row = rows.get(hidden[0]);
if (row) {
rows.delete(hidden[0]);
const text = row.querySelector("ha-button")?.lastChild;
if (text instanceof Text && text.data !== label) {
text.data = label;
}
} else {
row = this._createExpandRow(label);
hidden[0].before(row);
}
this._expandGaps.set(row, hidden);
hidden = [];
}
});
rows.forEach((row) => row.remove());
}
private _createExpandRow(label: string): HTMLSpanElement {
const row = document.createElement("span");
row.className = "expand";
const button = document.createElement("ha-button");
button.setAttribute("appearance", "filled");
button.setAttribute("variant", "neutral");
button.setAttribute("size", "s");
const icon = document.createElement("ha-svg-icon");
icon.slot = "start";
icon.path = mdiUnfoldMoreHorizontal;
button.append(icon, label);
button.addEventListener("click", () => {
const gap = this._expandGaps.get(row) ?? [];
const hadFocus = row.matches(":focus-within");
gap.forEach((hiddenLine) => this._expandedLines.add(hiddenLine));
this.filterLines(this._filter);
// The row is gone once its lines show, so keep focus on the first of them.
if (hadFocus && gap[0]) {
gap[0].tabIndex = -1;
gap[0].focus({ preventScroll: true });
}
});
row.append(button);
return row;
}
public trimLines(maxLines: number) {
const pre = this._pre;
if (!pre) {
const lines = this._divs;
if (lines.length <= maxLines) {
return;
}
while (pre.childElementCount > maxLines && pre.firstElementChild) {
pre.firstElementChild.remove();
Array.from(lines)
.slice(0, lines.length - maxLines)
.forEach((line) => line.remove());
if (this._filter) {
this.filterLines(this._filter);
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ export class HaIconButton extends LitElement {
<ha-button
appearance="plain"
variant="neutral"
.ariaLabel=${this.label ?? null}
.accessibleLabel=${this.label ?? ""}
.title=${this.hideTitle ? "" : (this.label ?? "")}
aria-haspopup=${ifDefined(this.ariaHasPopup)}
.disabled=${this.disabled}
+5
View File
@@ -126,6 +126,10 @@ export class HaInput extends WaInputMixin(LitElement) {
@property({ type: Boolean, attribute: "inset-label" })
public insetLabel = false;
/** Accessible name for the input when it has no visible label. */
@property({ attribute: "accessible-label" })
public accessibleLabel?: string;
/** Sets the native input id (and label `for`) on the inner `wa-input`. */
@property({ attribute: "input-id" })
public inputId?: string;
@@ -276,6 +280,7 @@ export class HaInput extends WaInputMixin(LitElement) {
.name=${this.name}
.disabled=${this.disabled}
.inputId=${this.inputId || "input"}
.accessibleLabel=${this.accessibleLabel ?? ""}
class=${classMap({
input: true,
invalid: this.invalid || this._invalid,
@@ -23,6 +23,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import { getShadowRootHost } from "../../common/dom/get-shadow-root-host";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { slugify } from "../../common/string/slugify";
import { stripDiacritics } from "../../common/string/strip-diacritics";
import { debounce } from "../../common/util/debounce";
import { UNAVAILABLE } from "../../data/entity/entity";
import type {
@@ -94,6 +95,13 @@ export interface MediaPlayerItemId {
type MediaClass = MediaPlayerItem["media_class"];
/** Folders with at least this many items get a filter field when their source cannot search. */
const FILTER_MIN_CHILDREN = 10;
/** Case and accent insensitive, so "stefan" finds "Ștefan" and "DIGI" finds "Digi". */
const normalizeForFilter = (value: string): string =>
stripDiacritics(value).toLowerCase().trim();
const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
can_expand: true,
can_play: false,
@@ -144,6 +152,8 @@ export class HaMediaPlayerBrowse extends LitElement {
@state() private _searchQuery = "";
@state() private _filterQuery = "";
@state() private _searchResults?: MediaPlayerItem[];
@state() private _searching = false;
@@ -233,6 +243,7 @@ export class HaMediaPlayerBrowse extends LitElement {
this._searchQuery = "";
this._searchResults = undefined;
this._mediaClassFilter = [];
this._filterQuery = "";
const currentId = navigateIds[navigateIds.length - 1];
const parentId =
navigateIds.length > 1 ? navigateIds[navigateIds.length - 2] : undefined;
@@ -447,13 +458,24 @@ export class HaMediaPlayerBrowse extends LitElement {
});
}
// Search is available on non-root pages that opt in via can_search, aside
// from the manual-entry and TTS pseudo-sources.
const showSearch =
// Search and filter are available on non-root pages, aside from the
// manual-entry and TTS pseudo-sources.
const allowsSearchOrFilter =
this.navigateIds.length > 1 &&
!isManualMediaSourceContentId(currentItem.media_content_id) &&
!isTTSMediaSource(currentItem.media_content_id) &&
currentItem.can_search;
!isTTSMediaSource(currentItem.media_content_id);
const showSearch = allowsSearchOrFilter && currentItem.can_search;
const showFilter =
allowsSearchOrFilter &&
!currentItem.can_search &&
children.length >= FILTER_MIN_CHILDREN;
const filterQuery = showFilter ? normalizeForFilter(this._filterQuery) : "";
const isFiltering = filterQuery !== "";
if (isFiltering) {
children = children.filter((child) =>
normalizeForFilter(child.title).includes(filterQuery)
);
}
// The backend reports which media classes are worth filtering by for this
// item; without them we still allow searching, just without the filter.
const mediaClassFilterOptions =
@@ -478,7 +500,7 @@ export class HaMediaPlayerBrowse extends LitElement {
return html`
${
canPickCurrent || showSearch
canPickCurrent || showSearch || showFilter
? html`
<div
class="header ${classMap({
@@ -494,7 +516,9 @@ export class HaMediaPlayerBrowse extends LitElement {
currentItem,
mediaClassFilterOptions
)
: nothing
: showFilter
? this._renderFilterRow(currentItem)
: nothing
}
${
canPickCurrent
@@ -617,7 +641,7 @@ export class HaMediaPlayerBrowse extends LitElement {
@tts-picked=${this._ttsPicked}
></ha-browse-media-tts>
`
: !children.length && !notShown
: !children.length && (!notShown || isFiltering)
? html`
<div class="container no-items">
${
@@ -625,25 +649,29 @@ export class HaMediaPlayerBrowse extends LitElement {
? this.hass.localize(
"ui.components.media-browser.search.no_results"
)
: currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
`
: this.hass.localize(
"ui.components.media-browser.no_items"
: isFiltering
? this.hass.localize(
"ui.components.media-browser.filter.no_matches"
)
: currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
`
: this.hass.localize(
"ui.components.media-browser.no_items"
)
}
</div>
`
@@ -773,6 +801,23 @@ export class HaMediaPlayerBrowse extends LitElement {
`;
}
private _renderFilterRow(currentItem: MediaPlayerItem): TemplateResult {
return html`
<div class="search-row">
<ha-input-search
class="search-input"
appearance="outlined"
.value=${this._filterQuery}
.placeholder=${this.hass.localize(
"ui.components.media-browser.filter.filter_placeholder",
{ name: currentItem.title }
)}
@input=${this._handleFilterInput}
></ha-input-search>
</div>
`;
}
private _renderMediaClassFilter(
mediaClassFilterOptions: MediaClass[]
): TemplateResult {
@@ -846,6 +891,12 @@ export class HaMediaPlayerBrowse extends LitElement {
}
}
private _handleFilterInput(ev: InputEvent): void {
this._filterQuery = (ev.target as HaInputSearch).value ?? "";
this._virtualizer?.scrollTo(0, 0);
this._content?.scrollTo(0, 0);
}
private _handleSearchKeydown(ev: KeyboardEvent): void {
if (ev.key === "Enter") {
ev.preventDefault();
@@ -424,7 +424,7 @@ class MoreInfoMediaPlayer extends LitElement {
min="0"
max=${duration}
step="1"
aria-label=${this._localize(
.accessibleLabel=${this._localize(
"ui.card.media_player.track_position"
)}
@change=${this._handleMediaSeekChanged}
@@ -25,6 +25,7 @@ import {
apiContext,
configContext,
connectionContext,
devicesContext,
formattersContext,
internationalizationContext,
} from "../../../data/context";
@@ -53,6 +54,8 @@ import type { HaMoreInfoUpdateBackup } from "../components/update/ha-more-info-u
// Dialogs that reflect their open state to the `open` attribute.
const DIALOG_ELEMENTS = new Set(["ha-adaptive-dialog", "ha-dialog"]);
const NON_APP_HASSIO_DEVICES = new Set(["core", "supervisor", "OS", "host"]);
@customElement("more-info-update")
class MoreInfoUpdate extends LitElement {
@property({ attribute: false }) public stateObj?: UpdateEntity;
@@ -75,6 +78,9 @@ class MoreInfoUpdate extends LitElement {
@consumeEntityRegistryEntry({ entityIdPath: ["stateObj", "entity_id"] })
private _entityEntry?: EntityRegistryDisplayEntry;
@consume({ context: devicesContext, subscribe: true })
private _devices!: ContextType<typeof devicesContext>;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@@ -432,7 +438,9 @@ class MoreInfoUpdate extends LitElement {
// The dialog was closed, or is closing, while the update was installing.
const toastHost = document.querySelector("home-assistant");
if (toastHost) {
if (toastHost instanceof HTMLElement) {
const filter = this._appSlug();
showToast(toastHost, {
message: this._localize(
"ui.dialogs.more_info_control.update.install_failed",
@@ -444,8 +452,7 @@ class MoreInfoUpdate extends LitElement {
text: this._localize(
"ui.dialogs.more_info_control.update.supervisor_logs"
),
action: () =>
this._showSupervisorLogs(toastHost as HTMLElement),
action: () => this._showSupervisorLogs(toastHost, filter),
}
: undefined,
});
@@ -460,17 +467,37 @@ class MoreInfoUpdate extends LitElement {
);
}
// Supervisor names the app by its slug in log lines; other hassio devices aren't apps.
private _appSlug(): string | undefined {
const deviceId = this._entityEntry?.device_id;
if (!deviceId) {
return undefined;
}
const id = this._devices[deviceId]?.identifiers.find(
([domain]) => domain === "hassio"
)?.[1];
if (!id || NON_APP_HASSIO_DEVICES.has(id) || id.startsWith("mount_")) {
return undefined;
}
return id;
}
private _handleShowSupervisorLogs(): void {
this._showSupervisorLogs(this);
this._showSupervisorLogs(this, this._appSlug());
}
// The toast outlives this element, so it opens the dialog from its own host.
private _showSupervisorLogs(host: HTMLElement): void {
private _showSupervisorLogs(host: HTMLElement, filter?: string): void {
showLogsDialog(host, {
provider: "supervisor",
title: this._localize(
"ui.dialogs.more_info_control.update.supervisor_logs"
),
filter,
});
}
+2 -2
View File
@@ -232,7 +232,7 @@ export class HAFullCalendar extends LitElement {
? html`<ha-button
size="s"
class="fab-header"
aria-label=${this.hass.localize(
.accessibleLabel=${this.hass.localize(
"ui.components.calendar.event.add"
)}
@click=${this._createEvent}
@@ -289,7 +289,7 @@ export class HAFullCalendar extends LitElement {
? html`<ha-button
size="s"
class="fab-header"
aria-label=${this.hass.localize(
.accessibleLabel=${this.hass.localize(
"ui.components.calendar.event.add"
)}
@click=${this._createEvent}
@@ -1574,6 +1574,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
showLogsDialog(this, {
provider: "supervisor",
title: this.i18n.localize("ui.panel.config.logs.supervisor_logs"),
filter: this.addon.slug,
});
}
@@ -89,14 +89,12 @@ type AreaQuickLinkKey =
const NAVIGATION_ACTIONS: {
value: string;
path: string;
params?: string;
icon: string;
countKey: AreaQuickLinkKey;
}[] = [
{
value: "navigate-devices",
path: "/config/devices/dashboard",
params: "&status=disabled",
icon: mdiDevices,
countKey: "devices",
},
@@ -854,9 +852,7 @@ class HaConfigAreaPage extends LitElement {
const navAction = NAVIGATION_ACTIONS.find((a) => a.value === action);
if (navAction) {
navigate(
`${navAction.path}?historyBack=1&area=${this.areaId}${navAction.params ?? ""}`
);
navigate(`${navAction.path}?historyBack=1&area=${this.areaId}`);
return;
}
@@ -238,7 +238,7 @@ class DialogImportBlueprint extends DirtyStateProviderMixin<BlueprintImportState
@click=${this._import}
.disabled=${this._importing}
.loading=${this._importing}
.ariaLabel=${this.hass.localize(
.accessibleLabel=${this.hass.localize(
`ui.panel.config.blueprint.add.${this._importing ? "importing" : "import_btn"}`
)}
>
@@ -253,7 +253,7 @@ class DialogImportBlueprint extends DirtyStateProviderMixin<BlueprintImportState
@click=${this._save}
.disabled=${this._saving || !!this._result.validation_errors}
.loading=${this._saving}
.ariaLabel=${this.hass.localize(
.accessibleLabel=${this.hass.localize(
`ui.panel.config.blueprint.add.${this._saving ? "saving" : this._result.exists ? "save_btn_override" : "save_btn"}`
)}
>
@@ -12,7 +12,6 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
import { consume } from "../../../common/decorators/consume";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -25,14 +24,12 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { deepEqual } from "../../../common/util/deep-equal";
import {
devicesFilterQueryParamConfig,
devicesFilterQueryParams,
devicesLinkFromUrl,
} from "../../../common/url/devices-query-params";
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
rejectedItems,
@@ -135,6 +132,8 @@ export class HaConfigDeviceDashboard extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _selected: string[] = [];
private _selectedCanDelete: string[] = [];
@@ -149,7 +148,7 @@ export class HaConfigDeviceDashboard extends LitElement {
state: true,
subscribe: false,
})
private _filter = "";
private _filter: string = getHistoryState()?.filter || "";
@state()
private _filters: DataTableFilters = {};
@@ -197,16 +196,39 @@ export class HaConfigDeviceDashboard extends LitElement {
callback: (entries) => entries[0]?.contentRect.width,
});
// The filters are kept in the URL by the page, the search, sorting and
// grouping by the table
private _filterParams = new QueryParamsController(
this,
devicesFilterQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyLink(),
private _ignoreLocationChange = false;
private _fromUrl = false;
public connectedCallback() {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._popState);
}
private _locationChanged = () => {
if (this._ignoreLocationChange) {
this._ignoreLocationChange = false;
return;
}
);
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _popState = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _states = memoizeOne((localize: LocalizeFunc) => [
{
@@ -219,7 +241,7 @@ export class HaConfigDeviceDashboard extends LitElement {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._filters = this._storageFilters;
this._applyLink();
this._setFiltersFromUrl();
}
if (changedProps.has("_selected") || changedProps.has("entries")) {
this._selectedCanDelete = this._selected.filter((d) => {
@@ -234,38 +256,60 @@ export class HaConfigDeviceDashboard extends LitElement {
}
}
// Links, like the ones on an integration page, say in the URL what to show.
// The router keeps the page open, so a link can land on it.
private _applyLink() {
const searchParams = this._filterParams.searchParams();
const link = searchParams ? devicesLinkFromUrl(searchParams) : undefined;
private _setFiltersFromUrl() {
const area = this._searchParms.get("area");
const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
const label = this._searchParms.has("label");
if (link) {
// The filters only work out their items again when their value changes,
// so keep the items of the ones the link leaves as they are
const filters = Object.fromEntries(
Object.entries(link.filters).map(([key, filter]) => {
const current = this._filters[key];
return [
key,
current && deepEqual(filter.value, current.value)
? current
: filter,
];
})
);
this._filter = link.search;
this._filters = filters;
this._storageFilters = filters;
if (!area && !domain && !configEntry && !label) {
return;
}
this._writeFilters();
this._fromUrl = true;
this._filter = getHistoryState()?.filter || "";
this._filters = {
"ha-filter-states": {
value: [
...((this._filters["ha-filter-states"]?.value as string[]) || []),
"disabled",
],
items: undefined,
},
"ha-filter-floor-areas": {
value: area ? { areas: [area] } : undefined,
items: undefined,
},
"ha-filter-integrations": {
value: domain ? [domain] : [],
items: undefined,
},
config_entry: {
value: configEntry ? [configEntry] : [],
items: undefined,
},
sub_entry: {
value: subEntry ? [subEntry] : [],
items: undefined,
},
};
this._filterLabel();
}
private _writeFilters() {
this._filterParams.write(devicesFilterQueryParams(this._filters));
private _filterLabel() {
const label = this._searchParms.get("label");
if (!label) {
return;
}
this._filters = {
...this._filters,
"ha-filter-labels": {
value: [label],
items: undefined,
},
};
}
private _reloadConfigEntries() {
@@ -274,8 +318,9 @@ export class HaConfigDeviceDashboard extends LitElement {
private _clearFilter() {
this._filters = {};
this._storageFilters = {};
this._writeFilters();
if (!this._fromUrl) {
this._storageFilters = {};
}
}
private _devicesAndFilterDomains = memoizeOne(
@@ -354,29 +399,24 @@ export class HaConfigDeviceDashboard extends LitElement {
Array.isArray(filter.value) &&
filter.value.length
) {
const configEntryIds = this._filters.config_entry?.value;
const subEntryIds = filter.value;
if (
!Array.isArray(configEntryIds) ||
!configEntryIds.length ||
!Array.isArray(subEntryIds)
) {
if (!(
Array.isArray(this._filters.config_entry?.value) &&
this._filters.config_entry.value.length === 1
)) {
return;
}
outputDevices = outputDevices.filter((device) =>
configEntryIds.some((configEntryId) =>
device.config_entries_subentries[configEntryId]?.some(
(subEntryId) =>
subEntryId !== null && subEntryIds.includes(subEntryId)
const configEntryId = this._filters.config_entry.value[0];
outputDevices = outputDevices.filter(
(device) =>
device.config_entries_subentries[configEntryId] &&
(filter.value as string[]).some((subEntryId) =>
device.config_entries_subentries[configEntryId].includes(
subEntryId
)
)
)
);
// The titles shown in the filter chip need a single config entry
if (configEntryIds.length === 1 && !this._subEntries) {
this._loadSubEntries(configEntryIds[0]);
if (!this._subEntries) {
this._loadSubEntries(configEntryId);
}
} else if (
key === "ha-filter-integrations" &&
@@ -614,7 +654,6 @@ export class HaConfigDeviceDashboard extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "integration",
minWidth: "120px",
template: (device) =>
!this.entries
@@ -630,7 +669,6 @@ export class HaConfigDeviceDashboard extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "parent",
defaultHidden: true,
minWidth: "120px",
template: (device) => device.parent_device_name || "",
@@ -640,7 +678,6 @@ export class HaConfigDeviceDashboard extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "manufacturer",
minWidth: "120px",
},
model: {
@@ -863,7 +900,6 @@ export class HaConfigDeviceDashboard extends LitElement {
@collapsed-changed=${this._handleCollapseChanged}
@row-click=${this._handleRowClicked}
clickable
sync-url
has-fab
class=${this.narrow ? "narrow" : ""}
>
@@ -1058,8 +1094,9 @@ export class HaConfigDeviceDashboard extends LitElement {
private _filterChanged(ev) {
const type = ev.target.localName;
this._filters = { ...this._filters, [type]: ev.detail };
this._storageFilters = this._filters;
this._writeFilters();
if (!this._fromUrl) {
this._storageFilters = this._filters;
}
}
private _batteryEntity(
@@ -1086,11 +1123,13 @@ export class HaConfigDeviceDashboard extends LitElement {
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const deviceId = ev.detail.id;
this._ignoreLocationChange = true;
navigate(`/config/devices/device/${deviceId}`);
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
updateHistoryState({ filter: this._filter });
}
private _addDevice() {
@@ -1118,13 +1157,8 @@ export class HaConfigDeviceDashboard extends LitElement {
});
return;
}
const integrations = this._filters["ha-filter-integrations"]?.value;
showAddIntegrationDialog(this, {
domain:
Array.isArray(integrations) && integrations.length === 1
? integrations[0]
: undefined,
domain: this._searchParms.get("domain") || undefined,
navigateToResult: true,
});
}
@@ -197,7 +197,7 @@ export class DialogEnergyCustomise
<ha-switch
.checked=${applicable && !this._hidden!.has(card.key)}
.disabled=${!applicable}
.ariaLabel=${label}
.accessibleLabel=${label}
data-card-key=${card.key}
@change=${this._toggleCard}
></ha-switch>
@@ -253,7 +253,7 @@ export class HaConfigEntryRow extends LitElement {
href=${
allDevices.length === 1
? `/config/devices/device/${allDevices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}&status=disabled`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`
}
>
<ha-dropdown-item value="devices">
@@ -278,7 +278,7 @@ export class HaConfigEntryRow extends LitElement {
href=${
allServices.length === 1
? `/config/devices/device/${allServices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}&status=disabled`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`
}
>
<ha-dropdown-item value="services">
@@ -617,7 +617,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
href=${
devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}&status=disabled`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
}
>
${this.hass.localize(
@@ -635,7 +635,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
href=${
services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}&status=disabled`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
}
>
${this.hass.localize(
@@ -113,7 +113,7 @@ class HaConfigSubEntryRow extends LitElement {
href=${
devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}&status=disabled`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`
}
>
<ha-dropdown-item value="devices">
@@ -138,7 +138,7 @@ class HaConfigSubEntryRow extends LitElement {
href=${
services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}&status=disabled`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`
}
>
<ha-dropdown-item value="services">
@@ -178,7 +178,7 @@ export class MatterConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -221,7 +221,7 @@ export class MQTTConfigPanel extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -237,7 +237,7 @@ class ZHAConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -16,7 +16,7 @@ export class DialogZWaveJSAddNode extends HTMLElement {
private async _openDialog() {
await navigate(
`/config/devices/dashboard?config_entry=${this.configEntryId}&status=disabled`,
`/config/devices/dashboard?config_entry=${this.configEntryId}`,
{
replace: true,
}
@@ -326,7 +326,7 @@ class ZWaveJSConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}&status=disabled`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
+3 -5
View File
@@ -478,7 +478,7 @@ export class HaConfigLabels extends LitElement {
this._navigateConfig("/config/entities");
break;
case "navigate-devices":
this._navigateConfig("/config/devices/dashboard", "&status=disabled");
this._navigateConfig("/config/devices/dashboard");
break;
case "navigate-automations":
this._navigateConfig("/config/automation/dashboard");
@@ -495,10 +495,8 @@ export class HaConfigLabels extends LitElement {
}
};
private _navigateConfig = (path: string, params = "") => {
navigate(
`${path}?historyBack=1&label=${this._overflowLabel.label_id}${params}`
);
private _navigateConfig = (path: string) => {
navigate(`${path}?historyBack=1&label=${this._overflowLabel.label_id}`);
};
private _handleSortingChanged(ev: CustomEvent) {
+62 -3
View File
@@ -3,11 +3,14 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../common/decorators/consume";
import "../../../components/ha-alert";
import "../../../components/ha-dialog";
import "../../../components/input/ha-input-search";
import { internationalizationContext } from "../../../data/context";
import { apiContext, internationalizationContext } from "../../../data/context";
import { fetchHassioLogsLegacy } from "../../../data/hassio/supervisor";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistantApi } from "../../../types";
import "./error-log-card";
import type { LogsDialogParams } from "./show-dialog-logs";
@@ -17,12 +20,24 @@ class LogsDialog extends DialogMixin<LogsDialogParams>(LitElement) {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state() private _filter = "";
// Set when the filter the dialog opened with matched none of the loaded lines.
@state() private _unmatchedFilter?: string;
public connectedCallback() {
super.connectedCallback();
this._filter = this.params?.filter ?? "";
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
// The log card's strings live in the config fragment, which isn't loaded on dashboards.
this._i18n.loadFragmentTranslation("config");
this._checkInitialFilter();
}
protected render() {
@@ -38,10 +53,21 @@ class LogsDialog extends DialogMixin<LogsDialogParams>(LitElement) {
.placeholder=${this._i18n.localize("ui.panel.config.logs.search")}
@input=${this._filterChanged}
></ha-input-search>
${
this._unmatchedFilter
? html`<ha-alert alert-type="info">
${this._i18n.localize(
"ui.panel.config.logs.no_matches_showing_all",
{ term: this._unmatchedFilter }
)}
</ha-alert>`
: nothing
}
<error-log-card
.header=${this.params.title}
.provider=${this.params.provider}
.filter=${this._filter}
class=${this._unmatchedFilter ? "with-alert" : ""}
></error-log-card>
</ha-dialog>
`;
@@ -49,18 +75,51 @@ class LogsDialog extends DialogMixin<LogsDialogParams>(LitElement) {
private _filterChanged(ev: InputEvent) {
this._filter = (ev.target as HTMLInputElement).value;
this._unmatchedFilter = undefined;
}
// Lines for a failed update don't always name the app, so fall back to all
// lines. The card's stream never completes, so check a snapshot instead: the
// Supervisor's default of 100 lines, the same as the card loads.
private async _checkInitialFilter() {
const filter = this.params?.filter;
if (!filter || !this.params) {
return;
}
let logs: string;
try {
logs = await fetchHassioLogsLegacy(this._api, this.params.provider);
} catch {
return;
}
if (
this._filter === filter &&
!logs.toLowerCase().includes(filter.toLowerCase())
) {
this._unmatchedFilter = filter;
this._filter = "";
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-input-search {
ha-input-search,
ha-alert {
display: block;
margin-bottom: var(--ha-space-2);
}
error-log-card {
--error-log-card-height: calc(100vh - 340px);
--error-log-card-margin: 0;
--error-log-card-height: max(80px, calc(100vh - 320px));
}
error-log-card.with-alert {
--error-log-card-height: max(80px, calc(100vh - 368px));
}
`,
];
+29 -3
View File
@@ -68,11 +68,16 @@ import {
getHassioLogDownloadUrl,
} from "../../../data/hassio/supervisor";
import type { HomeAssistantApi, HomeAssistantConfig } from "../../../types";
import { haStyleScrollbar } from "../../../resources/styles";
import { fileDownload } from "../../../util/file_download";
import { showDownloadLogsDialog } from "./show-dialog-download-logs";
const NUMBER_OF_LINES = 100;
// Lines shown around each search match, as a failure's cause is often logged
// on the lines next to its summary.
const SEARCH_CONTEXT_LINES = 3;
@customElement("error-log-card")
class ErrorLogCard extends LitElement {
@property({ attribute: false }) public localizeFunc?: LocalizeFunc<any>;
@@ -295,7 +300,7 @@ class ErrorLogCard extends LitElement {
}
</div>
</div>
<div class="card-content error-log">
<div class="card-content error-log ha-scrollbar">
<div id="scroll-top-marker"></div>
${
this._loadingPrevState === "loading"
@@ -334,6 +339,8 @@ class ErrorLogCard extends LitElement {
}
<ha-ansi-to-html
?wrap-disabled=${!this._wrapLines}
.contextLines=${SEARCH_CONTEXT_LINES}
.expandLabel=${this._expandLabel}
></ha-ansi-to-html>
<div id="scroll-bottom-marker"></div>
</div>
@@ -410,8 +417,19 @@ class ErrorLogCard extends LitElement {
this._newLogsIndicator = false;
}
if (changedProps.has("filter")) {
if (
changedProps.has("filter") ||
(changedProps.has("_loadingState") &&
this._loadingState === "loaded" &&
this.filter)
) {
this._debounceSearch();
} else if (
(changedProps.has("_localize") || changedProps.has("localizeFunc")) &&
this.filter
) {
// The hidden lines buttons are built outside Lit, so relabel them.
this._ansiToHtmlElement?.filterLines(this.filter);
}
if (
@@ -641,6 +659,12 @@ class ErrorLogCard extends LitElement {
}
}, 150);
private _expandLabel = (count: number) =>
(this.localizeFunc || this._localize)(
"ui.panel.config.logs.show_hidden_lines",
{ count }
);
private _debounceScrollToBottom = debounce(() => {
this._logElement!.scrollTop = this._logElement!.scrollHeight;
}, 300);
@@ -793,12 +817,14 @@ class ErrorLogCard extends LitElement {
}
static styles: CSSResultGroup = css`
${haStyleScrollbar}
:host {
direction: var(--direction);
}
.error-log-intro {
text-align: center;
margin: 16px;
margin: var(--error-log-card-margin, 16px);
}
ha-card {
@@ -3,6 +3,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
export interface LogsDialogParams {
provider: string;
title: string;
filter?: string;
}
export const showLogsDialog = (
@@ -34,7 +34,7 @@ class HuiEntitiesToggle extends LitElement {
return html`
<ha-switch
aria-label=${this.hass!.localize(
.accessibleLabel=${this.hass!.localize(
"ui.panel.lovelace.card.entities.toggle"
)}
.checked=${this._toggleEntities!.some((entityId) => {
@@ -71,6 +71,8 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
`;
}
const name = this.hass.formatEntityName(stateObj, this._config.name);
return html`
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
${
@@ -79,6 +81,7 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
<div class="flex">
<ha-slider
labeled
.accessibleLabel=${name}
.disabled=${stateObj.state === UNAVAILABLE}
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
@@ -94,6 +97,7 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
: html`
<div class="flex box">
<ha-input
.accessibleLabel=${name}
.disabled=${stateObj.state === UNAVAILABLE}
pattern="[0-9]+([\\.][0-9]+)?"
.step=${Number(stateObj.attributes.step)}
@@ -73,6 +73,8 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
`;
}
const name = this.hass.formatEntityName(stateObj, this._config.name);
return html`
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
${
@@ -81,6 +83,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
<div class="flex">
<ha-slider
labeled
.accessibleLabel=${name}
.disabled=${stateObj.state === UNAVAILABLE}
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
@@ -101,6 +104,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
: html`
<div class="flex box">
<ha-input
.accessibleLabel=${name}
auto-validate
.disabled=${stateObj.state === UNAVAILABLE}
pattern="[0-9]+([\\.][0-9]+)?"
@@ -277,7 +277,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
step="1"
.withTooltip=${false}
size="s"
aria-label=${this.hass.localize(
.accessibleLabel=${this.hass.localize(
"ui.card.media_player.track_position"
)}
?disabled=${!supportsFeature(
@@ -300,7 +300,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
step="1"
.withTooltip=${false}
size="s"
aria-label=${this.hass.localize(
.accessibleLabel=${this.hass.localize(
"ui.card.media_player.track_position"
)}
?disabled=${!supportsFeature(
-16
View File
@@ -31,21 +31,6 @@ const marketplaceTableParams = {
direction: "string?",
} satisfies Redirect["params"];
// The devices table reads these from the link
const devicesTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
status: "string?",
floor: "string?",
area: "string?",
domain: "string?",
label: "string?",
config_entry: "string?",
sub_entry: "string?",
} satisfies Redirect["params"];
export const getMyRedirects = (): Redirects => ({
application_credentials: {
redirect: "/config/application_credentials",
@@ -235,7 +220,6 @@ export const getMyRedirects = (): Redirects => ({
},
devices: {
redirect: "/config/devices/dashboard",
params: devicesTableParams,
},
entities: {
redirect: "/config/entities",
+11
View File
@@ -856,6 +856,11 @@
"entity-state-content-picker": {
"add": "Add",
"custom_attribute": "Custom attribute"
},
"entity-toggle": {
"toggle": "Toggle {name}",
"turn_on": "Turn {name} on",
"turn_off": "Turn {name} off"
}
},
"target-picker": {
@@ -1314,6 +1319,10 @@
"search_placeholder": "Search {name}",
"no_results": "No results found"
},
"filter": {
"filter_placeholder": "Filter {name}",
"no_matches": "No items match your filter"
},
"filter_media_type": "Filter by media type",
"no_items": "No items",
"not_shown": "{count} {count, plural,\n one {item}\n other {items}\n} incompatible with the currently selected player {count, plural,\n one {is}\n other {are}\n} hidden",
@@ -4895,6 +4904,8 @@
"search": "Search logs",
"failed_get_logs": "Failed to get {provider} logs, {error}",
"no_issues_search": "No issues found for search term ''{term}''",
"no_matches_showing_all": "Nothing in the recent logs mentions ''{term}'', so all lines are shown.",
"show_hidden_lines": "Show {count} hidden {count, plural,\n one {line}\n other {lines}\n}",
"load_logs": "Load logs",
"nr_of_lines": "Number of lines",
"loading_log": "Loading log…",
@@ -107,31 +107,6 @@ describe("QueryParamsController", () => {
expect(navigated).not.toHaveBeenCalled();
});
it("follows the router replacing the URL with its default page", () => {
setUrl("/page/dashboard?other=1");
window.dispatchEvent(
Object.assign(new Event("location-changed"), {
detail: { replace: true },
})
);
expect(navigated).toHaveBeenCalledOnce();
controller.write({ search: "abc" });
expect(currentUrl()).toBe("/page/dashboard?other=1&search=abc");
});
it("does nothing once a link pushes a page below it", () => {
setUrl("/page/item?other=1");
window.dispatchEvent(
Object.assign(new Event("location-changed"), {
detail: { replace: false },
})
);
controller.write({ search: "abc" });
expect(currentUrl()).toBe("/page/item?other=1");
expect(navigated).not.toHaveBeenCalled();
});
it("does nothing when not enabled", () => {
enabled = false;
controller.write({ search: "abc" });
@@ -1,70 +0,0 @@
import { describe, expect, it } from "vitest";
import {
devicesFilterQueryParamConfig,
devicesFilterQueryParams,
devicesLinkFromUrl,
} from "../../../src/common/url/devices-query-params";
import { createQueryString } from "../../../src/common/url/query-params";
describe("devices query params", () => {
it("reads nothing without table params", () => {
expect(devicesLinkFromUrl("?historyBack=1")).toBeUndefined();
});
it("replaces every filter and the search with what a link asks for", () => {
expect(devicesLinkFromUrl("?config_entry=abc&label=a,b")).toEqual({
search: "",
filters: {
"ha-filter-states": { value: [], items: undefined },
"ha-filter-floor-areas": { value: undefined, items: undefined },
"ha-filter-integrations": { value: [], items: undefined },
"ha-filter-labels": { value: ["a", "b"], items: undefined },
config_entry: { value: ["abc"], items: undefined },
sub_entry: { value: [], items: undefined },
},
});
});
it("round trips the filters", () => {
const filters = {
"ha-filter-states": { value: ["disabled"], items: undefined },
"ha-filter-floor-areas": {
value: { floors: ["ground"], areas: ["kitchen"] },
items: undefined,
},
"ha-filter-integrations": { value: ["hue"], items: undefined },
"ha-filter-labels": { value: ["a"], items: undefined },
config_entry: { value: ["abc"], items: undefined },
sub_entry: { value: ["def"], items: undefined },
};
expect(
devicesLinkFromUrl(
createQueryString(
devicesFilterQueryParams(filters),
devicesFilterQueryParamConfig
)
)
).toEqual({ search: "", filters });
});
it("writes the values, not the items the filters found", () => {
expect(
devicesFilterQueryParams({
"ha-filter-floor-areas": {
value: { areas: ["kitchen"] },
items: new Set(["device_1"]),
},
})
).toEqual({
status: undefined,
floor: undefined,
area: ["kitchen"],
domain: undefined,
label: undefined,
config_entry: undefined,
sub_entry: undefined,
});
});
});