diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts
index 86e421eb14..eb484fa2b2 100644
--- a/src/components/chart/ha-chart-base.ts
+++ b/src/components/chart/ha-chart-base.ts
@@ -280,18 +280,23 @@ export class HaChartBase extends LitElement {
${this._renderLegend()}
-
- ${this._isZoomed && !this.hideResetButton
- ? html`
`
- : nothing}
-
+
+
+
+ ${this._isZoomed && !this.hideResetButton
+ ? html``
+ : nothing}
+
+
`;
@@ -1116,16 +1121,35 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
- .chart-controls {
+ .top-controls {
position: absolute;
- top: 16px;
- right: 4px;
+ top: var(--ha-space-4);
+ inset-inline-start: var(--ha-space-4);
+ inset-inline-end: var(--ha-space-1);
+ display: flex;
+ align-items: flex-start;
+ gap: var(--ha-space-2);
+ z-index: 1;
+ pointer-events: none;
+ }
+ ::slotted([slot="search"]) {
+ flex: 1 1 250px;
+ min-width: 0;
+ max-width: 250px;
+ pointer-events: auto;
+ }
+ .chart-controls {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
+ margin-inline-start: auto;
+ flex-shrink: 0;
+ pointer-events: auto;
+ }
+ .top-controls.small {
+ top: 0;
}
.chart-controls.small {
- top: 0;
flex-direction: row;
}
.chart-controls ha-icon-button,
diff --git a/src/components/chart/ha-network-graph.ts b/src/components/chart/ha-network-graph.ts
index de9a7fb5e3..ccc9debe01 100644
--- a/src/components/chart/ha-network-graph.ts
+++ b/src/components/chart/ha-network-graph.ts
@@ -1,7 +1,9 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
+import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
+
import type {
CallbackDataParams,
TopLevelFormatterParams,
@@ -76,8 +78,20 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
params: TopLevelFormatterParams
) => string;
+ /**
+ * Optional callback that returns additional searchable strings for a node.
+ * These are matched against the search filter in addition to the node's name and context.
+ */
+ @property({ attribute: false }) public searchableAttributes?: (
+ nodeId: string
+ ) => string[];
+
+ @property({ attribute: false }) public searchFilter = "";
+
public hass!: HomeAssistant;
+ @state() private _highlightedNodes?: Set;
+
@state() private _reducedMotion = false;
@state() private _physicsEnabled = true;
@@ -117,6 +131,9 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
+ const hasHighlightedNodes =
+ this._highlightedNodes && this._highlightedNodes.size > 0;
+
return html`
+
();
+ for (const node of this.data.nodes) {
+ if (this._nodeMatchesFilter(node, lowerFilter)) {
+ matchingIds.add(node.id);
+ }
+ }
+ this._highlightedNodes = matchingIds;
+ }
+ this._applyHighlighting();
+ this._updateMouseoverHandler();
+ }
+ }
+
+ private _nodeMatchesFilter(node: NetworkNode, lowerFilter: string): boolean {
+ if (node.name?.toLowerCase().includes(lowerFilter)) {
+ return true;
+ }
+ if (node.context?.toLowerCase().includes(lowerFilter)) {
+ return true;
+ }
+ if (node.id?.toLowerCase().includes(lowerFilter)) {
+ return true;
+ }
+ if (this.searchableAttributes) {
+ const extraValues = this.searchableAttributes(node.id);
+ for (const value of extraValues) {
+ if (value?.toLowerCase().includes(lowerFilter)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
private _getSeries = memoizeOne(
(
data: NetworkData,
physicsEnabled: boolean,
reducedMotion: boolean,
showLabels: boolean,
- isMobile: boolean
+ isMobile: boolean,
+ hasHighlightedNodes?: boolean
) => ({
id: "network",
type: "graph",
@@ -214,7 +276,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
},
},
emphasis: {
- focus: isMobile ? "none" : "adjacency",
+ focus: hasHighlightedNodes ? "self" : isMobile ? "none" : "adjacency",
},
force: {
repulsion: [400, 600],
@@ -362,6 +424,68 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
});
}
+ private _applyHighlighting() {
+ const chart = this._baseChart?.chart;
+ if (!chart) {
+ return;
+ }
+ // Reset all nodes to normal opacity first
+ chart.dispatchAction({ type: "downplay" });
+
+ const highlighted = this._highlightedNodes;
+ if (!highlighted || highlighted.size === 0) {
+ return;
+ }
+ const dataIndices: number[] = [];
+ this.data.nodes.forEach((node, index) => {
+ if (highlighted.has(node.id)) {
+ dataIndices.push(index);
+ }
+ });
+ if (dataIndices.length > 0) {
+ chart.dispatchAction({ type: "highlight", dataIndex: dataIndices });
+ }
+ }
+
+ private _emphasisGuardHandler?: () => void;
+
+ private _updateMouseoverHandler() {
+ const chart = this._baseChart?.chart;
+ if (!chart) {
+ return;
+ }
+
+ // When there are highlighted nodes, re-apply highlighting on hover
+ // and mouseout to prevent hover from overriding the search state
+ if (this._highlightedNodes && this._highlightedNodes.size > 0) {
+ if (this._emphasisGuardHandler) {
+ // Guard already set
+ return;
+ }
+ this._emphasisGuardHandler = () => {
+ this._applyHighlighting();
+ };
+ chart.on("mouseover", this._emphasisGuardHandler);
+ chart.on("mouseout", this._emphasisGuardHandler);
+ } else {
+ if (!this._emphasisGuardHandler) {
+ return;
+ }
+ chart.off("mouseover", this._emphasisGuardHandler);
+ chart.off("mouseout", this._emphasisGuardHandler);
+ this._emphasisGuardHandler = undefined;
+ }
+ }
+
+ public disconnectedCallback(): void {
+ super.disconnectedCallback();
+ if (this._emphasisGuardHandler) {
+ this._baseChart?.chart?.off("mouseover", this._emphasisGuardHandler);
+ this._baseChart?.chart?.off("mouseout", this._emphasisGuardHandler);
+ this._emphasisGuardHandler = undefined;
+ }
+ }
+
private _togglePhysics() {
this._saveNodePositions();
this._physicsEnabled = !this._physicsEnabled;
diff --git a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-network-visualization.ts b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-network-visualization.ts
index 5d9c241854..9f285a2450 100644
--- a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-network-visualization.ts
+++ b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-network-visualization.ts
@@ -4,7 +4,7 @@ import type {
} from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
-import { css, html, LitElement } from "lit";
+import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
@@ -17,6 +17,7 @@ import type {
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
+import "../../../../../components/search-input-outlined";
import type {
BluetoothDeviceData,
BluetoothScannersDetails,
@@ -60,6 +61,8 @@ export class BluetoothNetworkVisualization extends LitElement {
@state() private _sourceDevices: Record = {};
+ @state() private _searchFilter = "";
+
private _unsub_advertisements?: UnsubscribeFunc;
private _unsub_scanners?: UnsubscribeFunc;
@@ -126,16 +129,56 @@ export class BluetoothNetworkVisualization extends LitElement {
)}
back-path="/config/bluetooth/dashboard"
>
+ ${this.narrow
+ ? html`
+
+
`
+ : nothing}
+ >
+ ${!this.narrow
+ ? html``
+ : nothing}
+
`;
}
+ private _getSearchableAttributes = (nodeId: string): string[] => {
+ const attributes: string[] = [];
+ const device = this._sourceDevices[nodeId];
+ if (device?.manufacturer) {
+ attributes.push(device.manufacturer);
+ }
+ if (device?.model) {
+ attributes.push(device.model);
+ }
+ const scanner = this._scanners[nodeId];
+ if (scanner?.name) {
+ attributes.push(scanner.name);
+ }
+ return attributes;
+ };
+
+ private _handleSearchChange(ev: CustomEvent): void {
+ this._searchFilter = ev.detail.value;
+ }
+
private _getRssiColorVar = memoizeOne((rssi: number): string => {
for (const [threshold, colorVar] of RSSI_COLOR_THRESHOLDS) {
if (rssi > threshold) {
@@ -347,6 +390,13 @@ export class BluetoothNetworkVisualization extends LitElement {
ha-network-graph {
height: 100%;
}
+ [slot="header"] {
+ display: flex;
+ align-items: center;
+ }
+ search-input-outlined {
+ flex: 1;
+ }
`,
];
}
diff --git a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts
index 53b69870ab..abd68a798c 100644
--- a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts
+++ b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts
@@ -4,12 +4,13 @@ import type {
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { CSSResultGroup, PropertyValues } from "lit";
-import { css, html, LitElement } from "lit";
+import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { getDeviceContext } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/chart/ha-network-graph";
import type { NetworkData } from "../../../../../components/chart/ha-network-graph";
+import "../../../../../components/search-input-outlined";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type { ZHADevice } from "../../../../../data/zha";
import { fetchDevices, refreshTopology } from "../../../../../data/zha";
@@ -38,6 +39,8 @@ export class ZHANetworkVisualizationPage extends LitElement {
@state()
private _devices: ZHADevice[] = [];
+ @state() private _searchFilter = "";
+
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
@@ -55,12 +58,31 @@ export class ZHANetworkVisualizationPage extends LitElement {
"ui.panel.config.zha.visualization.header"
)}
>
+ ${this.narrow
+ ? html`
+
+
`
+ : nothing}
+ ${!this.narrow
+ ? html``
+ : nothing}
{
+ const device = this._devices.find((d) => d.ieee === nodeId);
+ if (!device) {
+ return [];
+ }
+ const attributes: string[] = [];
+ if (device.user_given_name) {
+ attributes.push(device.user_given_name);
+ }
+ if (device.manufacturer) {
+ attributes.push(device.manufacturer);
+ }
+ if (device.model) {
+ attributes.push(device.model);
+ }
+ if (device.device_type) {
+ attributes.push(device.device_type);
+ }
+ if (device.nwk != null) {
+ attributes.push(formatAsPaddedHex(device.nwk));
+ }
+ return attributes;
+ };
+
+ private _handleSearchChange(ev: CustomEvent): void {
+ this._searchFilter = ev.detail.value;
+ }
+
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
const { dataType, data, name } = params as CallbackDataParams;
if (dataType === "edge") {
@@ -154,6 +204,13 @@ export class ZHANetworkVisualizationPage extends LitElement {
ha-network-graph {
height: 100%;
}
+ [slot="header"] {
+ display: flex;
+ align-items: center;
+ }
+ search-input-outlined {
+ flex: 1;
+ }
`,
];
}
diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-network-visualization.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-network-visualization.ts
index 4a455aed80..56018b79fb 100644
--- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-network-visualization.ts
+++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-network-visualization.ts
@@ -2,7 +2,7 @@ import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
-import { css, html, LitElement } from "lit";
+import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getDeviceContext } from "../../../../../common/entity/context/get_device_context";
@@ -14,6 +14,7 @@ import type {
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
+import "../../../../../components/search-input-outlined";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type {
ZWaveJSNodeStatisticsUpdatedMessage,
@@ -49,6 +50,8 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@state() private _devices: Record = {};
+ @state() private _searchFilter = "";
+
public hassSubscribe() {
const devices = Object.values(this.hass.devices).filter((device) =>
device.config_entries.some((entry) => entry === this.configEntryId)
@@ -80,15 +83,35 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
back-path="/config/zwave_js/dashboard?config_entry=${this
.configEntryId}"
>
+ ${this.narrow
+ ? html`
+
+
`
+ : nothing}
+ >
+ ${!this.narrow
+ ? html``
+ : nothing}
+
`;
}
@@ -105,6 +128,31 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
this._nodeStatuses = nodeStatuses;
}
+ private _getSearchableAttributes = (nodeId: string): string[] => {
+ const device = this._devices[Number(nodeId)];
+ const nodeStatus = this._nodeStatuses[Number(nodeId)];
+ const attributes: string[] = [];
+ if (device?.manufacturer) {
+ attributes.push(device.manufacturer);
+ }
+ if (device?.model) {
+ attributes.push(device.model);
+ }
+ if (nodeStatus) {
+ const statusText = this.hass.localize(
+ `ui.panel.config.zwave_js.node_status.${nodeStatus.status}` as any
+ );
+ if (statusText) {
+ attributes.push(statusText);
+ }
+ }
+ return attributes;
+ };
+
+ private _handleSearchChange(ev: CustomEvent): void {
+ this._searchFilter = ev.detail.value;
+ }
+
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
const { dataType, data } = params as CallbackDataParams;
if (dataType === "edge") {
@@ -330,6 +378,13 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
ha-network-graph {
height: 100%;
}
+ [slot="header"] {
+ display: flex;
+ align-items: center;
+ }
+ search-input-outlined {
+ flex: 1;
+ }
`,
];
}