Compare commits

...
7 Commits
5 changed files with 302 additions and 34 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);
}
};
+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);
}
`,
];
}
+225 -32
View File
@@ -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,
+12
View File
@@ -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 };
}
}
+7
View File
@@ -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",