@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.
Files changed (52) hide show
  1. package/dist/esm/pages/matter-network-view.d.ts +15 -0
  2. package/dist/esm/pages/matter-network-view.d.ts.map +1 -1
  3. package/dist/esm/pages/matter-network-view.js +171 -1
  4. package/dist/esm/pages/matter-network-view.js.map +1 -1
  5. package/dist/esm/pages/network/base-network-graph.d.ts +4 -0
  6. package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -1
  7. package/dist/esm/pages/network/base-network-graph.js +9 -0
  8. package/dist/esm/pages/network/base-network-graph.js.map +1 -1
  9. package/dist/esm/pages/network/border-router-store.d.ts +20 -0
  10. package/dist/esm/pages/network/border-router-store.d.ts.map +1 -0
  11. package/dist/esm/pages/network/border-router-store.js +29 -0
  12. package/dist/esm/pages/network/border-router-store.js.map +6 -0
  13. package/dist/esm/pages/network/network-details.d.ts +40 -12
  14. package/dist/esm/pages/network/network-details.d.ts.map +1 -1
  15. package/dist/esm/pages/network/network-details.js +440 -112
  16. package/dist/esm/pages/network/network-details.js.map +1 -1
  17. package/dist/esm/pages/network/network-types.d.ts +76 -0
  18. package/dist/esm/pages/network/network-types.d.ts.map +1 -1
  19. package/dist/esm/pages/network/network-types.js.map +1 -1
  20. package/dist/esm/pages/network/network-utils.d.ts +89 -22
  21. package/dist/esm/pages/network/network-utils.d.ts.map +1 -1
  22. package/dist/esm/pages/network/network-utils.js +233 -95
  23. package/dist/esm/pages/network/network-utils.js.map +1 -1
  24. package/dist/esm/pages/network/thread-graph.d.ts +68 -9
  25. package/dist/esm/pages/network/thread-graph.d.ts.map +1 -1
  26. package/dist/esm/pages/network/thread-graph.js +388 -50
  27. package/dist/esm/pages/network/thread-graph.js.map +2 -2
  28. package/dist/esm/util/device-icons.d.ts +6 -0
  29. package/dist/esm/util/device-icons.d.ts.map +1 -1
  30. package/dist/esm/util/device-icons.js +6 -0
  31. package/dist/esm/util/device-icons.js.map +1 -1
  32. package/dist/web/js/{attribute-write-dialog-g4B6BoRt.js → attribute-write-dialog-DlMTUiLK.js} +1 -1
  33. package/dist/web/js/{command-invoke-dialog-D6G704VK.js → command-invoke-dialog-DO3IyFcm.js} +1 -1
  34. package/dist/web/js/{commission-node-dialog-Bg3oo5ub.js → commission-node-dialog-CMSvCm0i.js} +4 -4
  35. package/dist/web/js/{commission-node-existing-DO3g1aQJ.js → commission-node-existing-D08jghFu.js} +2 -2
  36. package/dist/web/js/{commission-node-thread-DM432aH1.js → commission-node-thread-D5waY758.js} +2 -2
  37. package/dist/web/js/{commission-node-wifi-Bx40FXij.js → commission-node-wifi-ClBlCFTZ.js} +2 -2
  38. package/dist/web/js/{dialog-box-DjyfULWB.js → dialog-box-D9vS2SmP.js} +1 -1
  39. package/dist/web/js/{fire_event-BstgNPuh.js → fire_event-BPhROjTC.js} +1 -1
  40. package/dist/web/js/main.js +1 -1
  41. package/dist/web/js/{matter-dashboard-app-Cj88TtbZ.js → matter-dashboard-app-C9zTE5uH.js} +1359 -302
  42. package/dist/web/js/{node-binding-dialog-9yy2LE3_.js → node-binding-dialog-B5p-gbim.js} +1 -1
  43. package/dist/web/js/{settings-dialog-Cs2xMsXb.js → settings-dialog-BMFhom0W.js} +1 -1
  44. package/package.json +4 -4
  45. package/src/pages/matter-network-view.ts +185 -1
  46. package/src/pages/network/base-network-graph.ts +10 -0
  47. package/src/pages/network/border-router-store.ts +38 -0
  48. package/src/pages/network/network-details.ts +535 -140
  49. package/src/pages/network/network-types.ts +76 -0
  50. package/src/pages/network/network-utils.ts +390 -171
  51. package/src/pages/network/thread-graph.ts +532 -73
  52. 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 { createNodeIconDataUrl, createUnknownDeviceIconDataUrl } from "../../util/device-icons.js";
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 { NetworkGraphEdge, NetworkGraphNode, UnknownThreadDevice } from "./network-types.js";
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
- buildThreadConnections,
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
- /** Cache of unknown devices for the current render */
32
- private _unknownDevices: UnknownThreadDevice[] = [];
33
-
34
- /** Cached map of unknown devices (rebuilt in _updateGraph) */
35
- private _unknownDevicesMapCache: Map<
36
- string,
37
- { extAddressHex: string; isRouter: boolean; seenBy: string[]; bestRssi: number | null }
38
- > = new Map();
39
-
40
- /** Get unknown devices as a map for use by details panel */
41
- public get unknownDevicesMap(): Map<
42
- string,
43
- { extAddressHex: string; isRouter: boolean; seenBy: string[]; bestRssi: number | null }
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 edge colors since we're rebuilding edges
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 unknown devices (seen in neighbor tables but not commissioned)
108
- this._unknownDevices = findUnknownDevices(this.nodes, extAddrMap, rloc16Map);
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 Thread connections (including to unknown devices)
122
- const connections = buildThreadConnections(this.nodes, extAddrMap, rloc16Map, this._unknownDevices);
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
- // Create node data for vis.js - known Thread devices
125
- // Use string IDs to avoid precision loss for large bigint node IDs
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, isSelected, isOffline),
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
- // Add external devices with question mark icons
144
- for (const unknown of this._unknownDevices) {
145
- const isSelected = unknown.id === this._selectedNodeId;
146
- const typeLabel = unknown.isRouter ? "External Router" : "External Device";
147
- graphNodes.push({
148
- id: unknown.id,
149
- label: `${typeLabel} (${unknown.extAddressHex.slice(-8)})`,
150
- image: createUnknownDeviceIconDataUrl(unknown.isRouter, isSelected),
151
- shape: "image",
152
- networkType: "thread",
153
- isUnknown: true,
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
- // Create edge data for vis.js
158
- const graphEdges: NetworkGraphEdge[] = connections.map((conn, index) => {
159
- const isToUnknown = typeof conn.toNodeId === "string" && conn.toNodeId.startsWith("unknown_");
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
- // Check if either endpoint is offline - connection data may be stale
162
- const fromNode = this.nodes[String(conn.fromNodeId)];
163
- const toNode = this.nodes[String(conn.toNodeId)];
164
- const hasOfflineEndpoint = fromNode?.available === false || toNode?.available === false;
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
- return {
167
- id: `edge_${index}`,
168
- from: conn.fromNodeId,
169
- to: conn.toNodeId,
170
- color: {
171
- color: conn.signalColor,
172
- highlight: conn.signalColor,
173
- },
174
- width: 2,
175
- title: conn.rssi !== null ? `RSSI: ${conn.rssi} dBm, LQI: ${conn.lqi}` : `LQI: ${conn.lqi}`,
176
- dashes: isToUnknown || hasOfflineEndpoint, // Dashed lines to unknown or offline devices
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 (simpler than diff for edge data)
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 (threadNodes.length === 0) {
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>No Thread devices found</p>
205
- <p class="hint">Thread devices will appear here once commissioned</p>
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
  }