@matter-server/dashboard 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/pages/matter-network-view.d.ts +15 -0
- package/dist/esm/pages/matter-network-view.d.ts.map +1 -1
- package/dist/esm/pages/matter-network-view.js +171 -1
- package/dist/esm/pages/matter-network-view.js.map +1 -1
- package/dist/esm/pages/network/base-network-graph.d.ts +4 -0
- package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -1
- package/dist/esm/pages/network/base-network-graph.js +9 -0
- package/dist/esm/pages/network/base-network-graph.js.map +1 -1
- package/dist/esm/pages/network/border-router-store.d.ts +20 -0
- package/dist/esm/pages/network/border-router-store.d.ts.map +1 -0
- package/dist/esm/pages/network/border-router-store.js +29 -0
- package/dist/esm/pages/network/border-router-store.js.map +6 -0
- package/dist/esm/pages/network/network-details.d.ts +40 -12
- package/dist/esm/pages/network/network-details.d.ts.map +1 -1
- package/dist/esm/pages/network/network-details.js +440 -112
- package/dist/esm/pages/network/network-details.js.map +1 -1
- package/dist/esm/pages/network/network-types.d.ts +76 -0
- package/dist/esm/pages/network/network-types.d.ts.map +1 -1
- package/dist/esm/pages/network/network-types.js.map +1 -1
- package/dist/esm/pages/network/network-utils.d.ts +89 -22
- package/dist/esm/pages/network/network-utils.d.ts.map +1 -1
- package/dist/esm/pages/network/network-utils.js +233 -95
- package/dist/esm/pages/network/network-utils.js.map +1 -1
- package/dist/esm/pages/network/thread-graph.d.ts +68 -9
- package/dist/esm/pages/network/thread-graph.d.ts.map +1 -1
- package/dist/esm/pages/network/thread-graph.js +388 -50
- package/dist/esm/pages/network/thread-graph.js.map +2 -2
- package/dist/esm/util/device-icons.d.ts +6 -0
- package/dist/esm/util/device-icons.d.ts.map +1 -1
- package/dist/esm/util/device-icons.js +6 -0
- package/dist/esm/util/device-icons.js.map +1 -1
- package/dist/web/js/{attribute-write-dialog-g4B6BoRt.js → attribute-write-dialog-DlMTUiLK.js} +1 -1
- package/dist/web/js/{command-invoke-dialog-D6G704VK.js → command-invoke-dialog-DO3IyFcm.js} +1 -1
- package/dist/web/js/{commission-node-dialog-Bg3oo5ub.js → commission-node-dialog-CMSvCm0i.js} +4 -4
- package/dist/web/js/{commission-node-existing-DO3g1aQJ.js → commission-node-existing-D08jghFu.js} +2 -2
- package/dist/web/js/{commission-node-thread-DM432aH1.js → commission-node-thread-D5waY758.js} +2 -2
- package/dist/web/js/{commission-node-wifi-Bx40FXij.js → commission-node-wifi-ClBlCFTZ.js} +2 -2
- package/dist/web/js/{dialog-box-DjyfULWB.js → dialog-box-D9vS2SmP.js} +1 -1
- package/dist/web/js/{fire_event-BstgNPuh.js → fire_event-BPhROjTC.js} +1 -1
- package/dist/web/js/main.js +1 -1
- package/dist/web/js/{matter-dashboard-app-Cj88TtbZ.js → matter-dashboard-app-C9zTE5uH.js} +1359 -302
- package/dist/web/js/{node-binding-dialog-9yy2LE3_.js → node-binding-dialog-B5p-gbim.js} +1 -1
- package/dist/web/js/{settings-dialog-Cs2xMsXb.js → settings-dialog-BMFhom0W.js} +1 -1
- package/package.json +4 -4
- package/src/pages/matter-network-view.ts +185 -1
- package/src/pages/network/base-network-graph.ts +10 -0
- package/src/pages/network/border-router-store.ts +38 -0
- package/src/pages/network/network-details.ts +535 -140
- package/src/pages/network/network-types.ts +76 -0
- package/src/pages/network/network-utils.ts +390 -171
- package/src/pages/network/thread-graph.ts +532 -73
- package/src/util/device-icons.ts +13 -0
|
@@ -4,17 +4,29 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { BorderRouterEntry } from "@matter-server/ws-client";
|
|
7
8
|
import { html } from "lit";
|
|
8
|
-
import { customElement } from "lit/decorators.js";
|
|
9
|
-
import {
|
|
9
|
+
import { customElement, property } from "lit/decorators.js";
|
|
10
|
+
import {
|
|
11
|
+
createBorderRouterIconDataUrl,
|
|
12
|
+
createNodeIconDataUrl,
|
|
13
|
+
createUnknownDeviceIconDataUrl,
|
|
14
|
+
} from "../../util/device-icons.js";
|
|
10
15
|
import { BaseNetworkGraph } from "./base-network-graph.js";
|
|
11
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
NetworkGraphEdge,
|
|
18
|
+
NetworkGraphNode,
|
|
19
|
+
ThreadConnection,
|
|
20
|
+
ThreadEdgePair,
|
|
21
|
+
ThreadExternalDevice,
|
|
22
|
+
} from "./network-types.js";
|
|
12
23
|
import {
|
|
13
24
|
buildExtAddrMap,
|
|
14
25
|
buildRloc16Map,
|
|
15
|
-
|
|
26
|
+
buildThreadEdgePairs,
|
|
16
27
|
findUnknownDevices,
|
|
17
28
|
getDeviceName,
|
|
29
|
+
getEdgeSignalScore,
|
|
18
30
|
getNetworkType,
|
|
19
31
|
getThreadExtendedAddressHex,
|
|
20
32
|
getThreadRole,
|
|
@@ -26,25 +38,78 @@ declare global {
|
|
|
26
38
|
}
|
|
27
39
|
}
|
|
28
40
|
|
|
41
|
+
/** Reason an edge is hidden in base state */
|
|
42
|
+
type EdgeHiddenReason = "visible" | "filter" | "dedup";
|
|
43
|
+
|
|
44
|
+
/** Stored base state for each edge (after filter+dedup, before highlight) */
|
|
45
|
+
interface EdgeBaseState {
|
|
46
|
+
hiddenReason: EdgeHiddenReason;
|
|
47
|
+
width: number;
|
|
48
|
+
color: { color: string; highlight: string };
|
|
49
|
+
dashes: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
29
52
|
@customElement("thread-graph")
|
|
30
53
|
export class ThreadGraph extends BaseNetworkGraph {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
@property({ attribute: false }) borderRouters: ReadonlyMap<string, BorderRouterEntry> = new Map();
|
|
55
|
+
|
|
56
|
+
@property({ type: Boolean })
|
|
57
|
+
public hideOfflineNodes = false;
|
|
58
|
+
|
|
59
|
+
@property({ type: Boolean })
|
|
60
|
+
public hideWeakSignalEdges = false;
|
|
61
|
+
|
|
62
|
+
@property({ type: Boolean })
|
|
63
|
+
public hideMediumSignalEdges = false;
|
|
64
|
+
|
|
65
|
+
@property({ type: Boolean })
|
|
66
|
+
public hideStrongSignalEdges = false;
|
|
67
|
+
|
|
68
|
+
/** Cache of external Thread devices (Border Routers and unknown) for the current render */
|
|
69
|
+
private _unknownDevices: ThreadExternalDevice[] = [];
|
|
70
|
+
|
|
71
|
+
/** Cached map of external Thread devices (rebuilt in _updateGraph) */
|
|
72
|
+
private _unknownDevicesMapCache: Map<string, ThreadExternalDevice> = new Map();
|
|
73
|
+
|
|
74
|
+
/** All computed edge pairs (rebuilt in _updateGraph) */
|
|
75
|
+
private _edgePairs: Map<string, ThreadEdgePair> = new Map();
|
|
76
|
+
|
|
77
|
+
/** Base state of each edge after filter+dedup, before highlight */
|
|
78
|
+
private _edgeBaseState: Map<string | number, EdgeBaseState> = new Map();
|
|
79
|
+
|
|
80
|
+
/** Whether highlight is currently active */
|
|
81
|
+
private _isHighlighted = false;
|
|
82
|
+
|
|
83
|
+
/** Node ID currently highlighted (for icon restoration on clear) */
|
|
84
|
+
private _highlightedNodeId: string | null = null;
|
|
85
|
+
|
|
86
|
+
/** Get external Thread devices as a map for use by details panel */
|
|
87
|
+
public get unknownDevicesMap(): ReadonlyMap<string, ThreadExternalDevice> {
|
|
45
88
|
return this._unknownDevicesMapCache;
|
|
46
89
|
}
|
|
47
90
|
|
|
91
|
+
/** Get computed edge pairs (for potential use by other components) */
|
|
92
|
+
public get edgePairs(): Map<string, ThreadEdgePair> {
|
|
93
|
+
return this._edgePairs;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
override updated(changedProperties: Map<string, unknown>): void {
|
|
97
|
+
super.updated(changedProperties);
|
|
98
|
+
|
|
99
|
+
// Trigger graph update when any hide option changes, or when the BR registry
|
|
100
|
+
// refreshes (BaseNetworkGraph only watches `nodes`, so a BR-only change would
|
|
101
|
+
// otherwise leave stale labels/icons).
|
|
102
|
+
if (
|
|
103
|
+
changedProperties.has("hideOfflineNodes") ||
|
|
104
|
+
changedProperties.has("hideWeakSignalEdges") ||
|
|
105
|
+
changedProperties.has("hideMediumSignalEdges") ||
|
|
106
|
+
changedProperties.has("hideStrongSignalEdges") ||
|
|
107
|
+
changedProperties.has("borderRouters")
|
|
108
|
+
) {
|
|
109
|
+
this._debouncedUpdateGraph();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
48
113
|
/**
|
|
49
114
|
* Searches for a Thread node (known or unknown) by extended address and selects it.
|
|
50
115
|
* Accepts formats like:
|
|
@@ -86,8 +151,10 @@ export class ThreadGraph extends BaseNetworkGraph {
|
|
|
86
151
|
protected override _updateGraph(): void {
|
|
87
152
|
if (!this._nodesDataSet || !this._edgesDataSet) return;
|
|
88
153
|
|
|
89
|
-
// Clear stored
|
|
154
|
+
// Clear stored state since we're rebuilding everything
|
|
90
155
|
this._clearOriginalEdgeColors();
|
|
156
|
+
this._edgeBaseState.clear();
|
|
157
|
+
this._isHighlighted = false;
|
|
91
158
|
|
|
92
159
|
// Filter to Thread devices only
|
|
93
160
|
const threadNodes = Object.values(this.nodes).filter(node => getNetworkType(node) === "thread");
|
|
@@ -97,6 +164,7 @@ export class ThreadGraph extends BaseNetworkGraph {
|
|
|
97
164
|
this._edgesDataSet.clear();
|
|
98
165
|
this._unknownDevices = [];
|
|
99
166
|
this._unknownDevicesMapCache.clear();
|
|
167
|
+
this._edgePairs.clear();
|
|
100
168
|
return;
|
|
101
169
|
}
|
|
102
170
|
|
|
@@ -104,80 +172,188 @@ export class ThreadGraph extends BaseNetworkGraph {
|
|
|
104
172
|
const extAddrMap = buildExtAddrMap(this.nodes);
|
|
105
173
|
const rloc16Map = buildRloc16Map(this.nodes);
|
|
106
174
|
|
|
107
|
-
// Find
|
|
108
|
-
|
|
175
|
+
// Find external Thread devices (seen in neighbor tables but not commissioned),
|
|
176
|
+
// classified against the BR registry so mDNS-known routers render distinctly.
|
|
177
|
+
this._unknownDevices = findUnknownDevices(this.nodes, extAddrMap, rloc16Map, this.borderRouters);
|
|
109
178
|
|
|
110
|
-
// Rebuild the cached map
|
|
111
179
|
this._unknownDevicesMapCache.clear();
|
|
112
180
|
for (const device of this._unknownDevices) {
|
|
113
|
-
this._unknownDevicesMapCache.set(device.id,
|
|
114
|
-
extAddressHex: device.extAddressHex,
|
|
115
|
-
isRouter: device.isRouter,
|
|
116
|
-
seenBy: device.seenBy,
|
|
117
|
-
bestRssi: device.bestRssi,
|
|
118
|
-
});
|
|
181
|
+
this._unknownDevicesMapCache.set(device.id, device);
|
|
119
182
|
}
|
|
120
183
|
|
|
121
|
-
// Build
|
|
122
|
-
|
|
184
|
+
// Build ALL edge pairs (0-2 edges per connected pair, no dedup)
|
|
185
|
+
this._edgePairs = buildThreadEdgePairs(this.nodes, extAddrMap, rloc16Map, this._unknownDevices);
|
|
123
186
|
|
|
124
|
-
//
|
|
125
|
-
|
|
187
|
+
// Track which nodes should be hidden
|
|
188
|
+
const hiddenNodeIds = new Set<string>();
|
|
189
|
+
|
|
190
|
+
// --- Build graph nodes ---
|
|
191
|
+
|
|
192
|
+
// Known Thread devices
|
|
126
193
|
const graphNodes: NetworkGraphNode[] = threadNodes.map(node => {
|
|
127
194
|
const nodeId = String(node.node_id);
|
|
128
195
|
const threadRole = getThreadRole(node);
|
|
129
|
-
const isSelected = nodeId === String(this._selectedNodeId);
|
|
130
196
|
const isOffline = node.available === false;
|
|
197
|
+
const shouldHide = this.hideOfflineNodes && isOffline;
|
|
198
|
+
|
|
199
|
+
if (shouldHide) {
|
|
200
|
+
hiddenNodeIds.add(nodeId);
|
|
201
|
+
}
|
|
131
202
|
|
|
132
203
|
return {
|
|
133
204
|
id: nodeId,
|
|
134
205
|
label: getDeviceName(node),
|
|
135
|
-
image: createNodeIconDataUrl(node, threadRole,
|
|
136
|
-
shape: "image",
|
|
137
|
-
networkType: "thread",
|
|
206
|
+
image: createNodeIconDataUrl(node, threadRole, false, isOffline),
|
|
207
|
+
shape: "image" as const,
|
|
208
|
+
networkType: "thread" as const,
|
|
138
209
|
threadRole,
|
|
139
210
|
offline: isOffline,
|
|
211
|
+
hidden: shouldHide,
|
|
140
212
|
};
|
|
141
213
|
});
|
|
142
214
|
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
215
|
+
// External Thread devices: known Border Routers get a friendly label/icon,
|
|
216
|
+
// unidentified neighbors keep the generic question-mark style.
|
|
217
|
+
for (const device of this._unknownDevices) {
|
|
218
|
+
const isSelected = device.id === this._selectedNodeId;
|
|
219
|
+
|
|
220
|
+
// Hide if hideOfflineNodes is enabled AND all nodes that see it are offline
|
|
221
|
+
let shouldHide = false;
|
|
222
|
+
if (this.hideOfflineNodes) {
|
|
223
|
+
const hasOnlineSeenBy = device.seenBy.some(nodeId => {
|
|
224
|
+
const node = this.nodes[nodeId];
|
|
225
|
+
return node && node.available !== false;
|
|
226
|
+
});
|
|
227
|
+
shouldHide = !hasOnlineSeenBy;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (shouldHide) {
|
|
231
|
+
hiddenNodeIds.add(device.id);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (device.kind === "br") {
|
|
235
|
+
const hostname = device.hostname?.replace(/\.$/, "").replace(/\.local$/i, "");
|
|
236
|
+
// Only show network name on a second line when the first line came from a
|
|
237
|
+
// distinct hostname; otherwise `top` would already be the (possibly truncated)
|
|
238
|
+
// network name and the second line would just repeat it.
|
|
239
|
+
const top = (hostname ?? device.networkName ?? "Border Router").slice(0, 24);
|
|
240
|
+
const suffix =
|
|
241
|
+
hostname !== undefined && device.networkName !== undefined && device.networkName !== top
|
|
242
|
+
? `\n${device.networkName}`
|
|
243
|
+
: "";
|
|
244
|
+
const label = `${top}${suffix}`;
|
|
245
|
+
graphNodes.push({
|
|
246
|
+
id: device.id,
|
|
247
|
+
label,
|
|
248
|
+
image: createBorderRouterIconDataUrl(isSelected),
|
|
249
|
+
shape: "image" as const,
|
|
250
|
+
networkType: "thread" as const,
|
|
251
|
+
isUnknown: false,
|
|
252
|
+
hidden: shouldHide,
|
|
253
|
+
});
|
|
254
|
+
} else {
|
|
255
|
+
const typeLabel = device.isRouter ? "External Router" : "External Device";
|
|
256
|
+
const suffix = device.networkName !== undefined ? `\n${device.networkName}` : "";
|
|
257
|
+
graphNodes.push({
|
|
258
|
+
id: device.id,
|
|
259
|
+
label: `${typeLabel} (${device.extAddressHex.slice(-8)})${suffix}`,
|
|
260
|
+
image: createUnknownDeviceIconDataUrl(device.isRouter, isSelected),
|
|
261
|
+
shape: "image" as const,
|
|
262
|
+
networkType: "thread" as const,
|
|
263
|
+
isUnknown: true,
|
|
264
|
+
hidden: shouldHide,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
155
267
|
}
|
|
156
268
|
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
269
|
+
// --- Build graph edges from edge pairs ---
|
|
270
|
+
|
|
271
|
+
const graphEdges: NetworkGraphEdge[] = [];
|
|
272
|
+
|
|
273
|
+
for (const pair of this._edgePairs.values()) {
|
|
274
|
+
// Collect both directional edges for this pair
|
|
275
|
+
const edgesInPair: { conn: ThreadConnection; visEdge: NetworkGraphEdge; filterHidden: boolean }[] = [];
|
|
276
|
+
|
|
277
|
+
for (const conn of [pair.edgeAB, pair.edgeBA]) {
|
|
278
|
+
if (!conn) continue;
|
|
279
|
+
|
|
280
|
+
const fromId = String(conn.fromNodeId);
|
|
281
|
+
const toId = String(conn.toNodeId);
|
|
282
|
+
const isToUnknown = toId.startsWith("unknown_") || toId.startsWith("br_");
|
|
283
|
+
const fromNode = this.nodes[fromId];
|
|
284
|
+
const toNode = this.nodes[toId];
|
|
285
|
+
const hasOfflineEndpoint = fromNode?.available === false || toNode?.available === false;
|
|
286
|
+
|
|
287
|
+
// Apply filters to determine if edge should be hidden
|
|
288
|
+
let filterHidden = false;
|
|
289
|
+
|
|
290
|
+
// Cascade from hidden nodes (offline filter)
|
|
291
|
+
if (hiddenNodeIds.has(fromId) || hiddenNodeIds.has(toId)) {
|
|
292
|
+
filterHidden = true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Signal level filters
|
|
296
|
+
if (!filterHidden && this.hideWeakSignalEdges && conn.signalLevel === "weak") {
|
|
297
|
+
filterHidden = true;
|
|
298
|
+
}
|
|
299
|
+
if (!filterHidden && this.hideMediumSignalEdges && conn.signalLevel === "medium") {
|
|
300
|
+
filterHidden = true;
|
|
301
|
+
}
|
|
302
|
+
if (!filterHidden && this.hideStrongSignalEdges && conn.signalLevel === "strong") {
|
|
303
|
+
filterHidden = true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const edgeId = `edge_${fromId}_${toId}`;
|
|
307
|
+
|
|
308
|
+
const visEdge: NetworkGraphEdge = {
|
|
309
|
+
id: edgeId,
|
|
310
|
+
from: fromId,
|
|
311
|
+
to: toId,
|
|
312
|
+
color: { color: conn.signalColor, highlight: conn.signalColor },
|
|
313
|
+
width: 2,
|
|
314
|
+
title: conn.rssi !== null ? `RSSI: ${conn.rssi} dBm, LQI: ${conn.lqi}` : `LQI: ${conn.lqi}`,
|
|
315
|
+
dashes: isToUnknown || hasOfflineEndpoint,
|
|
316
|
+
hidden: filterHidden,
|
|
317
|
+
pairKey: pair.pairKey,
|
|
318
|
+
reportingNodeId: fromId,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
edgesInPair.push({ conn, visEdge, filterHidden });
|
|
322
|
+
}
|
|
160
323
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
324
|
+
// Dedup: among visible edges in this pair, keep the one with
|
|
325
|
+
// the weakest signal (worst case). Hide the rest.
|
|
326
|
+
const visibleInPair = edgesInPair.filter(e => !e.visEdge.hidden);
|
|
327
|
+
if (visibleInPair.length > 1) {
|
|
328
|
+
// Sort ascending by signal score (lowest = weakest = worst case)
|
|
329
|
+
visibleInPair.sort((a, b) => getEdgeSignalScore(a.conn) - getEdgeSignalScore(b.conn));
|
|
330
|
+
// Keep the weakest (index 0), hide the better one(s)
|
|
331
|
+
for (let i = 1; i < visibleInPair.length; i++) {
|
|
332
|
+
visibleInPair[i].visEdge.hidden = true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
165
335
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
336
|
+
// Save base state and collect edges for the dataset
|
|
337
|
+
for (const e of edgesInPair) {
|
|
338
|
+
const isHidden = e.visEdge.hidden ?? false;
|
|
339
|
+
let hiddenReason: EdgeHiddenReason = "visible";
|
|
340
|
+
if (isHidden) {
|
|
341
|
+
hiddenReason = e.filterHidden ? "filter" : "dedup";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
this._edgeBaseState.set(e.visEdge.id, {
|
|
345
|
+
hiddenReason,
|
|
346
|
+
width: e.visEdge.width,
|
|
347
|
+
color: { color: e.visEdge.color.color, highlight: e.visEdge.color.highlight },
|
|
348
|
+
dashes: e.visEdge.dashes ?? false,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
graphEdges.push(e.visEdge);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --- Update vis.js datasets ---
|
|
179
356
|
|
|
180
|
-
// Update datasets
|
|
181
357
|
const existingNodeIds = this._nodesDataSet.getIds();
|
|
182
358
|
const newNodeIds = new Set(graphNodes.map(n => n.id));
|
|
183
359
|
|
|
@@ -190,19 +366,302 @@ export class ThreadGraph extends BaseNetworkGraph {
|
|
|
190
366
|
// Update or add nodes
|
|
191
367
|
this._nodesDataSet.update(graphNodes);
|
|
192
368
|
|
|
193
|
-
// Replace all edges
|
|
369
|
+
// Replace all edges
|
|
194
370
|
this._edgesDataSet.clear();
|
|
195
371
|
this._edgesDataSet.add(graphEdges);
|
|
372
|
+
|
|
373
|
+
// Re-apply highlight if a node is selected
|
|
374
|
+
if (this._selectedNodeId !== null) {
|
|
375
|
+
this._highlightConnections(this._selectedNodeId);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Highlights edges connected to the selected node with swap/arrow logic.
|
|
381
|
+
*
|
|
382
|
+
* For each visible edge connected to the highlighted node:
|
|
383
|
+
* - If the visible edge comes from the remote node AND there is a
|
|
384
|
+
* dedup-hidden edge from the highlighted node → SWAP: show the
|
|
385
|
+
* highlighted node's edge instead (its signal is better or equal,
|
|
386
|
+
* but we prefer the highlighted node's perspective).
|
|
387
|
+
* - Otherwise → thicken the edge. If the pair is truly one-way (only
|
|
388
|
+
* one of edgeAB/edgeBA exists), also draw an arrow in the data
|
|
389
|
+
* direction so asymmetric visibility is visible at a glance.
|
|
390
|
+
*/
|
|
391
|
+
protected override _highlightConnections(nodeId: number | string): void {
|
|
392
|
+
if (!this._edgesDataSet || !this._nodesDataSet) return;
|
|
393
|
+
|
|
394
|
+
const nodeIdStr = String(nodeId);
|
|
395
|
+
|
|
396
|
+
// Re-selecting the same node is a no-op: a full restore + re-highlight
|
|
397
|
+
// would cause a visible flicker on dense graphs.
|
|
398
|
+
if (this._isHighlighted && this._highlightedNodeId === nodeIdStr) return;
|
|
399
|
+
|
|
400
|
+
// Restore base state first if already highlighted (e.g. switching nodes)
|
|
401
|
+
if (this._isHighlighted) {
|
|
402
|
+
this._restoreEdgeBaseState();
|
|
403
|
+
}
|
|
404
|
+
const allEdges = this._edgesDataSet.get();
|
|
405
|
+
const dimmedColor = this._getDimmedEdgeColor();
|
|
406
|
+
|
|
407
|
+
// Group edges by pair key for swap lookups
|
|
408
|
+
const edgesByPair = new Map<string, NetworkGraphEdge[]>();
|
|
409
|
+
for (const edge of allEdges) {
|
|
410
|
+
if (edge.pairKey) {
|
|
411
|
+
const list = edgesByPair.get(edge.pairKey) ?? [];
|
|
412
|
+
list.push(edge);
|
|
413
|
+
edgesByPair.set(edge.pairKey, list);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const connectedNodeIds = new Set<string | number>();
|
|
418
|
+
const edgeUpdates: Record<string, Partial<NetworkGraphEdge>> = {};
|
|
419
|
+
|
|
420
|
+
for (const edge of allEdges) {
|
|
421
|
+
const fromStr = String(edge.from);
|
|
422
|
+
const toStr = String(edge.to);
|
|
423
|
+
const isConnected = fromStr === nodeIdStr || toStr === nodeIdStr;
|
|
424
|
+
|
|
425
|
+
if (!isConnected) {
|
|
426
|
+
// Dim non-connected visible edges
|
|
427
|
+
const baseState = this._edgeBaseState.get(edge.id);
|
|
428
|
+
if (baseState && baseState.hiddenReason === "visible") {
|
|
429
|
+
edgeUpdates[String(edge.id)] = {
|
|
430
|
+
id: edge.id,
|
|
431
|
+
width: 1,
|
|
432
|
+
color: { color: dimmedColor, highlight: dimmedColor },
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// This edge is connected to the highlighted node.
|
|
439
|
+
// Only process edges that are visible in base state.
|
|
440
|
+
const baseState = this._edgeBaseState.get(edge.id);
|
|
441
|
+
if (!baseState || baseState.hiddenReason !== "visible") {
|
|
442
|
+
// Hidden edge — may be used as a swap target below, but
|
|
443
|
+
// we don't initiate processing from hidden edges.
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const remoteId = fromStr === nodeIdStr ? toStr : fromStr;
|
|
448
|
+
const reportingId = String(edge.reportingNodeId ?? edge.from);
|
|
449
|
+
connectedNodeIds.add(remoteId);
|
|
450
|
+
|
|
451
|
+
if (reportingId !== nodeIdStr) {
|
|
452
|
+
// Visible edge comes from the REMOTE node.
|
|
453
|
+
// Check if there's a dedup-hidden edge from the highlighted node
|
|
454
|
+
// that we can swap in to show the highlighted node's perspective.
|
|
455
|
+
const pairEdges = edge.pairKey ? edgesByPair.get(edge.pairKey) : undefined;
|
|
456
|
+
|
|
457
|
+
const swapCandidate = pairEdges?.find(e => {
|
|
458
|
+
const rid = String(e.reportingNodeId ?? e.from);
|
|
459
|
+
if (rid !== nodeIdStr) return false;
|
|
460
|
+
const bs = this._edgeBaseState.get(e.id);
|
|
461
|
+
return bs?.hiddenReason === "dedup";
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
if (swapCandidate) {
|
|
465
|
+
// SWAP: hide the remote's edge, show the highlighted node's edge
|
|
466
|
+
const swapBaseState = this._edgeBaseState.get(swapCandidate.id);
|
|
467
|
+
edgeUpdates[String(edge.id)] = {
|
|
468
|
+
id: edge.id,
|
|
469
|
+
hidden: true,
|
|
470
|
+
};
|
|
471
|
+
edgeUpdates[String(swapCandidate.id)] = {
|
|
472
|
+
id: swapCandidate.id,
|
|
473
|
+
hidden: false,
|
|
474
|
+
width: 3,
|
|
475
|
+
color: swapBaseState
|
|
476
|
+
? { color: swapBaseState.color.color, highlight: swapBaseState.color.highlight }
|
|
477
|
+
: { color: "#999999", highlight: "#999999" },
|
|
478
|
+
};
|
|
479
|
+
} else {
|
|
480
|
+
// No swap target means the displayed direction is reverse
|
|
481
|
+
// from the highlighted node's perspective and we cannot
|
|
482
|
+
// recover the outgoing direction (truly one-way OR
|
|
483
|
+
// filter-hidden). Both look the same to the eye, so always
|
|
484
|
+
// arrow; panel disambiguates via one-way badge vs (reverse).
|
|
485
|
+
edgeUpdates[String(edge.id)] = this._reverseEdgeUpdate(edge);
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
// Visible edge comes from the HIGHLIGHTED node. Only mark with
|
|
489
|
+
// an arrow when the peer's direction is genuinely absent — a
|
|
490
|
+
// peer-direction filter-hide here would just add noise to the
|
|
491
|
+
// user's own perspective.
|
|
492
|
+
edgeUpdates[String(edge.id)] = this._asymmetricEdgeUpdate(edge);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
this._edgesDataSet.update(Object.values(edgeUpdates));
|
|
497
|
+
|
|
498
|
+
// Update nodes — make neighbors more prominent
|
|
499
|
+
const allNodes = this._nodesDataSet.get();
|
|
500
|
+
const nodeUpdates = allNodes.map((node: NetworkGraphNode) => {
|
|
501
|
+
const isNeighbor = connectedNodeIds.has(node.id);
|
|
502
|
+
const isSelected = node.id === nodeId;
|
|
503
|
+
return {
|
|
504
|
+
id: node.id,
|
|
505
|
+
size: isSelected ? 40 : isNeighbor ? 35 : 25,
|
|
506
|
+
font: {
|
|
507
|
+
size: isSelected ? 14 : isNeighbor ? 13 : 11,
|
|
508
|
+
color: this._getFontColor(),
|
|
509
|
+
bold: isSelected || isNeighbor ? { color: this._getFontColor() } : undefined,
|
|
510
|
+
},
|
|
511
|
+
opacity: isSelected || isNeighbor ? 1 : 0.5,
|
|
512
|
+
};
|
|
513
|
+
});
|
|
514
|
+
this._nodesDataSet.update(nodeUpdates);
|
|
515
|
+
|
|
516
|
+
// Switching highlight without a full deselect (e.g. clicking a connection
|
|
517
|
+
// row in the side panel) keeps the previous node's icon in its selected
|
|
518
|
+
// variant unless we reset it here.
|
|
519
|
+
if (this._highlightedNodeId && this._highlightedNodeId !== nodeIdStr) {
|
|
520
|
+
this._setNodeIconHighlight(this._highlightedNodeId, false);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
this._highlightedNodeId = nodeIdStr;
|
|
524
|
+
this._setNodeIconHighlight(nodeIdStr, true);
|
|
525
|
+
|
|
526
|
+
this._isHighlighted = true;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Thicken a connected edge and add a directional arrow when the pair is
|
|
531
|
+
* truly one-way in data (only one of edgeAB/edgeBA exists). Used for the
|
|
532
|
+
* highlighted-reports-peer branch where filter-hidden peer directions
|
|
533
|
+
* should not draw an arrow on the user's own perspective.
|
|
534
|
+
*
|
|
535
|
+
* Explicit arrow object (vs the shorthand "to") keeps the head visible
|
|
536
|
+
* on dashed offline edges where some vis.js builds skip the shorthand.
|
|
537
|
+
*/
|
|
538
|
+
private _asymmetricEdgeUpdate(edge: NetworkGraphEdge): Partial<NetworkGraphEdge> {
|
|
539
|
+
const pair = edge.pairKey ? this._edgePairs.get(edge.pairKey) : undefined;
|
|
540
|
+
const isAsymmetric = pair ? !pair.edgeAB || !pair.edgeBA : false;
|
|
541
|
+
const update: Partial<NetworkGraphEdge> = {
|
|
542
|
+
id: edge.id,
|
|
543
|
+
width: 3,
|
|
544
|
+
};
|
|
545
|
+
if (isAsymmetric) {
|
|
546
|
+
update.arrows = { to: { enabled: true, scaleFactor: 1 } };
|
|
547
|
+
}
|
|
548
|
+
return update;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Thicken a connected edge and always add a directional arrow. Used for
|
|
553
|
+
* the remote-reports-highlighted branch where the displayed edge is the
|
|
554
|
+
* peer's direction (no swap target available); the user sees the same
|
|
555
|
+
* single line whether the outgoing direction is filter-hidden or absent.
|
|
556
|
+
*/
|
|
557
|
+
private _reverseEdgeUpdate(edge: NetworkGraphEdge): Partial<NetworkGraphEdge> {
|
|
558
|
+
return {
|
|
559
|
+
id: edge.id,
|
|
560
|
+
width: 3,
|
|
561
|
+
arrows: { to: { enabled: true, scaleFactor: 1 } },
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Swap a node's icon between the default and highlighted variants.
|
|
567
|
+
* Kept separate so both `_highlightConnections` (switching target) and
|
|
568
|
+
* `_clearHighlights` (fully unselecting) reach the same end state.
|
|
569
|
+
*/
|
|
570
|
+
private _setNodeIconHighlight(nodeId: string, isHighlighted: boolean): void {
|
|
571
|
+
if (!this._nodesDataSet) return;
|
|
572
|
+
const nodeData = this.nodes[nodeId];
|
|
573
|
+
if (nodeData) {
|
|
574
|
+
const threadRole = getThreadRole(nodeData);
|
|
575
|
+
const isOffline = nodeData.available === false;
|
|
576
|
+
this._nodesDataSet.update({
|
|
577
|
+
id: nodeId,
|
|
578
|
+
image: createNodeIconDataUrl(nodeData, threadRole, isHighlighted, isOffline),
|
|
579
|
+
});
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const external = this._unknownDevicesMapCache.get(nodeId);
|
|
583
|
+
if (external?.kind === "br") {
|
|
584
|
+
this._nodesDataSet.update({
|
|
585
|
+
id: nodeId,
|
|
586
|
+
image: createBorderRouterIconDataUrl(isHighlighted),
|
|
587
|
+
});
|
|
588
|
+
} else if (nodeId.startsWith("unknown_") || nodeId.startsWith("br_")) {
|
|
589
|
+
this._nodesDataSet.update({
|
|
590
|
+
id: nodeId,
|
|
591
|
+
image: createUnknownDeviceIconDataUrl(external?.isRouter ?? false, isHighlighted),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Clears all highlights and restores the graph to its base state
|
|
598
|
+
* (after filter+dedup, before highlight modifications).
|
|
599
|
+
*/
|
|
600
|
+
protected override _clearHighlights(): void {
|
|
601
|
+
if (!this._edgesDataSet || !this._nodesDataSet) return;
|
|
602
|
+
|
|
603
|
+
// Restore edges to their base state
|
|
604
|
+
this._restoreEdgeBaseState();
|
|
605
|
+
|
|
606
|
+
if (this._highlightedNodeId) {
|
|
607
|
+
this._setNodeIconHighlight(this._highlightedNodeId, false);
|
|
608
|
+
this._highlightedNodeId = null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Restore nodes to default styling
|
|
612
|
+
const allNodes = this._nodesDataSet.get();
|
|
613
|
+
const nodeUpdates = allNodes.map((node: NetworkGraphNode) => ({
|
|
614
|
+
id: node.id,
|
|
615
|
+
size: 30,
|
|
616
|
+
font: {
|
|
617
|
+
size: 12,
|
|
618
|
+
color: this._getFontColor(),
|
|
619
|
+
bold: undefined,
|
|
620
|
+
},
|
|
621
|
+
opacity: 1,
|
|
622
|
+
}));
|
|
623
|
+
this._nodesDataSet.update(nodeUpdates);
|
|
624
|
+
|
|
625
|
+
this._isHighlighted = false;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Restores all edges to their base state (undoes highlight modifications).
|
|
630
|
+
* This resets hidden/visible state, width, color, and dashes.
|
|
631
|
+
*/
|
|
632
|
+
private _restoreEdgeBaseState(): void {
|
|
633
|
+
if (!this._edgesDataSet) return;
|
|
634
|
+
|
|
635
|
+
const edgeUpdates: Partial<NetworkGraphEdge>[] = [];
|
|
636
|
+
for (const [id, baseState] of this._edgeBaseState) {
|
|
637
|
+
edgeUpdates.push({
|
|
638
|
+
id,
|
|
639
|
+
hidden: baseState.hiddenReason !== "visible",
|
|
640
|
+
width: baseState.width,
|
|
641
|
+
color: { color: baseState.color.color, highlight: baseState.color.highlight },
|
|
642
|
+
dashes: baseState.dashes,
|
|
643
|
+
arrows: "",
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
this._edgesDataSet.update(edgeUpdates);
|
|
196
647
|
}
|
|
197
648
|
|
|
198
649
|
override render() {
|
|
199
650
|
const threadNodes = Object.values(this.nodes).filter(node => getNetworkType(node) === "thread");
|
|
651
|
+
const visibleThreadNodes = this.hideOfflineNodes
|
|
652
|
+
? threadNodes.filter(node => node.available !== false)
|
|
653
|
+
: threadNodes;
|
|
200
654
|
|
|
201
|
-
if (
|
|
655
|
+
if (visibleThreadNodes.length === 0) {
|
|
656
|
+
const allOfflineFiltered = threadNodes.length > 0 && this.hideOfflineNodes;
|
|
202
657
|
return html`
|
|
203
658
|
<div class="empty-state">
|
|
204
|
-
<p
|
|
205
|
-
<p class="hint">
|
|
659
|
+
<p>${allOfflineFiltered ? "No online Thread devices" : "No Thread devices found"}</p>
|
|
660
|
+
<p class="hint">
|
|
661
|
+
${allOfflineFiltered
|
|
662
|
+
? 'Disable the "Offline nodes" filter to show offline devices'
|
|
663
|
+
: "Thread devices will appear here once commissioned"}
|
|
664
|
+
</p>
|
|
206
665
|
</div>
|
|
207
666
|
`;
|
|
208
667
|
}
|