@matter-server/dashboard 0.3.3 → 0.3.4

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 (66) hide show
  1. package/README.md +76 -0
  2. package/dist/esm/pages/components/node-details.js +1 -1
  3. package/dist/esm/pages/components/node-details.js.map +1 -1
  4. package/dist/esm/pages/matter-cluster-view.d.ts.map +1 -1
  5. package/dist/esm/pages/matter-cluster-view.js +9 -4
  6. package/dist/esm/pages/matter-cluster-view.js.map +1 -1
  7. package/dist/esm/pages/matter-endpoint-view.d.ts.map +1 -1
  8. package/dist/esm/pages/matter-endpoint-view.js +8 -2
  9. package/dist/esm/pages/matter-endpoint-view.js.map +1 -1
  10. package/dist/esm/pages/matter-node-view.d.ts.map +1 -1
  11. package/dist/esm/pages/matter-node-view.js +17 -3
  12. package/dist/esm/pages/matter-node-view.js.map +1 -1
  13. package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -1
  14. package/dist/esm/pages/network/base-network-graph.js +10 -2
  15. package/dist/esm/pages/network/base-network-graph.js.map +1 -1
  16. package/dist/esm/pages/network/device-icons.js +1 -1
  17. package/dist/esm/pages/network/network-details.d.ts +31 -1
  18. package/dist/esm/pages/network/network-details.d.ts.map +1 -1
  19. package/dist/esm/pages/network/network-details.js +233 -15
  20. package/dist/esm/pages/network/network-details.js.map +1 -1
  21. package/dist/esm/pages/network/network-types.d.ts +6 -0
  22. package/dist/esm/pages/network/network-types.d.ts.map +1 -1
  23. package/dist/esm/pages/network/network-utils.d.ts +26 -0
  24. package/dist/esm/pages/network/network-utils.d.ts.map +1 -1
  25. package/dist/esm/pages/network/network-utils.js +71 -3
  26. package/dist/esm/pages/network/network-utils.js.map +1 -1
  27. package/dist/esm/pages/network/thread-graph.d.ts.map +1 -1
  28. package/dist/esm/pages/network/thread-graph.js +5 -2
  29. package/dist/esm/pages/network/thread-graph.js.map +1 -1
  30. package/dist/esm/pages/network/update-connections-dialog.d.ts +55 -0
  31. package/dist/esm/pages/network/update-connections-dialog.d.ts.map +1 -0
  32. package/dist/esm/pages/network/update-connections-dialog.js +284 -0
  33. package/dist/esm/pages/network/update-connections-dialog.js.map +6 -0
  34. package/dist/esm/pages/network/wifi-graph.d.ts.map +1 -1
  35. package/dist/esm/pages/network/wifi-graph.js +5 -3
  36. package/dist/esm/pages/network/wifi-graph.js.map +1 -1
  37. package/dist/esm/util/format_hex.d.ts +18 -0
  38. package/dist/esm/util/format_hex.d.ts.map +1 -1
  39. package/dist/esm/util/format_hex.js +21 -1
  40. package/dist/esm/util/format_hex.js.map +1 -1
  41. package/dist/web/js/{commission-node-dialog-B1_khzZb.js → commission-node-dialog-CcMuttYO.js} +5 -5
  42. package/dist/web/js/{commission-node-existing-RpdajrwF.js → commission-node-existing-CqTRDMAr.js} +2 -2
  43. package/dist/web/js/{commission-node-thread-5f2itkTG.js → commission-node-thread-DgwtTVwK.js} +2 -2
  44. package/dist/web/js/{commission-node-wifi-DZ_pWqsa.js → commission-node-wifi-XaN2SEnE.js} +2 -2
  45. package/dist/web/js/{dialog-box-DEUxM4B1.js → dialog-box-COpDD8i7.js} +2 -2
  46. package/dist/web/js/{fire_event-BczBMT8E.js → fire_event-mDYWi2sw.js} +1 -1
  47. package/dist/web/js/{log-level-dialog-Cr3PfX1X.js → log-level-dialog-Bc32kZVw.js} +2 -2
  48. package/dist/web/js/main.js +1 -1
  49. package/dist/web/js/{matter-dashboard-app-BuCe_Jxf.js → matter-dashboard-app-CrBHT4fT.js} +1824 -208
  50. package/dist/web/js/{node-binding-dialog-DMiHNDLA.js → node-binding-dialog-C8fqOJiB.js} +2 -2
  51. package/dist/web/js/prevent_default-D-ohDGsN.js +8 -0
  52. package/package.json +4 -4
  53. package/src/pages/components/node-details.ts +1 -1
  54. package/src/pages/matter-cluster-view.ts +11 -4
  55. package/src/pages/matter-endpoint-view.ts +10 -3
  56. package/src/pages/matter-node-view.ts +19 -4
  57. package/src/pages/network/base-network-graph.ts +17 -3
  58. package/src/pages/network/device-icons.ts +1 -1
  59. package/src/pages/network/network-details.ts +281 -16
  60. package/src/pages/network/network-types.ts +6 -0
  61. package/src/pages/network/network-utils.ts +109 -0
  62. package/src/pages/network/thread-graph.ts +7 -1
  63. package/src/pages/network/update-connections-dialog.ts +327 -0
  64. package/src/pages/network/wifi-graph.ts +4 -3
  65. package/src/util/format_hex.ts +39 -0
  66. package/dist/web/js/prevent_default-D4FX_PIh.js +0 -774
