Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld bb8cde69f1 Only drop the search highlight this renderer registered
CSS.highlights is document-wide and every renderer shares the
search-results key. Disconnecting a renderer removed whatever highlight
was registered, so closing the logs dialog cleared the highlights of the
error log card still mounted behind it. Keep the Highlight the renderer
created and only delete the entry when it is still that one.
2026-10-22 17:08:46 +02:00
Maarten Lakerveld 2b67447629 Make the log viewport keyboard focusable
The Z-Wave logs page lost keyboard access when its readonly textarea was
replaced by the shared viewer. Safari does not focus scroll containers on
its own, so give the viewport a tabindex and an inset focus ring that the
host's overflow clipping does not hide.
2026-10-22 17:08:46 +02:00
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
7 changed files with 674 additions and 507 deletions

No files matched your search

+20 -4
View File
@@ -50,6 +50,8 @@ export class HaAnsiToHtml extends LitElement {
@litState() private _filter = "";
private _highlight?: Highlight;
// Hidden lines the user chose to show while a filter is set.
private _expandedLines = new WeakSet<HTMLDivElement>();
@@ -60,6 +62,11 @@ export class HaAnsiToHtml extends LitElement {
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._clearHighlight();
}
protected firstUpdated(_changedProperties: PropertyValues<this>): void {
super.firstUpdated(_changedProperties);
@@ -362,9 +369,7 @@ export class HaAnsiToHtml extends LitElement {
line.classList.remove("context");
});
numberOfFoundLines = lines.length;
if (CSS.highlights) {
CSS.highlights.delete("search-results");
}
this._clearHighlight();
} else {
const highlightRanges: Range[] = [];
@@ -461,7 +466,8 @@ export class HaAnsiToHtml extends LitElement {
}
});
if (CSS.highlights) {
CSS.highlights.set("search-results", new Highlight(...highlightRanges));
this._highlight = new Highlight(...highlightRanges);
CSS.highlights.set("search-results", this._highlight);
}
if (this.contextLines && numberOfFoundLines) {
@@ -476,6 +482,16 @@ export class HaAnsiToHtml extends LitElement {
return !!numberOfFoundLines;
}
private _clearHighlight() {
if (
this._highlight &&
CSS.highlights?.get("search-results") === this._highlight
) {
CSS.highlights.delete("search-results");
}
this._highlight = undefined;
}
// Filtering runs again for every new log line, so rows for unchanged gaps
// are kept rather than recreated, which would drop keyboard focus.
private _updateExpandButtons(lines: HTMLDivElement[], visible: boolean[]) {
+255
View File
@@ -0,0 +1,255 @@
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" tabindex="0" @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);
}
.log:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
.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;
}
+6 -3
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",
+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);
});
});