mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-23 22:24:24 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb8cde69f1 | ||
|
|
2b67447629 | ||
|
|
47329f348f | ||
|
|
ce801c9057 | ||
|
|
2f06c77961 |
No files matched your search
@@ -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[]) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user