Compare commits

...
9 changed files with 664 additions and 60 deletions

No files matched your search

@@ -26,8 +26,10 @@ export interface QueryParamsControllerOptions {
* Called when the page of the host is navigated to while the host is
* connected, like a second link to an open page or coming back to it. Read
* the URL with `searchParams()` and apply it, then `write()` the state back.
* Gets the `popstate` or `location-changed` event, or nothing when the host
* is connected again.
*/
navigated: () => void;
navigated: (ev?: Event) => void;
/**
* The path of the host's page, for a page that can first be connected on
* a sub-path it sends on to itself, like an add link. Defaults to the path
@@ -159,7 +161,7 @@ export class QueryParamsController<
}
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
this._options.navigated(ev);
}
};
+16 -1
View File
@@ -1,3 +1,4 @@
import type { SystemLogLevel } from "../../data/system_log";
import {
decodeQueryParams,
hasQueryParams,
@@ -17,7 +18,7 @@ export const logSearchQueryParamConfig = {
// `raw` shows the full Home Assistant Core log instead of the errors.
export const logsQueryParamConfig = {
string: ["provider", "search", "filter"],
list: ["integration"],
list: ["integration", "level"],
boolean: [{ key: "raw", trueValue: "1" }],
} as const satisfies QueryParamConfig;
@@ -28,13 +29,25 @@ export interface LogsState {
search: string;
// Only apply to the Home Assistant Core log
integrations: string[];
// Only apply to the Home Assistant Core errors, not the raw log
levels: SystemLogLevel[];
raw: boolean;
}
const LEVELS: readonly string[] = [
"critical",
"error",
"warning",
] satisfies SystemLogLevel[];
const isLevel = (level: string): level is SystemLogLevel =>
LEVELS.includes(level);
export const DEFAULT_LOGS_STATE: LogsState = {
provider: DEFAULT_LOG_PROVIDER,
search: "",
integrations: [],
levels: [],
raw: false,
};
@@ -53,6 +66,7 @@ export const logsQueryParams = (state: LogsState): LogsQueryParams => ({
search: state.search,
integration:
state.provider === DEFAULT_LOG_PROVIDER ? state.integrations : undefined,
level: state.provider === DEFAULT_LOG_PROVIDER ? state.levels : undefined,
raw: state.provider === DEFAULT_LOG_PROVIDER && state.raw,
});
@@ -71,6 +85,7 @@ export const logsLinkFromUrl = (
provider,
search: params.search ?? params.filter ?? "",
integrations: core ? (params.integration?.filter(Boolean) ?? []) : [],
levels: core ? (params.level?.filter(isLevel) ?? []) : [],
raw: core && !!params.raw,
};
};
+54
View File
@@ -6,6 +6,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import {
@@ -23,10 +24,21 @@ import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-badge";
import "./ha-list";
import "./ha-svg-icon";
import "./ha-tooltip";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
export interface IntegrationFilterCount {
key: string;
path: string;
count: number;
label: string;
color?: string;
}
@customElement("ha-filter-integrations")
export class HaFilterIntegrations extends LitElement {
@property({ attribute: false }) public value?: string[];
@@ -35,6 +47,12 @@ export class HaFilterIntegrations extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
/** Counts to show beside each integration, by domain. */
@property({ attribute: false }) public counts?: Record<
string,
IntegrationFilterCount[]
>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -120,6 +138,7 @@ export class HaFilterIntegrations extends LitElement {
brand-fallback
></ha-domain-icon>
${integration.name}
${this._renderCounts(integration.domain)}
</ha-check-list-item>`
)}
</ha-list>`
@@ -131,6 +150,32 @@ export class HaFilterIntegrations extends LitElement {
`;
}
private _renderCounts(domain: string) {
const counts = this.counts?.[domain];
if (!counts?.length) {
return nothing;
}
return html`<span class="counts">
${counts.map(
(item) =>
html`<ha-badge
id="count-${domain}-${item.key}"
role="img"
aria-label=${item.label}
style=${styleMap({ "--badge-color": item.color })}
>
<ha-svg-icon slot="icon" .path=${item.path}></ha-svg-icon>
${item.count}
</ha-badge>
<ha-tooltip for="count-${domain}-${item.key}">
${item.label}
</ha-tooltip>`
)}
</span>`;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -246,6 +291,15 @@ export class HaFilterIntegrations extends LitElement {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
.counts {
display: inline-flex;
gap: var(--ha-space-1);
margin-inline-start: var(--ha-space-2);
vertical-align: middle;
--ha-badge-size: 22px;
--ha-badge-icon-size: 14px;
--ha-badge-font-size: var(--ha-font-size-xs);
}
`,
];
}
+305 -37
View File
@@ -17,16 +17,14 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { atLeastVersion } from "../../../common/config/version";
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
import { consume } from "../../../common/decorators/consume";
import { storage } from "../../../common/decorators/storage";
import type {
HASSDomEvent,
HASSDomTargetEvent,
} from "../../../common/dom/fire_event";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import { stringCompare } from "../../../common/string/compare";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { currentPath } from "../../../common/url/current-path";
import { deepEqual } from "../../../common/util/deep-equal";
import {
@@ -35,12 +33,14 @@ import {
logsLinkFromUrl,
logsQueryParamConfig,
logsQueryParams,
type LogsState,
} from "../../../common/url/logs-query-params";
import { createQueryString } from "../../../common/url/query-params";
import "../../../components/ha-app-icon";
import "../../../components/ha-button";
import "../../../components/ha-combo-box-item";
import "../../../components/ha-filter-integrations";
import type { IntegrationFilterCount } from "../../../components/ha-filter-integrations";
import "../../../components/ha-filter-pane";
import "../../../components/ha-filter-pane-chip";
import "../../../components/ha-generic-picker";
@@ -55,17 +55,22 @@ import {
fetchHassioAddonsInfo,
type HassioAddonInfo,
} from "../../../data/hassio/addon";
import type { LoggedError, SystemLogLevel } from "../../../data/system_log";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { mdiHomeAssistant } from "../../../resources/home-assistant-logo-svg";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route, ValueChangedEvent } from "../../../types";
import "./error-log-card";
import "./ha-filter-log-levels";
import "./system-log-card";
import type { SystemLogCard } from "./system-log-card";
import { LOG_LEVELS } from "./util";
const NO_INTEGRATIONS: string[] = [];
const NO_LEVELS: SystemLogLevel[] = [];
const logProviders: LogProvider[] = [
{
key: "core",
@@ -93,8 +98,6 @@ const logProviders: LogProvider[] = [
},
];
const LOGS_HISTORY_STATE = "logsPage";
interface LogProviderPickerItem extends PickerComboBoxItem {
addon?: HassioAddonInfo;
hasAppIcon?: boolean;
@@ -128,6 +131,41 @@ export class HaConfigLogs extends LitElement {
@state() private _integrationsExpanded = true;
@state() private _levels: SystemLogLevel[] = [];
@state() private _levelsExpanded = true;
@state() private _logItems?: LoggedError[];
// What the user picked, shown again when the page is opened without params,
// like from the sidebar. What a link shows isn't stored.
@storage({
storage: "sessionStorage",
key: "logs-search",
state: false,
subscribe: false,
})
private _storedSearch = "";
@storage({
storage: "sessionStorage",
key: "logs-integrations",
state: false,
subscribe: false,
})
private _storedIntegrations: string[] = [];
@storage({
storage: "sessionStorage",
key: "logs-levels",
state: false,
subscribe: false,
})
private _storedLevels: SystemLogLevel[] = [];
@storage({ key: "logs-raw", state: false, subscribe: false })
private _storedRaw = false;
@state()
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: ContextType<typeof manifestsContext>;
@@ -138,7 +176,7 @@ export class HaConfigLogs extends LitElement {
private _queryParams = new QueryParamsController(this, logsQueryParamConfig, {
enabled: () => true,
navigated: () => this._applyUrl(),
navigated: (ev) => this._navigated(ev),
});
public connectedCallback() {
@@ -164,6 +202,10 @@ export class HaConfigLogs extends LitElement {
private _filterChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
this._filter = ev.target.value ?? "";
if (this._currentProvider() === DEFAULT_LOG_PROVIDER) {
this._storedSearch = this._filter;
}
this._writeUrl(true);
}
@@ -174,6 +216,8 @@ export class HaConfigLogs extends LitElement {
protected render(): TemplateResult {
const core = this._selectedLogProvider === DEFAULT_LOG_PROVIDER;
const integrations = core ? this._integrations : NO_INTEGRATIONS;
const levels = core && !this._detail ? this._levels : NO_LEVELS;
const filterCount = integrations.length + levels.length;
const filtersShown = core && this._showFilters;
const filtersLabel = this.hass.localize(
"ui.components.subpage-data-table.filters"
@@ -224,17 +268,37 @@ export class HaConfigLogs extends LitElement {
filtersShown
? html`<ha-filter-pane
.narrow=${this.narrow}
.count=${this._integrations.length}
.count=${filterCount}
@close-filter-pane=${this._closeFilters}
@clear-filter=${this._clearIntegrations}
@clear-filter=${this._clearFilters}
>
<ha-filter-integrations
.narrow=${this.narrow}
.value=${this._integrations}
.expanded=${this._integrationsExpanded}
.counts=${
this._detail
? undefined
: this._getIntegrationCounts(
this._logItems,
this._manifests,
this.hass.localize
)
}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._integrationsExpandedChanged}
></ha-filter-integrations>
${
this._detail
? nothing
: html`<ha-filter-log-levels
.value=${this._levels}
.expanded=${this._levelsExpanded}
.counts=${this._getLevelCounts(this._logItems)}
@data-table-filter-changed=${this._levelsChanged}
@expanded-changed=${this._levelsExpandedChanged}
></ha-filter-log-levels>`
}
</ha-filter-pane>`
: nothing
}
@@ -245,8 +309,8 @@ export class HaConfigLogs extends LitElement {
? html`<ha-filter-pane-chip
.label=${filtersLabel}
.path=${mdiFilterVariant}
.count=${this._integrations.length}
.active=${this._integrations.length > 0}
.count=${filterCount}
.active=${filterCount > 0}
@click=${this._toggleFilters}
></ha-filter-pane-chip>`
: nothing
@@ -276,7 +340,9 @@ export class HaConfigLogs extends LitElement {
integrations,
this._manifests
)}
.levels=${levels}
@switch-log-view=${this._showDetail}
@system-log-items-changed=${this._logItemsChanged}
></system-log-card>
`
: html`<error-log-card
@@ -301,6 +367,10 @@ export class HaConfigLogs extends LitElement {
private _showDetail() {
this._detail = !this._detail;
if (this._currentProvider() === DEFAULT_LOG_PROVIDER) {
this._storedRaw = this._detail;
}
this._writeUrl();
}
@@ -317,25 +387,27 @@ export class HaConfigLogs extends LitElement {
this._selectedLogProvider = provider;
this._filter = "";
this._integrations = [];
this._levels = [];
this._storedSearch = "";
this._storedIntegrations = [];
this._storedLevels = [];
// Keep the search typed so far on the entry Back returns to
this._queryParams.flush();
// Picking another provider is a navigation of its own, so Back returns
// to the previous one. Both entries are marked, so going back to one
// without params shows the default view instead of keeping the provider.
updateHistoryState({ [LOGS_HISTORY_STATE]: true });
// to the previous one
const search = createQueryString(
logsQueryParams({
provider,
search: "",
integrations: [],
levels: [],
raw: this._detail,
}),
logsQueryParamConfig
);
// From the current path, so a trailing slash still matches the controller
navigate(`${currentPath()}${search ? `?${search}` : ""}`, {
data: { [LOGS_HISTORY_STATE]: true },
});
navigate(`${currentPath()}${search ? `?${search}` : ""}`);
}
private _toggleFilters() {
@@ -351,12 +423,38 @@ export class HaConfigLogs extends LitElement {
) {
ev.stopPropagation();
const value = ev.detail.value;
this._integrations = Array.isArray(value) ? value : [];
const integrations = Array.isArray(value) ? value : [];
// The filter also sends the value it was given once it has its items, in
// its own order, so only a change is the user's
if (
this._currentProvider() === DEFAULT_LOG_PROVIDER &&
!deepEqual([...integrations].sort(), [...this._integrations].sort())
) {
this._storedIntegrations = integrations;
}
this._integrations = integrations;
this._writeUrl();
}
private _clearIntegrations() {
private _clearFilters() {
const core = this._currentProvider() === DEFAULT_LOG_PROVIDER;
this._integrations = [];
if (core) {
this._storedIntegrations = [];
}
// Levels don't apply to the raw log, so they're kept for the errors view
if (!this._detail) {
this._levels = [];
if (core) {
this._storedLevels = [];
}
}
this._writeUrl();
}
@@ -366,20 +464,74 @@ export class HaConfigLogs extends LitElement {
this._integrationsExpanded = ev.detail.expanded;
}
private _levelsChanged(ev: HASSDomEvent<{ value: DataTableFiltersValue }>) {
ev.stopPropagation();
const value = ev.detail.value;
const levels = Array.isArray(value)
? LOG_LEVELS.map((item) => item.level).filter((level) =>
value.includes(level)
)
: [];
this._levels = levels;
if (this._currentProvider() === DEFAULT_LOG_PROVIDER) {
this._storedLevels = levels;
}
this._writeUrl();
}
private _levelsExpandedChanged(ev: HASSDomEvent<{ expanded: boolean }>) {
this._levelsExpanded = ev.detail.expanded;
}
// Until the apps are loaded, the provider is the one the URL asks for
private _currentProvider(): string {
return this._providersLoaded
? this._selectedLogProvider
: (logsLinkFromUrl(this._queryParams.searchParams() ?? "")?.provider ??
DEFAULT_LOG_PROVIDER);
}
private _writeUrl(debounced = false) {
this._queryParams.write(
logsQueryParams({
// Until the apps are loaded, keep the provider the URL asks for
provider: this._providersLoaded
? this._selectedLogProvider
: (logsLinkFromUrl(this._queryParams.searchParams() ?? "")
?.provider ?? DEFAULT_LOG_PROVIDER),
search: this._filter,
integrations: this._integrations,
raw: this._detail,
}),
debounced
);
this._queryParams.write(logsQueryParams(this._urlState()), debounced);
}
private _urlState(): LogsState {
return {
provider: this._currentProvider(),
search: this._filter,
integrations: this._integrations,
levels: this._levels,
raw: this._detail,
};
}
// Closing a dialog, like an entry's details, goes back to the URL the page
// is already showing, so there's nothing to apply. A link is always applied.
private _navigated(ev?: Event) {
const searchParams = this._queryParams.searchParams();
if (ev?.type === "popstate" && searchParams) {
const shown = createQueryString(
logsQueryParams(this._urlState()),
logsQueryParamConfig
);
const back = createQueryString(
logsQueryParams(logsLinkFromUrl(searchParams) ?? DEFAULT_LOGS_STATE),
logsQueryParamConfig
);
if (back === shown) {
return;
}
}
this._applyUrl();
}
private async _init() {
@@ -389,18 +541,31 @@ export class HaConfigLogs extends LitElement {
// Keep a search typed while the apps were loading
this._queryParams.flush();
this._providersLoaded = true;
this._applyUrl();
// What was stored is already applied, and an empty URL now is a link like
// `?provider=core` that was tidied up
this._applyUrl(false);
}
private _applyUrl() {
private _applyUrl(restoreStored = true) {
const searchParams = this._queryParams.searchParams();
if (!searchParams) {
return;
}
// A link is shown, but not stored. Opened without params, like from the
// sidebar or Back to before another provider was picked, the page shows
// what the user picked.
const link =
logsLinkFromUrl(searchParams) ??
(getHistoryState()?.[LOGS_HISTORY_STATE]
? DEFAULT_LOGS_STATE
(searchParams.toString() === ""
? restoreStored
? {
...DEFAULT_LOGS_STATE,
search: this._storedSearch,
integrations: this._storedIntegrations,
levels: this._storedLevels,
raw: this._storedRaw,
}
: DEFAULT_LOGS_STATE
: undefined);
let missingProvider: string | undefined;
if (link) {
@@ -421,6 +586,10 @@ export class HaConfigLogs extends LitElement {
if (link.provider === DEFAULT_LOG_PROVIDER) {
this._detail = link.raw;
}
if (!deepEqual(link.levels, this._levels)) {
this._levels = link.levels;
}
const integrations = link.integrations.filter((domain) =>
loaded.includes(domain)
);
@@ -485,6 +654,105 @@ export class HaConfigLogs extends LitElement {
: undefined
);
private _getLevelCounts = memoizeOne(
(
items: LoggedError[] | undefined
): Partial<Record<SystemLogLevel, number>> | undefined => {
if (!items) {
return undefined;
}
const counts: Partial<Record<SystemLogLevel, number>> = {};
for (const item of items) {
counts[item.level] = (counts[item.level] ?? 0) + 1;
}
return counts;
}
);
// Matches entries to integrations by logger name, the same way
// `_getIntegrationLoggers` filters them
private _getIntegrationCounts = memoizeOne(
(
items: LoggedError[] | undefined,
manifests: ContextType<typeof manifestsContext> | undefined,
localize: LocalizeFunc
): Record<string, IntegrationFilterCount[]> | undefined => {
if (!items?.length || !manifests) {
return undefined;
}
const loggerDomains = new Map<string, string[]>();
for (const { domain, loggers } of Object.values(manifests)) {
for (const logger of [
`homeassistant.components.${domain}`,
`custom_components.${domain}`,
...(loggers ?? []),
]) {
loggerDomains.set(logger, [
...(loggerDomains.get(logger) ?? []),
domain,
]);
}
}
const levelCounts: Record<
string,
Partial<Record<SystemLogLevel, number>>
> = {};
for (const item of items) {
const parts = item.name.split(".");
const domains = new Set(
parts.flatMap(
(_part, index) =>
loggerDomains.get(parts.slice(0, index + 1).join(".")) ?? []
)
);
for (const domain of domains) {
const counts = (levelCounts[domain] ??= {});
counts[item.level] = (counts[item.level] ?? 0) + 1;
}
}
return Object.fromEntries(
Object.entries(levelCounts).map(([domain, counts]) => [
domain,
LOG_LEVELS.flatMap(({ level, path, color }) => {
const count = counts[level];
return count
? [
{
key: level,
path,
color,
count,
label: localize(
`ui.panel.config.logs.level_count.${level}`,
{ count }
),
},
]
: [];
}),
])
);
}
);
private _logItemsChanged(
ev: HASSDomEvent<HASSDomEvents["system-log-items-changed"]>
) {
this._logItems = ev.detail.items;
}
private _getLogProviderItems = (): LogProviderPickerItem[] =>
this._logProviders.map((provider) => ({
id: provider.key,
@@ -0,0 +1,199 @@
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { styleMap } from "lit/directives/style-map";
import {
FilterPanelController,
filterPanelStyles,
} from "../../../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-option";
import "../../../components/list/ha-list-selectable";
import type { SystemLogLevel } from "../../../data/system_log";
import { LOG_LEVELS } from "./util";
@customElement("ha-filter-log-levels")
export class HaFilterLogLevels extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public value?: SystemLogLevel[];
/** Number of log entries per level. */
@property({ attribute: false }) public counts?: Partial<
Record<SystemLogLevel, number>
>;
@property({ type: Boolean, reflect: true }) public expanded = false;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
protected render() {
const count = this.value?.length ?? 0;
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.panel.config.logs.level_filter.caption")}
${
count
? html`<div class="badge">${count}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
.label=${this._localize(
"ui.components.subpage-data-table.clear_filter"
)}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
</ha-expansion-panel>
${this._panel.showContent ? this._renderContent() : nothing}
`;
}
private _renderContent() {
const value = this.value ?? [];
return html`<div class="content" ${ref(this._content)}>
<ha-list-selectable
multi
controlled
aria-label=${this._localize("ui.panel.config.logs.level_filter.caption")}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${LOG_LEVELS.map(
({ level, path, color }) =>
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${level}
.selected=${value.includes(level)}
>
<ha-svg-icon
slot="start"
.path=${path}
style=${styleMap({ color })}
></ha-svg-icon>
<span slot="headline"
>${this._localize(
`ui.panel.config.logs.level_filter.${level}`
)}</span
>
${
this.counts
? html`<span slot="end" class="count"
>${this.counts[level] ?? 0}</span
>`
: nothing
}
</ha-list-item-option>`
)}
</ha-list-selectable>
</div>`;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _handleItemToggled(ev: CustomEvent<number>) {
// The options are rendered in `LOG_LEVELS` order
const level = LOG_LEVELS[ev.detail]?.level;
if (!level) {
return;
}
const value = new Set(this.value ?? []);
if (!value.delete(level)) {
value.add(level);
}
// Keep the levels in severity order, whatever order they were picked in
this.value = LOG_LEVELS.map((item) => item.level).filter((item) =>
value.has(item)
);
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
filterPanelStyles,
css`
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
.count {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-log-levels": HaFilterLogLevels;
}
}
+42 -17
View File
@@ -22,7 +22,7 @@ import {
} from "../../../data/error_log";
import { domainToName } from "../../../data/integration";
import { formatListWithAnds } from "../../../common/string/format-list";
import type { LoggedError } from "../../../data/system_log";
import type { LoggedError, SystemLogLevel } from "../../../data/system_log";
import {
fetchSystemLog,
getLoggedErrorIntegration,
@@ -46,6 +46,8 @@ export class SystemLogCard extends LitElement {
@property({ attribute: false }) public integrationLoggers?: string[];
@property({ attribute: false }) public levels?: SystemLogLevel[];
public loaded = false;
@state() private _items?: LoggedError[];
@@ -79,9 +81,13 @@ export class SystemLogCard extends LitElement {
localize: LocalizeFunc,
items: LoggedError[],
filter: string,
loggers: string[] | undefined
loggers: string[] | undefined,
levels: SystemLogLevel[] | undefined
) =>
items.filter((item: LoggedError) => {
if (levels?.length && !levels.includes(item.level)) {
return false;
}
if (
loggers &&
!loggers.some(
@@ -119,7 +125,8 @@ export class SystemLogCard extends LitElement {
this.hass.localize,
this._items,
this.filter.toLowerCase(),
this.integrations?.length ? this.integrationLoggers : undefined
this.integrations?.length ? this.integrationLoggers : undefined,
this.levels
)
: [];
const integrations = filteredItems.length
@@ -203,7 +210,9 @@ export class SystemLogCard extends LitElement {
</div>
`
: filteredItems.length === 0 &&
(this.filter || this.integrations?.length)
(this.filter ||
this.integrations?.length ||
this.levels?.length)
? html`<div class="card-content">
${
this.filter
@@ -211,20 +220,24 @@ export class SystemLogCard extends LitElement {
"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
: this.levels?.length
? this.hass.localize(
"ui.panel.config.logs.no_issues_filters"
)
: 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
@@ -294,6 +307,14 @@ export class SystemLogCard extends LitElement {
);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_items")) {
fireEvent(this, "system-log-items-changed", { items: this._items });
}
}
protected serviceCalled(ev): void {
// Check if this is for us
if (ev.detail.success && ev.detail.domain === "system_log") {
@@ -404,4 +425,8 @@ declare global {
interface HTMLElementTagNameMap {
"system-log-card": SystemLogCard;
}
interface HASSDomEvents {
"system-log-items-changed": { items: LoggedError[] | undefined };
}
}
+13
View File
@@ -1,8 +1,21 @@
import type { HassConfig } from "home-assistant-js-websocket";
import { mdiAlert, mdiAlertCircle, mdiAlertOctagon } from "@mdi/js";
import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time";
import { formatTimeWithSeconds } from "../../../common/datetime/format_time";
import type { SystemLogLevel } from "../../../data/system_log";
import type { FrontendLocaleData } from "../../../data/translation";
// Core's system log only keeps warnings and above
export const LOG_LEVELS = [
{ level: "critical", path: mdiAlertOctagon, color: "var(--error-color)" },
{ level: "error", path: mdiAlertCircle, color: "var(--error-color)" },
{ level: "warning", path: mdiAlert, color: "var(--warning-color)" },
] as const satisfies readonly {
level: SystemLogLevel;
path: string;
color: string;
}[];
export const formatSystemLogTime = (
date,
locale: FrontendLocaleData,
+12
View File
@@ -5061,6 +5061,7 @@
"failed_get_logs": "Failed to get {provider} logs, {error}",
"no_issues_search": "No issues found for search term ''{term}''",
"no_issues_integration": "No issues found for {integration}",
"no_issues_filters": "No issues match the selected filters",
"integration_log_waiting": "[%key:ui::panel::config::integrations::config_entry::live_log::waiting%]",
"integration_log_unsupported": "[%key:ui::panel::config::integrations::config_entry::live_log::unsupported%]",
"no_matches_showing_all": "Nothing in the recent logs mentions ''{term}'', so all lines are shown.",
@@ -5082,6 +5083,17 @@
"info": "INFO",
"debug": "DEBUG"
},
"level_filter": {
"caption": "Level",
"critical": "Critical",
"error": "Error",
"warning": "Warning"
},
"level_count": {
"critical": "{count} critical {count, plural,\n one {entry}\n other {entries}\n}",
"error": "{count} {count, plural,\n one {error}\n other {errors}\n}",
"warning": "{count} {count, plural,\n one {warning}\n other {warnings}\n}"
},
"custom_integration": "custom integration",
"error_from_custom_integration": "This log entry is from a custom integration.",
"show_full_logs": "Show raw logs",
+19 -3
View File
@@ -5,6 +5,7 @@ import {
logsLinkFromUrl,
logsQueryParamConfig,
logsQueryParams,
type LogsState,
} from "../../../src/common/url/logs-query-params";
import {
createQueryString,
@@ -30,25 +31,37 @@ describe("logs query params", () => {
provider: "core",
search: "timeout",
integrations: ["hue", "mqtt"],
levels: [],
raw: false,
});
expect(logsLinkFromUrl("?provider=supervisor")).toEqual({
provider: "supervisor",
search: "",
integrations: [],
levels: [],
raw: false,
});
expect(logsLinkFromUrl("?integration=hue&raw=1")?.raw).toBe(true);
});
it("reads the known levels of a link", () => {
expect(logsLinkFromUrl("?level=error,verbose,warning")?.levels).toEqual([
"error",
"warning",
]);
});
it("reads the old filter name as the search", () => {
expect(logsLinkFromUrl("?filter=zwave_js")?.search).toBe("zwave_js");
expect(logsLinkFromUrl("?search=new&filter=old")?.search).toBe("new");
});
it("ignores integrations and the raw view for other providers", () => {
const link = logsLinkFromUrl("?provider=supervisor&integration=hue&raw=1");
it("ignores integrations, levels and the raw view for other providers", () => {
const link = logsLinkFromUrl(
"?provider=supervisor&integration=hue&level=error&raw=1"
);
expect(link?.integrations).toEqual([]);
expect(link?.levels).toEqual([]);
expect(link?.raw).toBe(false);
});
@@ -59,6 +72,7 @@ describe("logs query params", () => {
provider: "core",
search: "",
integrations: [],
levels: [],
raw: false,
}),
logsQueryParamConfig
@@ -74,6 +88,7 @@ describe("logs query params", () => {
provider: "core",
search: "old",
integrations: [],
levels: [],
raw: false,
}),
logsQueryParamConfig
@@ -82,10 +97,11 @@ describe("logs query params", () => {
});
it("round trips the state", () => {
const state = {
const state: LogsState = {
provider: "core",
search: "timeout",
integrations: ["hue", "mqtt"],
levels: ["critical", "error"],
raw: true,
};
expect(