Config logs streaming (#22172)

* Add logs follow for error-log-card WIP

* Add stream config logs

* Add new logs indicator to error-log-card

* Add number of lines select for error-log-card

* Add improvements and nr of lines to error-log-card

* Fix error-log-card linter issue

* Use error-log-card in addon views

* Remove unused hassio-addon-logs

* Add backwards compatibility for error-log-card

* Remove version test flag in error-log-card

* Add recovery mode support to error-log-card

* Add search highlight for error-log-card

* Add search, add additional lines to ha-ansi-to-html

* Add infinity load older logs in error-log-card

* Fix hassio-supervisor-log using fetchHassioLogs

* Fix colored lines in ha-ansi-to-html

* Fix search and prevent empty parts in ha-ansi-to-html

* Fix load old logs initially in error-log-card

* Add download log lines dialog

* Fix load logs without stream in error-log-card

* Fix ha-ansi-to-html search

* Add debounce scroll for core provider in error-log-card

* Add hass.callApiRaw

* Fix variable naming for dialog-download-logs

* Improve scroll down wording in error-log-card
This commit is contained in:
Wendelin 2024-10-23 13:07:00 +02:00 committed by GitHub
parent f1ab24da99
commit ca20c2d292
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 881 additions and 259 deletions

View File

@ -37,7 +37,6 @@ import "./config/hassio-addon-config";
import "./config/hassio-addon-network";
import "./hassio-addon-router";
import "./info/hassio-addon-info";
import "./log/hassio-addon-logs";
@customElement("hassio-addon-dashboard")
class HassioAddonDashboard extends LitElement {
@ -161,16 +160,11 @@ class HassioAddonDashboard extends LitElement {
margin-bottom: 24px;
width: 600px;
}
hassio-addon-logs {
max-width: calc(100% - 8px);
min-width: 600px;
}
@media only screen and (max-width: 600px) {
hassio-addon-info,
hassio-addon-network,
hassio-addon-audio,
hassio-addon-config,
hassio-addon-logs {
hassio-addon-config {
max-width: 100%;
min-width: 100%;
}

View File

@ -1,12 +1,14 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import "../../../../src/components/ha-circular-progress";
import { HassioAddonDetails } from "../../../../src/data/hassio/addon";
import { Supervisor } from "../../../../src/data/supervisor/supervisor";
import { haStyle } from "../../../../src/resources/styles";
import { HomeAssistant } from "../../../../src/types";
import { hassioStyle } from "../../resources/hassio-style";
import "./hassio-addon-logs";
import "../../../../src/panels/config/logs/error-log-card";
import "../../../../src/components/search-input";
import { extractSearchParam } from "../../../../src/common/url/search-params";
@customElement("hassio-addon-log-tab")
class HassioAddonLogDashboard extends LitElement {
@ -16,6 +18,8 @@ class HassioAddonLogDashboard extends LitElement {
@property({ attribute: false }) public addon?: HassioAddonDetails;
@state() private _filter = extractSearchParam("filter") || "";
protected render(): TemplateResult {
if (!this.addon) {
return html`
@ -23,16 +27,31 @@ class HassioAddonLogDashboard extends LitElement {
`;
}
return html`
<div class="content">
<hassio-addon-logs
<div class="search">
<search-input
@value-changed=${this._filterChanged}
.hass=${this.hass}
.supervisor=${this.supervisor}
.addon=${this.addon}
></hassio-addon-logs>
.filter=${this._filter}
.label=${this.hass.localize("ui.panel.config.logs.search")}
></search-input>
</div>
<div class="content">
<error-log-card
.hass=${this.hass}
.header=${this.addon.name}
.provider=${this.addon.slug}
show
.filter=${this._filter}
>
</error-log-card>
</div>
`;
}
private async _filterChanged(ev) {
this._filter = ev.detail.value;
}
static get styles(): CSSResultGroup {
return [
haStyle,
@ -41,7 +60,21 @@ class HassioAddonLogDashboard extends LitElement {
.content {
margin: auto;
padding: 8px;
max-width: 1024px;
}
.search {
position: sticky;
top: 0;
z-index: 2;
}
search-input {
display: block;
--mdc-text-field-fill-color: var(--sidebar-background-color);
--mdc-text-field-idle-line-color: var(--divider-color);
}
@media all and (max-width: 870px) {
:host {
--error-log-card-height: calc(100vh - 304px);
}
}
`,
];

View File

@ -1,90 +0,0 @@
import "@material/mwc-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../src/components/ha-alert";
import "../../../../src/components/ha-ansi-to-html";
import "../../../../src/components/ha-card";
import {
fetchHassioAddonLogs,
HassioAddonDetails,
} from "../../../../src/data/hassio/addon";
import { extractApiErrorMessage } from "../../../../src/data/hassio/common";
import { Supervisor } from "../../../../src/data/supervisor/supervisor";
import { haStyle } from "../../../../src/resources/styles";
import { HomeAssistant } from "../../../../src/types";
import { hassioStyle } from "../../resources/hassio-style";
@customElement("hassio-addon-logs")
class HassioAddonLogs extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public supervisor!: Supervisor;
@property({ attribute: false }) public addon!: HassioAddonDetails;
@state() private _error?: string;
@state() private _content?: string;
public async connectedCallback(): Promise<void> {
super.connectedCallback();
await this._loadData();
}
protected render(): TemplateResult {
return html`
<h1>${this.addon.name}</h1>
<ha-card outlined>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<div class="card-content">
${this._content
? html`<ha-ansi-to-html
.content=${this._content}
></ha-ansi-to-html>`
: ""}
</div>
<div class="card-actions">
<mwc-button @click=${this._refresh}>
${this.supervisor.localize("common.refresh")}
</mwc-button>
</div>
</ha-card>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
hassioStyle,
css`
:host,
ha-card {
display: block;
}
`,
];
}
private async _loadData(): Promise<void> {
this._error = undefined;
try {
this._content = await fetchHassioAddonLogs(this.hass, this.addon.slug);
} catch (err: any) {
this._error = this.supervisor.localize("addon.logs.get_logs", {
error: extractApiErrorMessage(err),
});
}
}
private async _refresh(): Promise<void> {
await this._loadData();
}
}
declare global {
interface HTMLElementTagNameMap {
"hassio-addon-logs": HassioAddonLogs;
}
}

View File

@ -120,10 +120,12 @@ class HassioSupervisorLog extends LitElement {
this._error = undefined;
try {
this._content = await fetchHassioLogs(
const response = await fetchHassioLogs(
this.hass,
this._selectedLogProvider
);
this._content = await response.text();
} catch (err: any) {
this._error = this.supervisor.localize("system.log.get_logs", {
provider: this._selectedLogProvider,

View File

@ -1,5 +1,17 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import {
customElement,
property,
query,
state as litState,
} from "lit/decorators";
interface State {
bold: boolean;
@ -11,11 +23,24 @@ interface State {
}
@customElement("ha-ansi-to-html")
class HaAnsiToHtml extends LitElement {
export class HaAnsiToHtml extends LitElement {
@property() public content!: string;
@query("pre") private _pre?: HTMLPreElement;
@litState() private _filter = "";
protected render(): TemplateResult | void {
return html`${this._parseTextToColoredPre(this.content)}`;
return html`<pre></pre>`;
}
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
// handle initial content
if (this.content) {
this.parseTextToColoredPre(this.content);
}
}
static get styles(): CSSResultGroup {
@ -24,6 +49,7 @@ class HaAnsiToHtml extends LitElement {
overflow-x: auto;
white-space: pre-wrap;
overflow-wrap: break-word;
margin: 0;
}
.bold {
font-weight: bold;
@ -85,11 +111,33 @@ class HaAnsiToHtml extends LitElement {
.bg-white {
background-color: rgb(204, 204, 204);
}
::highlight(search-results) {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
`;
}
private _parseTextToColoredPre(text) {
const pre = document.createElement("pre");
/**
* add new lines to the log
* @param lines log lines
* @param top should the new lines be added to the top of the log
*/
public parseLinesToColoredPre(lines: string[], top = false) {
for (const line of lines) {
this.parseLineToColoredPre(line, top);
}
}
/**
* Add a single line to the log
* @param line log line
* @param top should the new line be added to the top of the log
*/
public parseLineToColoredPre(line, top = false) {
const lineDiv = document.createElement("div");
// eslint-disable-next-line no-control-regex
const re = /\x1b(?:\[(.*?)[@-~]|\].*?(?:\x07|\x1b\\))/g;
let i = 0;
@ -103,7 +151,7 @@ class HaAnsiToHtml extends LitElement {
backgroundColor: null,
};
const addSpan = (content) => {
const addPart = (content) => {
const span = document.createElement("span");
if (state.bold) {
span.classList.add("bold");
@ -124,15 +172,18 @@ class HaAnsiToHtml extends LitElement {
span.classList.add(`bg-${state.backgroundColor}`);
}
span.appendChild(document.createTextNode(content));
pre.appendChild(span);
lineDiv.appendChild(span);
};
/* eslint-disable no-cond-assign */
let match;
// eslint-disable-next-line
while ((match = re.exec(text)) !== null) {
while ((match = re.exec(line)) !== null) {
const j = match!.index;
addSpan(text.substring(i, j));
const substring = line.substring(i, j);
if (substring) {
addPart(substring);
}
i = j + match[0].length;
if (match[1] === undefined) {
@ -234,9 +285,93 @@ class HaAnsiToHtml extends LitElement {
}
});
}
addSpan(text.substring(i));
return pre;
const substring = line.substring(i);
if (substring) {
addPart(substring);
}
if (top) {
this._pre?.prepend(lineDiv);
lineDiv.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500 });
} else {
this._pre?.appendChild(lineDiv);
}
// filter new lines if a filter is set
if (this._filter) {
this.filterLines(this._filter);
}
}
public parseTextToColoredPre(text) {
const lines = text.split("\n");
for (const line of lines) {
this.parseLineToColoredPre(line);
}
}
/**
* Filter lines based on a search string, lines and search string will be converted to lowercase
* @param filter the search string
* @returns true if there are lines to display
*/
filterLines(filter: string): boolean {
this._filter = filter;
const lines = this.shadowRoot?.querySelectorAll("div") || [];
let numberOfFoundLines = 0;
if (!filter) {
lines.forEach((line) => {
line.style.display = "";
});
numberOfFoundLines = lines.length;
if (CSS.highlights) {
CSS.highlights.delete("search-results");
}
} else {
const highlightRanges: Range[] = [];
lines.forEach((line) => {
if (!line.textContent?.toLowerCase().includes(filter.toLowerCase())) {
line.style.display = "none";
} else {
line.style.display = "";
numberOfFoundLines++;
if (CSS.highlights && line.firstChild !== null && line.textContent) {
const spansOfLine = line.querySelectorAll("span");
spansOfLine.forEach((span) => {
const text = span.textContent.toLowerCase();
const indices: number[] = [];
let startPos = 0;
while (startPos < text.length) {
const index = text.indexOf(filter.toLowerCase(), startPos);
if (index === -1) break;
indices.push(index);
startPos = index + filter.length;
}
indices.forEach((index) => {
const range = new Range();
range.setStart(span.firstChild!, index);
range.setEnd(span.firstChild!, index + filter.length);
highlightRanges.push(range);
});
});
}
}
});
if (CSS.highlights) {
CSS.highlights.set("search-results", new Highlight(...highlightRanges));
}
}
return !!numberOfFoundLines;
}
public clear() {
if (this._pre) {
this._pre.innerHTML = "";
}
}
}

View File

@ -177,10 +177,34 @@ export const fetchHassioInfo = async (
);
};
export const fetchHassioLogs = async (hass: HomeAssistant, provider: string) =>
hass.callApi<string>(
export const fetchHassioLogs = async (
hass: HomeAssistant,
provider: string,
range?: string
) =>
hass.callApiRaw(
"GET",
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs`
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs`,
undefined,
range
? {
Range: range,
}
: undefined
);
export const fetchHassioLogsFollow = async (
hass: HomeAssistant,
provider: string,
signal: AbortSignal,
lines = 100
) =>
hass.callApiRaw(
"GET",
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs/follow?lines=${lines}`,
undefined,
undefined,
signal
);
export const getHassioLogDownloadUrl = (provider: string) =>
@ -188,6 +212,11 @@ export const getHassioLogDownloadUrl = (provider: string) =>
provider.includes("_") ? `addons/${provider}` : provider
}/logs`;
export const getHassioLogDownloadLinesUrl = (provider: string, lines: number) =>
`/api/hassio/${
provider.includes("_") ? `addons/${provider}` : provider
}/logs?lines=${lines}`;
export const setSupervisorOption = async (
hass: HomeAssistant,
data: SupervisorOptions

View File

@ -0,0 +1,146 @@
import { mdiClose } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import "../../../components/ha-md-dialog";
import "../../../components/ha-button";
import "../../../components/ha-dialog-header";
import "../../../components/ha-icon-button";
import type { HaMdDialog } from "../../../components/ha-md-dialog";
import { HomeAssistant } from "../../../types";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import { fireEvent } from "../../../common/dom/fire_event";
import { DownloadLogsDialogParams } from "./show-dialog-download-logs";
import "../../../components/ha-select";
import "../../../components/ha-list-item";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { getHassioLogDownloadLinesUrl } from "../../../data/hassio/supervisor";
import { getSignedPath } from "../../../data/auth";
import { fileDownload } from "../../../util/file_download";
@customElement("dialog-download-logs")
class DownloadLogsDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _dialogParams?: DownloadLogsDialogParams;
@state() private _lineCount = 100;
@query("ha-md-dialog") private _dialogElement!: HaMdDialog;
public showDialog(dialogParams: DownloadLogsDialogParams) {
this._dialogParams = dialogParams;
this._lineCount = this._dialogParams?.defaultLineCount ?? 100;
}
public closeDialog() {
this._dialogElement.close();
}
private _dialogClosed() {
this._dialogParams = undefined;
this._lineCount = 100;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._dialogParams) {
return nothing;
}
const numberOfLinesOptions = [100, 500, 1000, 5000, 10000];
if (!numberOfLinesOptions.includes(this._lineCount)) {
numberOfLinesOptions.push(this._lineCount);
numberOfLinesOptions.sort((a, b) => a - b);
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title" id="dialog-light-color-favorite-title">
${this.hass.localize("ui.panel.config.logs.download_full_log")}
</span>
<span slot="subtitle"> ${this._dialogParams.header} </span>
</ha-dialog-header>
<div slot="content" class="content">
<div>
${this.hass.localize(
"ui.panel.config.logs.select_number_of_lines"
)}:
</div>
<ha-select
.label=${this.hass.localize("ui.panel.config.logs.lines")}
@selected=${this._setNumberOfLogs}
fixedMenuPosition
naturalMenuWidth
@closed=${stopPropagation}
.value=${String(this._lineCount)}
>
${numberOfLinesOptions.map(
(option) => html`
<ha-list-item .value=${String(option)}>
${option}
</ha-list-item>
`
)}
</ha-select>
</div>
<div slot="actions">
<ha-button @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._dowloadLogs}>
${this.hass.localize("ui.common.download")}
</ha-button>
</div>
</ha-md-dialog>
`;
}
private async _dowloadLogs() {
const provider = this._dialogParams!.provider;
const timeString = new Date().toISOString().replace(/:/g, "-");
const downloadUrl = getHassioLogDownloadLinesUrl(provider, this._lineCount);
const logFileName =
provider !== "core"
? `${provider}_${timeString}.log`
: `home-assistant_${timeString}.log`;
const signedUrl = await getSignedPath(this.hass, downloadUrl);
fileDownload(signedUrl.path, logFileName);
this.closeDialog();
}
private _setNumberOfLogs(ev) {
this._lineCount = Number(ev.target.value);
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
:host {
direction: var(--direction);
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-download-logs": DownloadLogsDialog;
}
}

View File

@ -1,6 +1,5 @@
import "@material/mwc-button";
import "@material/mwc-list/mwc-list-item";
import { mdiRefresh, mdiDownload } from "@mdi/js";
import { mdiArrowCollapseDown, mdiDownload, mdiRefresh } from "@mdi/js";
import {
css,
CSSResultGroup,
@ -8,15 +7,21 @@ import {
LitElement,
PropertyValues,
TemplateResult,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { classMap } from "lit/directives/class-map";
// eslint-disable-next-line import/extensions
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import { customElement, property, state, query } from "lit/decorators";
import "../../../components/ha-alert";
import "../../../components/ha-ansi-to-html";
import type { HaAnsiToHtml } from "../../../components/ha-ansi-to-html";
import "../../../components/ha-card";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-select";
import "../../../components/ha-svg-icon";
import "../../../components/ha-circular-progress";
import { getSignedPath } from "../../../data/auth";
@ -24,11 +29,19 @@ import { fetchErrorLog, getErrorLogDownloadUrl } from "../../../data/error_log";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import {
fetchHassioLogs,
fetchHassioLogsFollow,
getHassioLogDownloadUrl,
} from "../../../data/hassio/supervisor";
import { HomeAssistant } from "../../../types";
import { debounce } from "../../../common/util/debounce";
import { fileDownload } from "../../../util/file_download";
import { HASSDomEvent } from "../../../common/dom/fire_event";
import { ConnectionStatus } from "../../../data/connection-status";
import { atLeastVersion } from "../../../common/config/version";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { debounce } from "../../../common/util/debounce";
import { showDownloadLogsDialog } from "./show-dialog-download-logs";
const NUMBER_OF_LINES = 100;
@customElement("error-log-card")
class ErrorLogCard extends LitElement {
@ -42,52 +55,130 @@ class ErrorLogCard extends LitElement {
@property({ type: Boolean, attribute: true }) public show = false;
@state() private _isLogLoaded = false;
@query(".error-log") private _logElement?: HTMLElement;
@state() private _logHTML?: TemplateResult[] | TemplateResult | string;
@query("#scroll-top-marker") private _scrollTopMarkerElement?: HTMLElement;
@query("#scroll-bottom-marker")
private _scrollBottomMarkerElement?: HTMLElement;
@query("ha-ansi-to-html") private _ansiToHtmlElement?: HaAnsiToHtml;
@state() private _firstCursor?: string;
@state() private _scrolledToBottomController =
new IntersectionController<boolean>(this, {
callback(this: IntersectionController<boolean>, entries) {
return entries[0].isIntersecting;
},
});
@state() private _scrolledToTopController =
new IntersectionController<boolean>(this, {});
@state() private _newLogsIndicator?: boolean;
@state() private _error?: string;
@state() private _logStreamAborter?: AbortController;
@state() private _streamSupported?: boolean;
@state() private _loadingState: "loading" | "empty" | "loaded" = "loading";
@state() private _loadingPrevState?: "loading" | "end" | "loaded";
@state() private _noSearchResults: boolean = false;
@state() private _numberOfLines?: number;
protected render(): TemplateResult {
return html`
<div class="error-log-intro">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
${this._logHTML
<ha-card outlined class=${classMap({ hidden: this.show === false })}>
<div class="header">
<h1 class="card-header">
${this.header ||
this.hass.localize("ui.panel.config.logs.show_full_logs")}
</h1>
<div class="action-buttons">
<ha-icon-button
.path=${mdiDownload}
@click=${this._downloadFullLog}
.label=${this.hass.localize(
"ui.panel.config.logs.download_full_log"
)}
></ha-icon-button>
${!this._streamSupported || this._error
? html`<ha-icon-button
.path=${mdiRefresh}
@click=${this._loadLogs}
.label=${this.hass.localize("ui.common.refresh")}
></ha-icon-button>`
: nothing}
</div>
</div>
<div class="card-content error-log">
<div id="scroll-top-marker"></div>
${this._loadingPrevState === "loading"
? html`<div class="loading-old">
<ha-circular-progress
.indeterminate=${this._loadingPrevState === "loading"}
></ha-circular-progress>
</div>`
: nothing}
${this._loadingState === "loading"
? html`<div>
${this.hass.localize("ui.panel.config.logs.loading_log")}
</div>`
: this._loadingState === "empty"
? html`<div>
${this.hass.localize("ui.panel.config.logs.no_errors")}
</div>`
: nothing}
${this._loadingState === "loaded" &&
this.filter &&
this._noSearchResults
? html`<div>
${this.hass.localize(
"ui.panel.config.logs.no_issues_search",
{ term: this.filter }
)}
</div>`
: nothing}
<ha-ansi-to-html></ha-ansi-to-html>
<div id="scroll-bottom-marker"></div>
</div>
<ha-button
class="new-logs-indicator ${classMap({
visible:
(this._newLogsIndicator &&
!this._scrolledToBottomController.value) ||
false,
})}"
@click=${this._scrollToBottom}
>
<ha-svg-icon
.path=${mdiArrowCollapseDown}
slot="icon"
></ha-svg-icon>
${this.hass.localize("ui.panel.config.logs.scroll_down_button")}
<ha-svg-icon
.path=${mdiArrowCollapseDown}
slot="trailingIcon"
></ha-svg-icon>
</ha-button>
</ha-card>
${this.show === false
? html`
<ha-card outlined>
<div class="header">
<h1 class="card-header">
${this.header ||
this.hass.localize("ui.panel.config.logs.show_full_logs")}
</h1>
<div>
<ha-icon-button
.path=${mdiRefresh}
@click=${this._refresh}
.label=${this.hass.localize("ui.common.refresh")}
></ha-icon-button>
<ha-icon-button
.path=${mdiDownload}
@click=${this._downloadFullLog}
.label=${this.hass.localize(
"ui.panel.config.logs.download_full_log"
)}
></ha-icon-button>
</div>
</div>
<div class="card-content error-log">${this._logHTML}</div>
</ha-card>
`
: ""}
${!this._logHTML
? html`
<mwc-button outlined @click=${this._downloadFullLog}>
<ha-button outlined @click=${this._downloadFullLog}>
<ha-svg-icon .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize("ui.panel.config.logs.download_full_log")}
</mwc-button>
<mwc-button raised @click=${this._refreshLogs}>
</ha-button>
<mwc-button raised @click=${this._showLogs}>
${this.hass.localize("ui.panel.config.logs.load_logs")}
</mwc-button>
`
@ -96,129 +187,307 @@ class ErrorLogCard extends LitElement {
`;
}
private _debounceSearch = debounce(
() => (this._isLogLoaded ? this._refreshLogs() : this._debounceSearch()),
150,
false
);
public connectedCallback() {
super.connectedCallback();
if (this._streamSupported === undefined) {
this._streamSupported = atLeastVersion(
this.hass.config.version,
2024,
11
);
}
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._scrolledToBottomController.observe(this._scrollBottomMarkerElement!);
this._scrolledToTopController.callback = this._handleTopScroll;
this._scrolledToTopController.observe(this._scrollTopMarkerElement!);
window.addEventListener("connection-status", this._handleConnectionStatus);
if (this.hass?.config.recovery_mode || this.show) {
this.hass.loadFragmentTranslation("config");
this._refreshLogs();
}
}
protected updated(changedProps) {
super.updated(changedProps);
if (changedProps.has("provider")) {
this._logHTML = undefined;
}
if (
(changedProps.has("show") && this.show) ||
(changedProps.has("provider") && this.show)
) {
this._refreshLogs();
return;
this._loadLogs();
}
if (this._newLogsIndicator && this._scrolledToBottomController.value) {
this._newLogsIndicator = false;
}
if (changedProps.has("filter")) {
this._debounceSearch();
}
if (
changedProps.has("_loadingState") &&
this._loadingState === "loaded" &&
this._scrolledToTopController.value &&
this._firstCursor &&
!this._loadingPrevState
) {
this._loadMoreLogs();
}
}
private async _refresh(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as any;
button.progress = true;
disconnectedCallback() {
super.disconnectedCallback();
await this._refreshLogs();
button.progress = false;
if (this._logStreamAborter) {
this._logStreamAborter.abort();
}
window.removeEventListener(
"connection-status",
this._handleConnectionStatus
);
}
private async _downloadFullLog(): Promise<void> {
const timeString = new Date().toISOString().replace(/:/g, "-");
const downloadUrl =
this.provider !== "core"
? getHassioLogDownloadUrl(this.provider)
: getErrorLogDownloadUrl;
const logFileName =
this.provider !== "core"
? `${this.provider}_${timeString}.log`
: `home-assistant_${timeString}.log`;
const signedUrl = await getSignedPath(this.hass, downloadUrl);
fileDownload(signedUrl.path, logFileName);
if (this._streamSupported) {
showDownloadLogsDialog(this, {
header: this.header,
provider: this.provider,
defaultLineCount: this._numberOfLines,
});
} else {
const timeString = new Date().toISOString().replace(/:/g, "-");
const downloadUrl =
this.provider && this.provider !== "core"
? getHassioLogDownloadUrl(this.provider)
: getErrorLogDownloadUrl;
const logFileName =
this.provider && this.provider !== "core"
? `${this.provider}_${timeString}.log`
: `home-assistant_${timeString}.log`;
const signedUrl = await getSignedPath(this.hass, downloadUrl);
fileDownload(signedUrl.path, logFileName);
}
}
private async _refreshLogs(): Promise<void> {
this._logHTML = this.hass.localize("ui.panel.config.logs.loading_log");
let log: string;
private _showLogs(): void {
this.show = true;
}
if (this.provider !== "core" && isComponentLoaded(this.hass, "hassio")) {
try {
log = await fetchHassioLogs(this.hass, this.provider);
if (this.filter) {
log = log
.split("\n")
.filter((entry) =>
entry.toLowerCase().includes(this.filter.toLowerCase())
)
.join("\n");
}
if (!log) {
this._logHTML = this.hass.localize("ui.panel.config.logs.no_errors");
return;
}
this._logHTML = html`<ha-ansi-to-html .content=${log}>
</ha-ansi-to-html>`;
this._isLogLoaded = true;
return;
} catch (err: any) {
this._error = this.hass.localize(
"ui.panel.config.logs.failed_get_logs",
{ provider: this.provider, error: extractApiErrorMessage(err) }
private async _loadLogs(): Promise<void> {
this._error = undefined;
this._loadingState = "loading";
this._loadingPrevState = undefined;
this._firstCursor = undefined;
this._numberOfLines = 0;
this._ansiToHtmlElement?.clear();
try {
if (this._logStreamAborter) {
this._logStreamAborter.abort();
}
this._logStreamAborter = new AbortController();
if (
this._streamSupported &&
isComponentLoaded(this.hass, "hassio") &&
this.provider
) {
const response = await fetchHassioLogsFollow(
this.hass,
this.provider,
this._logStreamAborter.signal,
NUMBER_OF_LINES
);
if (response.headers.has("X-First-Cursor")) {
this._firstCursor = response.headers.get("X-First-Cursor")!;
}
if (!response.body) {
throw new Error("No stream body found");
}
this._loadingState = "empty";
let tempLogLine = "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;
while (!done) {
// eslint-disable-next-line no-await-in-loop
const { value, done: readerDone } = await reader.read();
done = readerDone;
if (value) {
const chunk = decoder.decode(value, { stream: !done });
const scrolledToBottom = this._scrolledToBottomController.value;
const lines = `${tempLogLine}${chunk}`
.split("\n")
.filter((line) => line.trim() !== "");
// handle edge case where the last line is not complete
if (chunk.endsWith("\n")) {
tempLogLine = "";
} else {
tempLogLine = lines.splice(-1, 1)[0];
}
if (lines.length) {
this._ansiToHtmlElement?.parseLinesToColoredPre(lines);
this._numberOfLines += lines.length;
if (this._loadingState === "empty") {
// delay to avoid loading older logs immediately
setTimeout(() => {
this._loadingState = "loaded";
}, 100);
}
}
if (scrolledToBottom && this._logElement) {
this._scrollToBottom();
} else {
this._newLogsIndicator = true;
}
}
}
} else {
// fallback to old method
this._streamSupported = false;
let logs = "";
if (isComponentLoaded(this.hass, "hassio") && this.provider) {
const repsonse = await fetchHassioLogs(this.hass, this.provider);
logs = await repsonse.text();
} else {
logs = await fetchErrorLog(this.hass);
}
if (logs) {
this._ansiToHtmlElement?.parseTextToColoredPre(logs);
this._loadingState = "loaded";
this._scrollToBottom();
}
}
} catch (err: any) {
if (err.name === "AbortError") {
return;
}
} else {
log = await fetchErrorLog(this.hass!);
this._error = this.hass.localize("ui.panel.config.logs.failed_get_logs", {
provider: this.provider,
error: extractApiErrorMessage(err),
});
}
this._isLogLoaded = true;
const split = log && log.split("\n");
this._logHTML = split
? (this.filter
? split.filter((entry) => {
if (this.filter) {
return entry.toLowerCase().includes(this.filter.toLowerCase());
}
return entry;
})
: split
).map((entry) => {
if (entry.includes("INFO"))
return html`<div class="info">${entry}</div>`;
if (entry.includes("WARNING"))
return html`<div class="warning">${entry}</div>`;
if (
entry.includes("ERROR") ||
entry.includes("FATAL") ||
entry.includes("CRITICAL")
)
return html`<div class="error">${entry}</div>`;
return html`<div>${entry}</div>`;
})
: this.hass.localize("ui.panel.config.logs.no_errors");
}
private _debounceSearch = debounce(() => {
this._noSearchResults = !this._ansiToHtmlElement?.filterLines(this.filter);
if (!this.filter) {
this._scrollToBottom();
}
}, 150);
private _debounceScrollToBottom = debounce(() => {
this._logElement!.scrollTop = this._logElement!.scrollHeight;
}, 300);
private _scrollToBottom(): void {
if (this._logElement) {
this._newLogsIndicator = false;
if (this.provider !== "core") {
this._logElement!.scrollTo(0, this._logElement!.scrollHeight);
} else {
this._debounceScrollToBottom();
}
}
}
private _handleConnectionStatus = (ev: HASSDomEvent<ConnectionStatus>) => {
if (ev.detail === "disconnected" && this._logStreamAborter) {
this._logStreamAborter.abort();
}
if (ev.detail === "connected" && this.show) {
this._loadLogs();
}
};
private async _loadMoreLogs() {
if (
this._firstCursor &&
this._loadingPrevState !== "loading" &&
this._loadingState === "loaded" &&
this._logElement
) {
const scrolledToBottom = this._scrolledToBottomController.value;
const scrollPositionFromBottom =
this._logElement.scrollHeight - this._logElement.scrollTop;
this._loadingPrevState = "loading";
const response = await fetchHassioLogs(
this.hass,
this.provider,
`entries=${this._firstCursor}:-100:100`
);
if (response.headers.has("X-First-Cursor")) {
if (this._firstCursor === response.headers.get("X-First-Cursor")!) {
this._loadingPrevState = "end";
return;
}
this._firstCursor = response.headers.get("X-First-Cursor")!;
}
const body = await response.text();
if (body) {
const lines = body
.split("\n")
.filter((line) => line.trim() !== "")
.reverse();
this._ansiToHtmlElement?.parseLinesToColoredPre(lines, true);
this._numberOfLines! += lines.length;
this._loadingPrevState = "loaded";
} else {
this._loadingPrevState = "end";
}
if (scrolledToBottom) {
this._scrollToBottom();
} else if (this._loadingPrevState !== "end" && this._logElement) {
window.requestAnimationFrame(() => {
this._logElement!.scrollTop =
this._logElement!.scrollHeight - scrollPositionFromBottom;
});
}
}
}
private _handleTopScroll = (entries) => {
const isVisible = entries[0].isIntersecting;
if (
this._firstCursor &&
isVisible &&
this._loadingState === "loaded" &&
(!this._loadingPrevState || this._loadingPrevState === "loaded") &&
!this.filter
) {
this._loadMoreLogs();
}
return isVisible;
};
static styles: CSSResultGroup = css`
.error-log-intro {
text-align: center;
@ -226,7 +495,18 @@ class ErrorLogCard extends LitElement {
}
ha-card {
padding-top: 16px;
padding-top: 8px;
position: relative;
}
ha-card.hidden {
display: none;
}
ha-card .action-buttons {
display: flex;
align-items: center;
height: 100%;
}
.header {
@ -243,14 +523,11 @@ class ErrorLogCard extends LitElement {
line-height: 48px;
display: block;
margin-block-start: 0px;
margin-block-end: 0px;
font-weight: normal;
}
ha-select {
display: block;
max-width: 500px;
width: 100%;
white-space: nowrap;
max-width: calc(100% - 150px);
overflow: hidden;
text-overflow: ellipsis;
}
ha-icon-button {
@ -258,10 +535,24 @@ class ErrorLogCard extends LitElement {
}
.error-log {
position: relative;
font-family: var(--code-font-family, monospace);
clear: both;
text-align: left;
padding-top: 12px;
padding-bottom: 12px;
overflow-y: scroll;
min-height: var(--error-log-card-height, calc(100vh - 240px));
max-height: var(--error-log-card-height, calc(100vh - 240px));
border-top: 1px solid var(--divider-color);
}
@media all and (max-width: 870px) {
.error-log {
min-height: var(--error-log-card-height, calc(100vh - 190px));
max-height: var(--error-log-card-height, calc(100vh - 190px));
}
}
.error-log > div {
@ -273,6 +564,28 @@ class ErrorLogCard extends LitElement {
background-color: var(--secondary-background-color);
}
.new-logs-indicator {
--mdc-theme-primary: var(--text-primary-color);
overflow: hidden;
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 0;
background-color: var(--primary-color);
border-radius: 8px;
transition: height 0.4s ease-out;
display: flex;
justify-content: space-between;
align-items: center;
}
.new-logs-indicator.visible {
height: 24px;
}
.error {
color: var(--error-color);
}
@ -281,8 +594,11 @@ class ErrorLogCard extends LitElement {
color: var(--warning-color);
}
mwc-button {
direction: var(--direction);
.loading-old {
display: flex;
width: 100%;
justify-content: center;
padding: 16px;
}
`;
}

View File

@ -167,6 +167,7 @@ export class HaConfigLogs extends LitElement {
private _selectProvider(ev) {
this._selectedLogProvider = (ev.currentTarget as any).provider;
this._filter = "";
navigate(`/config/logs?provider=${this._selectedLogProvider}`);
}

View File

@ -0,0 +1,18 @@
import { fireEvent } from "../../../common/dom/fire_event";
export interface DownloadLogsDialogParams {
header?: string;
provider: string;
defaultLineCount?: number;
}
export const showDownloadLogsDialog = (
element: HTMLElement,
dialogParams: DownloadLogsDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-download-logs",
dialogImport: () => import("./dialog-download-logs"),
dialogParams,
});
};

View File

@ -27,11 +27,11 @@ import {
} from "../data/translation";
import { subscribePanels } from "../data/ws-panels";
import { translationMetadata } from "../resources/translations-metadata";
import { Constructor, HomeAssistant, ServiceCallResponse } from "../types";
import type { Constructor, HomeAssistant, ServiceCallResponse } from "../types";
import { getLocalLanguage } from "../util/common-translation";
import { fetchWithAuth } from "../util/fetch-with-auth";
import { getState } from "../util/ha-pref-storage";
import hassCallApi from "../util/hass-call-api";
import hassCallApi, { hassCallApiRaw } from "../util/hass-call-api";
import { HassBaseEl } from "./hass-base-mixin";
import { promiseTimeout } from "../common/util/promise-timeout";
import { subscribeFloorRegistry } from "../data/ws-floor_registry";
@ -160,6 +160,8 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
},
callApi: async (method, path, parameters, headers) =>
hassCallApi(auth, method, path, parameters, headers),
callApiRaw: async (method, path, parameters, headers, signal) =>
hassCallApiRaw(auth, method, path, parameters, headers, signal),
fetchWithAuth: (
path: string,
init: Parameters<typeof fetchWithAuth>[2]

View File

@ -2465,6 +2465,7 @@
"failed_get_logs": "Failed to get {provider} logs, {error}",
"no_issues_search": "No issues found for search term ''{term}''",
"load_logs": "Load full logs",
"nr_of_lines": "Number of lines",
"loading_log": "Loading full log…",
"no_errors": "No errors have been reported",
"no_issues": "There are no new issues!",
@ -2483,7 +2484,10 @@
"custom_integration": "custom integration",
"error_from_custom_integration": "This error originated from a custom integration.",
"show_full_logs": "Show full logs",
"select_number_of_lines": "Select number of lines to download",
"lines": "Lines",
"download_full_log": "Download full log",
"scroll_down_button": "New logs - Click to scroll",
"provider_not_found": "Log provider not found",
"provider_not_available": "Logs for ''{provider}'' are not available on your system.",
"detail": {

View File

@ -255,6 +255,13 @@ export interface HomeAssistant {
parameters?: Record<string, any>,
headers?: Record<string, string>
): Promise<T>;
callApiRaw(
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
parameters?: Record<string, any>,
headers?: Record<string, string>,
signal?: AbortSignal
): Promise<Response>;
fetchWithAuth(path: string, init?: Record<string, any>): Promise<Response>;
sendWS(msg: MessageBase): void;
callWS<T>(msg: MessageBase): Promise<T>;

View File

@ -70,3 +70,28 @@ export default async function hassCallApi<T>(
return handleFetchPromise<T>(fetchWithAuth(auth, url, init));
}
export async function hassCallApiRaw(
auth: Auth,
method: string,
path: string,
parameters?: Record<string, unknown>,
headers?: Record<string, string>,
signal?: AbortSignal
) {
const url = `${auth.data.hassUrl}/api/${path}`;
const init: RequestInit = {
method,
headers: headers || {},
signal: signal,
};
if (parameters) {
// @ts-ignore
init.headers["Content-Type"] = "application/json;charset=UTF-8";
init.body = JSON.stringify(parameters);
}
return fetchWithAuth(auth, url, init);
}