@@ -297,6 +297,38 @@ export function parseRouteTable(node: MatterNode): ThreadRoute[] {
297
297
  });
298
298
  }
299
299
 
300
+ /**
301
+ * Find a route table entry for a specific destination by extended address.
302
+ * Returns the route entry if found, undefined otherwise.
303
+ */
304
+ export function findRouteByExtAddress(node: MatterNode, targetExtAddr: bigint): ThreadRoute | undefined {
305
+ const routes = parseRouteTable(node);
306
+ return routes.find(route => route.extAddress === targetExtAddr && route.linkEstablished);
307
+ }
308
+
309
+ /**
310
+ * Count the number of routable destinations for a node (from route table).
311
+ * Only counts entries where allocated=true and linkEstablished=true.
312
+ * This is typically only meaningful for router nodes.
313
+ */
314
+ export function getRoutableDestinationsCount(node: MatterNode): number {
315
+ const routes = parseRouteTable(node);
316
+ return routes.filter(route => route.allocated && route.linkEstablished).length;
317
+ }
318
+
319
+ /**
320
+ * Calculate combined bidirectional LQI from route table entry.
321
+ * Returns average of lqiIn and lqiOut if both are non-zero.
322
+ */
323
+ export function getRouteBidirectionalLqi(route: ThreadRoute): number | undefined {
324
+ if (route.lqiIn > 0 && route.lqiOut > 0) {
325
+ return Math.round((route.lqiIn + route.lqiOut) / 2);
326
+ }
327
+ if (route.lqiIn > 0) return route.lqiIn;
328
+ if (route.lqiOut > 0) return route.lqiOut;
329
+ return undefined;
330
+ }
331
+
300
332
  /**
301
333
  * Gets the RLOC16 (short address) for a Thread node.
302
334
  * Uses attribute 0/53/64 (Rloc16, 0x0040).
@@ -437,6 +469,21 @@ export function getSignalColor(neighbor: ThreadNeighbor): string {
437
469
  return SIGNAL_COLOR_WEAK;
438
470
  }
439
471
 
472
+ /**
473
+ * Get signal color based on LQI value alone.
474
+ * Used for route table entries where only LQI is available.
475
+ * @param lqi Link Quality Indicator (0-255, higher is better)
476
+ */
477
+ export function getSignalColorFromLqi(lqi: number): string {
478
+ if (lqi > LQI_STRONG_THRESHOLD) {
479
+ return SIGNAL_COLOR_STRONG;
480
+ }
481
+ if (lqi > LQI_MEDIUM_THRESHOLD) {
482
+ return SIGNAL_COLOR_MEDIUM;
483
+ }
484
+ return SIGNAL_COLOR_WEAK;
485
+ }
486
+
440
487
  /**
441
488
  * Gets a human-readable display name for a node.
442
489
  * Format: nodeLabel || productName (serialNumber)
@@ -634,12 +681,58 @@ export function buildThreadConnections(
634
681
  }
635
682
  seenConnections.add(connectionKey);
636
683
 
684
+ // Look up route table entry for supplementary data (pathCost, bidirectional LQI)
685
+ const routeEntry = findRouteByExtAddress(node, neighbor.extAddress);
686
+ const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
687
+
688
+ // Always use neighbor table RSSI/LQI for signal color (most accurate link quality)
637
689
  connections.push({
638
690
  fromNodeId,
639
691
  toNodeId,
640
692
  signalColor: getSignalColor(neighbor),
641
693
  lqi: neighbor.lqi,
642
694
  rssi: neighbor.avgRssi ?? neighbor.lastRssi,
695
+ pathCost: routeEntry?.pathCost,
696
+ bidirectionalLqi,
697
+ });
698
+ }
699
+
700
+ // Check route table for connections not in neighbor table (supplementary data)
701
+ // This helps when neighbor table is stale or incomplete
702
+ const routes = parseRouteTable(node);
703
+ for (const route of routes) {
704
+ if (!route.linkEstablished || !route.allocated) continue;
705
+
706
+ let toNodeId: string | undefined = extAddrMap.get(route.extAddress);
707
+ if (toNodeId === undefined) {
708
+ toNodeId = unknownExtAddrMap.get(route.extAddress);
709
+ }
710
+ if (toNodeId === undefined || toNodeId === fromNodeId) continue;
711
+
712
+ const connectionKey = `${fromNodeId}-${toNodeId}`;
713
+ const reverseKey = `${toNodeId}-${fromNodeId}`;
714
+
715
+ // Only add if we don't already have this connection from neighbor table
716
+ if (seenConnections.has(connectionKey) || seenConnections.has(reverseKey)) {
717
+ continue;
718
+ }
719
+ seenConnections.add(connectionKey);
720
+
721
+ const bidirectionalLqi = getRouteBidirectionalLqi(route);
722
+ const signalColor =
723
+ bidirectionalLqi !== undefined
724
+ ? getSignalColorFromLqi(bidirectionalLqi)
725
+ : "var(--md-sys-color-outline, grey)"; // Unknown signal
726
+
727
+ connections.push({
728
+ fromNodeId,
729
+ toNodeId,
730
+ signalColor,
731
+ lqi: bidirectionalLqi ?? 0,
732
+ rssi: null,
733
+ pathCost: route.pathCost,
734
+ bidirectionalLqi,
735
+ fromRouteTable: true,
643
736
  });
644
737
  }
645
738
  }
@@ -666,6 +759,10 @@ export interface NodeConnection {
666
759
  isOutgoing: boolean;
667
760
  /** Whether this is an unknown/external device */
