Compare commits

...
6 changed files with 316 additions and 170 deletions

No files matched your search

+4
View File
@@ -58,6 +58,10 @@ export class HaAnsiToHtml extends LitElement {
// The hidden lines each expand row shows when clicked.
private _expandGaps = new WeakMap<HTMLElement, HTMLDivElement[]>();
public get hasLines(): boolean {
return !!this._pre?.childElementCount;
}
protected render(): TemplateResult {
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
}
@@ -0,0 +1,84 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { css, html, LitElement, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import "../animation/ha-fade-in";
import "./ha-skeleton-text";
// Varied widths so the placeholder reads as log lines rather than a block
const LINE_WIDTHS = [72, 58, 86, 64, 44, 78, 52, 90, 60, 70];
const LINE_HEIGHT_EM = 1.5;
/**
* Placeholder for log lines while a log loads. It covers its nearest
* positioned ancestor and shows as many whole lines as fit, so it never
* scrolls. Set `lines` to show that many lines in the flow instead. Like the
* data table's loading rows, the label is given to assistive technology
* rather than shown.
*
* @cssprop --ha-skeleton-log-padding - The space around the lines. Defaults to `var(--ha-space-2)`.
*/
@customElement("ha-skeleton-log")
export class HaSkeletonLog extends LitElement {
@property() public label?: string;
@property({ type: Number, reflect: true }) public lines?: number;
private _fitLineCount = new ResizeController(this, {
callback: (entries) => {
const height = entries[0]?.contentRect.height ?? 0;
const lineHeight =
parseFloat(getComputedStyle(this).fontSize) * LINE_HEIGHT_EM;
return lineHeight ? Math.floor(height / lineHeight) : 0;
},
});
protected render() {
return html`
<ha-fade-in .duration=${300} easing="ease-in">
<div role="progressbar" aria-label=${ifDefined(this.label)}>
${Array.from(
{ length: this.lines ?? this._fitLineCount.value ?? 0 },
(_, i) =>
html`<div class="line">
<ha-skeleton-text
style=${styleMap({
"--ha-skeleton-text-width": `${LINE_WIDTHS[i % LINE_WIDTHS.length]}%`,
})}
></ha-skeleton-text>
</div>`
)}
</div>
</ha-fade-in>
`;
}
static styles = css`
:host {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: var(--ha-skeleton-log-padding, var(--ha-space-2));
overflow: hidden;
}
:host([lines]) {
display: block;
position: static;
}
.line {
display: flex;
align-items: center;
height: ${unsafeCSS(LINE_HEIGHT_EM)}em;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-skeleton-log": HaSkeletonLog;
}
}
@@ -19,6 +19,7 @@ import type { HaLogViewer } from "../../../components/ha-log-viewer";
import "../../../components/ha-svg-icon";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import "../../../components/skeleton/ha-skeleton-log";
import {
connectionContext,
internationalizationContext,
@@ -51,6 +52,9 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
@state() private _live = false;
// The backend answers the subscription once it has read the backlog
@state() private _loading = false;
@state() private _search = "";
@state() private _wrapLines = true;
@@ -84,9 +88,11 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
(lines) => this._addLines(lines)
);
this._unsub = unsub;
this._loading = true;
unsub.then(
() => {
if (this._unsub === unsub) {
this._loading = false;
this._live = true;
}
},
@@ -95,6 +101,7 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
return;
}
this._unsub = undefined;
this._loading = false;
if (err?.code === ERR_UNKNOWN_COMMAND) {
this._unsupported = true;
} else if (
@@ -130,6 +137,7 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
// _subscribe reports subscription errors, so only ignore them here
this._unsub?.then((unsub) => unsub()).catch(() => undefined);
this._unsub = undefined;
this._loading = false;
this._live = false;
}
@@ -209,12 +217,18 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
${
this._lineCount
? nothing
: html`<div class="waiting">
${this._i18n.localize(
"ui.panel.config.integrations.config_entry.live_log.waiting",
{ integration: this.params.name }
)}
</div>`
: this._loading
? html`<ha-skeleton-log
.label=${this._i18n.localize(
"ui.panel.config.logs.loading_log"
)}
></ha-skeleton-log>`
: html`<div class="waiting">
${this._i18n.localize(
"ui.panel.config.integrations.config_entry.live_log.waiting",
{ integration: this.params.name }
)}
</div>`
}
</ha-log-viewer>
${
+22 -14
View File
@@ -35,8 +35,8 @@ import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import "../../../components/skeleton/ha-skeleton-log";
import { getSignedPath } from "../../../data/auth";
@@ -337,9 +337,10 @@ class ErrorLogCard extends LitElement {
<div id="scroll-top-marker"></div>
${
this._loadingPrevState === "loading"
? html`<div class="loading-old">
<ha-spinner></ha-spinner>
</div>`
? html`<ha-skeleton-log
lines="3"
.label=${localize("ui.panel.config.logs.loading_log")}
></ha-skeleton-log>`
: nothing
}
${
@@ -350,9 +351,13 @@ class ErrorLogCard extends LitElement {
)}
</ha-alert>`
: this._loadingState === "loading"
? html`<div>
${localize("ui.panel.config.logs.loading_log")}
</div>`
? // Lines kept while reconnecting stay visible, unless the search hides them all
this._ansiToHtmlElement?.hasLines &&
!(this.filter && this._noSearchResults)
? nothing
: html`<ha-skeleton-log
.label=${localize("ui.panel.config.logs.loading_log")}
></ha-skeleton-log>`
: this._loadingState === "empty"
? html`<div>
${
@@ -749,6 +754,8 @@ class ErrorLogCard extends LitElement {
// The backlog is sent again on every subscription
this._integrationLines = [];
this._ansiToHtmlElement?.clear();
// Show the skeleton again now the retained lines are gone
this.requestUpdate();
// Each subscription starts with the integration's backlog, which are
// merged in time order with the lines sent meanwhile once all arrived.
@@ -1106,6 +1113,14 @@ class ErrorLogCard extends LitElement {
overflow-wrap: break-word;
}
ha-skeleton-log {
--ha-skeleton-log-padding: var(--ha-space-4);
}
ha-skeleton-log[lines] {
--ha-skeleton-log-padding: var(--ha-space-2) 0;
}
.error-log > div:hover {
background-color: var(--secondary-background-color);
}
@@ -1131,13 +1146,6 @@ class ErrorLogCard extends LitElement {
color: var(--warning-color);
}
.loading-old {
display: flex;
width: 100%;
justify-content: center;
padding: 16px;
}
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
--ha-assist-chip-trailing-space: 8px;
+185 -150
View File
@@ -2,10 +2,12 @@ import { mdiDotsVertical, mdiDownload, mdiRefresh, mdiText } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/animation/ha-fade-in";
import "../../../components/buttons/ha-call-service-button";
import "../../../components/ha-alert";
import "../../../components/ha-card";
@@ -14,7 +16,7 @@ import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-spinner";
import "../../../components/skeleton/ha-skeleton-text";
import { getSignedPath } from "../../../data/auth";
import {
getCoreLogFileDownloadUnavailableReason,
@@ -34,6 +36,14 @@ import { showSystemLogDetailDialog } from "./show-dialog-system-log-detail";
import { formatSystemLogTime } from "./util";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
// Primary and secondary line widths, in percent, for the loading rows
const SKELETON_ROW_WIDTHS = [
[72, 40],
[56, 48],
[84, 36],
[64, 44],
];
@customElement("system-log-card")
export class SystemLogCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -128,157 +138,168 @@ export class SystemLogCard extends LitElement {
return html`
<div class="system-log-intro">
<ha-card outlined>
<div class="header">
<h1 class="card-header">
${
this.header ||
this.hass.localize("ui.panel.config.logs.caption")
}
</h1>
<div class="header-buttons">
${
logFileDownloadUnavailableReason
? nothing
: html`<ha-icon-button
.path=${mdiDownload}
@click=${this._downloadLogs}
.label=${this.hass.localize(
"ui.panel.config.logs.download_logs"
)}
></ha-icon-button>`
}
<ha-icon-button
.path=${mdiRefresh}
@click=${this.fetchData}
.label=${this.hass.localize("ui.common.refresh")}
></ha-icon-button>
${
logFileDownloadUnavailableReason && !this.integrations?.length
? nothing
: html`<ha-dropdown @wa-select=${this._handleOverflowAction}>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
.label=${this.hass.localize("ui.common.menu")}
></ha-icon-button>
<ha-dropdown-item value="show-full-logs">
<ha-svg-icon slot="icon" .path=${mdiText}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.logs.show_full_logs"
)}
</ha-dropdown-item>
</ha-dropdown>`
}
</div>
</div>
${
logFileDownloadUnavailableReason
? html`<ha-alert alert-type="warning">
${this.hass.localize(
`ui.panel.config.logs.log_file_disabled.${logFileDownloadUnavailableReason}`
)}
</ha-alert>`
: nothing
}
${
this._items === undefined
? html`
<div class="loading-container">
<ha-spinner></ha-spinner>
? html`<ha-fade-in .duration=${300} easing="ease-in">
<div
class="skeleton"
role="progressbar"
aria-label=${this.hass.localize(
"ui.panel.config.logs.loading_log"
)}
>
${SKELETON_ROW_WIDTHS.map(
([primary, secondary]) => html`
<div class="skeleton-row">
<ha-skeleton-text
class="primary"
style=${styleMap({
"--ha-skeleton-text-width": `${primary}%`,
})}
></ha-skeleton-text>
<ha-skeleton-text
class="secondary"
style=${styleMap({
"--ha-skeleton-text-width": `${secondary}%`,
})}
></ha-skeleton-text>
</div>
`
)}
</div>
`
: html`
<div class="header">
<h1 class="card-header">
${
this.header ||
this.hass.localize("ui.panel.config.logs.caption")
}
</h1>
<div class="header-buttons">
${
logFileDownloadUnavailableReason
? nothing
: html`<ha-icon-button
.path=${mdiDownload}
@click=${this._downloadLogs}
.label=${this.hass.localize(
"ui.panel.config.logs.download_logs"
)}
></ha-icon-button>`
}
<ha-icon-button
.path=${mdiRefresh}
@click=${this.fetchData}
.label=${this.hass.localize("ui.common.refresh")}
></ha-icon-button>
${
logFileDownloadUnavailableReason &&
!this.integrations?.length
? nothing
: html`<ha-dropdown
@wa-select=${this._handleOverflowAction}
>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
.label=${this.hass.localize("ui.common.menu")}
></ha-icon-button>
<ha-dropdown-item value="show-full-logs">
<ha-svg-icon
slot="icon"
.path=${mdiText}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.logs.show_full_logs"
)}
</ha-dropdown-item>
</ha-dropdown>`
}
</ha-fade-in>`
: this._items.length === 0
? html`
<div class="card-content empty-content">
${this.hass.localize("ui.panel.config.logs.no_issues")}
</div>
</div>
${
logFileDownloadUnavailableReason
? html`<ha-alert alert-type="warning">
${this.hass.localize(
`ui.panel.config.logs.log_file_disabled.${logFileDownloadUnavailableReason}`
)}
</ha-alert>`
: nothing
}
${
this._items.length === 0
? html`
<div class="card-content empty-content">
${this.hass.localize("ui.panel.config.logs.no_issues")}
</div>
`
: filteredItems.length === 0 &&
(this.filter || this.integrations?.length)
? html`<div class="card-content">
${
this.filter
? this.hass.localize(
"ui.panel.config.logs.no_issues_search",
{ term: this.filter }
)
: this.hass.localize(
"ui.panel.config.logs.no_issues_integration",
{
integration: formatListWithAnds(
this.hass.locale,
this.integrations!.map((domain) =>
domainToName(this.hass.localize, domain)
)
),
}
)
}
</div>`
: html`<ha-list
>${filteredItems.map(
(item, idx) => html`
<ha-list-item
@click=${this._openLog}
.logItem=${item}
twoline
>
${item.message[0]}
<span slot="secondary" class="secondary">
${this._timestamp(item)} –
${html`(<span class=${item.level}
>${this.hass.localize(
`ui.panel.config.logs.level.${item.level}`
)}</span
>) `}
${
integrations[idx]
? `${domainToName(
this.hass!.localize,
integrations[idx]!
)}${
isCustomIntegrationError(item)
? ` (${this.hass.localize(
"ui.panel.config.logs.custom_integration"
)})`
: ""
}`
: item.source[0]
}
${
item.count > 1
? html` - ${this._multipleMessages(item)} `
: nothing
}
</span>
</ha-list-item>
`
: filteredItems.length === 0 &&
(this.filter || this.integrations?.length)
? html`<div class="card-content">
${
this.filter
? this.hass.localize(
"ui.panel.config.logs.no_issues_search",
{ term: this.filter }
)
: this.hass.localize(
"ui.panel.config.logs.no_issues_integration",
{
integration: formatListWithAnds(
this.hass.locale,
this.integrations!.map((domain) =>
domainToName(
this.hass.localize,
domain
)
)
),
}
)
}
</div>`
: html`<ha-list
>${filteredItems.map(
(item, idx) => html`
<ha-list-item
@click=${this._openLog}
.logItem=${item}
twoline
>
${item.message[0]}
<span slot="secondary" class="secondary">
${this._timestamp(item)} –
${html`(<span class=${item.level}
>${this.hass.localize(
`ui.panel.config.logs.level.${item.level}`
)}</span
>) `}
${
integrations[idx]
? `${domainToName(
this.hass!.localize,
integrations[idx]!
)}${
isCustomIntegrationError(item)
? ` (${this.hass.localize(
"ui.panel.config.logs.custom_integration"
)})`
: ""
}`
: item.source[0]
}
${
item.count > 1
? html`
- ${this._multipleMessages(item)}
`
: nothing
}
</span>
</ha-list-item>
`
)}</ha-list
>`
}
<div class="card-actions">
<ha-call-service-button domain="system_log" service="clear"
>${this.hass.localize(
"ui.panel.config.logs.clear"
)}</ha-call-service-button
>
</div>
`
)}</ha-list
>`
}
${
this._items === undefined
? nothing
: html`<div class="card-actions">
<ha-call-service-button domain="system_log" service="clear"
>${this.hass.localize(
"ui.panel.config.logs.clear"
)}</ha-call-service-button
>
</div>`
}
</ha-card>
</div>
@@ -368,11 +389,25 @@ export class SystemLogCard extends LitElement {
margin: 16px;
}
.loading-container {
height: 100px;
.skeleton {
padding: var(--ha-space-2) 0;
}
.skeleton-row {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
gap: var(--ha-space-2);
height: 72px;
padding: 0 var(--ha-space-5);
}
.skeleton-row .primary {
font-size: var(--ha-font-size-l);
}
.skeleton-row .secondary {
font-size: var(--ha-font-size-m);
}
.secondary {
@@ -9,6 +9,7 @@ vi.mock("../../../../src/components/input/ha-input-search", () => ({}));
vi.mock("../../../../src/components/ha-button", () => ({}));
vi.mock("../../../../src/components/ha-dialog", () => ({}));
vi.mock("../../../../src/components/ha-dialog-footer", () => ({}));
vi.mock("../../../../src/components/skeleton/ha-skeleton-log", () => ({}));
vi.mock("../../../../src/components/ha-log-viewer", () => {
class HaLogViewerStub extends HTMLElement {
public lines: string[] = [];