Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld 47329f348f Trim evicted lines before filtering new ones
The viewer applied the search filter while the rows it was about to
evict were still in the DOM, so when the only matching line fell out of
the cap the view went blank without the no-matches message. Trim the
old rows first, as the dialog did before the extraction, so the filter
result reflects the retained lines.
2026-10-22 15:53:58 +02:00
Maarten Lakerveld ce801c9057 Move the log retention tests to ha-log-viewer
The line cap, follow-to-bottom, new logs indicator and search tests
describe behaviour that now lives in ha-log-viewer, so they drive the
viewer directly. The dialog test keeps the subscription lifecycle tests
and stubs the viewer, since that is all the dialog still owns.
2026-10-22 15:53:58 +02:00
Maarten Lakerveld 2f06c77961 Share one log viewer between the live log dialog and the Z-Wave logs page
The Z-Wave logs page appended every streamed line to a textarea and never
trimmed it, so a tab left open at debug level grew without bound. The
live log dialog already capped and followed its lines, but that logic
lived in the dialog, so nothing stopped another view from forgetting it.

Move the line cap, the kept lines for download, follow-to-bottom, the
new logs indicator and search filtering into ha-log-viewer, and render
both views through it. The Z-Wave page gets the cap for free and the
dialog loses its private copy. The error log card keeps its own handling
for now because it also prepends older lines.
2026-10-22 15:53:58 +02:00
39 changed files with 904 additions and 1931 deletions

No files matched your search

@@ -1,4 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,4 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,5 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1,5 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1,6 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 408 B

@@ -1,6 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 408 B

@@ -1,3 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,3 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,4 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,4 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,5 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1,5 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1,3 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,3 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,4 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,4 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,3 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="76" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,3 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="76" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