668
761
  isUnknown: boolean;
762
+ /** Path cost from route table (1 = direct, higher = multi-hop). Only available for routers. */
763
+ pathCost?: number;
764
+ /** Bidirectional LQI from route table (average of lqiIn and lqiOut) */
765
+ bidirectionalLqi?: number;
669
766
  }
670
767
 
671
768
  /**
@@ -705,6 +802,10 @@ export function getNodeConnections(
705
802
 
706
803
  seenConnectedIds.add(displayId);
707
804
 
805
+ // Look up route table entry for enhanced data
806
+ const routeEntry = findRouteByExtAddress(node, neighbor.extAddress);
807
+ const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
808
+
708
809
  connections.push({
709
810
  connectedNodeId: displayId,
710
811
  connectedNode,
@@ -714,6 +815,8 @@ export function getNodeConnections(
714
815
  rssi: neighbor.avgRssi ?? neighbor.lastRssi,
715
816
  isOutgoing: true,
716
817
  isUnknown,
818
+ pathCost: routeEntry?.pathCost,
819
+ bidirectionalLqi,
717
820
  });
718
821
  }
719
822
 
@@ -734,6 +837,10 @@ export function getNodeConnections(
734
837
  const otherExtAddr = getThreadExtendedAddress(otherNode);
735
838
  const extAddrHex = otherExtAddr ? otherExtAddr.toString(16).toUpperCase().padStart(16, "0") : "Unknown";
736
839
 
840
+ // Look up route table entry from the other node's perspective
841
+ const routeEntry = findRouteByExtAddress(otherNode, thisExtAddr);
842
+ const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
843
+
737
844
  connections.push({
738
845
  connectedNodeId: otherNodeId,
739
846
  connectedNode: otherNode,
@@ -743,6 +850,8 @@ export function getNodeConnections(
743
850
  rssi: reverseEntry.avgRssi ?? reverseEntry.lastRssi,
744
851
  isOutgoing: false,
745
852
  isUnknown: false,
853
+ pathCost: routeEntry?.pathCost,
854
+ bidirectionalLqi,
746
855
  });
747
856
  }
748
857
  }
@@ -115,6 +115,12 @@ export class ThreadGraph extends BaseNetworkGraph {
115
115
  // Create edge data for vis.js
116
116
  const graphEdges: NetworkGraphEdge[] = connections.map((conn, index) => {
117
117
  const isToUnknown = typeof conn.toNodeId === "string" && conn.toNodeId.startsWith("unknown_");
118
+
119
+ // Check if either endpoint is offline - connection data may be stale
120
+ const fromNode = this.nodes[String(conn.fromNodeId)];
121
+ const toNode = this.nodes[String(conn.toNodeId)];
122
+ const hasOfflineEndpoint = fromNode?.available === false || toNode?.available === false;
123
+
118
124
  return {
119
125
  id: `edge_${index}`,
120
126
  from: conn.fromNodeId,
@@ -125,7 +131,7 @@ export class ThreadGraph extends BaseNetworkGraph {
125
131
  },
126
132
  width: 2,
127
133
  title: conn.rssi !== null ? `RSSI: ${conn.rssi} dBm, LQI: ${conn.lqi}` : `LQI: ${conn.lqi}`,
128
- dashes: isToUnknown, // Dashed lines to unknown devices
134
+ dashes: isToUnknown || hasOfflineEndpoint, // Dashed lines to unknown or offline devices
129
135
  };
130
136
  });
131
137
 
@@ -0,0 +1,327 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import "@material/web/button/filled-button";
8
+ import "@material/web/button/text-button";
9
+ import "@material/web/checkbox/checkbox";
10
+ import "@material/web/dialog/dialog";
11
+ import type { MatterClient, MatterNode } from "@matter-server/ws-client";
12
+ import { mdiLoading } from "@mdi/js";
13
+ import { LitElement, css, html, nothing, svg } from "lit";
14
+ import { customElement, property, state } from "lit/decorators.js";
15
+ import { getNetworkType } from "./network-utils.js";
16
+
17
+ declare global {
18
+ interface HTMLElementTagNameMap {
19
+ "update-connections-dialog": UpdateConnectionsDialog;
20
+ }
21
+ }
22
+
23
+ /** Thread network attributes to read */
24
+ const THREAD_ATTRIBUTE_PATHS = ["0/53/7", "0/53/8", "0/51/0"]; // NeighborTable, RouteTable, NetworkInterfaces
25
+
26
+ /** WiFi network attributes to read */
27
+ const WIFI_ATTRIBUTE_PATHS = ["0/54/0", "0/54/3", "0/54/4"]; // BSSID, Channel, RSSI
28
+
29
+ @customElement("update-connections-dialog")
30
+ export class UpdateConnectionsDialog extends LitElement {
31
+ @property({ type: Object })
32
+ public client!: MatterClient;
33
+
34
+ @property({ type: Object })
35
+ public nodes: Record<string, MatterNode> = {};
36
+
37
+ @property({ type: String })
38
+ public selectedNodeType: "online" | "offline" | "unknown" = "online";
39
+
40
+ @property({ type: String })
41
+ public selectedNodeName: string = "";
42
+
43
+ @property()
44
+ public selectedNodeId: number | string | null = null;
45
+
46
+ @property({ type: Array })
47
+ public onlineNeighborIds: string[] = [];
48
+
49
+ @state()
50
+ private _includeNeighbors: boolean = false;
51
+
52
+ @state()
53
+ private _isUpdating: boolean = false;
54
+
55
+ /** Timeout ID for auto-close */
56
+ private _timeoutId: ReturnType<typeof setTimeout> | null = null;
57
+
58
+ /** Track if we've already dispatched close event to prevent double-firing */
59
+ private _hasClosedEvent: boolean = false;
60
+
61
+ override firstUpdated(): void {
62
+ // Open dialog when component is first rendered
63
+ const dialog = this.shadowRoot?.querySelector("md-dialog") as HTMLElement & { show: () => void };
64
+ dialog?.show();
65
+ }
66
+
67
+ override disconnectedCallback(): void {
68
+ super.disconnectedCallback();
69
+ // Clean up timeout when component is removed
70
+ if (this._timeoutId) {
71
+ clearTimeout(this._timeoutId);
72
+ this._timeoutId = null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Get the number of nodes that will be updated.
78
+ */
79
+ private get _updateCount(): number {
80
+ if (this.selectedNodeType === "online") {
81
+ return this._includeNeighbors ? 1 + this.onlineNeighborIds.length : 1;
82
+ }
83
+ // offline and unknown: update neighbors only
84
+ return this.onlineNeighborIds.length;
85
+ }
86
+
87
+ /**
88
+ * Get the attribute paths to read for a node based on its network type.
89
+ */
90
+ private _getAttributePathsForNode(nodeId: string): string[] {
91
+ const node = this.nodes[nodeId];
92
+ if (!node) return [];
93
+
94
+ const networkType = getNetworkType(node);
95
+
96
+ if (networkType === "thread") {
97
+ return THREAD_ATTRIBUTE_PATHS;
98
+ }
99
+ if (networkType === "wifi") {
100
+ return WIFI_ATTRIBUTE_PATHS;
101
+ }
102
+ // Ethernet and unknown have no dynamic network data
103
+ return [];
104
+ }
105
+
106
+ /**
107
+ * Get the list of node IDs to update based on current state.
108
+ */
109
+ private _getNodeIdsToUpdate(): string[] {
110
+ if (this.selectedNodeType === "online") {
111
+ const nodeIds = [String(this.selectedNodeId)];
112
+ if (this._includeNeighbors) {
113
+ nodeIds.push(...this.onlineNeighborIds);
114
+ }
115
+ return nodeIds;
116
+ }
117
+ // offline and unknown: update neighbors only
118
+ return this.onlineNeighborIds;
119
+ }
120
+
121
+ private async _executeUpdate(): Promise<void> {
122
+ if (this._isUpdating || this._updateCount === 0) return;
123
+
124
+ this._isUpdating = true;
125
+
126
+ // Set up 30s timeout to auto-close dialog
127
+ this._timeoutId = setTimeout(() => {
128
+ console.warn("Update connections timed out after 30s");
129
+ this._closeDialog();
130
+ }, 30000);
131
+
132
+ try {
133
+ const nodeIds = this._getNodeIdsToUpdate();
134
+
135
+ // Build promises for all node updates
136
+ const updatePromises = nodeIds.map(async nodeIdStr => {
137
+ const node = this.nodes[nodeIdStr];
138
+ if (!node) return;
139
+
140
+ const paths = this._getAttributePathsForNode(nodeIdStr);
141
+ if (paths.length === 0) return;
142
+
143
+ // Use the actual node_id from the node object (number | bigint)
144
+ await this.client.readAttribute(node.node_id, paths);
145
+ });
146
+
147
+ // Wait for all to complete (results come via events, we just need completion)
148
+ await Promise.all(updatePromises);
149
+
150
+ // Close dialog on success
151
+ this._closeDialog();
152
+ } catch (error) {
153
+ console.error("Failed to update connections:", error);
154
+ // Close dialog on error too - don't leave user stuck
155
+ this._closeDialog();
156
+ } finally {
157
+ // Clear timeout if we finished before 30s
158
+ if (this._timeoutId) {
159
+ clearTimeout(this._timeoutId);
160
+ this._timeoutId = null;
161
+ }
162
+ this._isUpdating = false;
163
+ }
164
+ }
165
+
166
+ private _closeDialog(): void {
167
+ // Prevent double-firing the close event
168
+ if (this._hasClosedEvent) return;
169
+ this._hasClosedEvent = true;
170
+
171
+ const dialog = this.shadowRoot?.querySelector("md-dialog") as HTMLElement & { close: () => void };
172
+ dialog?.close();
173
+ // Use 'dialog-closed' to avoid conflicting with network-details 'close' event
174
+ this.dispatchEvent(new CustomEvent("dialog-closed", { bubbles: true, composed: true }));
175
+ }
176
+
177
+ /** Handle native dialog closed event (ESC key, backdrop click, etc.) */
178
+ private _handleDialogClosed(): void {
179
+ this._closeDialog();
180
+ }
181
+
182
+ private _handleCheckboxChange(e: Event): void {
183
+ const checkbox = e.target as HTMLInputElement;
184
+ this._includeNeighbors = checkbox.checked;
185
+ }
186
+
187
+ private _renderOnlineContent(): unknown {
188
+ return html`
189
+ <p>Refresh network information for "<strong>${this.selectedNodeName}</strong>".</p>
190
+ ${this.onlineNeighborIds.length > 0
191
+ ? html`
192
+ <label class="checkbox-row">
193
+ <md-checkbox
194
+ ?checked=${this._includeNeighbors}
195
+ @change=${this._handleCheckboxChange}
196
+ ?disabled=${this._isUpdating}
197
+ ></md-checkbox>
198
+ <span
199
+ >Include ${this.onlineNeighborIds.length} connected online
200
+ neighbor${this.onlineNeighborIds.length !== 1 ? "s" : ""}</span
201
+ >
202
+ </label>
203
+ `
204
+ : nothing}
205
+ `;
206
+ }
207
+
208
+ private _renderOfflineContent(): unknown {
209
+ return html`
210
+ <p>"<strong>${this.selectedNodeName}</strong>" appears to be offline.</p>
211
+ ${this.onlineNeighborIds.length > 0
212
+ ? html`
213
+ <p>
214
+ Update network data from its ${this.onlineNeighborIds.length} online
215
+ neighbor${this.onlineNeighborIds.length !== 1 ? "s" : ""} to refresh connection info.
216
+ </p>
217
+ `
218
+ : html` <p>No online neighbors available to update.</p> `}
219
+ `;
220
+ }
221
+
222
+ private _renderUnknownContent(): unknown {
223
+ return html`
224
+ <p>This device is not commissioned to this fabric and cannot be queried directly.</p>
225
+ ${this.onlineNeighborIds.length > 0
226
+ ? html`
227
+ <p>
228
+ Update network data from ${this.onlineNeighborIds.length}
229
+ node${this.onlineNeighborIds.length !== 1 ? "s" : ""} that
230
+ see${this.onlineNeighborIds.length === 1 ? "s" : ""} this device to refresh info.
231
+ </p>
232
+ `
233
+ : html` <p>No online nodes available that see this device.</p> `}
234
+ `;
235
+ }
236
+
237
+ override render() {
238
+ const buttonText =
239
+ this._updateCount === 0
240
+ ? "No nodes to update"
241
+ : `Update ${this._updateCount} node${this._updateCount !== 1 ? "s" : ""}`;
242
+
243
+ return html`
244
+ <md-dialog @closed=${this._handleDialogClosed}>
245
+ <div slot="headline">Update Connection Data</div>
246
+ <div slot="content">
247
+ ${this.selectedNodeType === "online"
248
+ ? this._renderOnlineContent()
249
+ : this.selectedNodeType === "offline"
250
+ ? this._renderOfflineContent()
251
+ : this._renderUnknownContent()}
252
+ </div>
253
+ <div slot="actions">
254
+ <md-text-button @click=${this._closeDialog} ?disabled=${this._isUpdating}>Cancel</md-text-button>
255
+ <md-filled-button
256
+ @click=${this._executeUpdate}
257
+ ?disabled=${this._isUpdating || this._updateCount === 0}
258
+ >
259
+ ${this._isUpdating
260
+ ? html`<span class="updating-content"
261
+ >${svg`<svg class="spinner" viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="${mdiLoading}"/></svg>`}Updating...</span
262
+ >`
263
+ : buttonText}
264
+ </md-filled-button>
265
+ </div>
266
+ </md-dialog>
267
+ `;
268
+ }
269
+
270
+ static override styles = css`
271
+ md-dialog {
272
+ --md-dialog-container-color: var(--md-sys-color-surface, #fff);
273
+ }
274
+
275
+ [slot="content"] {
276
+ padding: 0 24px;
277
+ }
278
+
279
+ [slot="content"] p {
280
+ margin: 0 0 16px 0;
281
+ font-size: 0.875rem;
282
+ line-height: 1.5;
283
+ color: var(--md-sys-color-on-surface, #333);
284
+ }
285
+
286
+ [slot="content"] p:last-child {
287
+ margin-bottom: 0;
288
+ }
289
+
290
+ .checkbox-row {
291
+ display: flex;
292
+ align-items: center;
293
+ gap: 8px;
294
+ cursor: pointer;
295
+ font-size: 0.875rem;
296
+ color: var(--md-sys-color-on-surface, #333);
297
+ }
298
+
299
+ .updating-content {
300
+ display: inline-flex;
301
+ align-items: center;
302
+ gap: 8px;
303
+ }
304
+
305
+ .spinner {
306
+ animation: spin 1s linear infinite;
307
+ flex-shrink: 0;
308
+ }
309
+
310
+ .updating-content svg {
311
+ color: inherit;
312
+ }
313
+
314
+ @keyframes spin {
315
+ from {
316
+ transform: rotate(0deg);
317
+ }
318
+ to {
319
+ transform: rotate(360deg);
320
+ }
321
+ }
322
+
323
+ md-filled-button {
324
+ min-width: 140px;
325
+ }
326
+ `;
327
+ }
@@ -69,9 +69,9 @@ export class WiFiGraph extends BaseNetworkGraph {
69
69
  // Clear stored edge colors since we're rebuilding edges
70
70
  this._clearOriginalEdgeColors();
71
71
 
72
- // Get WiFi and Ethernet devices
72
+ // Get WiFi devices only (Ethernet has no dynamic network data)
73
73
  const categorized = categorizeDevices(this.nodes);
74
- const wifiNodeIds = [...categorized.wifi, ...categorized.ethernet];
74
+ const wifiNodeIds = categorized.wifi;
75
75
 
76
76
  if (wifiNodeIds.length === 0) {
77
77
  this._nodesDataSet.clear();
@@ -156,6 +156,7 @@ export class WiFiGraph extends BaseNetworkGraph {
156
156
  },
157
157
  width: 2,
158
158
  title: wifiDiag.rssi !== null ? `RSSI: ${wifiDiag.rssi} dBm` : "RSSI: Unknown",
159
+ dashes: isOffline, // Dashed lines for offline devices
159
160
  });
160
161
  }
161
162
  }
