mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-27 03:06:41 +00:00
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:
parent
f1ab24da99
commit
ca20c2d292
@ -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%;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
146
src/panels/config/logs/dialog-download-logs.ts
Normal file
146
src/panels/config/logs/dialog-download-logs.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
18
src/panels/config/logs/show-dialog-download-logs.ts
Normal file
18
src/panels/config/logs/show-dialog-download-logs.ts
Normal 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,
|
||||
});
|
||||
};
|
@ -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]
|
||||
|
@ -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": {
|
||||
|
@ -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>;
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user