-118
View File
@@ -1,118 +0,0 @@
import type {
DataTableFilter,
DataTableFilters,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
createQueryString,
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// The filters of the scripts table. Search, sorting and grouping are kept by
// the data table, see data-table-query-params.
export const scriptsFilterQueryParamConfig = {
list: [
"floor",
"area",
"device",
"entity",
"label",
"category",
"assistants",
"blueprint",
],
} as const satisfies QueryParamConfig;
export type ScriptsFilterQueryParams = QueryParamValues<
typeof scriptsFilterQueryParamConfig
>;
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 scriptsFilterQueryParams = (
filters: DataTableFilters
): ScriptsFilterQueryParams => ({
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
device: list(filters["ha-filter-devices"]),
entity: list(filters["ha-filter-entities"]),
label: list(filters["ha-filter-labels"]),
category: list(filters["ha-filter-categories"]),
assistants: list(filters["ha-filter-voice-assistants"]),
blueprint: list(filters["ha-filter-blueprints"]),
});
// The filters as the URL holds them, so values that filter the same, such as
// no list and an empty one, or a list in another order, compare equal. A
// filter list sends back what it was given in its own order.
export const scriptsFilterQueryString = (filters: DataTableFilters): string => {
const params = scriptsFilterQueryParams(filters);
const sorted: ScriptsFilterQueryParams = {};
for (const key of scriptsFilterQueryParamConfig.list) {
sorted[key] = params[key] && [...params[key]].sort();
}
return createQueryString(sorted, scriptsFilterQueryParamConfig);
};
// 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 ScriptsLink {
search: string;
filters: DataTableFilters;
}
export const scriptsLinkFromUrl = (
searchParams: SearchParamsSource
): ScriptsLink | undefined => {
if (
!hasQueryParams(searchParams, scriptsFilterQueryParamConfig) &&
!hasQueryParams(searchParams, dataTableQueryParamConfig)
) {
return undefined;
}
const params = decodeQueryParams(searchParams, scriptsFilterQueryParamConfig);
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof ScriptsFilterQueryParams): 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-floor-areas": filter(
floors.length || areas.length ? { floors, areas } : undefined
),
"ha-filter-devices": filter(values("device")),
"ha-filter-entities": filter(values("entity")),
"ha-filter-labels": filter(values("label")),
// The category filter picks one category
"ha-filter-categories": filter(values("category").slice(0, 1)),
"ha-filter-voice-assistants": filter(values("assistants")),
"ha-filter-blueprints": filter(values("blueprint")),
},
};
};
+5
View File
@@ -60,6 +60,11 @@ export class HaAnsiToHtml extends LitElement {
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
}
public disconnectedCallback(): void {
super.disconnectedCallback();
CSS.highlights?.delete("search-results");
}
protected firstUpdated(_changedProperties: PropertyValues<this>): void {
super.firstUpdated(_changedProperties);
+251
View File
@@ -0,0 +1,251 @@
import type { ContextType } from "@lit/context";
import { mdiArrowCollapseDown } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consume } from "../common/decorators/consume";
import { debounce } from "../common/util/debounce";
import { internationalizationContext } from "../data/context";
import "./ha-ansi-to-html";
import type { HaAnsiToHtml } from "./ha-ansi-to-html";
import "./ha-button";
import "./ha-svg-icon";
const SCROLL_FOLLOW_MARGIN = 40;
@customElement("ha-log-viewer")
export class HaLogViewer extends LitElement {
@property({ type: Number, attribute: "max-lines" }) public maxLines = 5000;
@property() public filter = "";
@property({ type: Boolean, attribute: "wrap-disabled" }) public wrapDisabled =
false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state() private _newLogsIndicator = false;
@state() private _noMatches = false;
@query(".log") private _logElement!: HTMLElement;
@query("ha-ansi-to-html") private _ansiToHtml!: HaAnsiToHtml;
private _lines: string[] = [];
private _followFrame?: number;
private _ready = false;
public get lines(): string[] {
return this._lines;
}
public addLines(lines: string[]) {
const newLines = lines
.flatMap((line) => line.split("\n"))
.slice(-this.maxLines);
if (!newLines.length) {
return;
}
const overflow = this._lines.length + newLines.length > this.maxLines;
this._lines = this._lines.concat(newLines).slice(-this.maxLines);
if (!this._ready) {
return;
}
const follow = this._isScrolledToBottom();
if (overflow) {
this._trimLines(this._lines.length - newLines.length);
}
this._renderLines(newLines);
if (follow) {
this._followBottom();
} else {
this._newLogsIndicator = true;
}
}
public clear() {
this._cancelFollowBottom();
this._lines = [];
this._newLogsIndicator = false;
this._noMatches = false;
if (this._ready) {
this._ansiToHtml.clear();
}
}
public scrollToBottom() {
this._newLogsIndicator = false;
if (this._ready) {
this._logElement.scrollTo(0, this._logElement.scrollHeight);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._cancelFollowBottom();
this._debouncedFilter.cancel();
}
protected render() {
return html`
<div class="log" @scroll=${this._handleScroll}>
<slot></slot>
${
this._noMatches
? html`<div class="no-matches">
${this._i18n.localize("ui.components.log_viewer.no_matches")}
</div>`
: nothing
}
<ha-ansi-to-html ?wrap-disabled=${this.wrapDisabled}></ha-ansi-to-html>
</div>
<ha-button
class="new-logs-indicator ${classMap({
visible: this._newLogsIndicator,
})}"
size="s"
appearance="filled"
.disabled=${!this._newLogsIndicator}
@click=${this.scrollToBottom}
>
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="start"></ha-svg-icon>
${this._i18n.localize("ui.components.log_viewer.scroll_down_button")}
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="end"></ha-svg-icon>
</ha-button>
`;
}
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
await this._ansiToHtml.updateComplete;
this._renderLines(this._lines);
this._ready = true;
this.scrollToBottom();
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (
changedProps.has("filter") &&
changedProps.get("filter") !== undefined
) {
this._debouncedFilter();
}
}
private _renderLines(lines: string[]) {
this._ansiToHtml.parseLinesToColoredPre(lines);
if (this.filter) {
this._applyFilter();
}
}
private _debouncedFilter = debounce(() => {
this._applyFilter();
if (!this.filter) {
this.scrollToBottom();
}
}, 150);
private _applyFilter() {
this._noMatches =
this._lines.length > 0 && !this._ansiToHtml.filterLines(this.filter);
}
private _trimLines(keptLines: number) {
const logElement = this._logElement;
const scrollTopBeforeTrim = logElement.scrollTop;
const scrollHeightBeforeTrim = logElement.scrollHeight;
this._ansiToHtml.trimLines(keptLines);
const removedHeight = scrollHeightBeforeTrim - logElement.scrollHeight;
logElement.scrollTop = Math.max(0, scrollTopBeforeTrim - removedHeight);
}
private _isScrolledToBottom(): boolean {
const log = this._logElement;
return (
log.scrollHeight - log.scrollTop - log.clientHeight < SCROLL_FOLLOW_MARGIN
);
}
private _handleScroll() {
if (this._newLogsIndicator && this._isScrolledToBottom()) {
this._newLogsIndicator = false;
}
}
private _followBottom() {
this._cancelFollowBottom();
const logElement = this._logElement;
const scrollTopBeforeFrame = logElement.scrollTop;
this._followFrame = requestAnimationFrame(() => {
this._followFrame = undefined;
if (logElement.scrollTop === scrollTopBeforeFrame) {
logElement.scrollTop = logElement.scrollHeight;
}
});
}
private _cancelFollowBottom() {
if (this._followFrame !== undefined) {
cancelAnimationFrame(this._followFrame);
this._followFrame = undefined;
}
}
static styles = css`
:host {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-md);
overflow: hidden;
}
.log {
flex: 1;
min-height: 0;
padding: var(--ha-space-2) var(--ha-space-2) var(--ha-space-8);
box-sizing: border-box;
overflow: auto;
overflow-anchor: none;
direction: ltr;
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-s);
}
.no-matches {
color: var(--secondary-text-color);
padding: var(--ha-space-2);
}
.new-logs-indicator {
overflow: hidden;
position: absolute;
bottom: 4px;
inset-inline-start: 4px;
height: 0;
transition: height 0.4s ease-out;
}
.new-logs-indicator.visible {
height: 32px;
}
@media (prefers-reduced-motion: reduce) {
.new-logs-indicator {
transition: none;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-log-viewer": HaLogViewer;
}
}
+17 -210
View File
@@ -4,25 +4,18 @@ import {
type UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import {
mdiArrowCollapseDown,
mdiCircle,
mdiWrap,
mdiWrapDisabled,
} from "@mdi/js";
import { mdiCircle, mdiWrap, mdiWrapDisabled } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { consume } from "../../../common/decorators/consume";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-alert";
import "../../../components/ha-ansi-to-html";
import type { HaAnsiToHtml } from "../../../components/ha-ansi-to-html";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-icon-button";
import "../../../components/ha-log-viewer";
import type { HaLogViewer } from "../../../components/ha-log-viewer";
import "../../../components/ha-svg-icon";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
@@ -40,12 +33,6 @@ import type { LiveLogDialogParams } from "./show-dialog-live-log";
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
// Stay close to the bottom to keep following new lines
const SCROLL_FOLLOW_MARGIN = 40;
// Keep only the newest lines so memory stays bounded
const DEFAULT_LOG_LINE_LIMIT = 5000;
@customElement("dialog-live-log")
class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
@state()
@@ -64,29 +51,16 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
@state() private _live = false;
@state() private _newLogsIndicator = false;
@state() private _search = "";
@state() private _wrapLines = true;
@state() private _noMatches = false;
@query(".log") private _logElement?: HTMLElement;
@query("ha-ansi-to-html") private _ansiToHtml?: HaAnsiToHtml;
private _lines: string[] = [];
// Number of stored lines already rendered in the view
private _shownCount = 0;
@query("ha-log-viewer") private _logViewer?: HaLogViewer;
private _unsub?: Promise<UnsubscribeFunc>;
private _resubscribeOnReady = false;
private _followFrame?: number;
connectedCallback() {
super.connectedCallback();
const connection = this._connection.connection;
@@ -147,150 +121,39 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
}
this._unsub = undefined;
this._resubscribeOnReady = false;
this._cancelFollowBottom();
this._lines = [];
this._shownCount = 0;
this._lineCount = 0;
this._newLogsIndicator = false;
this._noMatches = false;
this._ansiToHtml?.clear();
this._logViewer?.clear();
this._subscribe();
};
private _unsubscribe() {
this._cancelFollowBottom();
this._debounceSearch.cancel();
// _subscribe reports subscription errors, so only ignore them here
this._unsub?.then((unsub) => unsub()).catch(() => undefined);
this._unsub = undefined;
this._live = false;
}
private _isScrolledToBottom(): boolean {
const log = this._logElement;
return (
!log ||
log.scrollHeight - log.scrollTop - log.clientHeight < SCROLL_FOLLOW_MARGIN
);
}
private _handleScroll() {
if (this._newLogsIndicator && this._isScrolledToBottom()) {
this._newLogsIndicator = false;
}
}
private _scrollToBottom() {
this._newLogsIndicator = false;
this._logElement?.scrollTo(0, this._logElement.scrollHeight);
}
private _followBottom() {
this._cancelFollowBottom();
const logElement = this._logElement;
const scrollTopBeforeFrame = logElement ? logElement.scrollTop : 0;
this._followFrame = requestAnimationFrame(() => {
this._followFrame = undefined;
if (logElement && logElement.scrollTop === scrollTopBeforeFrame) {
logElement.scrollTop = logElement.scrollHeight;
}
});
}
private _cancelFollowBottom() {
if (this._followFrame !== undefined) {
cancelAnimationFrame(this._followFrame);
this._followFrame = undefined;
}
}
private _addLines(lines: string[]) {
const newLines = lines
.flatMap((line) => line.split("\n"))
.slice(-DEFAULT_LOG_LINE_LIMIT);
if (!newLines.length) {
const logViewer = this._logViewer;
if (!logViewer) {
return;
}
const follow = this._isScrolledToBottom();
this._lines = this._lines.concat(newLines);
this._trimLines();
this._lineCount = this._lines.length;
this._showNewLines(follow);
}
private _trimLines() {
const excessLineCount = this._lines.length - DEFAULT_LOG_LINE_LIMIT;
if (excessLineCount <= 0) {
return;
}
const logElement = this._logElement;
const scrollTopBeforeTrimming = logElement ? logElement.scrollTop : 0;
const scrollHeightBeforeTrimming = logElement ? logElement.scrollHeight : 0;
this._lines.splice(0, excessLineCount);
this._shownCount = Math.max(0, this._shownCount - excessLineCount);
if (this._ansiToHtml) {
this._ansiToHtml.trimLines(this._shownCount);
}
if (logElement) {
const removedHeight =
scrollHeightBeforeTrimming - logElement.scrollHeight;
logElement.scrollTop = Math.max(
0,
scrollTopBeforeTrimming - removedHeight
);
}
}
private _showNewLines(follow: boolean) {
const newLines = this._lines.slice(this._shownCount);
if (!newLines.length) {
return;
}
this._shownCount = this._lines.length;
this._renderLines(newLines);
if (follow) {
this._followBottom();
} else {
this._newLogsIndicator = true;
}
}
private _renderLines(lines: string[]) {
const ansiToHtml = this._ansiToHtml;
if (!ansiToHtml) {
return;
}
ansiToHtml.parseLinesToColoredPre(lines);
if (this._search) {
this._noMatches =
this._shownCount > 0 && !ansiToHtml.filterLines(this._search);
}
logViewer.addLines(lines);
this._lineCount = logViewer.lines.length;
}
private _searchChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
this._search = ev.target.value ?? "";
this._debounceSearch();
}
private _debounceSearch = debounce(() => {
if (this._ansiToHtml) {
this._noMatches =
this._shownCount > 0 && !this._ansiToHtml.filterLines(this._search);
}
if (!this._search) {
this._scrollToBottom();
}
}, 150);
private _toggleLineWrap() {
this._wrapLines = !this._wrapLines;
}
private _download() {
const text = this._lines.join("\n").replace(ANSI_ESCAPE, "");
const text = (this._logViewer?.lines ?? [])
.join("\n")
.replace(ANSI_ESCAPE, "");
const timeString = new Date().toISOString().replace(/:/g, "-");
const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
fileDownload(
@@ -339,7 +202,10 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
></ha-icon-button>
</div>
<div class="log-container">
<div class="log" @scroll=${this._handleScroll}>
<ha-log-viewer
.filter=${this._search}
?wrap-disabled=${!this._wrapLines}
>
${
this._lineCount
? nothing
@@ -350,38 +216,7 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
)}
</div>`
}
${
this._noMatches
? html`<div class="waiting">
${this._i18n.localize(
"ui.panel.config.integrations.config_entry.live_log.no_matches"
)}
</div>`
: nothing
}
<ha-ansi-to-html
?wrap-disabled=${!this._wrapLines}
></ha-ansi-to-html>
</div>
<ha-button
class="new-logs-indicator ${classMap({
visible: this._newLogsIndicator,
})}"
size="s"
appearance="filled"
.disabled=${!this._newLogsIndicator}
@click=${this._scrollToBottom}
>
<ha-svg-icon
.path=${mdiArrowCollapseDown}
slot="start"
></ha-svg-icon>
${this._i18n.localize("ui.panel.config.logs.scroll_down_button")}
<ha-svg-icon
.path=${mdiArrowCollapseDown}
slot="end"
></ha-svg-icon>
</ha-button>
</ha-log-viewer>
${
this._live
? html`<div class="live-indicator">
@@ -428,34 +263,9 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-md);
overflow: hidden;
}
.log {
/* Fills the dialog when it is full screen on narrow screens */
flex: 1 1 60vh;
min-height: 0;
padding: var(--ha-space-2) var(--ha-space-2) var(--ha-space-8);
box-sizing: border-box;
overflow: auto;
overflow-anchor: none;
direction: ltr;
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-s);
}
.new-logs-indicator {
overflow: hidden;
position: absolute;
bottom: 4px;
inset-inline-start: 4px;
height: 0;
transition: height 0.4s ease-out;
}
.new-logs-indicator.visible {
height: 32px;
}
@keyframes breathe {
from {
@@ -482,9 +292,6 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
width: 14px;
}
@media (prefers-reduced-motion: reduce) {
.new-logs-indicator {
transition: none;
}
.live-indicator ha-svg-icon {
animation: none;
}
@@ -6,6 +6,8 @@ import { customElement, property, query, state } from "lit/decorators";
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-log-viewer";
import type { HaLogViewer } from "../../../../../components/ha-log-viewer";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import type { ZWaveJSLogConfig } from "../../../../../data/zwave_js";
@@ -32,29 +34,26 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
@state() private _logConfig?: ZWaveJSLogConfig;
@query("textarea", true) private _textarea?: HTMLTextAreaElement;
@query("ha-log-viewer") private _logViewer?: HaLogViewer;
public hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeZWaveJSLogs(this.hass, this.configEntryId, (update) => {
if (!this.hasUpdated) {
return;
}
if (update.type === "log_message") {
if (Array.isArray(update.log_message.message)) {
for (const line of update.log_message.message) {
this._textarea!.value += `${line}\n`;
}
} else {
this._textarea!.value += `${update.log_message.message}\n`;
}
this._logViewer?.addLines(
Array.isArray(update.log_message.message)
? update.log_message.message
: [update.log_message.message]
);
} else {
this._logConfig = update.log_config;
}
}).then((unsub) => {
this._textarea!.value += `${this.hass.localize(
"ui.panel.config.zwave_js.logs.subscribed_to_logs"
)}\n`;
this._logViewer?.addLines([
this.hass.localize(
"ui.panel.config.zwave_js.logs.subscribed_to_logs"
),
]);
return unsub;
}),
];
@@ -112,7 +111,7 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
.path=${mdiDownload}
></ha-icon-button>
</ha-card>
<textarea readonly></textarea>
<ha-log-viewer></ha-log-viewer>
</div>
</hass-subpage>
`;
@@ -136,7 +135,7 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
private _downloadLogs() {
fileDownload(
`data:text/plain;charset=utf-8,${encodeURIComponent(
this._textarea!.value
this._logViewer!.lines.join("\n")
)}`,
`zwave_js.log`
);
@@ -151,10 +150,11 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
return;
}
setZWaveJSLogLevel(this.hass!, this.configEntryId, selected);
this._textarea!.value += `${this.hass.localize(
"ui.panel.config.zwave_js.logs.log_level_changed",
{ level: capitalizeFirstLetter(selected) }
)}\n`;
this._logViewer?.addLines([
this.hass.localize("ui.panel.config.zwave_js.logs.log_level_changed", {
level: capitalizeFirstLetter(selected),
}),
]);
}
static get styles(): CSSResultArray {
@@ -168,11 +168,6 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
box-sizing: border-box;
padding: 16px;
}
textarea {
flex-grow: 1;
padding: 16px;
font-family: var(--ha-font-family-code);
}
ha-card {
margin: 16px 0;
}
+141 -107
View File
@@ -23,21 +23,12 @@ import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
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";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
scriptsFilterQueryParamConfig,
scriptsFilterQueryParams,
scriptsFilterQueryString,
scriptsLinkFromUrl,
} from "../../../common/url/scripts-query-params";
import { deepEqual } from "../../../common/util/deep-equal";
import {
hasRejectedItems,
rejectedItems,
@@ -46,6 +37,7 @@ import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/ha-button";
@@ -71,10 +63,7 @@ import {
} from "../../../data/category_registry";
import type { CloudStatus } from "../../../data/cloud";
import { fullEntitiesContext, labelsContext } from "../../../data/context";
import type {
DataTableFilter,
DataTableFilters,
} from "../../../data/data_table_filters";
import type { DataTableFilters } from "../../../data/data_table_filters";
import {
deserializeFilters,
isFilterUsed,
@@ -99,6 +88,7 @@ import {
showScriptEditor,
triggerScript,
} from "../../../data/script";
import { findRelated } from "../../../data/search";
import {
showAlertDialog,
showConfirmationDialog,
@@ -154,21 +144,22 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _selected: string[] = [];
@state() private _activeFilters?: string[];
@state() private _filteredEntityIds?: string[] | null;
private _tableSettings = new DataTableSettingsController(
this,
{
search: "script-table-search",
sorting: "script-table-sort",
grouping: "script-table-grouping",
},
{ defaultGrouping: "category" }
);
@state()
@storage({
storage: "sessionStorage",
key: "script-table-search",
state: true,
subscribe: false,
})
private _filter = "";
@state()
private _filters: DataTableFilters = {};
@@ -183,16 +174,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
})
private _storageFilters: DataTableFilters = {};
// The filters are kept in the URL by the page, the search, sorting and
// grouping by the table
private _filterParams = new QueryParamsController(
this,
scriptsFilterQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyLink(),
}
);
private _fromUrl = false;
@state() private _expandedFilter?: string;
@@ -207,6 +189,12 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@storage({ key: "script-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "script-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "script-table-collapsed",
state: false,
@@ -451,14 +439,14 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
{ number: scripts.length }
)}
has-filters
.initialGroupColumn=${this._tableSettings.grouping}
.initialGroupColumn=${this._activeGrouping ?? "category"}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._tableSettings.sorting}
.initialSorting=${this._activeSorting}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@columns-changed=${this._handleColumnsChanged}
@sorting-changed=${this._tableSettings.sortingChanged}
@grouping-changed=${this._tableSettings.groupingChanged}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
selectable
.selected=${this._selected.length}
@@ -482,9 +470,8 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
"ui.panel.config.script.picker.no_scripts"
)}
@clear-filter=${this._clearFilter}
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
sync-url
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
has-fab
clickable
class=${this.narrow ? "narrow" : ""}
@@ -718,6 +705,10 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
`;
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
private _filterExpanded(ev) {
if (ev.detail.expanded) {
this._expandedFilter = ev.target.localName;
@@ -728,38 +719,34 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
private _labelClicked = (ev: CustomEvent) => {
const label = ev.detail.label;
this._setFilter("ha-filter-labels", {
value: [label.label_id],
items: undefined,
});
this._filters = {
...this._filters,
"ha-filter-labels": {
value: [label.label_id],
items: undefined,
},
};
if (!this._fromUrl) {
this._storageFilters = this._filters;
}
this._applyFilters();
};
private _filterChanged(ev) {
this._setFilter(ev.target.localName, ev.detail);
}
private _setFilter(type: string, filter: DataTableFilter) {
const filters = { ...this._filters, [type]: filter };
// A filter also sends the value it was given, once it has found its items.
// Only a change to what the URL holds is the user's, so a link isn't stored.
if (
scriptsFilterQueryString(filters) !==
scriptsFilterQueryString(this._filters)
) {
this._storageFilters = { ...this._storageFilters, [type]: filter };
const type = ev.target.localName;
this._filters = { ...this._filters, [type]: ev.detail };
if (!this._fromUrl) {
this._storageFilters = this._filters;
}
this._filters = filters;
this._applyFilters();
this._writeFilters();
}
private _clearFilter() {
this._filters = {};
this._storageFilters = {};
if (!this._fromUrl) {
this._storageFilters = {};
}
this._applyFilters();
this._writeFilters();
}
private _applyFilters() {
@@ -810,55 +797,29 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._filters = this._storageFilters;
this._applyLink();
const hasUrlFilter =
this._searchParms.has("area") ||
this._searchParms.has("blueprint") ||
this._searchParms.has("device") ||
this._searchParms.has("label");
if (!hasUrlFilter) {
this._filters = this._storageFilters;
}
if (this._searchParms.has("area")) {
this._filterArea();
}
if (this._searchParms.has("device")) {
this._filterDevice();
}
if (this._searchParms.has("blueprint")) {
this._filterBlueprint();
}
if (this._searchParms.has("label")) {
this._filterLabel();
}
}
}
// Links, like the ones on a device or area 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 ? scriptsLinkFromUrl(searchParams) : undefined;
// Shown, but not stored as the user's choice
if (link) {
this._tableSettings.showSearch(link.search);
this._filters = this._keepItems(link.filters);
this._applyFilters();
} else if (searchParams?.toString() === "") {
// Opened again without params, like from the sidebar. Written once the
// table has seen the URL without params, so it restores what it stored
this._filters = this._keepItems(this._storageFilters);
this._applyFilters();
queueMicrotask(() => this._writeFilters());
return;
}
this._writeFilters();
}
// The filters only work out their items again when their value changes, so
// keep the items of the ones that stay as they are
private _keepItems(filters: DataTableFilters): DataTableFilters {
return Object.fromEntries(
Object.entries(filters).map(([key, filter]) => {
const current = this._filters[key];
return [
key,
current && deepEqual(filter.value, current.value) ? current : filter,
];
})
);
}
private _writeFilters() {
this._filterParams.write(scriptsFilterQueryParams(this._filters));
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_entityReg")) {
@@ -866,6 +827,71 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
}
}
private _filterArea() {
const area = this._searchParms.get("area");
if (!area) {
return;
}
this._fromUrl = true;
this._filters = {
...this._filters,
"ha-filter-floor-areas": {
value: { areas: [area] },
items: undefined,
},
};
this._applyFilters();
}
private _filterDevice() {
const device = this._searchParms.get("device");
if (!device) {
return;
}
this._fromUrl = true;
this._filters = {
...this._filters,
"ha-filter-devices": {
value: [device],
items: undefined,
},
};
this._applyFilters();
}
private _filterLabel() {
const label = this._searchParms.get("label");
if (!label) {
return;
}
this._fromUrl = true;
this._filters = {
...this._filters,
"ha-filter-labels": {
value: [label],
items: undefined,
},
};
this._applyFilters();
}
private async _filterBlueprint() {
const blueprint = this._searchParms.get("blueprint");
if (!blueprint) {
return;
}
this._fromUrl = true;
const related = await findRelated(this.hass, "script_blueprint", blueprint);
this._filters = {
...this._filters,
"ha-filter-blueprints": {
value: [blueprint],
items: new Set(related.automation || []),
},
};
this._applyFilters();
}
private _editCategory(script: any) {
const entityReg = this._entityReg.find(
(reg) => reg.entity_id === script.entity_id
@@ -1315,6 +1341,14 @@ ${rejected
}
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value ?? "";
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
@@ -1,153 +0,0 @@
import type { ContextType } from "@lit/context";
import { mdiAutoFix, mdiContentPaste, mdiViewGridPlusOutline } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import deepClone from "deep-clone-simple";
import { consume } from "../../../../common/decorators/consume";
import { storage } from "../../../../common/decorators/storage";
import "../../../../components/ha-adaptive-dialog";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/list/ha-list-base";
import { internationalizationContext } from "../../../../data/context";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
import { generateDefaultSection } from "../../views/default-section";
import { addSection } from "../config-util";
import type { CreateSectionDialogParams } from "./show-create-section-dialog";
import { showSelectSectionStrategyDialog } from "./show-create-section-dialog";
@customElement("hui-dialog-create-section")
export class HuiDialogCreateSection extends DialogMixin<CreateSectionDialogParams>(
LitElement
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@storage({
key: "dashboardSectionClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
})
private _clipboard?: LovelaceSectionRawConfig;
protected render() {
if (!this.params) {
return nothing;
}
const localize = this._i18n.localize;
return html`
<ha-adaptive-dialog
open
header-title=${localize("ui.panel.lovelace.editor.create_section.header")}
>
<ha-list-base>
<ha-list-item-button @click=${this._createManual}>
<ha-svg-icon
slot="start"
.path=${mdiViewGridPlusOutline}
></ha-svg-icon>
<span slot="headline">
${localize("ui.panel.lovelace.editor.create_section.manual")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.lovelace.editor.create_section.manual_description"
)}
</span>
</ha-list-item-button>
<ha-list-item-button @click=${this._pickStrategy}>
<ha-svg-icon slot="start" .path=${mdiAutoFix}></ha-svg-icon>
<span slot="headline">
${localize("ui.panel.lovelace.editor.create_section.automatic")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.lovelace.editor.create_section.automatic_description"
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
${
this._clipboard
? html`
<ha-list-item-button @click=${this._paste}>
<ha-svg-icon
slot="start"
.path=${mdiContentPaste}
></ha-svg-icon>
<span slot="headline">
${localize("ui.panel.lovelace.editor.create_section.paste")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.lovelace.editor.create_section.paste_description"
)}
</span>
</ha-list-item-button>
`
: nothing
}
</ha-list-base>
</ha-adaptive-dialog>
`;
}
private _createManual() {
const { lovelace, containerPath } = this.params!;
this.closeDialog();
lovelace.saveConfig(
addSection(
lovelace.config,
containerPath,
generateDefaultSection(this._i18n.localize, true)
)
);
}
private _pickStrategy() {
showSelectSectionStrategyDialog(this, this.params!);
this.closeDialog();
}
private async _paste() {
if (!this._clipboard) {
return;
}
const { lovelace, containerPath } = this.params!;
const section = deepClone(this._clipboard);
this.closeDialog();
try {
await lovelace.saveConfig(
addSection(lovelace.config, containerPath, section)
);
} catch (_err: unknown) {
lovelace.showToast({
message: this._i18n.localize(
"ui.panel.lovelace.editor.section.paste_error"
),
});
}
}
static styles = css`
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
ha-list-base {
--ha-list-padding: 0 0 var(--ha-space-3);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-create-section": HuiDialogCreateSection;
}
}
@@ -1,38 +1,26 @@
import {
mdiCellphone,
mdiClose,
mdiDotsVertical,
mdiFileMoveOutline,
mdiMonitor,
} from "@mdi/js";
import { mdiClose, mdiDotsVertical, mdiFileMoveOutline } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { cache } from "lit/directives/cache";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { withViewTransition } from "../../../../common/util/view-transition";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import {
DEFAULT_SECTION_COLUMN_SPAN,
isStrategySection,
} from "../../../../data/lovelace/config/section";
import { isStrategySection } from "../../../../data/lovelace/config/section";
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { saveConfig } from "../../../../data/lovelace/config/types";
@@ -44,7 +32,6 @@ import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider
import {
haStyleDialog,
haStyleDialogFixedTop,
haStyleScrollbar,
} from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import { cleanLegacyStrategyConfig } from "../../strategies/legacy-strategy";
@@ -54,13 +41,10 @@ import {
deleteAtPath,
getAtPath,
getViewPath,
insertAtPath,
moveAtPath,
setAtPath,
} from "../lovelace-path";
import { showSelectViewDialog } from "../select-view/show-select-view-dialog";
import "../../sections/hui-section";
import "../../sections/hui-section-background";
import "./hui-section-settings-editor";
import "./hui-section-visibility-editor";
import "./hui-section-strategy-element-editor";
@@ -69,20 +53,11 @@ import type { ConfigChangedEvent } from "../hui-element-editor";
import type { EditSectionDialogParams } from "./show-edit-section-dialog";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import { getViewType } from "../../views/get-view-type";
import { DEFAULT_MAX_COLUMNS, SECTIONS_VIEW_LAYOUT } from "../../views/const";
import { SECTIONS_VIEW_LAYOUT } from "../../views/const";
import { LOVELACE_EDIT_TOAST_ID } from "../../types";
const TABS = ["tab-strategy", "tab-appearance", "tab-visibility"] as const;
type PreviewMode = "desktop" | "mobile";
const PREVIEW_MODES: { mode: PreviewMode; iconPath: string }[] = [
{ mode: "mobile", iconPath: mdiCellphone },
{ mode: "desktop", iconPath: mdiMonitor },
];
const TWO_COLUMNS_MIN_WIDTH = 2 * 320 + 3 * 32;
@customElement("hui-dialog-edit-section")
export class HuiDialogEditSection
extends DirtyStateProviderMixin<LovelaceSectionRawConfig>()(LitElement)
@@ -92,8 +67,6 @@ export class HuiDialogEditSection
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ type: Boolean, reflect: true }) public large = false;
@state() private _params?: EditSectionDialogParams;
@state() private _config?: LovelaceSectionRawConfig;
@@ -110,15 +83,6 @@ export class HuiDialogEditSection
@state() private _yamlError = false;
@storage({
key: "dashboardSectionPreviewMode",
state: true,
subscribe: false,
})
private _storedPreviewMode?: PreviewMode;
@state() private _defaultPreviewMode: PreviewMode = "mobile";
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
@query("hui-section-strategy-element-editor")
@@ -140,9 +104,10 @@ export class HuiDialogEditSection
this.lovelace = params.lovelace;
this._config =
params.newSection ??
getAtPath<LovelaceSectionRawConfig>(params.lovelaceConfig, params.path);
this._config = getAtPath<LovelaceSectionRawConfig>(
params.lovelaceConfig,
params.path
);
this._currTab =
this._config && isStrategySection(this._config)
? "tab-strategy"
@@ -152,18 +117,7 @@ export class HuiDialogEditSection
params.lovelaceConfig,
viewPath
);
if (params.newSection) {
this._initDirtyTracking({ type: "deep" }, {});
this._updateDirtyState(params.newSection);
} else {
this._initDirtyTracking({ type: "deep" }, this._config);
}
this._defaultPreviewMode =
window.innerWidth >= TWO_COLUMNS_MIN_WIDTH ? "desktop" : "mobile";
}
private get _previewMode(): PreviewMode {
return this._storedPreviewMode ?? this._defaultPreviewMode;
this._initDirtyTracking({ type: "deep" }, this._config);
}
public closeDialog() {
@@ -173,13 +127,11 @@ export class HuiDialogEditSection
private _dialogClosed(): void {
this._params = undefined;
this.large = false;
this._yamlMode = false;
this._config = undefined;
this._currTab = TABS[0];
this._strategyError = false;
this._yamlError = false;
this._discardDirtyStateChanges();
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -188,23 +140,19 @@ export class HuiDialogEditSection
return nothing;
}
const heading = this.hass!.localize(
"ui.panel.lovelace.editor.edit_section.header"
);
const strategy = isStrategySection(this._config)
? cleanLegacyStrategyConfig(this._config.strategy)
: undefined;
const heading = this._computeHeading(strategy);
const tabs = strategy ? TABS : TABS.filter((tab) => tab !== "tab-strategy");
const currentTab =
!strategy && this._currTab === "tab-strategy"
? "tab-appearance"
: this._currTab;
const columnSpan = Math.min(
this._config.column_span ?? DEFAULT_SECTION_COLUMN_SPAN,
this._viewConfig?.max_columns || DEFAULT_MAX_COLUMNS
);
const singleColumn = columnSpan === 1;
const previewColumnSpan = this._previewMode === "desktop" ? columnSpan : 1;
let content: TemplateResult<1> | typeof nothing = nothing;
if (this._yamlMode) {
@@ -254,160 +202,65 @@ export class HuiDialogEditSection
return html`
<ha-dialog
.open=${this._open}
.width=${this.large ? "full" : "large"}
.preventScrimClose=${this.isDirtyState}
@keydown=${this._ignoreKeydown}
@closed=${this._dialogClosed}
class=${classMap({
"yaml-mode": this._yamlMode,
})}
>
<ha-icon-button
slot="headerNavigationIcon"
@click=${this._cancel}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span
slot="headerTitle"
class="title-enlargeable"
@click=${this._enlarge}
>${heading}</span
>
${
this._params.newSection
? nothing
: html`
<ha-dropdown
slot="headerActionItems"
placement="bottom-end"
@closed=${stopPropagation}
@wa-select=${this._handleAction}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="move-to-view">
<ha-svg-icon
slot="icon"
.path=${mdiFileMoveOutline}
></ha-svg-icon>
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.move_to_view"
<ha-dialog-header show-border slot="header">
<ha-icon-button
slot="navigationIcon"
@click=${this._cancel}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title">${heading}</span>
<ha-dropdown
slot="actionItems"
placement="bottom-end"
@closed=${stopPropagation}
@wa-select=${this._handleAction}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="move-to-view">
<ha-svg-icon
slot="icon"
.path=${mdiFileMoveOutline}
></ha-svg-icon>
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.move_to_view"
)}
</ha-dropdown-item>
</ha-dropdown>
${
!this._yamlMode
? html`
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${tabs.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${currentTab === tab}
>
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
)}
</ha-tab-group-tab>
`
)}
</ha-dropdown-item>
</ha-dropdown>
`
}
<div class="content">
<div class="element-editor ha-scrollbar">
${
!this._yamlMode
? html`
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${tabs.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${currentTab === tab}
>
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
)}
</ha-tab-group-tab>
`
)}
</ha-tab-group>
`
: nothing
}
${this._yamlMode ? content : cache(content)}
</div>
<div class="element-preview ha-scrollbar">
<div class="preview-header">
<span id="preview-label">
${this.hass.localize(
"ui.panel.lovelace.editor.edit_section.preview"
)}
</span>
${
singleColumn
? nothing
: html`
<div
class="preview-modes"
role="group"
aria-labelledby="preview-label"
>
${PREVIEW_MODES.map(
({ mode, iconPath }) => html`
<button
id="preview-mode-${mode}"
class=${classMap({
"preview-mode": true,
active: this._previewMode === mode,
})}
aria-pressed=${this._previewMode === mode}
aria-label=${this.hass.localize(
`ui.panel.lovelace.editor.edit_section.preview_${mode}`
)}
.mode=${mode}
@click=${this._previewModeClicked}
>
<ha-svg-icon .path=${iconPath}></ha-svg-icon>
</button>
<ha-tooltip
for="preview-mode-${mode}"
placement="bottom"
>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_section.preview_${mode}`
)}
</ha-tooltip>
`
)}
</div>
`
}
</div>
<div class="preview-frame">
<div
class=${classMap({
"section-preview": true,
wide: previewColumnSpan > 1,
})}
style=${styleMap({
"--column-span": previewColumnSpan,
})}
>
<div
class=${classMap({
"section-container": true,
"has-background": this._config.background !== undefined,
blur: this._yamlError || this._strategyError,
})}
>
${
this._config.background !== undefined
? html`
<hui-section-background
.hass=${this.hass}
.background=${this._config.background}
.theme=${this._config.theme}
></hui-section-background>
`
: nothing
}
<hui-section
.hass=${this.hass}
.config=${this._config}
preview
></hui-section>
</div>
</div>
</div>
</div>
</div>
</ha-tab-group>
`
: nothing
}
</ha-dialog-header>
${this._yamlMode ? content : cache(content)}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@@ -466,37 +319,6 @@ export class HuiDialogEditSection
this._updateDirtyState(this._config);
}
private _computeHeading(strategy?: LovelaceStrategyConfig) {
const header = this.hass.localize(
"ui.panel.lovelace.editor.edit_section.header"
);
if (!strategy) {
return header;
}
const strategyName = this.hass.localize(
`ui.panel.lovelace.editor.section_strategy.${strategy.type}.name`
);
if (!strategyName) {
return header;
}
return this.hass.localize(
"ui.panel.lovelace.editor.edit_section.typed_header",
{ type: strategyName }
);
}
private _previewModeClicked(ev: Event) {
this._storedPreviewMode = (
ev.currentTarget as HTMLElement & { mode: PreviewMode }
).mode;
}
private _enlarge() {
withViewTransition(() => {
this.large = !this.large;
});
}
private _handleTabChanged(ev: CustomEvent): void {
const newTab = ev.detail.name;
if (newTab === this._currTab) {
@@ -694,13 +516,11 @@ export class HuiDialogEditSection
) {
return;
}
const newConfig = this._params.newSection
? insertAtPath(
this._params.lovelaceConfig,
this._params.path,
this._config
)
: setAtPath(this._params.lovelaceConfig, this._params.path, this._config);
const newConfig = setAtPath(
this._params.lovelaceConfig,
this._params.path,
this._config
);
this._params.saveConfig(newConfig);
this._markDirtyStateClean();
@@ -711,153 +531,15 @@ export class HuiDialogEditSection
return [
haStyleDialog,
haStyleDialogFixedTop,
haStyleScrollbar,
css`
:host {
--code-mirror-max-height: calc(100vh - 209px);
}
ha-dialog {
--dialog-content-padding: var(--ha-space-2);
--dialog-content-padding: var(--ha-space-6);
}
.content {
display: flex;
flex-direction: column;
width: 100%;
max-width: 100%;
ha-dialog.yaml-mode {
--dialog-content-padding: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.content {
gap: var(--ha-space-3);
}
}
@media all and (min-width: 451px) and (min-height: 501px) {
:host([large]) .content {
max-width: none;
}
}
.content .preview-frame {
padding: 4px;
overflow-x: auto;
}
.content .section-preview {
--preview-column-width: 390px;
display: block;
margin: 0 auto;
max-width: var(--preview-column-width);
}
.content .section-preview.wide {
max-width: none;
width: calc(
var(--column-span) * var(--preview-column-width) +
(var(--column-span) - 1) *
var(--ha-view-sections-column-gap, 32px)
);
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-2);
min-height: 32px;
padding: var(--ha-space-1) var(--ha-space-2);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
}
.preview-modes {
display: flex;
gap: 2px;
padding: 2px;
border-radius: var(--ha-border-radius-pill);
background: var(--ha-color-fill-neutral-normal-resting);
}
.preview-mode {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
padding: 0;
border: none;
border-radius: var(--ha-border-radius-pill);
background: none;
color: var(--secondary-text-color);
cursor: pointer;
--mdc-icon-size: 18px;
}
.preview-mode:hover {
color: var(--primary-text-color);
}
.preview-mode.active {
color: var(--primary-text-color);
background: var(--card-background-color);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
}
.preview-mode:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.content .element-editor {
padding-inline-start: var(--ha-space-1);
padding-inline-end: var(--ha-space-2);
margin-bottom: 0;
}
@media (min-width: 1000px) {
.content {
flex-direction: row;
max-height: var(--code-mirror-max-height);
}
.content > .element-editor {
padding-inline-end: var(--ha-space-4);
max-width: 500px;
}
.content > .element-editor,
.content > .element-preview {
flex-basis: 0;
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
height: auto;
}
.content .preview-frame {
padding: 8px 10px;
}
}
.element-editor {
margin-bottom: 8px;
}
.blur {
filter: blur(2px) grayscale(100%);
}
.element-preview {
position: relative;
height: max-content;
background: var(--primary-background-color);
padding: 4px;
border-radius: var(--ha-border-radius-sm);
}
.section-container {
position: relative;
}
.section-container.has-background {
padding: var(--ha-space-2);
border-radius: var(
--ha-section-border-radius,
var(--ha-border-radius-xl)
);
}
.section-container hui-section {
display: block;
}
ha-tab-group {
margin-bottom: 16px;
.gui-mode-button {
margin-inline-end: auto;
}
ha-tab-group-tab {
flex: 1;
@@ -866,14 +548,6 @@ export class HuiDialogEditSection
width: 100%;
justify-content: center;
}
.gui-mode-button {
margin-right: auto;
margin-inline-end: auto;
margin-inline-start: initial;
}
.title-enlargeable {
display: block;
}
`,
];
}
@@ -1,304 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-adaptive-dialog";
import "../../../../components/ha-icon-button-arrow-prev";
import "../../../../components/ha-ripple";
import type { LovelaceStrategySectionConfig } from "../../../../data/lovelace/config/section";
import { CUSTOM_TYPE_PREFIX } from "../../../../data/lovelace_custom_cards";
import { getCustomStrategiesForType } from "../../../../data/lovelace_custom_strategies";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../../types";
import "../../sections/hui-section";
import { getLovelaceStrategy } from "../../strategies/get-strategy";
import type { LovelaceSectionStrategy } from "../../strategies/types";
import { getAtPath } from "../lovelace-path";
import type { CreateSectionDialogParams } from "./show-create-section-dialog";
import { showCreateSectionDialog } from "./show-create-section-dialog";
import { showEditSectionDialog } from "./show-edit-section-dialog";
interface BuiltInSectionStrategy {
type: "common-controls";
isAvailable?: (hass: HomeAssistant) => boolean;
}
const BUILT_IN_SECTION_STRATEGIES: BuiltInSectionStrategy[] = [
{
type: "common-controls",
isAvailable: (hass) => isComponentLoaded(hass.config, "usage_prediction"),
},
];
interface StrategyOption {
type: string;
name: string;
description: string;
}
@customElement("hui-dialog-select-section-strategy")
export class HuiDialogSelectSectionStrategy
extends LitElement
implements HassDialog<CreateSectionDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: CreateSectionDialogParams;
@state() private _open = false;
@state() private _previews: Record<string, LovelaceStrategySectionConfig> =
{};
public showDialog(params: CreateSectionDialogParams): void {
this._params = params;
this._open = true;
this._previews = {};
this._loadPreviews(params);
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._previews = {};
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _availableBuiltInTypes() {
return BUILT_IN_SECTION_STRATEGIES.filter(
(strategy) => strategy.isAvailable?.(this.hass) ?? true
).map((strategy) => strategy.type);
}
private _builtInOptions(): StrategyOption[] {
return this._availableBuiltInTypes().map((type) => ({
type,
name: this.hass.localize(
`ui.panel.lovelace.editor.section_strategy.${type}.name`
),
description: this.hass.localize(
`ui.panel.lovelace.editor.section_strategy.${type}.description`
),
}));
}
private _customOptions(): StrategyOption[] {
return getCustomStrategiesForType("section").map((strategy) => ({
type: CUSTOM_TYPE_PREFIX + strategy.type,
name: strategy.name || strategy.type,
description: strategy.description || "",
}));
}
private async _loadPreviews(params: CreateSectionDialogParams) {
const types = [
...this._availableBuiltInTypes(),
...getCustomStrategiesForType("section").map(
(strategy) => CUSTOM_TYPE_PREFIX + strategy.type
),
];
await Promise.all(
types.map(async (type) => {
try {
const strategy = (await getLovelaceStrategy(
"section",
type
)) as LovelaceSectionStrategy;
const stubConfig = await strategy.getStubConfig?.(this.hass);
if (!stubConfig || this._params !== params) {
return;
}
this._previews = {
...this._previews,
[type]: { strategy: stubConfig },
};
} catch (_err: unknown) {
// The tile is shown without a preview
}
})
);
}
protected render() {
if (!this._params) {
return nothing;
}
const customOptions = this._customOptions();
return html`
<ha-adaptive-dialog
.open=${this._open}
width="large"
@closed=${this._dialogClosed}
header-title=${this.hass.localize(
"ui.panel.lovelace.editor.select_section_strategy.header"
)}
>
<ha-icon-button-arrow-prev
slot="headerNavigationIcon"
@click=${this._back}
></ha-icon-button-arrow-prev>
${
customOptions.length
? html`
<h3>
${this.hass.localize(
"ui.panel.lovelace.editor.select_section_strategy.built_in"
)}
</h3>
`
: nothing
}
<div class="options">
${this._builtInOptions().map((option) => this._renderOption(option))}
</div>
${
customOptions.length
? html`
<h3>
${this.hass.localize(
"ui.panel.lovelace.editor.select_section_strategy.custom"
)}
</h3>
<div class="options">
${customOptions.map((option) => this._renderOption(option))}
</div>
`
: nothing
}
</ha-adaptive-dialog>
`;
}
private _renderOption(option: StrategyOption) {
const preview = this._previews[option.type];
return html`
<div
class="option"
role="button"
tabindex="0"
aria-label=${option.name}
data-type=${option.type}
@click=${this._strategyPicked}
@keydown=${this._handleKeyDown}
>
<div class="header">
<h4>${option.name}</h4>
<p>${option.description}</p>
</div>
${
preview
? html`
<div class="preview" inert>
<hui-section
.hass=${this.hass}
.config=${preview}
preview
></hui-section>
</div>
`
: nothing
}
<ha-ripple></ha-ripple>
</div>
`;
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
(ev.currentTarget as HTMLElement).click();
}
}
private _back() {
showCreateSectionDialog(this, this._params!);
this.closeDialog();
}
private _strategyPicked(ev: Event) {
const type = (ev.currentTarget as HTMLElement).dataset.type!;
const { lovelace, containerPath } = this._params!;
const sectionsPath = [...containerPath, "sections"];
const sectionCount =
getAtPath<unknown[]>(lovelace.config, sectionsPath)?.length ?? 0;
showEditSectionDialog(this, {
lovelace,
lovelaceConfig: lovelace.config,
saveConfig: (newConfig) => {
lovelace.saveConfig(newConfig);
},
path: [...sectionsPath, sectionCount],
newSection: this._previews[type] ?? { strategy: { type } },
});
this.closeDialog();
}
static styles = css`
h3 {
margin: 0 0 var(--ha-space-3) 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
}
.options + h3 {
margin-top: var(--ha-space-6);
}
.options {
display: grid;
gap: var(--ha-space-4);
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
}
.option {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
cursor: pointer;
outline: none;
}
.option:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.header {
flex: 1;
padding: var(--ha-space-3) var(--ha-space-4);
}
h4 {
margin: 0 0 var(--ha-space-1) 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
}
p {
margin: 0;
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
}
.preview {
height: 200px;
overflow: hidden;
padding: var(--ha-space-3);
background: var(--primary-background-color);
pointer-events: none;
mask-image: linear-gradient(to bottom, black 75%, transparent);
}
hui-section {
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-select-section-strategy": HuiDialogSelectSectionStrategy;
}
}
@@ -19,71 +19,8 @@ import {
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import { DEFAULT_MAX_COLUMNS } from "../../views/const";
const COLUMN_SPAN_NAMES = ["single", "double", "triple"] as const;
const MAX_NAMED_COLUMN_SPAN = COLUMN_SPAN_NAMES.length + 1;
const computeColumnSpanLabel = (
span: number,
maxColumns: number,
localize: LocalizeFunc
) => {
if (span >= maxColumns) {
return localize(
"ui.panel.lovelace.editor.edit_section.settings.column_span_options.full"
);
}
return localize(
`ui.panel.lovelace.editor.edit_section.settings.column_span_options.${COLUMN_SPAN_NAMES[span - 1]}`
);
};
const computeColumnSpanSchemas = (
maxColumns: number,
localize: LocalizeFunc
) => {
if (maxColumns <= 1) {
return [];
}
if (maxColumns > MAX_NAMED_COLUMN_SPAN) {
return [
{
name: "column_span",
selector: {
number: { min: 1, max: maxColumns, slider_ticks: true },
},
},
] as const;
}
const options = Array.from({ length: maxColumns }, (_, i) => {
const span = i + 1;
return {
value: String(span),
label: computeColumnSpanLabel(span, maxColumns, localize),
image: {
src: `/static/images/form/section_column_span_${span}_of_${maxColumns}.svg`,
src_dark: `/static/images/form/section_column_span_${span}_of_${maxColumns}_dark.svg`,
flip_rtl: true,
},
};
});
return [
{
name: "column_span",
selector: {
select: {
mode: "box",
box_max_columns:
maxColumns === MAX_NAMED_COLUMN_SPAN ? 2 : maxColumns,
options,
},
},
},
] as const;
};
interface SettingsData {
column_span?: string | number;
column_span?: number;
background_enabled?: boolean;
background_color?: string;
background_opacity?: number;
@@ -99,30 +36,20 @@ export class HuiDialogEditSection extends LitElement {
@property({ attribute: false }) public viewConfig!: LovelaceViewConfig;
private get _maxColumns() {
return this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS;
}
private get _displayedColumnSpan() {
return String(
Math.min(
this.config.column_span ?? DEFAULT_SECTION_COLUMN_SPAN,
this._maxColumns
)
);
}
private get _columnSpanFormValue() {
if (this._maxColumns > MAX_NAMED_COLUMN_SPAN) {
return Number(this._displayedColumnSpan);
}
return this._displayedColumnSpan;
}
private _schema = memoizeOne(
(localize: LocalizeFunc, maxColumns: number) =>
(maxColumns: number, localize: LocalizeFunc) =>
[
...computeColumnSpanSchemas(maxColumns, localize),
{
name: "column_span",
default: DEFAULT_SECTION_COLUMN_SPAN,
selector: {
number: {
min: 1,
max: maxColumns,
slider_ticks: true,
},
},
},
{
name: "background_enabled",
selector: { boolean: {} },
@@ -182,14 +109,17 @@ export class HuiDialogEditSection extends LitElement {
const background = resolveSectionBackground(this.config.background);
const data: SettingsData = {
column_span: this._columnSpanFormValue,
column_span: this.config.column_span,
background_enabled: backgroundEnabled,
background_color: background?.color,
background_opacity: background?.opacity,
theme: this.config.theme,
};
const schema = this._schema(this._localize, this._maxColumns);
const schema = this._schema(
this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS,
this._localize
);
return html`
<ha-form
@@ -224,15 +154,10 @@ export class HuiDialogEditSection extends LitElement {
...this.config,
};
if (newData.column_span === undefined) {
if (newData.column_span) {
newConfig.column_span = newData.column_span;
} else {
delete newConfig.column_span;
} else if (String(newData.column_span) !== this._displayedColumnSpan) {
const columnSpan = Number(newData.column_span);
if (columnSpan === DEFAULT_SECTION_COLUMN_SPAN) {
delete newConfig.column_span;
} else {
newConfig.column_span = columnSpan;
}
}
if (newData.background_enabled) {
@@ -1,30 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { Lovelace } from "../../types";
import type { LovelacePath } from "../lovelace-path";
export interface CreateSectionDialogParams {
lovelace: Lovelace;
containerPath: LovelacePath;
}
export const showCreateSectionDialog = (
element: HTMLElement,
params: CreateSectionDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-create-section",
dialogImport: () => import("./hui-dialog-create-section"),
dialogParams: params,
});
};
export const showSelectSectionStrategyDialog = (
element: HTMLElement,
params: CreateSectionDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-select-section-strategy",
dialogImport: () => import("./hui-dialog-select-section-strategy"),
dialogParams: params,
});
};
@@ -1,5 +1,4 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { Lovelace } from "../../types";
import type { LovelacePath } from "../lovelace-path";
@@ -9,7 +8,6 @@ export interface EditSectionDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelacePath;
newSection?: LovelaceSectionRawConfig;
}
const importEditSectionDialog = () => import("./hui-dialog-edit-section");
+1 -5
View File
@@ -279,8 +279,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
}
private async _initializeConfig() {
const config = this.config;
let sectionConfig = { ...config };
let sectionConfig = { ...this.config };
let isStrategy = false;
if (isStrategySection(sectionConfig)) {
@@ -289,9 +288,6 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
sectionConfig,
this.hass!
);
if (config !== this.config) {
return;
}
}
sectionConfig = {
+1 -5
View File
@@ -35,11 +35,7 @@ export type LovelaceDashboardStrategyGetCreateSuggestions = (
export interface LovelaceViewStrategy extends LovelaceStrategy<LovelaceViewConfig> {}
export interface LovelaceSectionStrategy extends LovelaceStrategy<LovelaceSectionConfig> {
getStubConfig?(
hass: HomeAssistant
): LovelaceStrategyConfig | Promise<LovelaceStrategyConfig>;
}
export interface LovelaceSectionStrategy extends LovelaceStrategy<LovelaceSectionConfig> {}
export interface LovelaceStrategyEditor extends LovelaceGenericElementEditor {
setConfig(config: LovelaceStrategyConfig): void;
@@ -40,10 +40,6 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
);
}
public static getStubConfig(): CommonControlsSectionStrategyConfig {
return { type: "common-controls" };
}
static async generate(
config: CommonControlsSectionStrategyConfig,
hass: HomeAssistant
@@ -30,7 +30,6 @@ import "../components/hui-section-edit-mode";
import { addSection } from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import { getAtPath, moveAtPath } from "../editor/lovelace-path";
import { showCreateSectionDialog } from "../editor/section-editor/show-create-section-dialog";
import type { HuiSection } from "../sections/hui-section";
import "../sections/hui-section-background";
import type { Lovelace } from "../types";
@@ -475,10 +474,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
}
private _createSection(): void {
showCreateSectionDialog(this, {
lovelace: this.lovelace!,
containerPath: this.path!,
});
const newConfig = addSection(
this.lovelace!.config,
this.path!,
generateDefaultSection(this.hass.localize, true)
);
this.lovelace!.saveConfig(newConfig);
}
private _sectionMoved(ev: CustomEvent) {
-17
View File
@@ -93,22 +93,6 @@ const scenesTableParams = {
assistants: "string?",
} satisfies Redirect["params"];
// The scripts table reads these from the link
const scriptsTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
floor: "string?",
area: "string?",
device: "string?",
entity: "string?",
label: "string?",
category: "string?",
assistants: "string?",
blueprint: "string?",
} satisfies Redirect["params"];
export const getMyRedirects = (): Redirects => ({
application_credentials: {
redirect: "/config/application_credentials",
@@ -347,7 +331,6 @@ export const getMyRedirects = (): Redirects => ({
scripts: {
component: "script",
redirect: "/config/script/dashboard",
params: scriptsTableParams,
},
helpers: {
redirect: "/config/helpers",
+8 -35
View File
@@ -751,6 +751,10 @@
}
}
},
"log_viewer": {
"scroll_down_button": "New logs - Click to scroll",
"no_matches": "No log lines match the search"
},
"logbook": {
"entries_not_found": "No activity found.",
"automation_triggered": "Triggered",
@@ -4985,7 +4989,7 @@
"log_file_disabled_debug_download": {
"environment": "Debug logging has been disabled, but no log file was downloaded because HA_DISABLE_LOG_FILE is set and Home Assistant is not writing a log file."
},
"scroll_down_button": "New logs - Click to scroll",
"scroll_down_button": "[%key:ui::components::log_viewer::scroll_down_button%]",
"live": "Live",
"provider_not_found": "Log provider not found",
"provider_not_available": "Logs for ''{provider}'' are not available on your system.",
@@ -7766,8 +7770,7 @@
"live_log": {
"title": "Live log",
"waiting": "Waiting for log messages from {integration}…",
"unsupported": "This version of Home Assistant can't stream the log.",
"no_matches": "No log lines match the search"
"unsupported": "This version of Home Assistant can't stream the log."
},
"state": {
"loaded": "Loaded",
@@ -10405,32 +10408,8 @@
"error_section": {
"title": "Error"
},
"section_strategy": {
"common-controls": {
"name": "Common controls",
"description": "Shows the entities you use most, based on your activity."
}
},
"create_section": {
"header": "Create section",
"manual": "Manual section",
"manual_description": "Start empty and add your own cards.",
"automatic": "[%key:ui::panel::lovelace::editor::section::automatic%]",
"automatic_description": "Fills itself with cards and stays up to date.",
"paste": "Paste section",
"paste_description": "Add the section you copied."
},
"select_section_strategy": {
"header": "Choose an automatic section",
"built_in": "Built-in sections",
"custom": "Community sections"
},
"edit_section": {
"header": "Section configuration",
"typed_header": "{type} section configuration",
"preview": "Preview",
"preview_desktop": "Desktop preview",
"preview_mobile": "Mobile preview",
"header": "Edit section",
"tab_strategy": "Configuration",
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_view::tab_visibility%]",
"tab_appearance": "Appearance",
@@ -10438,13 +10417,7 @@
"edit_yaml": "[%key:ui::panel::lovelace::editor::edit_view::edit_yaml%]",
"settings": {
"column_span": "Width",
"column_span_helper": "Smaller screens may show it narrower, and phones stack sections one under the other.",
"column_span_options": {
"single": "Single",
"double": "Double",
"triple": "Triple",
"full": "Full width"
},
"column_span_helper": "Larger sections will be made smaller to fit the display. (for example, on mobile devices)",
"background": "Background options",
"background_enabled": "Background",
"background_enabled_helper": "Display a colored background behind the section",
@@ -1,73 +0,0 @@
import { describe, expect, it } from "vitest";
import {
scriptsFilterQueryParamConfig,
scriptsFilterQueryParams,
scriptsFilterQueryString,
scriptsLinkFromUrl,
} from "../../../src/common/url/scripts-query-params";
import { createQueryString } from "../../../src/common/url/query-params";
const filter = (value: string[] | Record<string, string[]> | undefined) => ({
value,
items: undefined,
});
describe("scripts query params", () => {
it("reads nothing without table params", () => {
expect(scriptsLinkFromUrl("?historyBack=1")).toBeUndefined();
});
it("replaces every filter and the search with what a link asks for", () => {
expect(scriptsLinkFromUrl("?blueprint=abc")).toEqual({
search: "",
filters: {
"ha-filter-floor-areas": filter(undefined),
"ha-filter-devices": filter([]),
"ha-filter-entities": filter([]),
"ha-filter-labels": filter([]),
"ha-filter-categories": filter([]),
"ha-filter-voice-assistants": filter([]),
"ha-filter-blueprints": filter(["abc"]),
},
});
});
it("round trips the filters", () => {
const filters = {
"ha-filter-floor-areas": filter({ floors: ["ground"], areas: ["hall"] }),
"ha-filter-devices": filter(["a", "b"]),
"ha-filter-entities": filter(["light.hall"]),
"ha-filter-labels": filter(["test"]),
"ha-filter-categories": filter(["cat"]),
"ha-filter-voice-assistants": filter(["conversation"]),
"ha-filter-blueprints": filter(["confirmable_notification.yaml"]),
};
expect(
scriptsLinkFromUrl(
createQueryString(
scriptsFilterQueryParams(filters),
scriptsFilterQueryParamConfig
)
)
).toEqual({ search: "", filters });
});
it("compares the same filters in another order as equal", () => {
expect(
scriptsFilterQueryString({ "ha-filter-labels": filter(["b", "a"]) })
).toBe(
scriptsFilterQueryString({ "ha-filter-labels": filter(["a", "b"]) })
);
expect(scriptsFilterQueryString({ "ha-filter-labels": filter([]) })).toBe(
scriptsFilterQueryString({})
);
});
it("keeps one category, as the category filter picks one", () => {
expect(
scriptsLinkFromUrl("?category=a,b")?.filters["ha-filter-categories"]
).toEqual(filter(["a"]));
});
});
+333
View File
@@ -0,0 +1,333 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "../../src/components/ha-log-viewer";
vi.mock("../../src/components/ha-button", () => ({}));
vi.mock("../../src/components/ha-svg-icon", () => ({}));
const MAX_RETAINED_LINES = 5000;
const LOG_LINE_HEIGHT = 20;
const LOG_VIEWPORT_HEIGHT = 200;
const ANIMATION_FRAME_DURATION = 16;
const FILTER_DEBOUNCE = 150;
let viewer: HTMLElementTagNameMap["ha-log-viewer"];
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
if (viewer) {
viewer.remove();
}
vi.useRealTimers();
vi.restoreAllMocks();
vi.clearAllMocks();
});
const openLogViewer = async () => {
viewer = document.createElement("ha-log-viewer");
Object.assign(viewer, {
_i18n: { localize: (key: string) => key },
});
document.body.append(viewer);
await viewer.updateComplete;
const scrollContainer =
viewer.shadowRoot!.querySelector<HTMLElement>(".log")!;
scrollContainer.scrollTo = vi.fn();
const logRenderer = viewer.shadowRoot!.querySelector("ha-ansi-to-html")!;
await logRenderer.updateComplete;
vi.mocked(scrollContainer.scrollTo).mockClear();
const renderedLog = logRenderer.shadowRoot!.querySelector("pre")!;
vi.spyOn(scrollContainer, "scrollHeight", "get").mockImplementation(() => {
const renderedRows = Array.from(renderedLog.querySelectorAll("div"));
const visibleRows = renderedRows.filter(
(row) => row.style.display !== "none"
);
return visibleRows.length * LOG_LINE_HEIGHT;
});
vi.spyOn(scrollContainer, "clientHeight", "get").mockReturnValue(
LOG_VIEWPORT_HEIGHT
);
return { renderedLog, scrollContainer };
};
const getVisibleLines = (renderedLog: HTMLPreElement) =>
Array.from(renderedLog.querySelectorAll("div"))
.filter((row) => row.style.display !== "none")
.map((row) => row.textContent);
const createLogLines = (firstLineNumber: number, numberOfLines: number) => {
const lines: string[] = [];
for (let index = 0; index < numberOfLines; index++) {
const lineNumber = firstLineNumber + index;
lines.push(`line ${lineNumber}`);
}
return lines;
};
const openViewerWithFullLog = async () => {
const logViewer = await openLogViewer();
const initialLines = createLogLines(0, MAX_RETAINED_LINES);
viewer.addLines(initialLines);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
return logViewer;
};
const expectRetainedLines = (
renderedLog: HTMLPreElement,
expectedLines: string[]
) => {
const renderedRows = renderedLog.querySelectorAll("div");
const renderedLines = Array.from(renderedRows, (row) => row.textContent);
expect(viewer.lines).toEqual(expectedLines);
expect(renderedLines).toEqual(expectedLines);
};
const search = async (value: string) => {
viewer.filter = value;
await viewer.updateComplete;
await vi.advanceTimersByTimeAsync(FILTER_DEBOUNCE);
};
describe("ha-log-viewer retention", { timeout: 15000 }, () => {
it("renders lines added before the first render", async () => {
viewer = document.createElement("ha-log-viewer");
Object.assign(viewer, {
_i18n: { localize: (key: string) => key },
});
viewer.addLines(["early 1", "early 2"]);
document.body.append(viewer);
await viewer.updateComplete;
viewer.shadowRoot!.querySelector<HTMLElement>(".log")!.scrollTo = vi.fn();
const logRenderer = viewer.shadowRoot!.querySelector("ha-ansi-to-html")!;
await logRenderer.updateComplete;
const renderedLog = logRenderer.shadowRoot!.querySelector("pre")!;
expectRetainedLines(renderedLog, ["early 1", "early 2"]);
});
it("evicts the oldest stored and DOM lines as new batches arrive", async () => {
const { renderedLog } = await openViewerWithFullLog();
const initialLines = createLogLines(0, MAX_RETAINED_LINES);
expectRetainedLines(renderedLog, initialLines);
const linesPerBatch = 100;
const numberOfBatches = 3;
let totalReceivedLines = MAX_RETAINED_LINES;
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const incomingLines = createLogLines(totalReceivedLines, linesPerBatch);
viewer.addLines(incomingLines);
totalReceivedLines += linesPerBatch;
const firstRetainedLineNumber = totalReceivedLines - MAX_RETAINED_LINES;
const expectedLines = createLogLines(
firstRetainedLineNumber,
MAX_RETAINED_LINES
);
expectRetainedLines(renderedLog, expectedLines);
}
});
it("retains only the newest lines up to the limit from an oversized multiline batch", async () => {
const { renderedLog } = await openLogViewer();
const excessLineCount = 1000;
const incomingLines = createLogLines(
0,
MAX_RETAINED_LINES + excessLineCount
);
const multilineMessages: string[] = [];
for (let lineIndex = 0; lineIndex < incomingLines.length; lineIndex += 2) {
const firstLine = incomingLines[lineIndex];
const secondLine = incomingLines[lineIndex + 1];
multilineMessages.push(`${firstLine}\n${secondLine}`);
}
viewer.addLines(multilineMessages);
const expectedLines = createLogLines(excessLineCount, MAX_RETAINED_LINES);
expectRetainedLines(renderedLog, expectedLines);
viewer.addLines([]);
expectRetainedLines(renderedLog, expectedLines);
});
it("preserves the reading position when lines above it are removed", async () => {
const { renderedLog, scrollContainer } = await openViewerWithFullLog();
const initialScrollTop = 2007;
scrollContainer.scrollTop = initialScrollTop;
const incomingLines = createLogLines(MAX_RETAINED_LINES, 2);
viewer.addLines(incomingLines);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
const removedLineCount = incomingLines.length;
const removedHeight = removedLineCount * LOG_LINE_HEIGHT;
const expectedScrollTop = initialScrollTop - removedHeight;
const expectedLines = createLogLines(removedLineCount, MAX_RETAINED_LINES);
expect(scrollContainer.scrollTop).toBe(expectedScrollTop);
expectRetainedLines(renderedLog, expectedLines);
});
it("scrolls to the top when the line being read is removed", async () => {
const { renderedLog, scrollContainer } = await openViewerWithFullLog();
scrollContainer.scrollTop = 7;
const incomingLines = createLogLines(MAX_RETAINED_LINES, 2);
viewer.addLines(incomingLines);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
const removedLineCount = incomingLines.length;
const expectedLines = createLogLines(removedLineCount, MAX_RETAINED_LINES);
expect(scrollContainer.scrollTop).toBe(0);
expectRetainedLines(renderedLog, expectedLines);
});
it("continues following the bottom after evicting old lines", async () => {
const { renderedLog, scrollContainer } = await openViewerWithFullLog();
const bottomScrollPosition =
scrollContainer.scrollHeight - scrollContainer.clientHeight;
scrollContainer.scrollTop = bottomScrollPosition;
const incomingLines = createLogLines(MAX_RETAINED_LINES, 1);
viewer.addLines(incomingLines);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
const removedLineCount = incomingLines.length;
const expectedLines = createLogLines(removedLineCount, MAX_RETAINED_LINES);
expect(scrollContainer.scrollTop).toBe(scrollContainer.scrollHeight);
expectRetainedLines(renderedLog, expectedLines);
});
it("clears the stored and rendered lines", async () => {
const { renderedLog } = await openLogViewer();
viewer.addLines(["one", "two"]);
viewer.clear();
expectRetainedLines(renderedLog, []);
});
});
describe("ha-log-viewer high volume", { timeout: 15000 }, () => {
it("bounds high-volume batches without exceeding the function argument limit", async () => {
const { renderedLog } = await openLogViewer();
const batchSize = 150000;
for (let batchIndex = 0; batchIndex < 2; batchIndex++) {
viewer.addLines(createLogLines(batchIndex * batchSize, batchSize));
const firstRetainedLine =
(batchIndex + 1) * batchSize - MAX_RETAINED_LINES;
expectRetainedLines(
renderedLog,
createLogLines(firstRetainedLine, MAX_RETAINED_LINES)
);
}
});
it("keeps following after a scroll event caused by trimming a batch", async () => {
const { scrollContainer } = await openViewerWithFullLog();
scrollContainer.scrollTop =
scrollContainer.scrollHeight - LOG_VIEWPORT_HEIGHT;
viewer.addLines(createLogLines(MAX_RETAINED_LINES, 10));
scrollContainer.dispatchEvent(new Event("scroll"));
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
expect(scrollContainer.scrollTop).toBe(scrollContainer.scrollHeight);
});
it("shows the new logs indicator when scrolled up", async () => {
const { scrollContainer } = await openLogViewer();
viewer.addLines(createLogLines(0, 50));
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
scrollContainer.scrollTop = 107;
scrollContainer.dispatchEvent(new Event("scroll"));
viewer.addLines(["new line"]);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
expect(scrollContainer.scrollTop).toBe(107);
expect(viewer["_newLogsIndicator"]).toBe(true);
});
it("disables the new logs indicator while it is hidden", async () => {
const { scrollContainer } = await openLogViewer();
const indicator = viewer.shadowRoot!.querySelector<
HTMLElement & { disabled: boolean }
>(".new-logs-indicator")!;
expect(indicator.disabled).toBe(true);
viewer.addLines(createLogLines(0, 50));
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
scrollContainer.scrollTop = 107;
scrollContainer.dispatchEvent(new Event("scroll"));
viewer.addLines(["new line"]);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
await viewer.updateComplete;
expect(indicator.disabled).toBe(false);
});
});
describe("ha-log-viewer search", () => {
it("hides lines that don't match and reports when nothing matches", async () => {
const { renderedLog } = await openLogViewer();
viewer.addLines(["first match", "other", "second match"]);
await search("match");
expect(getVisibleLines(renderedLog)).toEqual([
"first match",
"second match",
]);
expect(viewer["_noMatches"]).toBe(false);
await search("missing");
expect(viewer["_noMatches"]).toBe(true);
});
it("filters lines added while a search is active", async () => {
const { renderedLog } = await openLogViewer();
viewer.addLines(["first match"]);
await search("match");
viewer.addLines(["other", "second match"]);
expect(getVisibleLines(renderedLog)).toEqual([
"first match",
"second match",
]);
});
it("reports no matches when the only matching line is evicted", async () => {
const { renderedLog } = await openLogViewer();
viewer.addLines(["match", ...createLogLines(1, MAX_RETAINED_LINES - 1)]);
await search("match");
expect(viewer["_noMatches"]).toBe(false);
viewer.addLines(createLogLines(MAX_RETAINED_LINES, 1));
expect(getVisibleLines(renderedLog)).toEqual([]);
expect(viewer["_noMatches"]).toBe(true);
});
it("keeps the reading position while searching and scrolls down when cleared", async () => {
const { scrollContainer } = await openViewerWithFullLog();
await search("line 1");
expect(scrollContainer.scrollTo).not.toHaveBeenCalled();
await search("");
expect(scrollContainer.scrollTo).toHaveBeenCalledTimes(1);
});
});
@@ -9,15 +9,25 @@ vi.mock("../../../../src/components/input/ha-input-search", () => ({}));
vi.mock("../../../../src/components/ha-button", () => ({}));
vi.mock("../../../../src/components/ha-dialog", () => ({}));
vi.mock("../../../../src/components/ha-dialog-footer", () => ({}));
vi.mock("../../../../src/components/ha-log-viewer", () => {
class HaLogViewerStub extends HTMLElement {
public lines: string[] = [];
public addLines(lines: string[]) {
this.lines = this.lines.concat(lines);
}
public clear() {
this.lines = [];
}
}
customElements.define("ha-log-viewer", HaLogViewerStub);
return {};
});
vi.mock("../../../../src/data/error_log", () => ({
subscribeIntegrationLog: vi.fn(async () => vi.fn()),
}));
const MAX_RETAINED_LINES = 5000;
const LOG_LINE_HEIGHT = 20;
const LOG_VIEWPORT_HEIGHT = 200;
const ANIMATION_FRAME_DURATION = 16;
let dialog: HTMLElementTagNameMap["dialog-live-log"];
const connectionListeners: Record<string, () => void> = {};
@@ -56,249 +66,34 @@ const mountDialog = async () => {
const openLogDialog = async () => {
await mountDialog();
const logRenderer = dialog.shadowRoot!.querySelector("ha-ansi-to-html")!;
await logRenderer.updateComplete;
const renderedLog = logRenderer.shadowRoot!.querySelector("pre")!;
const scrollContainer =
dialog.shadowRoot!.querySelector<HTMLElement>(".log")!;
vi.spyOn(scrollContainer, "scrollHeight", "get").mockImplementation(() => {
const renderedRows = Array.from(renderedLog.querySelectorAll("div"));
const visibleRows = renderedRows.filter(
(row) => row.style.display !== "none"
);
return visibleRows.length * LOG_LINE_HEIGHT;
});
vi.spyOn(scrollContainer, "clientHeight", "get").mockReturnValue(
LOG_VIEWPORT_HEIGHT
);
scrollContainer.scrollTo = vi.fn();
const logViewer = dialog.shadowRoot!.querySelector("ha-log-viewer")!;
const subscriptionCall = vi.mocked(subscribeIntegrationLog).mock.calls[0];
const receiveLogLines = subscriptionCall[2];
return { receiveLogLines, renderedLog, scrollContainer };
return { receiveLogLines, logViewer };
};
const getRenderedLines = (renderedLog: HTMLPreElement) =>
Array.from(renderedLog.querySelectorAll("div"), (row) => row.textContent);
const createLogLines = (firstLineNumber: number, numberOfLines: number) => {
const lines: string[] = [];
for (let index = 0; index < numberOfLines; index++) {
const lineNumber = firstLineNumber + index;
lines.push(`line ${lineNumber}`);
}
return lines;
};
const openDialogWithFullLog = async () => {
const logDialog = await openLogDialog();
const initialLines = createLogLines(0, MAX_RETAINED_LINES);
logDialog.receiveLogLines(initialLines);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
return logDialog;
};
const expectRetainedLines = (
renderedLog: HTMLPreElement,
expectedLines: string[]
) => {
const storedLines = dialog["_lines"];
const renderedRows = renderedLog.querySelectorAll("div");
const renderedLines = Array.from(renderedRows, (row) => row.textContent);
expect(storedLines).toEqual(expectedLines);
expect(renderedLines).toEqual(expectedLines);
};
describe("dialog-live-log retention", { timeout: 15000 }, () => {
it("evicts the oldest stored and DOM lines as new batches arrive", async () => {
const { receiveLogLines, renderedLog } = await openDialogWithFullLog();
const initialLines = createLogLines(0, MAX_RETAINED_LINES);
expectRetainedLines(renderedLog, initialLines);
const linesPerBatch = 100;
const numberOfBatches = 3;
let totalReceivedLines = MAX_RETAINED_LINES;
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const incomingLines = createLogLines(totalReceivedLines, linesPerBatch);
receiveLogLines(incomingLines);
totalReceivedLines += linesPerBatch;
const firstRetainedLineNumber = totalReceivedLines - MAX_RETAINED_LINES;
const expectedLines = createLogLines(
firstRetainedLineNumber,
MAX_RETAINED_LINES
);
expectRetainedLines(renderedLog, expectedLines);
}
});
it("retains only the newest lines up to the limit from an oversized multiline batch", async () => {
const { receiveLogLines, renderedLog } = await openLogDialog();
const excessLineCount = 1000;
const incomingLines = createLogLines(
0,
MAX_RETAINED_LINES + excessLineCount
);
const multilineMessages: string[] = [];
for (let lineIndex = 0; lineIndex < incomingLines.length; lineIndex += 2) {
const firstLine = incomingLines[lineIndex];
const secondLine = incomingLines[lineIndex + 1];
multilineMessages.push(`${firstLine}\n${secondLine}`);
}
receiveLogLines(multilineMessages);
const expectedLines = createLogLines(excessLineCount, MAX_RETAINED_LINES);
expectRetainedLines(renderedLog, expectedLines);
receiveLogLines([]);
expectRetainedLines(renderedLog, expectedLines);
});
it("preserves the reading position when lines above it are removed", async () => {
const { receiveLogLines, renderedLog, scrollContainer } =
await openDialogWithFullLog();
const initialScrollTop = 2007;
scrollContainer.scrollTop = initialScrollTop;
const incomingLines = createLogLines(MAX_RETAINED_LINES, 2);
receiveLogLines(incomingLines);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
const removedLineCount = incomingLines.length;
const removedHeight = removedLineCount * LOG_LINE_HEIGHT;
const expectedScrollTop = initialScrollTop - removedHeight;
const expectedLines = createLogLines(removedLineCount, MAX_RETAINED_LINES);
expect(scrollContainer.scrollTop).toBe(expectedScrollTop);
expectRetainedLines(renderedLog, expectedLines);
});
it("scrolls to the top when the line being read is removed", async () => {
const { receiveLogLines, renderedLog, scrollContainer } =
await openDialogWithFullLog();
scrollContainer.scrollTop = 7;
const incomingLines = createLogLines(MAX_RETAINED_LINES, 2);
receiveLogLines(incomingLines);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
const removedLineCount = incomingLines.length;
const expectedLines = createLogLines(removedLineCount, MAX_RETAINED_LINES);
expect(scrollContainer.scrollTop).toBe(0);
expectRetainedLines(renderedLog, expectedLines);
});
it("continues following the bottom after evicting old lines", async () => {
const { receiveLogLines, renderedLog, scrollContainer } =
await openDialogWithFullLog();
const bottomScrollPosition =
scrollContainer.scrollHeight - scrollContainer.clientHeight;
scrollContainer.scrollTop = bottomScrollPosition;
const incomingLines = createLogLines(MAX_RETAINED_LINES, 1);
receiveLogLines(incomingLines);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
const removedLineCount = incomingLines.length;
const expectedLines = createLogLines(removedLineCount, MAX_RETAINED_LINES);
expect(scrollContainer.scrollTop).toBe(scrollContainer.scrollHeight);
expectRetainedLines(renderedLog, expectedLines);
});
});
describe("dialog-live-log high volume", { timeout: 15000 }, () => {
it("bounds high-volume batches without exceeding the function argument limit", async () => {
const { receiveLogLines, renderedLog } = await openLogDialog();
const batchSize = 150000;
for (let batchIndex = 0; batchIndex < 2; batchIndex++) {
receiveLogLines(createLogLines(batchIndex * batchSize, batchSize));
const firstRetainedLine =
(batchIndex + 1) * batchSize - MAX_RETAINED_LINES;
expectRetainedLines(
renderedLog,
createLogLines(firstRetainedLine, MAX_RETAINED_LINES)
);
}
});
it("keeps following after a scroll event caused by trimming a batch", async () => {
const { receiveLogLines, scrollContainer } = await openDialogWithFullLog();
scrollContainer.scrollTop =
scrollContainer.scrollHeight - LOG_VIEWPORT_HEIGHT;
receiveLogLines(createLogLines(MAX_RETAINED_LINES, 10));
scrollContainer.dispatchEvent(new Event("scroll"));
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
expect(scrollContainer.scrollTop).toBe(scrollContainer.scrollHeight);
});
it("shows the new logs indicator when scrolled up", async () => {
const { receiveLogLines, scrollContainer } = await openLogDialog();
receiveLogLines(createLogLines(0, 50));
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
scrollContainer.scrollTop = 107;
scrollContainer.dispatchEvent(new Event("scroll"));
receiveLogLines(["new line"]);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
expect(scrollContainer.scrollTop).toBe(107);
expect(dialog["_newLogsIndicator"]).toBe(true);
});
it("disables the new logs indicator while it is hidden", async () => {
const { receiveLogLines, scrollContainer } = await openLogDialog();
const indicator = dialog.shadowRoot!.querySelector<
HTMLElement & { disabled: boolean }
>(".new-logs-indicator")!;
expect(indicator.disabled).toBe(true);
receiveLogLines(createLogLines(0, 50));
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
scrollContainer.scrollTop = 107;
scrollContainer.dispatchEvent(new Event("scroll"));
receiveLogLines(["new line"]);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
await dialog.updateComplete;
expect(indicator.disabled).toBe(false);
});
});
describe("dialog-live-log connection", () => {
it("subscribes again without duplicating the backlog after a reconnect", async () => {
const { receiveLogLines, renderedLog } = await openLogDialog();
const { receiveLogLines, logViewer } = await openLogDialog();
receiveLogLines(["backlog 1", "backlog 2"]);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
expect(dialog["_lineCount"]).toBe(2);
connectionListeners.disconnected();
await dialog.updateComplete;
expect(dialog["_live"]).toBe(false);
connectionListeners.ready();
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
await vi.advanceTimersByTimeAsync(0);
expect(subscribeIntegrationLog).toHaveBeenCalledTimes(2);
expect(dialog["_live"]).toBe(true);
expect(dialog["_lineCount"]).toBe(0);
const receiveAfterReconnect = vi.mocked(subscribeIntegrationLog).mock
.calls[1][2];
receiveAfterReconnect(["backlog 1", "backlog 2", "new"]);
await vi.advanceTimersByTimeAsync(ANIMATION_FRAME_DURATION);
expect(getRenderedLines(renderedLog)).toEqual([
"backlog 1",
"backlog 2",
"new",
]);
expect(logViewer.lines).toEqual(["backlog 1", "backlog 2", "new"]);
expect(dialog["_lineCount"]).toBe(3);
});
it("ignores a failed subscription when removed before it settles", async () => {
@@ -372,40 +167,3 @@ describe("dialog-live-log subscription errors", () => {
expect(dialog["_live"]).toBe(true);
});
});
describe("dialog-live-log search", () => {
const search = async (value: string) => {
const input = dialog.shadowRoot!.querySelector("ha-input-search")!;
input.value = value;
input.dispatchEvent(new Event("input"));
await vi.advanceTimersByTimeAsync(150);
};
it("hides lines that don't match and reports when nothing matches", async () => {
const { receiveLogLines, renderedLog } = await openLogDialog();
receiveLogLines(["first match", "other", "second match"]);
await search("match");
const visibleRows = Array.from(renderedLog.querySelectorAll("div")).filter(
(row) => row.style.display !== "none"
);
expect(visibleRows.map((row) => row.textContent)).toEqual([
"first match",
"second match",
]);
expect(dialog["_noMatches"]).toBe(false);
await search("missing");
expect(dialog["_noMatches"]).toBe(true);
});
it("keeps the reading position while searching and scrolls down when cleared", async () => {
const { scrollContainer } = await openDialogWithFullLog();
await search("line 1");
expect(scrollContainer.scrollTo).not.toHaveBeenCalled();
await search("");
expect(scrollContainer.scrollTo).toHaveBeenCalledTimes(1);
});
});