mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-30 01:24:19 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa4c76e61 | ||
|
|
1eb6daebf5 | ||
|
|
59620e652d | ||
|
|
bfa7b9a541 | ||
|
|
ebba11980a | ||
|
|
68f6b3dbab | ||
|
|
25abbb2ba0 | ||
|
|
d02c03dacf |
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user