1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 00:27:49 +01:00
Files
frontend/src/panels/config/developer-tools/statistics/developer-tools-statistics.ts
2026-03-31 12:47:17 +02:00

817 lines
25 KiB
TypeScript

import {
mdiArrowDown,
mdiArrowUp,
mdiClose,
mdiFormatListChecks,
mdiMenuDown,
mdiSlopeUphill,
mdiTableCog,
mdiUnfoldLessHorizontal,
mdiUnfoldMoreHorizontal,
} from "@mdi/js";
import "@home-assistant/webawesome/dist/components/divider/divider";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, type CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/chips/ha-assist-chip";
import "../../../../components/data-table/ha-data-table";
import type {
DataTableColumnContainer,
HaDataTable,
SelectionChangedEvent,
SortingDirection,
} from "../../../../components/data-table/ha-data-table";
import { showDataTableSettingsDialog } from "../../../../components/data-table/show-dialog-data-table-settings";
import "../../../../components/ha-button";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../components/input/ha-input-search";
import type {
StatisticsMetaData,
StatisticsValidationResult,
} from "../../../../data/recorder";
import {
clearStatistics,
getStatisticIds,
StatisticMeanType,
updateStatisticsIssues,
validateStatistics,
} from "../../../../data/recorder";
import { KeyboardShortcutMixin } from "../../../../mixins/keyboard-shortcut-mixin";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showConfirmationDialog } from "../../../lovelace/custom-card-helpers";
import { fixStatisticsIssue } from "./fix-statistics";
import { showStatisticsAdjustSumDialog } from "./show-dialog-statistics-adjust-sum";
const FIX_ISSUES_ORDER: Record<StatisticsValidationResult["type"], number> = {
no_state: 0,
entity_no_longer_recorded: 1,
entity_not_recorded: 1,
state_class_removed: 2,
units_changed: 3,
mean_type_changed: 4,
};
const FIXABLE_ISSUES: StatisticsValidationResult["type"][] = [
"no_state",
"entity_no_longer_recorded",
"state_class_removed",
"units_changed",
"mean_type_changed",
];
type StatisticData = StatisticsMetaData & {
issues?: StatisticsValidationResult[];
state?: HassEntity;
selectable?: boolean;
};
type DisplayedStatisticData = StatisticData & {
displayName: string;
issues_string?: string;
};
@customElement("developer-tools-statistics")
class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _data: StatisticData[] = [] as StatisticsMetaData[];
@state() private filter = "";
@state() private _selected: string[] = [];
@state() private groupOrder?: string[];
@state() private columnOrder?: string[];
@state() private hiddenColumns?: string[];
@state() private _sortColumn?: string;
@state() private _sortDirection: SortingDirection = null;
@state() private _groupColumn?: string;
@state() private _selectMode = false;
@query("ha-data-table", true) private _dataTable!: HaDataTable;
@query("ha-input-search") private _searchInput!: HaInputSearch;
protected firstUpdated() {
this._validateStatistics();
}
private _displayData = memoizeOne(
(data: StatisticData[], localize: LocalizeFunc): DisplayedStatisticData[] =>
data.map((item) => ({
...item,
displayName: item.state
? computeStateName(item.state)
: item.name || item.statistic_id,
issues_string: item.issues
?.map(
(issue) =>
localize(
`ui.panel.config.developer-tools.tabs.statistics.issues.${issue.type}`,
issue.data
) || issue.type
)
.join(" "),
}))
);
private _columns = memoizeOne(
(
localize: LocalizeFunc
): DataTableColumnContainer<DisplayedStatisticData> => ({
displayName: {
title: localize(
"ui.panel.config.developer-tools.tabs.statistics.data_table.name"
),
main: true,
sortable: true,
filterable: true,
flex: 2,
},
statistic_id: {
title: localize(
"ui.panel.config.developer-tools.tabs.statistics.data_table.statistic_id"
),
sortable: true,
filterable: true,
hidden: this.narrow,
},
statistics_unit_of_measurement: {
title: localize(
"ui.panel.config.developer-tools.tabs.statistics.data_table.statistics_unit"
),
sortable: true,
filterable: true,
forceLTR: true,
},
source: {
title: localize(
"ui.panel.config.developer-tools.tabs.statistics.data_table.source"
),
sortable: true,
filterable: true,
groupable: true,
},
issues_string: {
title: localize(
"ui.panel.config.developer-tools.tabs.statistics.data_table.issue"
),
sortable: true,
filterable: true,
groupable: true,
direction: "asc",
flex: 2,
template: (statistic) =>
html`${statistic.issues_string ??
localize(
"ui.panel.config.developer-tools.tabs.statistics.no_issue"
)}`,
},
fix: {
title: "",
label: this.hass.localize(
"ui.panel.config.developer-tools.tabs.statistics.fix_issue.fix"
),
type: "icon",
template: (statistic) =>
html`${statistic.issues
? html`<ha-button
@click=${this._fixIssue}
.data=${statistic.issues}
appearance="plain"
size="small"
>
${localize(
statistic.issues.some((issue) =>
FIXABLE_ISSUES.includes(issue.type)
)
? "ui.panel.config.developer-tools.tabs.statistics.fix_issue.fix"
: "ui.panel.config.developer-tools.tabs.statistics.fix_issue.info"
)}
</ha-button>`
: "—"}`,
minWidth: "113px",
maxWidth: "113px",
showNarrow: true,
},
actions: {
title: "",
label: localize(
"ui.panel.config.developer-tools.tabs.statistics.adjust_sum"
),
type: "icon-button",
showNarrow: true,
template: (statistic) =>
statistic.has_sum
? html`
<ha-icon-button
.label=${localize(
"ui.panel.config.developer-tools.tabs.statistics.adjust_sum"
)}
.path=${mdiSlopeUphill}
.statistic=${statistic}
@click=${this._showStatisticsAdjustSumDialog}
></ha-icon-button>
`
: "",
},
})
);
protected render() {
const localize = this.hass.localize;
const columns = this._columns(this.hass.localize);
const selectModeBtn = !this._selectMode
? html`<ha-assist-chip
class="has-dropdown select-mode-chip"
.active=${this._selectMode}
@click=${this._enableSelectMode}
.title=${localize(
"ui.components.subpage-data-table.enter_selection_mode"
)}
>
<ha-svg-icon slot="icon" .path=${mdiFormatListChecks}></ha-svg-icon>
</ha-assist-chip> `
: nothing;
const searchBar = html`<ha-input-search
appearance="outlined"
.value=${this.filter}
@input=${this._handleSearchChange}
>
</ha-input-search>`;
const sortByMenu = Object.values(columns).find((col) => col.sortable)
? html`
<ha-dropdown @wa-select=${this._handleSortBy}>
<ha-assist-chip
slot="trigger"
.label=${localize("ui.components.subpage-data-table.sort_by", {
sortColumn: this._sortColumn
? ` ${columns[this._sortColumn]?.title || columns[this._sortColumn]?.label}` ||
""
: "",
})}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${Object.entries(columns).map(([id, column]) =>
column.sortable
? html`
<ha-dropdown-item
.value=${id}
.selected=${id === this._sortColumn}
>
${this._sortColumn === id
? html`
<ha-svg-icon
slot="details"
.path=${this._sortDirection === "desc"
? mdiArrowDown
: mdiArrowUp}
></ha-svg-icon>
`
: nothing}
${column.title || column.label}
</ha-dropdown-item>
`
: nothing
)}
</ha-dropdown>
`
: nothing;
const groupByMenu = Object.values(columns).find((col) => col.groupable)
? html`
<ha-dropdown @wa-select=${this._handleOverflowGroupBy}>
<ha-assist-chip
slot="trigger"
.label=${localize("ui.components.subpage-data-table.group_by", {
groupColumn: this._groupColumn
? ` ${columns[this._groupColumn].title || columns[this._groupColumn].label}`
: "",
})}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon
></ha-assist-chip>
${Object.entries(columns).map(([id, column]) =>
column.groupable
? html`
<ha-dropdown-item
.value=${id}
.selected=${id === this._groupColumn}
>
${column.title || column.label}
</ha-dropdown-item>
`
: nothing
)}
<ha-dropdown-item
value="none"
.selected=${this._groupColumn === undefined}
>
${localize("ui.components.subpage-data-table.dont_group_by")}
</ha-dropdown-item>
<wa-divider></wa-divider>
<ha-dropdown-item
value="collapse_all"
@click=${this._collapseAllGroups}
.disabled=${this._groupColumn === undefined}
>
<ha-svg-icon
slot="icon"
.path=${mdiUnfoldLessHorizontal}
></ha-svg-icon>
${localize(
"ui.components.subpage-data-table.collapse_all_groups"
)}
</ha-dropdown-item>
<ha-dropdown-item
@click=${this._expandAllGroups}
.disabled=${this._groupColumn === undefined}
>
<ha-svg-icon
slot="icon"
.path=${mdiUnfoldMoreHorizontal}
></ha-svg-icon>
${localize("ui.components.subpage-data-table.expand_all_groups")}
</ha-dropdown-item>
</ha-dropdown>
`
: nothing;
const settingsButton = html`<ha-assist-chip
class="has-dropdown select-mode-chip"
@click=${this._openSettings}
.title=${localize("ui.components.subpage-data-table.settings")}
>
<ha-svg-icon slot="icon" .path=${mdiTableCog}></ha-svg-icon>
</ha-assist-chip>`;
return html`
<div class="table-with-toolbars">
${this._selectMode
? html`<div class="selection-bar">
<div class="selection-controls">
<ha-icon-button
.path=${mdiClose}
@click=${this._disableSelectMode}
.label=${localize(
"ui.components.subpage-data-table.exit_selection_mode"
)}
></ha-icon-button>
<ha-dropdown>
<ha-assist-chip
.label=${localize(
"ui.components.subpage-data-table.select"
)}
slot="trigger"
>
<ha-svg-icon
slot="icon"
.path=${mdiFormatListChecks}
></ha-svg-icon>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon
></ha-assist-chip>
<ha-dropdown-item @click=${this._selectAll}>
${localize("ui.components.subpage-data-table.select_all")}
</ha-dropdown-item>
<ha-dropdown-item @click=${this._selectAllIssues}>
${localize(
"ui.panel.config.developer-tools.tabs.statistics.data_table.select_all_issues"
)}
</ha-dropdown-item>
<ha-dropdown-item @click=${this._selectNone}>
${localize("ui.components.subpage-data-table.select_none")}
</ha-dropdown-item>
<wa-divider></wa-divider>
<ha-dropdown-item @click=${this._disableSelectMode}>
${localize(
"ui.components.subpage-data-table.exit_selection_mode"
)}
</ha-dropdown-item>
</ha-dropdown>
<p>
${localize("ui.components.subpage-data-table.selected", {
selected: this._selected.length,
})}
</p>
</div>
<div class="center-vertical">
<slot name="selection-bar"></slot>
</div>
<ha-assist-chip
.label=${localize(
"ui.panel.config.developer-tools.tabs.statistics.delete_selected"
)}
.disabled=${!this._selected.length}
@click=${this._clearSelected}
>
</ha-assist-chip>
</div>`
: nothing}
<div slot="toolbar-icon">
<slot name="toolbar-icon"></slot>
</div>
${this.narrow
? html`
<div slot="header">
<slot name="header">
<div class="search-toolbar">${searchBar}</div>
</slot>
</div>
`
: ""}
<ha-data-table
.narrow=${this.narrow}
.columns=${columns}
.data=${this._displayData(this._data, this.hass.localize)}
.noDataText=${this.hass.localize(
"ui.panel.config.developer-tools.tabs.statistics.data_table.no_statistics"
)}
.filter=${this.filter}
.selectable=${this._selectMode}
id="statistic_id"
clickable
.sortColumn=${this._sortColumn}
.sortDirection=${this._sortDirection}
.groupColumn=${this._groupColumn}
.groupOrder=${this.groupOrder}
.columnOrder=${this.columnOrder}
.hiddenColumns=${this.hiddenColumns}
@row-click=${this._rowClicked}
@selection-changed=${this._handleSelectionChanged}
@sorting-changed=${this._handleTableSortingChanged}
>
${!this.narrow
? html`
<div slot="header">
<slot name="header">
<div class="table-header">
${selectModeBtn}${searchBar}${groupByMenu}${sortByMenu}${settingsButton}
</div>
</slot>
</div>
`
: html`<div slot="header"></div>
<div slot="header-row" class="narrow-header-row">
${selectModeBtn}${groupByMenu}${sortByMenu}${settingsButton}
</div>`}
</ha-data-table>
</div>
`;
}
private _handleSearchChange(ev: InputEvent) {
if (this.filter === (ev.target as HaInputSearch).value) {
return;
}
this.filter = (ev.target as HaInputSearch).value ?? "";
}
private _handleSelectionChanged(
ev: HASSDomEvent<SelectionChangedEvent>
): void {
this._selected = ev.detail.value;
}
private _handleTableSortingChanged(
ev: CustomEvent<{ column: string; direction: SortingDirection }>
) {
const { column, direction } = ev.detail;
this._sortColumn = column;
this._sortDirection = direction;
}
private _handleSortBy(ev: HaDropdownSelectEvent) {
ev.preventDefault(); // keep dropdown open
const columnId = ev.detail.item.value;
if (!this._sortDirection || this._sortColumn !== columnId) {
this._sortDirection = "asc";
} else if (this._sortDirection === "asc") {
this._sortDirection = "desc";
} else {
this._sortDirection = "asc";
}
this._sortColumn = columnId;
}
private _handleOverflowGroupBy = (ev: HaDropdownSelectEvent) => {
const action = ev.detail.item.value;
if (!action) {
return;
}
switch (action) {
case "collapse_all":
this._collapseAllGroups();
return;
case "expand_all":
this._expandAllGroups();
return;
case "none":
this._setGroupColumn();
return;
default:
this._setGroupColumn(action);
}
};
private _setGroupColumn(columnId?: string) {
this._groupColumn = columnId;
}
private _openSettings() {
showDataTableSettingsDialog(this, {
columns: this._columns(this.hass.localize),
hiddenColumns: this.hiddenColumns,
columnOrder: this.columnOrder,
onUpdate: (
columnOrder: string[] | undefined,
hiddenColumns: string[] | undefined
) => {
this.columnOrder = columnOrder;
this.hiddenColumns = hiddenColumns;
},
localizeFunc: this.hass.localize,
});
}
private _collapseAllGroups() {
this._dataTable.collapseAllGroups();
}
private _expandAllGroups() {
this._dataTable.expandAllGroups();
}
private _enableSelectMode() {
this._selectMode = true;
}
private _disableSelectMode() {
this._selectMode = false;
this._dataTable.clearSelection();
}
private _selectAll() {
this._dataTable.selectAll();
}
private _selectNone() {
this._dataTable.clearSelection();
}
private _selectAllIssues() {
this._dataTable.select(
this._data
.filter((statistic) => statistic.issues)
.map((statistic) => statistic.statistic_id),
true
);
}
private _showStatisticsAdjustSumDialog(ev) {
ev.stopPropagation();
showStatisticsAdjustSumDialog(this, {
statistic: ev.currentTarget.statistic,
});
}
private _rowClicked(ev) {
const id = ev.detail.id;
if (id in this.hass.states) {
fireEvent(this, "hass-more-info", { entityId: id });
}
}
private async _validateStatistics() {
const [statisticIds, issues] = await Promise.all([
getStatisticIds(this.hass),
validateStatistics(this.hass),
]);
updateStatisticsIssues(this.hass);
const statsIds = new Set();
this._data = statisticIds.map((statistic) => {
statsIds.add(statistic.statistic_id);
return {
...statistic,
state: this.hass.states[statistic.statistic_id],
issues: issues[statistic.statistic_id],
};
});
Object.keys(issues).forEach((statisticId) => {
if (!statsIds.has(statisticId)) {
this._data.push({
statistic_id: statisticId,
statistics_unit_of_measurement: "",
source: "",
state: this.hass.states[statisticId],
issues: issues[statisticId],
mean_type: StatisticMeanType.NONE,
has_sum: false,
unit_class: null,
});
}
});
}
private _clearSelected = async () => {
if (!this._selected.length) {
return;
}
const deletableIds = this._selected;
await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.developer-tools.tabs.statistics.multi_delete.title"
),
text: html`${this.hass.localize(
"ui.panel.config.developer-tools.tabs.statistics.multi_delete.info_text",
{ statistic_count: deletableIds.length }
)}`,
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
confirm: async () => {
await clearStatistics(this.hass, deletableIds);
this._validateStatistics();
this._dataTable.clearSelection();
},
});
};
private _fixIssue = async (ev) => {
const issues = (ev.currentTarget.data as StatisticsValidationResult[]).sort(
(itemA, itemB) =>
(FIX_ISSUES_ORDER[itemA.type] ?? 99) -
(FIX_ISSUES_ORDER[itemB.type] ?? 99)
);
const issue = issues[0];
await fixStatisticsIssue(this, issue);
this._validateStatistics();
};
protected supportedShortcuts(): SupportedShortcuts {
return {
f: () => this._searchInput.focus(),
};
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
height: 100%;
}
.table-with-toolbars {
height: 100%;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
ha-data-table {
width: 100%;
flex-grow: 1;
--data-table-border-width: 0;
}
:host([narrow]) {
--expansion-panel-summary-padding: 0 16px;
}
.table-header {
display: flex;
align-items: center;
--mdc-shape-small: 0;
height: 56px;
width: 100%;
justify-content: space-between;
padding: 0 var(--ha-space-4);
gap: var(--ha-space-4);
box-sizing: border-box;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
}
ha-input-search {
flex: 1;
}
.search-toolbar {
display: flex;
align-items: center;
color: var(--secondary-text-color);
}
.narrow-header-row {
display: flex;
align-items: center;
gap: var(--ha-space-4);
padding: 0 var(--ha-space-4);
overflow-x: scroll;
-ms-overflow-style: none;
scrollbar-width: none;
}
.selection-bar {
background: rgba(var(--rgb-primary-color), 0.1);
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--ha-space-2) var(--ha-space-3);
box-sizing: border-box;
font-size: var(--ha-font-size-m);
--ha-assist-chip-container-color: var(--card-background-color);
}
.selection-controls {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.selection-controls p {
margin-left: var(--ha-space-2);
margin-inline-start: var(--ha-space-2);
margin-inline-end: initial;
}
.center-vertical {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.relative {
position: relative;
}
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
--ha-assist-chip-container-color: var(--card-background-color);
}
.select-mode-chip {
--md-assist-chip-icon-label-space: 0;
--md-assist-chip-trailing-space: 8px;
}
ha-dialog {
--mdc-dialog-min-width: 100vw;
--mdc-dialog-max-width: 100vw;
--mdc-dialog-min-height: 100%;
--mdc-dialog-max-height: 100%;
--vertical-align-dialog: flex-end;
--ha-dialog-border-radius: var(--ha-border-radius-square);
--dialog-content-padding: 0;
}
ha-dropdown ha-assist-chip {
--md-assist-chip-trailing-space: 8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"developer-tools-statistics": HaPanelDevStatistics;
}
}