@matter-server/dashboard 0.3.2 → 0.3.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 (104) hide show
  1. package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts +2 -2
  2. package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts.map +1 -1
  3. package/dist/esm/pages/cluster-commands/base-cluster-commands.js.map +1 -1
  4. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts +36 -0
  5. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts.map +1 -0
  6. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js +159 -0
  7. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js.map +6 -0
  8. package/dist/esm/pages/cluster-commands/index.d.ts +1 -0
  9. package/dist/esm/pages/cluster-commands/index.d.ts.map +1 -1
  10. package/dist/esm/pages/cluster-commands/index.js +1 -0
  11. package/dist/esm/pages/cluster-commands/index.js.map +1 -1
  12. package/dist/esm/pages/components/footer.d.ts.map +1 -1
  13. package/dist/esm/pages/components/footer.js +4 -7
  14. package/dist/esm/pages/components/footer.js.map +1 -1
  15. package/dist/esm/pages/components/header.d.ts +5 -0
  16. package/dist/esm/pages/components/header.d.ts.map +1 -1
  17. package/dist/esm/pages/components/header.js +75 -0
  18. package/dist/esm/pages/components/header.js.map +1 -1
  19. package/dist/esm/pages/components/node-details.js +1 -1
  20. package/dist/esm/pages/components/node-details.js.map +1 -1
  21. package/dist/esm/pages/components/server-details.d.ts.map +1 -1
  22. package/dist/esm/pages/components/server-details.js +0 -1
  23. package/dist/esm/pages/components/server-details.js.map +1 -1
  24. package/dist/esm/pages/matter-dashboard-app.d.ts +12 -0
  25. package/dist/esm/pages/matter-dashboard-app.d.ts.map +1 -1
  26. package/dist/esm/pages/matter-dashboard-app.js +84 -4
  27. package/dist/esm/pages/matter-dashboard-app.js.map +1 -1
  28. package/dist/esm/pages/matter-network-view.d.ts +52 -0
  29. package/dist/esm/pages/matter-network-view.d.ts.map +1 -0
  30. package/dist/esm/pages/matter-network-view.js +309 -0
  31. package/dist/esm/pages/matter-network-view.js.map +6 -0
  32. package/dist/esm/pages/matter-node-view.d.ts.map +1 -1
  33. package/dist/esm/pages/matter-node-view.js +70 -1
  34. package/dist/esm/pages/matter-node-view.js.map +1 -1
  35. package/dist/esm/pages/matter-server-view.d.ts +4 -0
  36. package/dist/esm/pages/matter-server-view.d.ts.map +1 -1
  37. package/dist/esm/pages/matter-server-view.js +16 -1
  38. package/dist/esm/pages/matter-server-view.js.map +1 -1
  39. package/dist/esm/pages/network/base-network-graph.d.ts +74 -0
  40. package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -0
  41. package/dist/esm/pages/network/base-network-graph.js +403 -0
  42. package/dist/esm/pages/network/base-network-graph.js.map +6 -0
  43. package/dist/esm/pages/network/device-icons.d.ts +52 -0
  44. package/dist/esm/pages/network/device-icons.d.ts.map +1 -0
  45. package/dist/esm/pages/network/device-icons.js +197 -0
  46. package/dist/esm/pages/network/device-icons.js.map +6 -0
  47. package/dist/esm/pages/network/device-panel.d.ts +31 -0
  48. package/dist/esm/pages/network/device-panel.d.ts.map +1 -0
  49. package/dist/esm/pages/network/device-panel.js +183 -0
  50. package/dist/esm/pages/network/device-panel.js.map +6 -0
  51. package/dist/esm/pages/network/network-details.d.ts +47 -0
  52. package/dist/esm/pages/network/network-details.d.ts.map +1 -0
  53. package/dist/esm/pages/network/network-details.js +686 -0
  54. package/dist/esm/pages/network/network-details.js.map +6 -0
  55. package/dist/esm/pages/network/network-types.d.ts +153 -0
  56. package/dist/esm/pages/network/network-types.d.ts.map +1 -0
  57. package/dist/esm/pages/network/network-types.js +19 -0
  58. package/dist/esm/pages/network/network-types.js.map +6 -0
  59. package/dist/esm/pages/network/network-utils.d.ts +170 -0
  60. package/dist/esm/pages/network/network-utils.d.ts.map +1 -0
  61. package/dist/esm/pages/network/network-utils.js +472 -0
  62. package/dist/esm/pages/network/network-utils.js.map +6 -0
  63. package/dist/esm/pages/network/thread-graph.d.ts +27 -0
  64. package/dist/esm/pages/network/thread-graph.d.ts.map +1 -0
  65. package/dist/esm/pages/network/thread-graph.js +134 -0
  66. package/dist/esm/pages/network/thread-graph.js.map +6 -0
  67. package/dist/esm/pages/network/wifi-graph.d.ts +27 -0
  68. package/dist/esm/pages/network/wifi-graph.d.ts.map +1 -0
  69. package/dist/esm/pages/network/wifi-graph.js +167 -0
  70. package/dist/esm/pages/network/wifi-graph.js.map +6 -0
  71. package/dist/web/js/{commission-node-dialog-CBSDiqRW.js → commission-node-dialog-B1_khzZb.js} +5 -5
  72. package/dist/web/js/{commission-node-existing-TP6s8Tez.js → commission-node-existing-RpdajrwF.js} +2 -5
  73. package/dist/web/js/{commission-node-thread-DOB8pu6x.js → commission-node-thread-5f2itkTG.js} +2 -5
  74. package/dist/web/js/{commission-node-wifi-tzavmk1j.js → commission-node-wifi-DZ_pWqsa.js} +2 -5
  75. package/dist/web/js/{dialog-box-Dknil_Be.js → dialog-box-DEUxM4B1.js} +2 -2
  76. package/dist/web/js/{fire_event-DRpOSjJR.js → fire_event-BczBMT8E.js} +1 -1
  77. package/dist/web/js/{log-level-dialog-TXkma-7Z.js → log-level-dialog-Cr3PfX1X.js} +2 -3
  78. package/dist/web/js/main.js +1 -1
  79. package/dist/web/js/matter-dashboard-app-BuCe_Jxf.js +29990 -0
  80. package/dist/web/js/{node-binding-dialog-D52FCBFP.js → node-binding-dialog-DMiHNDLA.js} +2 -4
  81. package/dist/web/js/{prevent_default-BPgSQsuY.js → prevent_default-D4FX_PIh.js} +2 -42
  82. package/package.json +5 -4
  83. package/src/pages/cluster-commands/base-cluster-commands.ts +2 -2
  84. package/src/pages/cluster-commands/clusters/basic-information-commands.ts +171 -0
  85. package/src/pages/cluster-commands/index.ts +1 -0
  86. package/src/pages/components/footer.ts +4 -7
  87. package/src/pages/components/header.ts +81 -0
  88. package/src/pages/components/node-details.ts +2 -2
  89. package/src/pages/components/server-details.ts +0 -1
  90. package/src/pages/matter-dashboard-app.ts +105 -5
  91. package/src/pages/matter-network-view.ts +325 -0
  92. package/src/pages/matter-node-view.ts +75 -1
  93. package/src/pages/matter-server-view.ts +17 -1
  94. package/src/pages/network/base-network-graph.ts +463 -0
  95. package/src/pages/network/device-icons.ts +283 -0
  96. package/src/pages/network/device-panel.ts +180 -0
  97. package/src/pages/network/network-details.ts +750 -0
  98. package/src/pages/network/network-types.ts +161 -0
  99. package/src/pages/network/network-utils.ts +752 -0
  100. package/src/pages/network/thread-graph.ts +164 -0
  101. package/src/pages/network/wifi-graph.ts +192 -0
  102. package/dist/web/js/matter-dashboard-app-B7GUghkC.js +0 -17254
  103. package/dist/web/js/outlined-text-field-D1DyKQY-.js +0 -968
  104. package/dist/web/js/validator-C735j770.js +0 -1122
