diff --git a/hassio/src/addon-view/hassio-addon-dashboard.ts b/hassio/src/addon-view/hassio-addon-dashboard.ts index 6c6ec8f12bba..e4b0c5f3a758 100644 --- a/hassio/src/addon-view/hassio-addon-dashboard.ts +++ b/hassio/src/addon-view/hassio-addon-dashboard.ts @@ -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%; } diff --git a/hassio/src/addon-view/log/hassio-addon-log-tab.ts b/hassio/src/addon-view/log/hassio-addon-log-tab.ts index df727abad018..04ce803dfc5b 100644 --- a/hassio/src/addon-view/log/hassio-addon-log-tab.ts +++ b/hassio/src/addon-view/log/hassio-addon-log-tab.ts @@ -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` +
- + .header=${this.addon.name} + .provider=${this.addon.slug} + show + .filter=${this._filter} + > +
`; } + 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); + } } `, ]; diff --git a/hassio/src/addon-view/log/hassio-addon-logs.ts b/hassio/src/addon-view/log/hassio-addon-logs.ts deleted file mode 100644 index c2cd53f962fc..000000000000 --- a/hassio/src/addon-view/log/hassio-addon-logs.ts +++ /dev/null @@ -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 { - super.connectedCallback(); - await this._loadData(); - } - - protected render(): TemplateResult { - return html` -

${this.addon.name}

