mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-29 09:04:22 +00:00
Compare commits
7
Commits
rc
...
logs-filter-counts
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import {
|
||||
mdiAlert,
|
||||
mdiAlertCircle,
|
||||
mdiAlertOctagon,
|
||||
mdiBugOutline,
|
||||
mdiChevronDown,
|
||||
mdiChip,
|
||||
mdiDns,
|
||||
mdiFilterVariant,
|
||||
mdiInformationOutline,
|
||||
mdiPackageVariant,
|
||||
mdiPuzzle,
|
||||
mdiRadar,
|
||||
@@ -17,16 +22,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 +38,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,6 +60,7 @@ 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";
|
||||
@@ -66,6 +72,18 @@ import type { SystemLogCard } from "./system-log-card";
|
||||
|
||||
const NO_INTEGRATIONS: string[] = [];
|
||||
|
||||
const LOG_LEVELS: {
|
||||
level: SystemLogLevel;
|
||||
path: string;
|
||||
color?: string;
|
||||
}[] = [
|
||||
{ level: "critical", path: mdiAlertOctagon, color: "var(--error-color)" },
|
||||
{ level: "error", path: mdiAlertCircle, color: "var(--error-color)" },
|
||||
{ level: "warning", path: mdiAlert, color: "var(--warning-color)" },
|
||||
{ level: "info", path: mdiInformationOutline },
|
||||
{ level: "debug", path: mdiBugOutline },
|
||||
];
|
||||
|
||||
const logProviders: LogProvider[] = [
|
||||
{
|
||||
key: "core",
|
||||
@@ -93,8 +111,6 @@ const logProviders: LogProvider[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const LOGS_HISTORY_STATE = "logsPage";
|
||||
|
||||
interface LogProviderPickerItem extends PickerComboBoxItem {
|
||||
addon?: HassioAddonInfo;
|
||||
hasAppIcon?: boolean;
|
||||
@@ -128,6 +144,29 @@ export class HaConfigLogs extends LitElement {
|
||||
|
||||
@state() private _integrationsExpanded = 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({ key: "logs-raw", state: false, subscribe: false })
|
||||
private _storedRaw = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: manifestsContext, subscribe: true })
|
||||
private _manifests?: ContextType<typeof manifestsContext>;
|
||||
@@ -138,7 +177,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 +203,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);
|
||||
}
|
||||
|
||||
@@ -232,6 +275,15 @@ export class HaConfigLogs extends LitElement {
|
||||
.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>
|
||||
@@ -277,6 +329,7 @@ export class HaConfigLogs extends LitElement {
|
||||
this._manifests
|
||||
)}
|
||||
@switch-log-view=${this._showDetail}
|
||||
@system-log-items-changed=${this._logItemsChanged}
|
||||
></system-log-card>
|
||||
`
|
||||
: html`<error-log-card
|
||||
@@ -301,6 +354,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,12 +374,12 @@ export class HaConfigLogs extends LitElement {
|
||||
this._selectedLogProvider = provider;
|
||||
this._filter = "";
|
||||
this._integrations = [];
|
||||
this._storedSearch = "";
|
||||
this._storedIntegrations = [];
|
||||
// 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,
|
||||
@@ -332,10 +389,9 @@ export class HaConfigLogs extends LitElement {
|
||||
}),
|
||||
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 +407,27 @@ 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() {
|
||||
this._integrations = [];
|
||||
|
||||
if (this._currentProvider() === DEFAULT_LOG_PROVIDER) {
|
||||
this._storedIntegrations = [];
|
||||
}
|
||||
this._writeUrl();
|
||||
}
|
||||
|
||||
@@ -366,20 +437,49 @@ export class HaConfigLogs extends LitElement {
|
||||
this._integrationsExpanded = 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,
|
||||
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 +489,30 @@ 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,
|
||||
raw: this._storedRaw,
|
||||
}
|
||||
: DEFAULT_LOGS_STATE
|
||||
: undefined);
|
||||
let missingProvider: string | undefined;
|
||||
if (link) {
|
||||
@@ -485,6 +597,87 @@ export class HaConfigLogs extends LitElement {
|
||||
: undefined
|
||||
);
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -294,6 +294,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 +412,8 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"system-log-card": SystemLogCard;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"system-log-items-changed": { items: LoggedError[] | undefined };
|
||||
}
|
||||
}
|
||||
@@ -5082,6 +5082,13 @@
|
||||
"info": "INFO",
|
||||
"debug": "DEBUG"
|
||||
},
|
||||
"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}",
|
||||
"info": "{count} info {count, plural,\n one {entry}\n other {entries}\n}",
|
||||
"debug": "{count} debug {count, plural,\n one {entry}\n other {entries}\n}"
|
||||
},
|
||||
"custom_integration": "custom integration",
|
||||
"error_from_custom_integration": "This log entry is from a custom integration.",
|
||||
"show_full_logs": "Show raw logs",
|
||||
|
||||
Reference in new issue
Block a user