@@ -176,7 +177,7 @@ export class WiFiGraph extends BaseNetworkGraph {
176
177
 
177
178
  override render() {
178
179
  const categorized = categorizeDevices(this.nodes);
179
- const wifiCount = categorized.wifi.length + categorized.ethernet.length;
180
+ const wifiCount = categorized.wifi.length;
180
181
 
181
182
  if (wifiCount === 0) {
182
183
  return html`
@@ -43,3 +43,42 @@ export function formatNodeAddress(fabricIndex: number | undefined, nodeId: numbe
43
43
  const fabricPart = fabricIndex !== undefined ? fabricIndex : "?";
44
44
  return `@${fabricPart}:${nodeId.toString(16)}`;
45
45
  }
46
+
47
+ /**
48
+ * Get the effective fabric index for formatting a node address.
49
+ * Returns undefined for test nodes or when fabric index is not available,
50
+ * which will result in "?" being displayed.
51
+ *
52
+ * @param serverFabricIndex - The fabric_index from client.serverInfo
53
+ * @param isTestNode - Whether this is a test node (from isTestNodeId())
54
+ */
55
+ export function getEffectiveFabricIndex(
56
+ serverFabricIndex: number | undefined,
57
+ isTestNode: boolean,
58
+ ): number | undefined {
59
+ return isTestNode || serverFabricIndex === undefined ? undefined : serverFabricIndex;
60
+ }
61
+
62
+ /**
63
+ * Format a node address, handling string node IDs.
64
+ * Useful when node IDs come from various sources (string keys, numbers, bigints).
65
+ *
66
+ * @param fabricIndex - The fabric index (or undefined for "?")
67
+ * @param nodeId - The node ID as string, number, or bigint
68
+ * @returns Formatted address like "@1:7b" or empty string if nodeId is invalid
69
+ */
70
+ export function formatNodeAddressFromAny(fabricIndex: number | undefined, nodeId: number | bigint | string): string {
71
+ let numericId: bigint;
72
+ if (typeof nodeId === "bigint") {
73
+ numericId = nodeId;
74
+ } else if (typeof nodeId === "number") {
75
+ numericId = BigInt(nodeId);
76
+ } else {
77
+ try {
78
+ numericId = BigInt(nodeId);
79
+ } catch {
80
+ return "";
81
+ }
82
+ }
83
+ return formatNodeAddress(fabricIndex, numericId);
84
+ }