- - ${this._error - ? html`${this._error}` - : ""} -
- ${this._content - ? html`` - : ""} -
-
- - ${this.supervisor.localize("common.refresh")} - -
-
- `; - } - - static get styles(): CSSResultGroup { - return [ - haStyle, - hassioStyle, - css` - :host, - ha-card { - display: block; - } - `, - ]; - } - - private async _loadData(): Promise { - 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 { - await this._loadData(); - } -} - -declare global { - interface HTMLElementTagNameMap { - "hassio-addon-logs": HassioAddonLogs; - } -} diff --git a/hassio/src/system/hassio-supervisor-log.ts b/hassio/src/system/hassio-supervisor-log.ts index 6d426566161a..f64f5f8f8530 100644 --- a/hassio/src/system/hassio-supervisor-log.ts +++ b/hassio/src/system/hassio-supervisor-log.ts @@ -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, diff --git a/src/components/ha-ansi-to-html.ts b/src/components/ha-ansi-to-html.ts index f788a82fd11a..68f9d12aba5c 100644 --- a/src/components/ha-ansi-to-html.ts +++ b/src/components/ha-ansi-to-html.ts @@ -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`
`;
+  }
+
+  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 = "";
+    }
   }
 }
 
diff --git a/src/data/hassio/supervisor.ts b/src/data/hassio/supervisor.ts
index 25807089a5fd..8bf9d715703d 100644
--- a/src/data/hassio/supervisor.ts
+++ b/src/data/hassio/supervisor.ts
@@ -177,10 +177,34 @@ export const fetchHassioInfo = async (
   );
 };
 
-export const fetchHassioLogs = async (hass: HomeAssistant, provider: string) =>
-  hass.callApi(
+export const fetchHassioLogs = async (
+  hass: HomeAssistant,
+  provider: string,
+  range?: string
+) =>
+  hass.callApiRaw(
+    "GET",
+    `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`
+    `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
diff --git a/src/panels/config/logs/dialog-download-logs.ts b/src/panels/config/logs/dialog-download-logs.ts
new file mode 100644
index 000000000000..cd711fa5a2b4
--- /dev/null
+++ b/src/panels/config/logs/dialog-download-logs.ts
@@ -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`
+      
+        
+          
+          
+            ${this.hass.localize("ui.panel.config.logs.download_full_log")}
+          
+           ${this._dialogParams.header} 
+        
+        
+
+ ${this.hass.localize( + "ui.panel.config.logs.select_number_of_lines" + )}: +
+ + ${numberOfLinesOptions.map( + (option) => html` + + ${option} + + ` + )} + +
+
+ + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.download")} + +
+
+ `; + } + + 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; + } +} diff --git a/src/panels/config/logs/error-log-card.ts b/src/panels/config/logs/error-log-card.ts index e7c88ef4ac45..de1c3378dc32 100644 --- a/src/panels/config/logs/error-log-card.ts +++ b/src/panels/config/logs/error-log-card.ts @@ -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; + + @query("#scroll-top-marker") private _scrollTopMarkerElement?: HTMLElement; - @state() private _logHTML?: TemplateResult[] | TemplateResult | string; + @query("#scroll-bottom-marker") + private _scrollBottomMarkerElement?: HTMLElement; + + @query("ha-ansi-to-html") private _ansiToHtmlElement?: HaAnsiToHtml; + + @state() private _firstCursor?: string; + + @state() private _scrolledToBottomController = + new IntersectionController(this, { + callback(this: IntersectionController, entries) { + return entries[0].isIntersecting; + }, + }); + + @state() private _scrolledToTopController = + new IntersectionController(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`
${this._error ? html`${this._error}` : ""} - ${this._logHTML - ? html` - -
-

- ${this.header || - this.hass.localize("ui.panel.config.logs.show_full_logs")} -

-
- - -
-
-
${this._logHTML}
-
- ` - : ""} - ${!this._logHTML + +
+

+ ${this.header || + this.hass.localize("ui.panel.config.logs.show_full_logs")} +

+
+ + ${!this._streamSupported || this._error + ? html`` + : nothing} +
+
+
+
+ ${this._loadingPrevState === "loading" + ? html`
+ +
` + : nothing} + ${this._loadingState === "loading" + ? html`
+ ${this.hass.localize("ui.panel.config.logs.loading_log")} +
` + : this._loadingState === "empty" + ? html`
+ ${this.hass.localize("ui.panel.config.logs.no_errors")} +
` + : nothing} + ${this._loadingState === "loaded" && + this.filter && + this._noSearchResults + ? html`
+ ${this.hass.localize( + "ui.panel.config.logs.no_issues_search", + { term: this.filter } + )} +
` + : nothing} + +
+
+ + + ${this.hass.localize("ui.panel.config.logs.scroll_down_button")} + + +
+ ${this.show === false ? html` - + ${this.hass.localize("ui.panel.config.logs.download_full_log")} - - + + ${this.hass.localize("ui.panel.config.logs.load_logs")} ` @@ -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 { - const button = ev.currentTarget as any; - button.progress = true; + disconnectedCallback() { + super.disconnectedCallback(); + + if (this._logStreamAborter) { + this._logStreamAborter.abort(); + } - await this._refreshLogs(); - button.progress = false; + window.removeEventListener( + "connection-status", + this._handleConnectionStatus + ); } private async _downloadFullLog(): Promise { - 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 { - this._logHTML = this.hass.localize("ui.panel.config.logs.loading_log"); - let log: string; - - 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"); + private _showLogs(): void { + this.show = true; + } + + private async _loadLogs(): Promise { + 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 (!log) { - this._logHTML = this.hass.localize("ui.panel.config.logs.no_errors"); - return; + + if (!response.body) { + throw new Error("No stream body found"); } - this._logHTML = 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) } - ); + + 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), + }); } + } + + private _debounceSearch = debounce(() => { + this._noSearchResults = !this._ansiToHtmlElement?.filterLines(this.filter); - this._isLogLoaded = true; + 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(); + } + } + } - const split = log && log.split("\n"); + private _handleConnectionStatus = (ev: HASSDomEvent) => { + if (ev.detail === "disconnected" && this._logStreamAborter) { + this._logStreamAborter.abort(); + } + if (ev.detail === "connected" && this.show) { + this._loadLogs(); + } + }; - 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`
${entry}
`; - - if (entry.includes("WARNING")) - return html`
${entry}
`; - - if ( - entry.includes("ERROR") || - entry.includes("FATAL") || - entry.includes("CRITICAL") - ) - return html`
${entry}
`; - - return html`
${entry}
`; - }) - : this.hass.localize("ui.panel.config.logs.no_errors"); + 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; } `; } diff --git a/src/panels/config/logs/ha-config-logs.ts b/src/panels/config/logs/ha-config-logs.ts index aa7b87692b61..40e0f90b9288 100644 --- a/src/panels/config/logs/ha-config-logs.ts +++ b/src/panels/config/logs/ha-config-logs.ts @@ -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}`); } diff --git a/src/panels/config/logs/show-dialog-download-logs.ts b/src/panels/config/logs/show-dialog-download-logs.ts new file mode 100644 index 000000000000..e61d0c22ad7e --- /dev/null +++ b/src/panels/config/logs/show-dialog-download-logs.ts @@ -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, + }); +}; diff --git a/src/state/connection-mixin.ts b/src/state/connection-mixin.ts index 533532c57f58..c7b6832ef670 100644 --- a/src/state/connection-mixin.ts +++ b/src/state/connection-mixin.ts @@ -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 = >( }, 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[2] diff --git a/src/translations/en.json b/src/translations/en.json index 029a4cffc85f..de751f46ba6d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2473,6 +2473,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!", @@ -2491,7 +2492,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": { diff --git a/src/types.ts b/src/types.ts index d5d36f27e8a8..d0f154fdeace 100644 --- a/src/types.ts +++ b/src/types.ts @@ -255,6 +255,13 @@ export interface HomeAssistant { parameters?: Record, headers?: Record ): Promise; + callApiRaw( + method: "GET" | "POST" | "PUT" | "DELETE", + path: string, + parameters?: Record, + headers?: Record, + signal?: AbortSignal + ): Promise; fetchWithAuth(path: string, init?: Record): Promise; sendWS(msg: MessageBase): void; callWS(msg: MessageBase): Promise; diff --git a/src/util/hass-call-api.ts b/src/util/hass-call-api.ts index 4214ff46a01e..1d8f0bd78b37 100644 --- a/src/util/hass-call-api.ts +++ b/src/util/hass-call-api.ts @@ -70,3 +70,28 @@ export default async function hassCallApi( return handleFetchPromise(fetchWithAuth(auth, url, init)); } + +export async function hassCallApiRaw( + auth: Auth, + method: string, + path: string, + parameters?: Record, + headers?: Record, + 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); +}