@@ -0,0 +1,750 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import "@material/web/divider/divider";
8
+ import type { MatterNode } from "@matter-server/ws-client";
9
+ import { mdiClose, mdiSignal, mdiSignalCellular1, mdiSignalCellular2 } from "@mdi/js";
10
+ import { LitElement, TemplateResult, css, html, nothing } from "lit";
11
+ import { customElement, property } from "lit/decorators.js";
12
+ import "../../components/ha-svg-icon";
13
+ import type { ThreadNeighbor } from "./network-types.js";
14
+ import type { NodeConnection } from "./network-utils.js";
15
+ import {
16
+ buildExtAddrMap,
17
+ getDeviceName,
18
+ getNetworkType,
19
+ getNodeConnections,
20
+ getSignalColor,
21
+ getSignalColorFromRssi,
22
+ getThreadChannel,
23
+ getThreadExtendedAddressHex,
24
+ getThreadRole,
25
+ getThreadRoleName,
26
+ getWiFiDiagnostics,
27
+ getWiFiSecurityTypeName,
28
+ getWiFiVersionName,
29
+ parseNeighborTable,
30
+ } from "./network-utils.js";
31
+
32
+ declare global {
33
+ interface HTMLElementTagNameMap {
34
+ "network-details": NetworkDetails;
35
+ }
36
+ }
37
+
38
+ @customElement("network-details")
39
+ export class NetworkDetails extends LitElement {
40
+ @property()
41
+ public selectedNodeId: number | string | null = null;
42
+
43
+ @property({ type: Object })
44
+ public nodes: Record<string, MatterNode> = {};
45
+
46
+ @property({ type: Object })
47
+ public unknownDevices: Map<
48
+ string,
49
+ { extAddressHex: string; isRouter: boolean; seenBy: string[]; bestRssi: number | null }
50
+ > = new Map();
51
+
52
+ @property({ type: Object })
53
+ public wifiAccessPoints: Map<string, { bssid: string; connectedNodes: string[] }> = new Map();
54
+
55
+ private _handleClose(): void {
56
+ this.dispatchEvent(
57
+ new CustomEvent("close", {
58
+ bubbles: true,
59
+ composed: true,
60
+ }),
61
+ );
62
+ }
63
+
64
+ private _handleSelectNode(nodeId: number | string): void {
65
+ this.dispatchEvent(
66
+ new CustomEvent("select-node", {
67
+ detail: { nodeId },
68
+ bubbles: true,
69
+ composed: true,
70
+ }),
71
+ );
72
+ }
73
+
74
+ /** Handle keyboard interaction for clickable elements (Enter/Space activates) */
75
+ private _handleKeyDown(event: KeyboardEvent, nodeId: number | string): void {
76
+ if (event.key === "Enter" || event.key === " ") {
77
+ event.preventDefault();
78
+ this._handleSelectNode(nodeId);
79
+ }
80
+ }
81
+
82
+ private _formatExtAddress(extAddr: bigint | string | undefined): string {
83
+ if (extAddr === undefined || extAddr === "") return "Unknown";
84
+ if (typeof extAddr === "bigint") {
85
+ return extAddr.toString(16).toUpperCase().padStart(16, "0");
86
+ }
87
+ return extAddr;
88
+ }
89
+
90
+ private _getSignalIcon(neighbor: ThreadNeighbor): string {
91
+ const color = getSignalColor(neighbor);
92
+ if (color === "#4caf50") return mdiSignal; // Strong
93
+ if (color === "#ff9800") return mdiSignalCellular2; // Medium
94
+ return mdiSignalCellular1; // Weak
95
+ }
96
+
97
+ private _getSignalIconFromColor(color: string): string {
98
+ if (color === "#4caf50") return mdiSignal; // Strong
99
+ if (color === "#ff9800") return mdiSignalCellular2; // Medium
100
+ return mdiSignalCellular1; // Weak
101
+ }
102
+
103
+ private _renderWiFiInfo(node: MatterNode): TemplateResult | typeof nothing {
104
+ const wifiDiag = getWiFiDiagnostics(node);
105
+
106
+ if (!wifiDiag.bssid && wifiDiag.rssi === null) {
107
+ return nothing;
108
+ }
109
+
110
+ const signalColor = getSignalColorFromRssi(wifiDiag.rssi);
111
+
112
+ return html`
113
+ <div class="section">
114
+ <h4>WiFi Network</h4>
115
+ ${wifiDiag.bssid
116
+ ? html`
117
+ <div class="info-row">
118
+ <span class="label">BSSID:</span>
119
+ <span class="value mono">${wifiDiag.bssid}</span>
120
+ </div>
121
+ `
122
+ : nothing}
123
+ ${wifiDiag.rssi !== null
124
+ ? html`
125
+ <div class="info-row">
126
+ <span class="label">Signal:</span>
127
+ <span class="value" style="color: ${signalColor}">${wifiDiag.rssi} dBm</span>
128
+ </div>
129
+ `
130
+ : nothing}
131
+ ${wifiDiag.channel !== null
132
+ ? html`
133
+ <div class="info-row">
134
+ <span class="label">Channel:</span>
135
+ <span class="value">${wifiDiag.channel}</span>
136
+ </div>
137
+ `
138
+ : nothing}
139
+ ${wifiDiag.securityType !== null
140
+ ? html`
141
+ <div class="info-row">
142
+ <span class="label">Security:</span>
143
+ <span class="value">${getWiFiSecurityTypeName(wifiDiag.securityType)}</span>
144
+ </div>
145
+ `
146
+ : nothing}
147
+ ${wifiDiag.wifiVersion !== null
148
+ ? html`
149
+ <div class="info-row">
150
+ <span class="label">WiFi Version:</span>
151
+ <span class="value">${getWiFiVersionName(wifiDiag.wifiVersion)}</span>
152
+ </div>
153
+ `
154
+ : nothing}
155
+ </div>
156
+ `;
157
+ }
158
+
159
+ private _renderThreadInfo(node: MatterNode): TemplateResult | typeof nothing {
160
+ const threadRole = getThreadRole(node);
161
+ const channel = getThreadChannel(node);
162
+ const extAddressHex = getThreadExtendedAddressHex(node);
163
+ const extAddrMap = buildExtAddrMap(this.nodes);
164
+
165
+ // Get all connections (bidirectional) - this matches what the graph shows
166
+ // Use string to avoid BigInt precision loss
167
+ const nodeId = String(node.node_id);
168
+ const connections = getNodeConnections(nodeId, this.nodes, extAddrMap);
169
+
170
+ return html`
171
+ <div class="section">
172
+ <h4>Thread Network</h4>
173
+ <div class="info-row">
174
+ <span class="label">Role:</span>
175
+ <span class="value">${getThreadRoleName(threadRole)}</span>
176
+ </div>
177
+ ${channel !== undefined
178
+ ? html`
179
+ <div class="info-row">
180
+ <span class="label">Channel:</span>
181
+ <span class="value">${channel}</span>
182
+ </div>
183
+ `
184
+ : nothing}
185
+ ${extAddressHex
186
+ ? html`
187
+ <div class="info-row">
188
+ <span class="label">Extended Address:</span>
189
+ <span class="value mono">${extAddressHex}</span>
190
+ </div>
191
+ `
192
+ : nothing}
193
+ </div>
194
+
195
+ ${connections.length > 0
196
+ ? html`
197
+ <md-divider></md-divider>
198
+ <div class="section">
199
+ <h4>Connections (${connections.length})</h4>
200
+ <div class="neighbors-list">
201
+ ${connections.map((conn: NodeConnection) => {
202
+ return html`
203
+ <div
204
+ class="neighbor-item clickable"
205
+ role="button"
206
+ tabindex="0"
207
+ @click=${() => this._handleSelectNode(conn.connectedNodeId)}
208
+ @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, conn.connectedNodeId)}
209
+ >
210
+ <ha-svg-icon
211
+ .path=${this._getSignalIconFromColor(conn.signalColor)}
212
+ style="--icon-primary-color: ${conn.signalColor}"
213
+ ></ha-svg-icon>
214
+ <div class="neighbor-info">
215
+ <div class="neighbor-name">
216
+ ${conn.connectedNode
217
+ ? html`Node ${conn.connectedNodeId}:
218
+ ${getDeviceName(conn.connectedNode)}`
219
+ : html`External: <span class="mono">${conn.extAddressHex}</span>`}
220
+ </div>
221
+ <div class="neighbor-signal">
222
+ ${conn.rssi !== null ? html`RSSI: ${conn.rssi} dBm, ` : nothing}
223
+ ${conn.lqi !== null ? html`LQI: ${conn.lqi}` : nothing}
224
+ ${!conn.isOutgoing
225
+ ? html`<span class="direction-hint">(reverse)</span>`
226
+ : nothing}
227
+ </div>
228
+ </div>
229
+ </div>
230
+ `;
231
+ })}
232
+ </div>
233
+ </div>
234
+ `
235
+ : nothing}
236
+ `;
237
+ }
238
+
239
+ private _renderNodeInfo(node: MatterNode): TemplateResult | typeof nothing {
240
+ const networkType = getNetworkType(node);
241
+
242
+ return html`
243
+ <div class="section">
244
+ <h4>Device Info</h4>
245
+ <div class="info-row">
246
+ <span class="label">Name:</span>
247
+ <span class="value">${getDeviceName(node)}</span>
248
+ </div>
249
+ <div class="info-row">
250
+ <span class="label">Vendor:</span>
251
+ <span class="value">${node.vendorName ?? "Unknown"}</span>
252
+ </div>
253
+ <div class="info-row">
254
+ <span class="label">Product:</span>
255
+ <span class="value">${node.productName ?? "Unknown"}</span>
256
+ </div>
257
+ ${node.serialNumber
258
+ ? html`
259
+ <div class="info-row">
260
+ <span class="label">Serial:</span>
261
+ <span class="value mono">${node.serialNumber}</span>
262
+ </div>
263
+ `
264
+ : nothing}
265
+ <div class="info-row">
266
+ <span class="label">Network:</span>
267
+ <span class="value">${networkType.charAt(0).toUpperCase() + networkType.slice(1)}</span>
268
+ </div>
269
+ <div class="info-row">
270
+ <span class="label">Status:</span>
271
+ <span class="value ${node.available ? "status-online" : "status-offline"}"
272
+ >${node.available ? "Online" : "Offline"}</span
273
+ >
274
+ </div>
275
+ </div>
276
+
277
+ ${networkType === "thread"
278
+ ? html`
279
+ <md-divider></md-divider>
280
+ ${this._renderThreadInfo(node)}
281
+ `
282
+ : nothing}
283
+ ${networkType === "wifi"
284
+ ? html`
285
+ <md-divider></md-divider>
286
+ ${this._renderWiFiInfo(node)}
287
+ `
288
+ : nothing}
289
+ `;
290
+ }
291
+
292
+ /**
293
+ * Find the neighbor entry for an unknown device from a node's neighbor table.
294
+ */
295
+ private _findNeighborEntry(node: MatterNode, unknownExtAddrHex: string): ThreadNeighbor | null {
296
+ const neighbors = parseNeighborTable(node);
297
+ for (const neighbor of neighbors) {
298
+ const neighborHex = this._formatExtAddress(neighbor.extAddress);
299
+ if (neighborHex === unknownExtAddrHex) {
300
+ return neighbor;
301
+ }
302
+ }
303
+ return null;
304
+ }
305
+
306
+ private _renderUnknownDeviceInfo(deviceId: string): TemplateResult | typeof nothing {
307
+ const unknown = this.unknownDevices.get(deviceId);
308
+ if (!unknown) {
309
+ return html`<p>Unknown device data not available</p>`;
310
+ }
311
+
312
+ return html`
313
+ <div class="section">
314
+ <h4>Unknown Device</h4>
315
+ <div class="info-row">
316
+ <span class="label">Type:</span>
317
+ <span class="value">${unknown.isRouter ? "Router (external)" : "End Device (external)"}</span>
318
+ </div>
319
+ <div class="info-row">
320
+ <span class="label">Extended Address:</span>
321
+ <span class="value mono">${unknown.extAddressHex}</span>
322
+ </div>
323
+ ${unknown.bestRssi !== null
324
+ ? html`
325
+ <div class="info-row">
326
+ <span class="label">Best RSSI:</span>
327
+ <span class="value">${unknown.bestRssi} dBm</span>
328
+ </div>
329
+ `
330
+ : nothing}
331
+ </div>
332
+
333
+ ${unknown.seenBy.length > 0
334
+ ? html`
335
+ <md-divider></md-divider>
336
+ <div class="section">
337
+ <h4>Neighbors (${unknown.seenBy.length})</h4>
338
+ <div class="neighbors-list">
339
+ ${unknown.seenBy.map(nodeId => {
340
+ const node = this.nodes[nodeId.toString()];
341
+ if (!node) return nothing;
342
+
343
+ // Find the neighbor entry to get RSSI/LQI
344
+ const neighborEntry = this._findNeighborEntry(node, unknown.extAddressHex);
345
+ const signalColor = neighborEntry ? getSignalColor(neighborEntry) : "#999";
346
+ const rssi = neighborEntry?.avgRssi ?? neighborEntry?.lastRssi ?? null;
347
+ const lqi = neighborEntry?.lqi;
348
+
349
+ return html`
350
+ <div
351
+ class="neighbor-item clickable"
352
+ role="button"
353
+ tabindex="0"
354
+ @click=${() => this._handleSelectNode(nodeId)}
355
+ @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, nodeId)}
356
+ >
357
+ ${neighborEntry
358
+ ? html`
359
+ <ha-svg-icon
360
+ .path=${this._getSignalIcon(neighborEntry)}
361
+ style="--icon-primary-color: ${signalColor}"
362
+ ></ha-svg-icon>
363
+ `
364
+ : nothing}
365
+ <div class="neighbor-info">
366
+ <div class="neighbor-name">Node ${nodeId}: ${getDeviceName(node)}</div>
367
+ ${neighborEntry
368
+ ? html`
369
+ <div class="neighbor-signal">
370
+ ${rssi !== null ? html`RSSI: ${rssi} dBm, ` : nothing}
371
+ ${lqi !== undefined ? html`LQI: ${lqi}` : nothing}
372
+ </div>
373
+ `
374
+ : nothing}
375
+ </div>
376
+ </div>
377
+ `;
378
+ })}
379
+ </div>
380
+ </div>
381
+ `
382
+ : nothing}
383
+
384
+ <md-divider></md-divider>
385
+ <div class="section">
386
+ <p class="hint-text">
387
+ This device appears in Thread neighbor tables but is not commissioned to this fabric. It may be a
388
+ Thread Border Router or a device from another Matter ecosystem.
389
+ </p>
390
+ </div>
391
+ `;
392
+ }
393
+
394
+ private _renderWiFiAccessPointInfo(apId: string): TemplateResult | typeof nothing {
395
+ const ap = this.wifiAccessPoints.get(apId);
396
+ if (!ap) {
397
+ return html`<p>Access point data not available</p>`;
398
+ }
399
+
400
+ return html`
401
+ <div class="section">
402
+ <h4>WiFi Access Point</h4>
403
+ <div class="info-row">
404
+ <span class="label">BSSID:</span>
405
+ <span class="value mono">${ap.bssid}</span>
406
+ </div>
407
+ <div class="info-row">
408
+ <span class="label">Connected devices:</span>
409
+ <span class="value">${ap.connectedNodes.length}</span>
410
+ </div>
411
+ </div>
412
+ ${ap.connectedNodes.length > 0
413
+ ? html`
414
+ <md-divider></md-divider>
415
+ <div class="section">
416
+ <h4>Connected Nodes</h4>
417
+ <div class="connected-nodes-list">
418
+ ${ap.connectedNodes.map(nodeId => {
419
+ const node = this.nodes[nodeId.toString()];
420
+ if (!node) return nothing;
421
+ const wifiDiag = getWiFiDiagnostics(node);
422
+ const signalColor = getSignalColorFromRssi(wifiDiag.rssi);
423
+
424
+ return html`
425
+ <div
426
+ class="connected-node-item clickable"
427
+ role="button"
428
+ tabindex="0"
429
+ @click=${() => this._handleSelectNode(nodeId)}
430
+ @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, nodeId)}
431
+ >
432
+ <div class="node-name">Node ${nodeId}: ${getDeviceName(node)}</div>
433
+ ${wifiDiag.rssi !== null
434
+ ? html`<div class="node-signal" style="color: ${signalColor}">
435
+ ${wifiDiag.rssi} dBm
436
+ </div>`
437
+ : nothing}
438
+ </div>
439
+ `;
440
+ })}
441
+ </div>
442
+ </div>
443
+ `
444
+ : nothing}
445
+ <md-divider></md-divider>
446
+ <div class="section">
447
+ <p class="hint-text">
448
+ This is a WiFi access point that Matter devices connect to. It is not a Matter device itself.
449
+ </p>
450
+ </div>
451
+ `;
452
+ }
453
+
454
+ override render() {
455
+ if (this.selectedNodeId === null) {
456
+ return html`
457
+ <div class="empty-state">
458
+ <p>Select a device to view details</p>
459
+ </div>
460
+ `;
461
+ }
462
+
463
+ // Check if this is an unknown Thread device
464
+ const isUnknown = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_");
465
+
466
+ if (isUnknown) {
467
+ return html`
468
+ <div class="details-panel">
469
+ <div class="header">
470
+ <h3>External Device</h3>
471
+ <button class="close-button" @click=${this._handleClose} aria-label="Close details panel">
472
+ <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
473
+ </button>
474
+ </div>
475
+ <div class="content">${this._renderUnknownDeviceInfo(this.selectedNodeId as string)}</div>
476
+ </div>
477
+ `;
478
+ }
479
+
480
+ // Check if this is a WiFi access point
481
+ const isAccessPoint = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("ap_");
482
+
483
+ if (isAccessPoint) {
484
+ return html`
485
+ <div class="details-panel">
486
+ <div class="header">
487
+ <h3>Access Point</h3>
488
+ <button class="close-button" @click=${this._handleClose} aria-label="Close details panel">
489
+ <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
490
+ </button>
491
+ </div>
492
+ <div class="content">${this._renderWiFiAccessPointInfo(this.selectedNodeId as string)}</div>
493
+ </div>
494
+ `;
495
+ }
496
+
497
+ const node = this.nodes[this.selectedNodeId.toString()];
498
+ if (!node) {
499
+ return html`
500
+ <div class="empty-state">
501
+ <p>Device not found</p>
502
+ </div>
503
+ `;
504
+ }
505
+
506
+ return html`
507
+ <div class="details-panel">
508
+ <div class="header">
509
+ <h3>Node ${this.selectedNodeId}</h3>
510
+ <button class="close-button" @click=${this._handleClose} aria-label="Close details panel">
511
+ <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
512
+ </button>
513
+ </div>
514
+ <div class="content">${this._renderNodeInfo(node)}</div>
515
+ <div class="footer">
516
+ <a href="#node/${this.selectedNodeId}" class="view-link">View node details</a>
517
+ </div>
518
+ </div>
519
+ `;
520
+ }
521
+
522
+ static override styles = css`
523
+ :host {
524
+ display: block;
525
+ height: 100%;
526
+ }
527
+
528
+ .empty-state {
529
+ display: flex;
530
+ align-items: center;
531
+ justify-content: center;
532
+ height: 100%;
533
+ color: var(--md-sys-color-on-surface-variant, #666);
534
+ text-align: center;
535
+ padding: 24px;
536
+ }
537
+
538
+ .details-panel {
539
+ display: flex;
540
+ flex-direction: column;
541
+ height: 100%;
542
+ background-color: var(--md-sys-color-surface, #fff);
543
+ border-radius: 8px;
544
+ border: 1px solid var(--md-sys-color-outline-variant, #ccc);
545
+ overflow: hidden;
546
+ }
547
+
548
+ .header {
549
+ display: flex;
550
+ align-items: center;
551
+ justify-content: space-between;
552
+ padding: 12px 16px;
553
+ background-color: var(--md-sys-color-surface-container, #f5f5f5);
554
+ border-bottom: 1px solid var(--md-sys-color-outline-variant, #ccc);
555
+ }
556
+
557
+ .header h3 {
558
+ margin: 0;
559
+ font-size: 1rem;
560
+ font-weight: 500;
561
+ color: var(--md-sys-color-on-surface, #333);
562
+ }
563
+
564
+ .close-button {
565
+ background: none;
566
+ border: none;
567
+ padding: 4px;
568
+ cursor: pointer;
569
+ border-radius: 50%;
570
+ display: flex;
571
+ align-items: center;
572
+ justify-content: center;
573
+ }
574
+
575
+ .close-button:hover {
576
+ background-color: var(--md-sys-color-surface-container-high, #e8e8e8);
577
+ }
578
+
579
+ .close-button ha-svg-icon {
580
+ --icon-primary-color: var(--md-sys-color-on-surface-variant, #666);
581
+ }
582
+
583
+ .content {
584
+ flex: 1;
585
+ overflow-y: auto;
586
+ padding: 0;
587
+ }
588
+
589
+ .section {
590
+ padding: 16px;
591
+ }
592
+
593
+ .section h4 {
594
+ margin: 0 0 12px 0;
595
+ font-size: 0.875rem;
596
+ font-weight: 500;
597
+ color: var(--md-sys-color-primary, #6200ee);
598
+ text-transform: uppercase;
599
+ letter-spacing: 0.5px;
600
+ }
601
+
602
+ .info-row {
603
+ display: flex;
604
+ justify-content: space-between;
605
+ padding: 6px 0;
606
+ font-size: 0.875rem;
607
+ }
608
+
609
+ .label {
610
+ color: var(--md-sys-color-on-surface-variant, #666);
611
+ }
612
+
613
+ .value {
614
+ color: var(--md-sys-color-on-surface, #333);
615
+ font-weight: 500;
616
+ text-align: right;
617
+ word-break: break-all;
618
+ max-width: 60%;
619
+ }
620
+
621
+ .value.mono {
622
+ font-family: monospace;
623
+ font-size: 0.8rem;
624
+ }
625
+
626
+ .status-online {
627
+ color: #4caf50;
628
+ }
629
+
630
+ .status-offline {
631
+ color: var(--danger-color, #f44336);
632
+ }
633
+
634
+ .neighbors-list {
635
+ display: flex;
636
+ flex-direction: column;
637
+ gap: 8px;
638
+ }
639
+
640
+ .neighbor-item {
641
+ display: flex;
642
+ align-items: flex-start;
643
+ gap: 12px;
644
+ padding: 8px;
645
+ background-color: var(--md-sys-color-surface-container, #f5f5f5);
646
+ border-radius: 4px;
647
+ }
648
+
649
+ .neighbor-item.clickable {
650
+ cursor: pointer;
651
+ transition: background-color 0.15s;
652
+ }
653
+
654
+ .neighbor-item.clickable:hover {
655
+ background-color: var(--md-sys-color-surface-container-high, #e8e8e8);
656
+ }
657
+
658
+ .neighbor-item ha-svg-icon {
659
+ flex-shrink: 0;
660
+ margin-top: 2px;
661
+ }
662
+
663
+ .neighbor-info {
664
+ flex: 1;
665
+ min-width: 0;
666
+ }
667
+
668
+ .neighbor-name {
669
+ font-size: 0.875rem;
670
+ color: var(--md-sys-color-on-surface, #333);
671
+ word-break: break-word;
672
+ }
673
+
674
+ .neighbor-signal {
675
+ font-size: 0.75rem;
676
+ color: var(--md-sys-color-on-surface-variant, #666);
677
+ margin-top: 2px;
678
+ }
679
+
680
+ .direction-hint {
681
+ font-style: italic;
682
+ opacity: 0.8;
683
+ }
684
+
685
+ .footer {
686
+ padding: 12px 16px;
687
+ border-top: 1px solid var(--md-sys-color-outline-variant, #ccc);
688
+ text-align: center;
689
+ }
690
+
691
+ .view-link {
692
+ color: var(--md-sys-color-primary, #6200ee);
693
+ text-decoration: none;
694
+ font-size: 0.875rem;
695
+ font-weight: 500;
696
+ }
697
+
698
+ .view-link:hover {
699
+ text-decoration: underline;
700
+ }
701
+
702
+ md-divider {
703
+ --md-divider-color: var(--md-sys-color-outline-variant, #ccc);
704
+ }
705
+
706
+ .hint-text {
707
+ font-size: 0.8rem;
708
+ color: var(--md-sys-color-on-surface-variant, #666);
709
+ line-height: 1.4;
710
+ margin: 0;
711
+ }
712
+
713
+ .connected-nodes-list {
714
+ display: flex;
715
+ flex-direction: column;
716
+ gap: 8px;
717
+ }
718
+
719
+ .connected-node-item {
720
+ display: flex;
721
+ align-items: center;
722
+ justify-content: space-between;
723
+ padding: 8px;
724
+ background-color: var(--md-sys-color-surface-container, #f5f5f5);
725
+ border-radius: 4px;
726
+ }
727
+
728
+ .connected-node-item.clickable {
729
+ cursor: pointer;
730
+ transition: background-color 0.15s;
731
+ }
732
+
733
+ .connected-node-item.clickable:hover {
734
+ background-color: var(--md-sys-color-surface-container-high, #e8e8e8);
735
+ }
736
+
737
+ .connected-node-item .node-name {
738
+ font-size: 0.875rem;
739
+ color: var(--md-sys-color-on-surface, #333);
740
+ word-break: break-word;
741
+ }
742
+
743
+ .connected-node-item .node-signal {
744
+ font-size: 0.8rem;
745
+ font-weight: 500;
746
+ flex-shrink: 0;
747
+ margin-left: 8px;
748
+ }
749
+ `;
750
+ }