Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld aa298cbf66 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-19 10:00:30 +02:00
5 changed files with 302 additions and 241 deletions

No files matched your search

+5
View File
@@ -40,6 +40,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);
+254
View File
@@ -0,0 +1,254 @@
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();
this._renderLines(newLines);
if (overflow) {
this._trimLines();
}
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[]) {
if (!this.filter) {
this._ansiToHtml.parseLinesToColoredPre(lines);
return;
}
this._ansiToHtml.filterLines("");
this._ansiToHtml.parseLinesToColoredPre(lines);
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() {
const logElement = this._logElement;
const scrollTopBeforeTrim = logElement.scrollTop;
const scrollHeightBeforeTrim = logElement.scrollHeight;
this._ansiToHtml.trimLines(this.maxLines);
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 -213
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,153 +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;
}
if (!this._search) {
ansiToHtml.parseLinesToColoredPre(lines);
return;
}
// ha-ansi-to-html re-filters all lines after every added line while a
// search is set, so search once after adding them
ansiToHtml.filterLines("");
ansiToHtml.parseLinesToColoredPre(lines);
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(
@@ -342,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
@@ -353,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">
@@ -431,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 {
@@ -485,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
@@ -747,6 +747,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",
@@ -4917,7 +4921,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.",
@@ -7676,8 +7680,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",