mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-28 03:36:44 +00:00
Echarts network graph for ZHA (#25457)
* Echarts network graph for ZHA * improve layout * better diff * remove vis-network * not bad layout * fix LQI and clean up a bit * Use ha-chart-base and remove header * legend * use color vars * use colorVariables * fix * add physics toggle * tweak lines * remove vis-network * minor tweaks * dynamically load graph chart * type fix * fix height * navigate to device page on label click * PR comments * aria tweak * make extraComponents non reactive * PR comments * quick fix * just make hass non reactive * button tweak * Update src/components/chart/ha-network-graph.ts Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com> --------- Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
This commit is contained in:
parent
4b72a6029c
commit
c4e391c264
@ -137,7 +137,6 @@
|
||||
"tinykeys": "3.0.0",
|
||||
"ua-parser-js": "2.0.3",
|
||||
"vis-data": "7.1.9",
|
||||
"vis-network": "9.1.9",
|
||||
"vue": "2.7.16",
|
||||
"vue2-daterange-picker": "0.6.8",
|
||||
"weekstart": "2.0.0",
|
||||
|
@ -48,7 +48,8 @@ export class HaChartBase extends LitElement {
|
||||
@property({ attribute: "expand-legend", type: Boolean })
|
||||
public expandLegend?: boolean;
|
||||
|
||||
@property({ attribute: false }) public extraComponents?: any[];
|
||||
// extraComponents is not reactive and should not trigger updates
|
||||
public extraComponents?: any[];
|
||||
|
||||
@state()
|
||||
@consume({ context: themesContext, subscribe: true })
|
||||
@ -106,6 +107,7 @@ export class HaChartBase extends LitElement {
|
||||
})
|
||||
);
|
||||
|
||||
if (!this.options?.dataZoom) {
|
||||
// Add keyboard event listeners
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
if (
|
||||
@ -141,7 +143,6 @@ export class HaChartBase extends LitElement {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
this._listeners.push(
|
||||
@ -149,6 +150,7 @@ export class HaChartBase extends LitElement {
|
||||
() => window.removeEventListener("keyup", handleKeyUp)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this._setupChart();
|
||||
@ -191,6 +193,7 @@ export class HaChartBase extends LitElement {
|
||||
<div class="chart"></div>
|
||||
</div>
|
||||
${this._renderLegend()}
|
||||
<div class="chart-controls">
|
||||
${this._isZoomed
|
||||
? html`<ha-icon-button
|
||||
class="zoom-reset"
|
||||
@ -201,6 +204,8 @@ export class HaChartBase extends LitElement {
|
||||
)}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
<slot name="button"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -210,7 +215,7 @@ export class HaChartBase extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
const legend = ensureArray(this.options.legend)[0] as LegendComponentOption;
|
||||
if (!legend.show) {
|
||||
if (!legend.show || legend.type !== "custom") {
|
||||
return nothing;
|
||||
}
|
||||
const datasets = ensureArray(this.data);
|
||||
@ -315,7 +320,9 @@ export class HaChartBase extends LitElement {
|
||||
this.chart.on("click", (e: ECElementEvent) => {
|
||||
fireEvent(this, "chart-click", e);
|
||||
});
|
||||
if (!this.options?.dataZoom) {
|
||||
this.chart.getZr().on("dblclick", this._handleClickZoom);
|
||||
}
|
||||
if (this._isTouchDevice) {
|
||||
this.chart.getZr().on("click", (e: ECElementEvent) => {
|
||||
if (!e.zrByTouch) {
|
||||
@ -410,6 +417,12 @@ export class HaChartBase extends LitElement {
|
||||
} as XAXisOption;
|
||||
});
|
||||
}
|
||||
let legend = this.options?.legend;
|
||||
if (legend) {
|
||||
legend = ensureArray(legend).map((l) =>
|
||||
l.type === "custom" ? { show: false } : l
|
||||
);
|
||||
}
|
||||
const options = {
|
||||
animation: !this._reducedMotion,
|
||||
darkMode: this._themes.darkMode ?? false,
|
||||
@ -424,7 +437,7 @@ export class HaChartBase extends LitElement {
|
||||
iconStyle: { opacity: 0 },
|
||||
},
|
||||
...this.options,
|
||||
legend: { show: false },
|
||||
legend,
|
||||
xAxis,
|
||||
};
|
||||
|
||||
@ -725,16 +738,26 @@ export class HaChartBase extends LitElement {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.zoom-reset {
|
||||
.chart-controls {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.chart-controls ha-icon-button,
|
||||
.chart-controls ::slotted(ha-icon-button) {
|
||||
background: var(--card-background-color);
|
||||
border-radius: 4px;
|
||||
--mdc-icon-button-size: 32px;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
.chart-controls ha-icon-button.inactive,
|
||||
.chart-controls ::slotted(ha-icon-button.inactive) {
|
||||
color: var(--state-inactive-color);
|
||||
}
|
||||
.chart-legend {
|
||||
max-height: 60%;
|
||||
overflow-y: auto;
|
||||
|
278
src/components/chart/ha-network-graph.ts
Normal file
278
src/components/chart/ha-network-graph.ts
Normal file
@ -0,0 +1,278 @@
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type { GraphSeriesOption } from "echarts/charts";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state, query } from "lit/decorators";
|
||||
import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
|
||||
import { mdiGoogleCirclesGroup } from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import type { ECOption } from "../../resources/echarts";
|
||||
import "./ha-chart-base";
|
||||
import type { HaChartBase } from "./ha-chart-base";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export interface NetworkNode {
|
||||
id: string;
|
||||
name?: string;
|
||||
category?: number;
|
||||
label?: string;
|
||||
value?: number;
|
||||
symbolSize?: number;
|
||||
symbol?: string;
|
||||
itemStyle?: {
|
||||
color?: string;
|
||||
borderColor?: string;
|
||||
borderWidth?: number;
|
||||
};
|
||||
fixed?: boolean;
|
||||
/**
|
||||
* Distance from the center, where 0 is the center and 1 is the edge
|
||||
*/
|
||||
polarDistance?: number;
|
||||
}
|
||||
|
||||
export interface NetworkLink {
|
||||
source: string;
|
||||
target: string;
|
||||
value?: number;
|
||||
reverseValue?: number;
|
||||
lineStyle?: {
|
||||
width?: number;
|
||||
color?: string;
|
||||
type?: "solid" | "dashed" | "dotted";
|
||||
};
|
||||
symbolSize?: number | number[];
|
||||
label?: {
|
||||
show?: boolean;
|
||||
formatter?: string;
|
||||
};
|
||||
ignoreForceLayout?: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkData {
|
||||
nodes: NetworkNode[];
|
||||
links: NetworkLink[];
|
||||
categories?: { name: string }[];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
|
||||
let GraphChart: typeof import("echarts/lib/chart/graph/install");
|
||||
|
||||
@customElement("ha-network-graph")
|
||||
export class HaNetworkGraph extends LitElement {
|
||||
public chart?: EChartsType;
|
||||
|
||||
@property({ attribute: false }) public data?: NetworkData;
|
||||
|
||||
@property({ attribute: false }) public tooltipFormatter?: (
|
||||
params: TopLevelFormatterParams
|
||||
) => string;
|
||||
|
||||
public hass!: HomeAssistant;
|
||||
|
||||
@state() private _reducedMotion = false;
|
||||
|
||||
@state() private _physicsEnabled = true;
|
||||
|
||||
private _listeners: (() => void)[] = [];
|
||||
|
||||
private _nodePositions: Record<string, { x: number; y: number }> = {};
|
||||
|
||||
@query("ha-chart-base") private _baseChart?: HaChartBase;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
if (!GraphChart) {
|
||||
import("echarts/lib/chart/graph/install").then((module) => {
|
||||
GraphChart = module;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._listeners.push(
|
||||
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
|
||||
if (this._reducedMotion !== matches) {
|
||||
this._reducedMotion = matches;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
while (this._listeners.length) {
|
||||
this._listeners.pop()!();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!GraphChart) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.data=${this._getSeries(
|
||||
this.data,
|
||||
this._physicsEnabled,
|
||||
this._reducedMotion
|
||||
)}
|
||||
.options=${this._createOptions(this.data?.categories)}
|
||||
height="100%"
|
||||
.extraComponents=${[GraphChart]}
|
||||
>
|
||||
<slot name="button" slot="button"></slot>
|
||||
<ha-icon-button
|
||||
slot="button"
|
||||
class=${this._physicsEnabled ? "active" : "inactive"}
|
||||
.path=${mdiGoogleCirclesGroup}
|
||||
@click=${this._togglePhysics}
|
||||
label=${this.hass.localize(
|
||||
"ui.panel.config.common.graph.toggle_physics"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</ha-chart-base>`;
|
||||
}
|
||||
|
||||
private _createOptions = memoizeOne(
|
||||
(categories?: NetworkData["categories"]): ECOption => ({
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
confine: true,
|
||||
formatter: this.tooltipFormatter,
|
||||
},
|
||||
legend: {
|
||||
show: !!categories?.length,
|
||||
data: categories,
|
||||
top: 8,
|
||||
},
|
||||
dataZoom: {
|
||||
type: "inside",
|
||||
filterMode: "none",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
private _getSeries = memoizeOne(
|
||||
(data?: NetworkData, physicsEnabled?: boolean, reducedMotion?: boolean) => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const containerWidth = this.clientWidth;
|
||||
const containerHeight = this.clientHeight;
|
||||
return [
|
||||
{
|
||||
id: "network",
|
||||
type: "graph",
|
||||
layout: physicsEnabled ? "force" : "none",
|
||||
draggable: true,
|
||||
roam: true,
|
||||
selectedMode: "single",
|
||||
label: {
|
||||
show: true,
|
||||
position: "right",
|
||||
},
|
||||
emphasis: {
|
||||
focus: "adjacency",
|
||||
},
|
||||
force: {
|
||||
repulsion: [400, 600],
|
||||
edgeLength: [200, 300],
|
||||
gravity: 0.1,
|
||||
layoutAnimation: !reducedMotion,
|
||||
},
|
||||
edgeSymbol: ["none", "arrow"],
|
||||
edgeSymbolSize: 10,
|
||||
data: data.nodes.map((node) => {
|
||||
const echartsNode: NonNullable<GraphSeriesOption["data"]>[number] =
|
||||
{
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
category: node.category,
|
||||
value: node.value,
|
||||
symbolSize: node.symbolSize || 30,
|
||||
symbol: node.symbol || "circle",
|
||||
itemStyle: node.itemStyle || {},
|
||||
fixed: node.fixed,
|
||||
};
|
||||
if (this._nodePositions[node.id]) {
|
||||
echartsNode.x = this._nodePositions[node.id].x;
|
||||
echartsNode.y = this._nodePositions[node.id].y;
|
||||
} else if (typeof node.polarDistance === "number") {
|
||||
// set the position of the node at polarDistance from the center in a random direction
|
||||
const angle = Math.random() * 2 * Math.PI;
|
||||
echartsNode.x =
|
||||
containerWidth / 2 +
|
||||
((Math.cos(angle) * containerWidth) / 2) * node.polarDistance;
|
||||
echartsNode.y =
|
||||
containerHeight / 2 +
|
||||
((Math.sin(angle) * containerHeight) / 2) * node.polarDistance;
|
||||
this._nodePositions[node.id] = {
|
||||
x: echartsNode.x,
|
||||
y: echartsNode.y,
|
||||
};
|
||||
}
|
||||
return echartsNode;
|
||||
}),
|
||||
links: data.links.map((link) => ({
|
||||
...link,
|
||||
value: link.reverseValue
|
||||
? Math.max(link.value ?? 0, link.reverseValue)
|
||||
: link.value,
|
||||
// remove arrow for bidirectional links
|
||||
symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work
|
||||
})),
|
||||
categories: data.categories || [],
|
||||
},
|
||||
] as any;
|
||||
}
|
||||
);
|
||||
|
||||
private _togglePhysics() {
|
||||
if (this._baseChart?.chart) {
|
||||
this._baseChart.chart
|
||||
// @ts-ignore private method but no other way to get the graph positions
|
||||
.getModel()
|
||||
.getSeriesByIndex(0)
|
||||
.getGraph()
|
||||
.eachNode((node: any) => {
|
||||
const layout = node.getLayout();
|
||||
if (layout) {
|
||||
this._nodePositions[node.id] = {
|
||||
x: layout[0],
|
||||
y: layout[1],
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
this._physicsEnabled = !this._physicsEnabled;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
ha-chart-base {
|
||||
height: 100%;
|
||||
--chart-max-height: 100%;
|
||||
}
|
||||
|
||||
ha-icon-button,
|
||||
::slotted(ha-icon-button) {
|
||||
margin-right: 12px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-network-graph": HaNetworkGraph;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"node-selected": { id: string };
|
||||
}
|
||||
}
|
@ -287,6 +287,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
},
|
||||
} as YAXisOption,
|
||||
legend: {
|
||||
type: "custom",
|
||||
show: this.showNames,
|
||||
},
|
||||
grid: {
|
||||
|
@ -308,6 +308,7 @@ export class StatisticsChart extends LitElement {
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
type: "custom",
|
||||
show: !this.hideLegend,
|
||||
data: this._legendData,
|
||||
},
|
||||
|
@ -1,27 +1,26 @@
|
||||
import "@material/mwc-button";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { Edge, EdgeOptions, Node } from "vis-network/peer/esm/vis-network";
|
||||
import { Network } from "vis-network/peer/esm/vis-network";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import "../../../../../components/search-input";
|
||||
import "../../../../../components/device/ha-device-picker";
|
||||
import "../../../../../components/ha-button-menu";
|
||||
import "../../../../../components/ha-checkbox";
|
||||
import type { HaCheckbox } from "../../../../../components/ha-checkbox";
|
||||
import "../../../../../components/ha-formfield";
|
||||
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type {
|
||||
CallbackDataParams,
|
||||
TopLevelFormatterParams,
|
||||
} from "echarts/types/dist/shared";
|
||||
import { mdiRefresh } from "@mdi/js";
|
||||
import "../../../../../components/chart/ha-network-graph";
|
||||
import type {
|
||||
NetworkData,
|
||||
NetworkNode,
|
||||
NetworkLink,
|
||||
} from "../../../../../components/chart/ha-network-graph";
|
||||
import type { ZHADevice } from "../../../../../data/zha";
|
||||
import { fetchDevices, refreshTopology } from "../../../../../data/zha";
|
||||
import "../../../../../layouts/hass-tabs-subpage";
|
||||
import type {
|
||||
ValueChangedEvent,
|
||||
HomeAssistant,
|
||||
Route,
|
||||
} from "../../../../../types";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import { formatAsPaddedHex } from "./functions";
|
||||
import { zhaTabs } from "./zha-config-dashboard";
|
||||
import { colorVariables } from "../../../../../resources/theme/color.globals";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
|
||||
@customElement("zha-network-visualization-page")
|
||||
export class ZHANetworkVisualizationPage extends LitElement {
|
||||
@ -33,103 +32,18 @@ export class ZHANetworkVisualizationPage extends LitElement {
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public zoomedDeviceIdFromURL?: string;
|
||||
@state()
|
||||
private _networkData?: NetworkData;
|
||||
|
||||
@state()
|
||||
private zoomedDeviceId?: string;
|
||||
|
||||
@query("#visualization", true)
|
||||
private _visualization?: HTMLElement;
|
||||
|
||||
@state()
|
||||
private _devices = new Map<string, ZHADevice>();
|
||||
|
||||
@state()
|
||||
private _devicesByDeviceId = new Map<string, ZHADevice>();
|
||||
|
||||
@state()
|
||||
private _nodes: Node[] = [];
|
||||
|
||||
@state()
|
||||
private _network?: Network;
|
||||
|
||||
@state()
|
||||
private _filter?: string;
|
||||
|
||||
private _autoZoom = true;
|
||||
|
||||
private _enablePhysics = true;
|
||||
private _devices: ZHADevice[] = [];
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
|
||||
// prevent zoomedDeviceIdFromURL from being restored to zoomedDeviceId after the user clears it
|
||||
if (this.zoomedDeviceIdFromURL) {
|
||||
this.zoomedDeviceId = this.zoomedDeviceIdFromURL;
|
||||
}
|
||||
|
||||
if (this.hass) {
|
||||
this._fetchData();
|
||||
}
|
||||
|
||||
this._network = new Network(
|
||||
this._visualization!,
|
||||
{},
|
||||
{
|
||||
autoResize: true,
|
||||
layout: {
|
||||
improvedLayout: true,
|
||||
},
|
||||
physics: {
|
||||
barnesHut: {
|
||||
springConstant: 0,
|
||||
avoidOverlap: 10,
|
||||
damping: 0.09,
|
||||
},
|
||||
},
|
||||
nodes: {
|
||||
font: {
|
||||
multi: "html",
|
||||
},
|
||||
},
|
||||
edges: {
|
||||
smooth: {
|
||||
enabled: true,
|
||||
type: "continuous",
|
||||
forceDirection: "none",
|
||||
roundness: 0.6,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this._network.on("doubleClick", (properties) => {
|
||||
const ieee = properties.nodes[0];
|
||||
if (ieee) {
|
||||
const device = this._devices.get(ieee);
|
||||
if (device) {
|
||||
navigate(`/config/devices/device/${device.device_reg_id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._network.on("click", (properties) => {
|
||||
const ieee = properties.nodes[0];
|
||||
if (ieee) {
|
||||
const device = this._devices.get(ieee);
|
||||
if (device && this._autoZoom) {
|
||||
this.zoomedDeviceId = device.device_reg_id;
|
||||
this._zoomToDevice();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._network.on("stabilized", () => {
|
||||
if (this.zoomedDeviceId) {
|
||||
this._zoomToDevice();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@ -144,359 +58,297 @@ export class ZHANetworkVisualizationPage extends LitElement {
|
||||
"ui.panel.config.zha.visualization.header"
|
||||
)}
|
||||
>
|
||||
${this.narrow
|
||||
? html`
|
||||
<div slot="header">
|
||||
<search-input
|
||||
<ha-network-graph
|
||||
.hass=${this.hass}
|
||||
class="header"
|
||||
@value-changed=${this._handleSearchChange}
|
||||
.filter=${this._filter}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.zha.visualization.highlight_label"
|
||||
)}
|
||||
.data=${this._networkData}
|
||||
.tooltipFormatter=${this._tooltipFormatter}
|
||||
@chart-click=${this._handleChartClick}
|
||||
>
|
||||
</search-input>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
<div class="header">
|
||||
${!this.narrow
|
||||
? html`<search-input
|
||||
.hass=${this.hass}
|
||||
@value-changed=${this._handleSearchChange}
|
||||
.filter=${this._filter}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.zha.visualization.highlight_label"
|
||||
)}
|
||||
></search-input>`
|
||||
: ""}
|
||||
<ha-device-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.zoomedDeviceId}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.zha.visualization.zoom_label"
|
||||
)}
|
||||
.deviceFilter=${this._filterDevices}
|
||||
@value-changed=${this._onZoomToDevice}
|
||||
></ha-device-picker>
|
||||
<div class="controls">
|
||||
<ha-formfield
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.zha.visualization.auto_zoom"
|
||||
)}
|
||||
>
|
||||
<ha-checkbox
|
||||
@change=${this._handleAutoZoomCheckboxChange}
|
||||
.checked=${this._autoZoom}
|
||||
>
|
||||
</ha-checkbox>
|
||||
</ha-formfield>
|
||||
<ha-formfield
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.zha.visualization.enable_physics"
|
||||
)}
|
||||
><ha-checkbox
|
||||
@change=${this._handlePhysicsCheckboxChange}
|
||||
.checked=${this._enablePhysics}
|
||||
>
|
||||
</ha-checkbox
|
||||
></ha-formfield>
|
||||
<mwc-button @click=${this._refreshTopology}>
|
||||
${this.hass!.localize(
|
||||
<ha-icon-button
|
||||
slot="button"
|
||||
class="refresh-button"
|
||||
.path=${mdiRefresh}
|
||||
@click=${this._refreshTopology}
|
||||
label=${this.hass.localize(
|
||||
"ui.panel.config.zha.visualization.refresh_topology"
|
||||
)}
|
||||
</mwc-button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="visualization"></div>
|
||||
></ha-icon-button>
|
||||
</ha-network-graph>
|
||||
</hass-tabs-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _fetchData() {
|
||||
const devices = await fetchDevices(this.hass!);
|
||||
this._devices = new Map(
|
||||
devices.map((device: ZHADevice) => [device.ieee, device])
|
||||
);
|
||||
this._devicesByDeviceId = new Map(
|
||||
devices.map((device: ZHADevice) => [device.device_reg_id, device])
|
||||
);
|
||||
this._updateDevices(devices);
|
||||
this._devices = await fetchDevices(this.hass!);
|
||||
this._networkData = this._createChartData(this._devices);
|
||||
}
|
||||
|
||||
private _updateDevices(devices: ZHADevice[]) {
|
||||
this._nodes = [];
|
||||
const edges: Edge[] = [];
|
||||
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
|
||||
const { dataType, data, name } = params as CallbackDataParams;
|
||||
if (dataType === "edge") {
|
||||
const { source, target, value } = data as any;
|
||||
const targetName = this._networkData!.nodes.find(
|
||||
(node) => node.id === target
|
||||
)!.name;
|
||||
const sourceName = this._networkData!.nodes.find(
|
||||
(node) => node.id === source
|
||||
)!.name;
|
||||
const tooltipText = `${sourceName} → ${targetName}${value ? ` <b>LQI:</b> ${value}` : ""}`;
|
||||
|
||||
devices.forEach((device) => {
|
||||
this._nodes.push({
|
||||
id: device.ieee,
|
||||
label: this._buildLabel(device),
|
||||
shape: this._getShape(device),
|
||||
mass: this._getMass(device),
|
||||
fixed: device.device_type === "Coordinator",
|
||||
color: {
|
||||
background: device.available ? "#66FF99" : "#FF9999",
|
||||
},
|
||||
});
|
||||
|
||||
if (device.neighbors && device.neighbors.length > 0) {
|
||||
device.neighbors.forEach((neighbor) => {
|
||||
const idx = edges.findIndex(
|
||||
(e) => device.ieee === e.to && neighbor.ieee === e.from
|
||||
);
|
||||
if (idx === -1) {
|
||||
const edge_options = this._getEdgeOptions(parseInt(neighbor.lqi));
|
||||
edges.push({
|
||||
from: device.ieee,
|
||||
to: neighbor.ieee,
|
||||
label: neighbor.lqi + "",
|
||||
color: edge_options.color,
|
||||
width: edge_options.width,
|
||||
length: edge_options.length,
|
||||
physics: edge_options.physics,
|
||||
arrows: {
|
||||
from: {
|
||||
enabled: neighbor.relationship !== "Child",
|
||||
},
|
||||
},
|
||||
dashes: neighbor.relationship !== "Child",
|
||||
});
|
||||
} else {
|
||||
const edge_options = this._getEdgeOptions(
|
||||
Math.min(parseInt(edges[idx].label!), parseInt(neighbor.lqi))
|
||||
);
|
||||
edges[idx].label += " & " + neighbor.lqi;
|
||||
edges[idx].color = edge_options.color;
|
||||
edges[idx].width = edge_options.width;
|
||||
edges[idx].length = edge_options.length;
|
||||
edges[idx].physics = edge_options.physics;
|
||||
delete edges[idx].arrows;
|
||||
delete edges[idx].dashes;
|
||||
const reverseValue = this._networkData!.links.find(
|
||||
(link) => link.source === source && link.target === target
|
||||
)?.reverseValue;
|
||||
if (reverseValue) {
|
||||
return `${tooltipText}<br>${targetName} → ${sourceName} <b>LQI:</b> ${reverseValue}`;
|
||||
}
|
||||
});
|
||||
return tooltipText;
|
||||
}
|
||||
});
|
||||
|
||||
this._network?.setData({ nodes: this._nodes, edges: edges });
|
||||
const device = this._devices.find((d) => d.ieee === (data as any).id);
|
||||
if (!device) {
|
||||
return name;
|
||||
}
|
||||
|
||||
private _getEdgeOptions(lqi: number): EdgeOptions {
|
||||
const length = 2000 - 4 * lqi;
|
||||
if (lqi > 192) {
|
||||
return {
|
||||
color: { color: "#17ab00", highlight: "#17ab00" },
|
||||
width: lqi / 20,
|
||||
length: length,
|
||||
physics: false,
|
||||
};
|
||||
}
|
||||
if (lqi > 128) {
|
||||
return {
|
||||
color: { color: "#e6b402", highlight: "#e6b402" },
|
||||
width: 9,
|
||||
length: length,
|
||||
physics: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
color: { color: "#bfbfbf", highlight: "#bfbfbf" },
|
||||
width: 1,
|
||||
length: length,
|
||||
physics: false,
|
||||
};
|
||||
}
|
||||
|
||||
private _getMass(device: ZHADevice): number {
|
||||
if (!device.available) {
|
||||
return 6;
|
||||
}
|
||||
if (device.device_type === "Coordinator") {
|
||||
return 2;
|
||||
}
|
||||
if (device.device_type === "Router") {
|
||||
return 4;
|
||||
}
|
||||
return 5;
|
||||
}
|
||||
|
||||
private _getShape(device: ZHADevice): string {
|
||||
if (device.device_type === "Coordinator") {
|
||||
return "box";
|
||||
}
|
||||
if (device.device_type === "Router") {
|
||||
return "ellipse";
|
||||
}
|
||||
return "circle";
|
||||
}
|
||||
|
||||
private _buildLabel(device: ZHADevice): string {
|
||||
let label =
|
||||
device.user_given_name !== null
|
||||
? `<b>${device.user_given_name}</b>\n`
|
||||
: "";
|
||||
label += `<b>IEEE: </b>${device.ieee}`;
|
||||
label += `\n<b>Device Type: </b>${device.device_type.replace("_", " ")}`;
|
||||
let label = `<b>IEEE: </b>${device.ieee}`;
|
||||
label += `<br><b>Device Type: </b>${device.device_type.replace("_", " ")}`;
|
||||
if (device.nwk != null) {
|
||||
label += `\n<b>NWK: </b>${formatAsPaddedHex(device.nwk)}`;
|
||||
label += `<br><b>NWK: </b>${formatAsPaddedHex(device.nwk)}`;
|
||||
}
|
||||
if (device.manufacturer != null && device.model != null) {
|
||||
label += `\n<b>Device: </b>${device.manufacturer} ${device.model}`;
|
||||
label += `<br><b>Device: </b>${device.manufacturer} ${device.model}`;
|
||||
} else {
|
||||
label += "\n<b>Device is not in <i>'zigbee.db'</i></b>";
|
||||
label += "<br><b>Device is not in <i>'zigbee.db'</i></b>";
|
||||
}
|
||||
if (device.area_id) {
|
||||
label += `\n<b>Area ID: </b>${device.area_id}`;
|
||||
const area = this.hass.areas[device.area_id];
|
||||
if (area) {
|
||||
label += `<br><b>Area: </b>${area.name}`;
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
const filterText = this._filter!.toLowerCase();
|
||||
if (!this._network) {
|
||||
return;
|
||||
}
|
||||
if (this._filter) {
|
||||
const filteredNodeIds: (string | number)[] = [];
|
||||
this._nodes.forEach((node) => {
|
||||
if (node.label && node.label.toLowerCase().includes(filterText)) {
|
||||
filteredNodeIds.push(node.id!);
|
||||
}
|
||||
});
|
||||
this.zoomedDeviceId = "";
|
||||
this._zoomOut();
|
||||
this._network.selectNodes(filteredNodeIds, true);
|
||||
} else {
|
||||
this._network.unselectAll();
|
||||
}
|
||||
}
|
||||
|
||||
private _onZoomToDevice(event: ValueChangedEvent<string>) {
|
||||
event.stopPropagation();
|
||||
this.zoomedDeviceId = event.detail.value;
|
||||
if (!this._network) {
|
||||
return;
|
||||
}
|
||||
this._zoomToDevice();
|
||||
}
|
||||
|
||||
private _zoomToDevice() {
|
||||
this._filter = "";
|
||||
if (!this.zoomedDeviceId) {
|
||||
this._zoomOut();
|
||||
} else {
|
||||
const device: ZHADevice | undefined = this._devicesByDeviceId.get(
|
||||
this.zoomedDeviceId
|
||||
);
|
||||
if (device) {
|
||||
this._network!.fit({
|
||||
nodes: [device.ieee],
|
||||
animation: { duration: 500, easingFunction: "easeInQuad" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _zoomOut() {
|
||||
this._network!.fit({
|
||||
nodes: [],
|
||||
animation: { duration: 500, easingFunction: "easeOutQuad" },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private async _refreshTopology(): Promise<void> {
|
||||
await refreshTopology(this.hass);
|
||||
await this._fetchData();
|
||||
}
|
||||
|
||||
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
|
||||
if (!this.hass) {
|
||||
return false;
|
||||
}
|
||||
for (const parts of device.identifiers) {
|
||||
for (const part of parts) {
|
||||
if (part === "zha") {
|
||||
return true;
|
||||
private _handleChartClick(e: CustomEvent): void {
|
||||
if (
|
||||
e.detail.dataType === "node" &&
|
||||
e.detail.event.target.cursor === "pointer"
|
||||
) {
|
||||
const { id } = e.detail.data;
|
||||
const device = this._devices.find((d) => d.ieee === id);
|
||||
if (device) {
|
||||
navigate(`/config/devices/device/${device.device_reg_id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
private _handleAutoZoomCheckboxChange(ev: Event) {
|
||||
this._autoZoom = (ev.target as HaCheckbox).checked;
|
||||
}
|
||||
|
||||
private _handlePhysicsCheckboxChange(ev: Event) {
|
||||
this._enablePhysics = (ev.target as HaCheckbox).checked;
|
||||
|
||||
this._network!.setOptions(
|
||||
this._enablePhysics
|
||||
? { physics: { enabled: true } }
|
||||
: { physics: { enabled: false } }
|
||||
);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
.header {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--header-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header > * {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
:host([narrow]) .header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
height: var(--header-height) * 2;
|
||||
}
|
||||
|
||||
.search-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--secondary-text-color);
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
search-input {
|
||||
flex: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
search-input.header {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-device-picker {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#visualization {
|
||||
height: calc(100% - var(--header-height));
|
||||
width: 100%;
|
||||
}
|
||||
:host([narrow]) #visualization {
|
||||
height: calc(100% - (var(--header-height) * 2));
|
||||
ha-network-graph {
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
private _createChartData(devices: ZHADevice[]): NetworkData {
|
||||
const primaryColor = colorVariables["primary-color"];
|
||||
const routerColor = colorVariables["cyan-color"];
|
||||
const endDeviceColor = colorVariables["teal-color"];
|
||||
const offlineColor = colorVariables["error-color"];
|
||||
const nodes: NetworkNode[] = [];
|
||||
const links: NetworkLink[] = [];
|
||||
const categories = [
|
||||
{
|
||||
name: "Coordinator",
|
||||
icon: "roundRect",
|
||||
itemStyle: { color: primaryColor },
|
||||
},
|
||||
{ name: "Router", icon: "circle", itemStyle: { color: routerColor } },
|
||||
{
|
||||
name: "End Device",
|
||||
icon: "circle",
|
||||
itemStyle: { color: endDeviceColor },
|
||||
},
|
||||
{ name: "Offline", icon: "circle", itemStyle: { color: offlineColor } },
|
||||
];
|
||||
|
||||
// Create all the nodes and links
|
||||
devices.forEach((device) => {
|
||||
const isCoordinator = device.device_type === "Coordinator";
|
||||
let category: number;
|
||||
if (!device.available) {
|
||||
category = 3; // Offline
|
||||
} else if (isCoordinator) {
|
||||
category = 0;
|
||||
} else if (device.device_type === "Router") {
|
||||
category = 1;
|
||||
} else {
|
||||
category = 2; // End Device
|
||||
}
|
||||
|
||||
// Create node
|
||||
nodes.push({
|
||||
id: device.ieee,
|
||||
name: device.user_given_name || device.name || device.ieee,
|
||||
category,
|
||||
value: isCoordinator ? 3 : device.device_type === "Router" ? 2 : 1,
|
||||
symbolSize: isCoordinator
|
||||
? 40
|
||||
: device.device_type === "Router"
|
||||
? 30
|
||||
: 20,
|
||||
symbol: isCoordinator ? "roundRect" : "circle",
|
||||
itemStyle: {
|
||||
color: device.available
|
||||
? isCoordinator
|
||||
? primaryColor
|
||||
: device.device_type === "Router"
|
||||
? routerColor
|
||||
: endDeviceColor
|
||||
: offlineColor,
|
||||
},
|
||||
polarDistance: category === 0 ? 0 : category === 1 ? 0.5 : 0.9,
|
||||
});
|
||||
|
||||
// Create links (edges)
|
||||
const existingLinks = links.filter(
|
||||
(link) => link.source === device.ieee || link.target === device.ieee
|
||||
);
|
||||
if (device.routes && device.routes.length > 0) {
|
||||
device.routes.forEach((route) => {
|
||||
const neighbor = device.neighbors.find(
|
||||
(n) => n.nwk === route.next_hop
|
||||
);
|
||||
if (!neighbor) {
|
||||
return;
|
||||
}
|
||||
const existingLink = existingLinks.find(
|
||||
(link) =>
|
||||
link.source === neighbor.ieee || link.target === neighbor.ieee
|
||||
);
|
||||
|
||||
if (existingLink) {
|
||||
if (existingLink.source === device.ieee) {
|
||||
existingLink.value = Math.max(
|
||||
existingLink.value!,
|
||||
parseInt(neighbor.lqi)
|
||||
);
|
||||
} else {
|
||||
existingLink.reverseValue = Math.max(
|
||||
existingLink.reverseValue ?? 0,
|
||||
parseInt(neighbor.lqi)
|
||||
);
|
||||
}
|
||||
const width = this._getLQIWidth(parseInt(neighbor.lqi));
|
||||
existingLink.symbolSize = (width / 4) * 6 + 3; // range 3-9
|
||||
existingLink.lineStyle = {
|
||||
...existingLink.lineStyle,
|
||||
width,
|
||||
color:
|
||||
route.route_status === "Active"
|
||||
? primaryColor
|
||||
: existingLink.lineStyle!.color,
|
||||
type: ["Child", "Parent"].includes(neighbor.relationship)
|
||||
? "solid"
|
||||
: existingLink.lineStyle!.type,
|
||||
};
|
||||
} else {
|
||||
// Create a new link
|
||||
const width = this._getLQIWidth(parseInt(neighbor.lqi));
|
||||
const link: NetworkLink = {
|
||||
source: device.ieee,
|
||||
target: neighbor.ieee,
|
||||
value: parseInt(neighbor.lqi),
|
||||
lineStyle: {
|
||||
width,
|
||||
color:
|
||||
route.route_status === "Active"
|
||||
? primaryColor
|
||||
: colorVariables["disabled-color"],
|
||||
type: ["Child", "Parent"].includes(neighbor.relationship)
|
||||
? "solid"
|
||||
: "dotted",
|
||||
},
|
||||
symbolSize: (width / 4) * 6 + 3, // range 3-9
|
||||
// By default, all links should be ignored for force layout
|
||||
ignoreForceLayout: true,
|
||||
};
|
||||
links.push(link);
|
||||
existingLinks.push(link);
|
||||
}
|
||||
});
|
||||
} else if (existingLinks.length === 0) {
|
||||
// If there are no links, create a link to the closest neighbor
|
||||
const neighbors: { ieee: string; lqi: string }[] =
|
||||
device.neighbors ?? [];
|
||||
if (neighbors.length === 0) {
|
||||
// If there are no neighbors, look for links from other devices
|
||||
devices.forEach((d) => {
|
||||
if (d.neighbors && d.neighbors.length > 0) {
|
||||
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
|
||||
if (neighbor) {
|
||||
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const closestNeighbor = neighbors.sort(
|
||||
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
|
||||
)[0];
|
||||
if (closestNeighbor) {
|
||||
links.push({
|
||||
source: device.ieee,
|
||||
target: closestNeighbor.ieee,
|
||||
value: parseInt(closestNeighbor.lqi),
|
||||
symbolSize: 5,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: colorVariables["disabled-color"],
|
||||
type: "dotted",
|
||||
},
|
||||
ignoreForceLayout: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Now set ignoreForceLayout to false for the strongest connection of each device
|
||||
// Except for the coordinator which can have multiple strong connections
|
||||
devices.forEach((device) => {
|
||||
if (device.device_type === "Coordinator") {
|
||||
links.forEach((link) => {
|
||||
if (link.source === device.ieee || link.target === device.ieee) {
|
||||
link.ignoreForceLayout = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Find the link that corresponds to this strongest connection
|
||||
let strongestLink: NetworkLink | undefined;
|
||||
links.forEach((link) => {
|
||||
if (
|
||||
(link.source === device.ieee || link.target === device.ieee) &&
|
||||
link.value! > (strongestLink?.value ?? 0)
|
||||
) {
|
||||
strongestLink = link;
|
||||
}
|
||||
});
|
||||
|
||||
if (strongestLink) {
|
||||
strongestLink.ignoreForceLayout = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes, links, categories };
|
||||
}
|
||||
|
||||
private _getLQIWidth(lqi: number): number {
|
||||
return Math.max(1, Math.floor((lqi / 256) * 4));
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
@ -184,13 +184,11 @@ export class HuiEnergyDevicesDetailGraphCard
|
||||
...commonOptions,
|
||||
legend: {
|
||||
show: true,
|
||||
type: "scroll",
|
||||
animationDurationUpdate: 400,
|
||||
type: "custom",
|
||||
selected: this._hiddenStats.reduce((acc, stat) => {
|
||||
acc[stat] = false;
|
||||
return acc;
|
||||
}, {}),
|
||||
icon: "circle",
|
||||
},
|
||||
grid: {
|
||||
top: 15,
|
||||
|
@ -29,6 +29,7 @@ import type {
|
||||
LineSeriesOption,
|
||||
CustomSeriesOption,
|
||||
SankeySeriesOption,
|
||||
GraphSeriesOption,
|
||||
} from "echarts/charts";
|
||||
import type {
|
||||
// The component option types are defined with the ComponentOption suffix
|
||||
@ -53,6 +54,7 @@ export type ECOption = ComposeOption<
|
||||
| DataZoomComponentOption
|
||||
| VisualMapComponentOption
|
||||
| SankeySeriesOption
|
||||
| GraphSeriesOption
|
||||
>;
|
||||
|
||||
// Register the required components
|
||||
|
@ -355,6 +355,7 @@ const darkColorStyles = css`
|
||||
}
|
||||
`;
|
||||
export const colorDerivedVariables = extractDerivedVars(colorStyles);
|
||||
export const colorVariables = extractVars(colorStyles);
|
||||
export const darkColorVariables = extractVars(darkColorStyles);
|
||||
|
||||
export const DefaultPrimaryColor = extractVar(colorStyles, "primary-color");
|
||||
|
@ -2113,7 +2113,10 @@
|
||||
"learn_more": "Learn more",
|
||||
"show_url": "Show full URL",
|
||||
"hide_url": "Hide URL",
|
||||
"copy_link": "Copy link"
|
||||
"copy_link": "Copy link",
|
||||
"graph": {
|
||||
"toggle_physics": "Toggle physics"
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
"caption": "Updates",
|
||||
@ -5696,10 +5699,6 @@
|
||||
"visualization": {
|
||||
"header": "Network visualization",
|
||||
"caption": "Visualization",
|
||||
"highlight_label": "Highlight devices",
|
||||
"zoom_label": "Zoom to device",
|
||||
"auto_zoom": "Auto zoom",
|
||||
"enable_physics": "Enable physics",
|
||||
"refresh_topology": "Refresh topology"
|
||||
},
|
||||
"device_binding": {
|
||||
|
3
src/types/echarts.d.ts
vendored
Normal file
3
src/types/echarts.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
declare module "echarts/lib/chart/graph/install" {
|
||||
export const install: EChartsExtensionInstaller;
|
||||
}
|
15
yarn.lock
15
yarn.lock
@ -9545,7 +9545,6 @@ __metadata:
|
||||
typescript-eslint: "npm:8.32.1"
|
||||
ua-parser-js: "npm:2.0.3"
|
||||
vis-data: "npm:7.1.9"
|
||||
vis-network: "npm:9.1.9"
|
||||
vite-tsconfig-paths: "npm:5.1.4"
|
||||
vitest: "npm:3.1.3"
|
||||
vue: "npm:2.7.16"
|
||||
@ -15123,20 +15122,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vis-network@npm:9.1.9":
|
||||
version: 9.1.9
|
||||
resolution: "vis-network@npm:9.1.9"
|
||||
peerDependencies:
|
||||
"@egjs/hammerjs": ^2.0.0
|
||||
component-emitter: ^1.3.0
|
||||
keycharm: ^0.2.0 || ^0.3.0 || ^0.4.0
|
||||
uuid: ^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0
|
||||
vis-data: ^6.3.0 || ^7.0.0
|
||||
vis-util: ^5.0.1
|
||||
checksum: 10/929b2645ff62645d030e6a03f2f618d0e66c9a83782c4f6f3257160d48ea49fc06eb62403762b010eb07d137bee72a1bd62e5c58ebbfb9091dbb3ca1f8f1887d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vite-node@npm:3.1.3":
|
||||
version: 3.1.3
|
||||
resolution: "vite-node@npm:3.1.3"
|
||||
|
Loading…
x
Reference in New Issue
Block a user