@matter-server/dashboard 0.7.2-alpha.0-20260601-e778038 → 0.8.0

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 (61) hide show
  1. package/dist/esm/components/dialogs/node-label-dialog/node-label-dialog.d.ts +28 -0
  2. package/dist/esm/components/dialogs/node-label-dialog/node-label-dialog.d.ts.map +1 -0
  3. package/dist/esm/components/dialogs/node-label-dialog/node-label-dialog.js +99 -0
  4. package/dist/esm/components/dialogs/node-label-dialog/node-label-dialog.js.map +6 -0
  5. package/dist/esm/components/dialogs/node-label-dialog/show-node-label-dialog.d.ts +8 -0
  6. package/dist/esm/components/dialogs/node-label-dialog/show-node-label-dialog.d.ts.map +1 -0
  7. package/dist/esm/components/dialogs/node-label-dialog/show-node-label-dialog.js +16 -0
  8. package/dist/esm/components/dialogs/node-label-dialog/show-node-label-dialog.js.map +6 -0
  9. package/dist/esm/components/dialogs/settings/log-level-section.d.ts.map +1 -1
  10. package/dist/esm/components/dialogs/settings/log-level-section.js +1 -0
  11. package/dist/esm/components/dialogs/settings/log-level-section.js.map +1 -1
  12. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts.map +1 -1
  13. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js +7 -18
  14. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js.map +1 -1
  15. package/dist/esm/pages/components/header.d.ts +2 -0
  16. package/dist/esm/pages/components/header.d.ts.map +1 -1
  17. package/dist/esm/pages/components/header.js +13 -4
  18. package/dist/esm/pages/components/header.js.map +1 -1
  19. package/dist/esm/pages/components/node-details.d.ts +1 -0
  20. package/dist/esm/pages/components/node-details.d.ts.map +1 -1
  21. package/dist/esm/pages/components/node-details.js +28 -2
  22. package/dist/esm/pages/components/node-details.js.map +1 -1
  23. package/dist/esm/pages/matter-network-view.js +4 -4
  24. package/dist/esm/pages/matter-network-view.js.map +1 -1
  25. package/dist/esm/pages/network/thread-graph.d.ts +5 -6
  26. package/dist/esm/pages/network/thread-graph.d.ts.map +1 -1
  27. package/dist/esm/pages/network/thread-graph.js +39 -20
  28. package/dist/esm/pages/network/thread-graph.js.map +1 -1
  29. package/dist/esm/util/device-icons.d.ts +12 -3
  30. package/dist/esm/util/device-icons.d.ts.map +1 -1
  31. package/dist/esm/util/device-icons.js +28 -13
  32. package/dist/esm/util/device-icons.js.map +1 -1
  33. package/dist/esm/util/node-label.d.ts +16 -0
  34. package/dist/esm/util/node-label.d.ts.map +1 -0
  35. package/dist/esm/util/node-label.js +20 -0
  36. package/dist/esm/util/node-label.js.map +6 -0
  37. package/dist/web/index.html +6 -0
  38. package/dist/web/js/{attribute-write-dialog-BfQ9Xflh.js → attribute-write-dialog-f6XnfRjW.js} +1 -1
  39. package/dist/web/js/{command-invoke-dialog-Zj6gySV_.js → command-invoke-dialog-jzzpwI81.js} +1 -1
  40. package/dist/web/js/{commission-node-dialog-BpEVqGkZ.js → commission-node-dialog-B3RBDYQU.js} +5 -5
  41. package/dist/web/js/{commission-node-existing-4zO8iG_s.js → commission-node-existing-Cf6SG9fC.js} +2 -2
  42. package/dist/web/js/{commission-node-thread-AHWmXDx1.js → commission-node-thread-BlC2ZhGB.js} +2 -2
  43. package/dist/web/js/{commission-node-wifi-C07wuota.js → commission-node-wifi-BzeVHU3H.js} +2 -2
  44. package/dist/web/js/{dialog-box-BVHU0m4j.js → dialog-box-8CugytjX.js} +1 -1
  45. package/dist/web/js/{fire_event-YKA6y_5c.js → fire_event-B0dFAh3R.js} +1 -1
  46. package/dist/web/js/main.js +4 -4
  47. package/dist/web/js/{matter-dashboard-app-DIak2OyX.js → matter-dashboard-app-D7-YX94X.js} +170 -66
  48. package/dist/web/js/{node-binding-dialog-DILw-ecn.js → node-binding-dialog-Dpjn-GtG.js} +1 -1
  49. package/dist/web/js/node-label-dialog-B31BzWy6.js +80 -0
  50. package/dist/web/js/{settings-dialog-CBVhNIXT.js → settings-dialog-Di2Qnh3-.js} +4 -1
  51. package/package.json +7 -7
  52. package/src/components/dialogs/node-label-dialog/node-label-dialog.ts +93 -0
  53. package/src/components/dialogs/node-label-dialog/show-node-label-dialog.ts +15 -0
  54. package/src/components/dialogs/settings/log-level-section.ts +1 -0
  55. package/src/pages/cluster-commands/clusters/basic-information-commands.ts +7 -26
  56. package/src/pages/components/header.ts +15 -4
  57. package/src/pages/components/node-details.ts +31 -2
  58. package/src/pages/matter-network-view.ts +4 -4
  59. package/src/pages/network/thread-graph.ts +46 -22
  60. package/src/util/device-icons.ts +59 -14
  61. package/src/util/node-label.ts +22 -0
@@ -0,0 +1,80 @@
1
+ import { r, n, g as t, b as i, M as MAX_NODE_LABEL_LENGTH, d as b, w as writeNodeLabel, s as showAlertDialog } from './matter-dashboard-app-D7-YX94X.js';
2
+ import { p as preventDefault } from './prevent_default-D-ohDGsN.js';
3
+ import './main.js';
4
+
5
+ var __defProp = Object.defineProperty;
6
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
7
+ var __decorateClass = (decorators, target, key, kind) => {
8
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
9
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
10
+ if (kind && result) __defProp(target, key, result);
11
+ return result;
12
+ };
13
+ let NodeLabelDialog = class extends i {
14
+ constructor() {
15
+ super(...arguments);
16
+ this._nodeLabel = "";
17
+ this._saving = false;
18
+ }
19
+ firstUpdated() {
20
+ this._nodeLabel = this.node.nodeLabel;
21
+ }
22
+ render() {
23
+ return b`
24
+ <md-dialog open @cancel=${preventDefault} @closed=${this._handleClosed}>
25
+ <div slot="headline">Edit Node Label</div>
26
+ <div slot="content">
27
+ <md-outlined-text-field
28
+ label="Node Label"
29
+ .value=${this._nodeLabel}
30
+ @input=${this._handleInput}
31
+ maxlength=${MAX_NODE_LABEL_LENGTH}
32
+ ?disabled=${this._saving}
33
+ supporting-text="Max ${MAX_NODE_LABEL_LENGTH} characters"
34
+ style="width: 100%; margin-top: 8px;"
35
+ ></md-outlined-text-field>
36
+ </div>
37
+ <div slot="actions">
38
+ <md-text-button @click=${this._close} ?disabled=${this._saving}>Cancel</md-text-button>
39
+ <md-text-button @click=${this._save} ?disabled=${this._saving}>Save</md-text-button>
40
+ </div>
41
+ </md-dialog>
42
+ `;
43
+ }
44
+ _handleInput(e) {
45
+ const input = e.target;
46
+ this._nodeLabel = input.value;
47
+ }
48
+ _close() {
49
+ this.shadowRoot.querySelector("md-dialog").close();
50
+ }
51
+ _handleClosed() {
52
+ this.parentNode.removeChild(this);
53
+ }
54
+ async _save() {
55
+ this._saving = true;
56
+ try {
57
+ await writeNodeLabel(this.client, this.node, this._nodeLabel);
58
+ this._close();
59
+ } catch (error) {
60
+ const errorMessage = error instanceof Error ? error.message : String(error);
61
+ showAlertDialog({
62
+ title: "Failed to set node label",
63
+ text: errorMessage
64
+ });
65
+ } finally {
66
+ this._saving = false;
67
+ }
68
+ }
69
+ };
70
+ __decorateClass([n({
71
+ attribute: false
72
+ })], NodeLabelDialog.prototype, "client", 2);
73
+ __decorateClass([n({
74
+ attribute: false
75
+ })], NodeLabelDialog.prototype, "node", 2);
76
+ __decorateClass([r()], NodeLabelDialog.prototype, "_nodeLabel", 2);
77
+ __decorateClass([r()], NodeLabelDialog.prototype, "_saving", 2);
78
+ NodeLabelDialog = __decorateClass([t("node-label-dialog")], NodeLabelDialog);
79
+
80
+ export { NodeLabelDialog };
@@ -1,4 +1,4 @@
1
- import { i, c, a as clientContext, w as tickContext, r, e, j as i$1, f as fireAndForget, s as showAlertDialog, b, A, h as handleAsync, v as t, q as n, D as DevModeService, o as mdiWifi, k as mdiAccessPoint, n as mdiEyeOff, l as mdiEye } from './matter-dashboard-app-DIak2OyX.js';
1
+ import { i, c, a as clientContext, t as tickContext, r, e, b as i$1, f as fireAndForget, s as showAlertDialog, d as b, A, h as handleAsync, g as t, n, D as DevModeService, m as mdiWifi, j as mdiAccessPoint, k as mdiEyeOff, l as mdiEye } from './matter-dashboard-app-D7-YX94X.js';
2
2
  import { p as preventDefault } from './prevent_default-D-ohDGsN.js';
3
3
  import './main.js';
4
4
 
@@ -19,6 +19,9 @@ const LOG_LEVELS = [{
19
19
  }, {
20
20
  value: "warning",
21
21
  label: "Warning"
22
+ }, {
23
+ value: "notice",
24
+ label: "Notice"
22
25
  }, {
23
26
  value: "info",
24
27
  label: "Info"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matter-server/dashboard",
3
- "version": "0.7.2-alpha.0-20260601-e778038",
3
+ "version": "0.8.0",
4
4
  "description": "Dashboard for OHF Matter Server",
5
5
  "homepage": "https://github.com/matter-js/matterjs-server",
6
6
  "bugs": {
@@ -33,8 +33,8 @@
33
33
  "dependencies": {
34
34
  "@lit/context": "^1.1.6",
35
35
  "@material/web": "^2.4.1",
36
- "@matter-server/custom-clusters": "0.7.2-alpha.0-20260601-e778038",
37
- "@matter-server/ws-client": "0.7.2-alpha.0-20260601-e778038",
36
+ "@matter-server/custom-clusters": "0.8.0",
37
+ "@matter-server/ws-client": "0.8.0",
38
38
  "@mdi/js": "^7.4.47",
39
39
  "lit": "^3.3.3",
40
40
  "tslib": "^2.8.1",
@@ -42,14 +42,14 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@babel/preset-env": "^7.29.7",
45
- "@matter/main": "0.17.0",
46
- "@rollup/plugin-babel": "^7.0.0",
47
- "@rollup/plugin-commonjs": "^29.0.2",
45
+ "@matter/main": "0.17.1-alpha.0-20260602-24f28a953",
46
+ "@rollup/plugin-babel": "^7.1.0",
47
+ "@rollup/plugin-commonjs": "^29.0.3",
48
48
  "@rollup/plugin-json": "^6.1.0",
49
49
  "@rollup/plugin-node-resolve": "^16.0.3",
50
50
  "@rollup/plugin-terser": "^1.0.0",
51
51
  "@rollup/plugin-typescript": "^12.3.0",
52
- "rollup": "^4.60.4",
52
+ "rollup": "^4.61.0",
53
53
  "rollup-plugin-copy": "^3.5.0",
54
54
  "serve": "^14.2.6"
55
55
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import "@material/web/button/text-button";
8
+ import "@material/web/dialog/dialog";
9
+ import "@material/web/textfield/outlined-text-field";
10
+ import type { MdDialog } from "@material/web/dialog/dialog.js";
11
+ import type { MatterClient, MatterNode } from "@matter-server/ws-client";
12
+ import { html, LitElement } from "lit";
13
+ import { customElement, property, state } from "lit/decorators.js";
14
+ import { MAX_NODE_LABEL_LENGTH, writeNodeLabel } from "../../../util/node-label.js";
15
+ import { preventDefault } from "../../../util/prevent_default.js";
16
+ import { showAlertDialog } from "../../dialog-box/show-dialog-box.js";
17
+
18
+ @customElement("node-label-dialog")
19
+ export class NodeLabelDialog extends LitElement {
20
+ @property({ attribute: false })
21
+ public client!: MatterClient;
22
+
23
+ @property({ attribute: false })
24
+ public node!: MatterNode;
25
+
26
+ @state()
27
+ private _nodeLabel: string = "";
28
+
29
+ @state()
30
+ private _saving: boolean = false;
31
+
32
+ protected override firstUpdated() {
33
+ this._nodeLabel = this.node.nodeLabel;
34
+ }
35
+
36
+ protected override render() {
37
+ return html`
38
+ <md-dialog open @cancel=${preventDefault} @closed=${this._handleClosed}>
39
+ <div slot="headline">Edit Node Label</div>
40
+ <div slot="content">
41
+ <md-outlined-text-field
42
+ label="Node Label"
43
+ .value=${this._nodeLabel}
44
+ @input=${this._handleInput}
45
+ maxlength=${MAX_NODE_LABEL_LENGTH}
46
+ ?disabled=${this._saving}
47
+ supporting-text="Max ${MAX_NODE_LABEL_LENGTH} characters"
48
+ style="width: 100%; margin-top: 8px;"
49
+ ></md-outlined-text-field>
50
+ </div>
51
+ <div slot="actions">
52
+ <md-text-button @click=${this._close} ?disabled=${this._saving}>Cancel</md-text-button>
53
+ <md-text-button @click=${this._save} ?disabled=${this._saving}>Save</md-text-button>
54
+ </div>
55
+ </md-dialog>
56
+ `;
57
+ }
58
+
59
+ private _handleInput(e: Event) {
60
+ const input = e.target as HTMLInputElement;
61
+ this._nodeLabel = input.value;
62
+ }
63
+
64
+ private _close() {
65
+ this.shadowRoot!.querySelector<MdDialog>("md-dialog")!.close();
66
+ }
67
+
68
+ private _handleClosed() {
69
+ this.parentNode!.removeChild(this);
70
+ }
71
+
72
+ private async _save() {
73
+ this._saving = true;
74
+ try {
75
+ await writeNodeLabel(this.client, this.node, this._nodeLabel);
76
+ this._close();
77
+ } catch (error) {
78
+ const errorMessage = error instanceof Error ? error.message : String(error);
79
+ showAlertDialog({
80
+ title: "Failed to set node label",
81
+ text: errorMessage,
82
+ });
83
+ } finally {
84
+ this._saving = false;
85
+ }
86
+ }
87
+ }
88
+
89
+ declare global {
90
+ interface HTMLElementTagNameMap {
91
+ "node-label-dialog": NodeLabelDialog;
92
+ }
93
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { MatterClient, MatterNode } from "@matter-server/ws-client";
8
+
9
+ export const showNodeLabelDialog = async (client: MatterClient, node: MatterNode) => {
10
+ await import("./node-label-dialog.js");
11
+ const dialog = document.createElement("node-label-dialog");
12
+ dialog.client = client;
13
+ dialog.node = node;
14
+ document.querySelector("matter-dashboard-app")?.renderRoot.appendChild(dialog);
15
+ };
@@ -20,6 +20,7 @@ const LOG_LEVELS: { value: LogLevelString; label: string }[] = [
20
20
  { value: "critical", label: "Critical" },
21
21
  { value: "error", label: "Error" },
22
22
  { value: "warning", label: "Warning" },
23
+ { value: "notice", label: "Notice" },
23
24
  { value: "info", label: "Info" },
24
25
  { value: "debug", label: "Debug" },
25
26
  ];
@@ -10,13 +10,10 @@ import { css, html, nothing, type CSSResultGroup } from "lit";
10
10
  import { customElement, state } from "lit/decorators.js";
11
11
  import { showAlertDialog } from "../../../components/dialog-box/show-dialog-box.js";
12
12
  import { handleAsync } from "../../../util/async-handler.js";
13
+ import { MAX_NODE_LABEL_LENGTH, NODE_LABEL_CLUSTER_ID, writeNodeLabel } from "../../../util/node-label.js";
13
14
  import { BaseClusterCommands } from "../base-cluster-commands.js";
14
15
  import { registerClusterCommands } from "../registry.js";
15
16
 
16
- const CLUSTER_ID = 0x28; // BasicInformation cluster (40 decimal)
17
- const NODE_LABEL_ATTRIBUTE_ID = 5;
18
- const MAX_NODE_LABEL_LENGTH = 32;
19
-
20
17
  /**
21
18
  * Command panel for BasicInformation cluster (ID: 0x28 / 40).
22
19
  * Provides ability to edit the NodeLabel attribute.
@@ -44,22 +41,7 @@ export class BasicInformationClusterCommands extends BaseClusterCommands {
44
41
  if (!this.node) {
45
42
  return;
46
43
  }
47
-
48
- // First check the direct nodeLabel property on the node
49
- if (this.node.nodeLabel) {
50
- this._nodeLabel = this.node.nodeLabel;
51
- return;
52
- }
53
-
54
- // Fallback to attribute path: endpoint/cluster/attribute = 0/40/5
55
- // BasicInformation cluster is always on endpoint 0 per Matter specification
56
- const attributePath = `0/${CLUSTER_ID}/${NODE_LABEL_ATTRIBUTE_ID}`;
57
- const currentValue = this.node.attributes[attributePath];
58
- if (typeof currentValue === "string") {
59
- this._nodeLabel = currentValue;
60
- } else {
61
- this._nodeLabel = "";
62
- }
44
+ this._nodeLabel = this.node.nodeLabel;
63
45
  }
64
46
 
65
47
  /**
@@ -115,13 +97,13 @@ export class BasicInformationClusterCommands extends BaseClusterCommands {
115
97
  this._saving = true;
116
98
 
117
99
  try {
118
- // BasicInformation cluster is always on endpoint 0 per Matter specification
119
- const attributePath = `0/${CLUSTER_ID}/${NODE_LABEL_ATTRIBUTE_ID}`;
120
- await this.client.writeAttribute(this.node.node_id, attributePath, this._nodeLabel);
100
+ const label = this._nodeLabel.trim();
101
+ await writeNodeLabel(this.client, this.node, label);
102
+ this._nodeLabel = label;
121
103
 
122
104
  showAlertDialog({
123
105
  title: "Success",
124
- text: `Node label set to "${this._nodeLabel}"`,
106
+ text: `Node label set to "${label}"`,
125
107
  });
126
108
  } catch (error) {
127
109
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -161,8 +143,7 @@ export class BasicInformationClusterCommands extends BaseClusterCommands {
161
143
  ];
162
144
  }
163
145
 
164
- // Register this component for cluster ID 0x28 (40 decimal)
165
- registerClusterCommands(CLUSTER_ID, "basic-information-cluster-commands");
146
+ registerClusterCommands(NODE_LABEL_CLUSTER_ID, "basic-information-cluster-commands");
166
147
 
167
148
  declare global {
168
149
  interface HTMLElementTagNameMap {
@@ -11,7 +11,7 @@ import "@material/web/list/list";
11
11
  import "@material/web/list/list-item";
12
12
  import { consume } from "@lit/context";
13
13
  import { MatterClient } from "@matter-server/ws-client";
14
- import { mdiArrowLeft, mdiBrightnessAuto, mdiCog, mdiLogout, mdiWeatherNight, mdiWeatherSunny } from "@mdi/js";
14
+ import { mdiArrowLeft, mdiBrightnessAuto, mdiCog, mdiHome, mdiLogout, mdiWeatherNight, mdiWeatherSunny } from "@mdi/js";
15
15
  import { LitElement, css, html, nothing } from "lit";
16
16
  import { customElement, property, state } from "lit/decorators.js";
17
17
  import { clientContext, tickContext } from "../../client/client-context.js";
@@ -67,6 +67,16 @@ export class DashboardHeader extends LitElement {
67
67
  this._unsubscribeDevMode?.();
68
68
  }
69
69
 
70
+ private _goBack() {
71
+ if (this.backButton) {
72
+ location.hash = this.backButton;
73
+ }
74
+ }
75
+
76
+ private _goHome() {
77
+ location.hash = "#";
78
+ }
79
+
70
80
  private _cycleTheme() {
71
81
  ThemeService.cycleTheme();
72
82
  }
@@ -144,11 +154,12 @@ export class DashboardHeader extends LitElement {
144
154
  <div class="header">
145
155
  <!-- optional back button -->
146
156
  ${this.backButton
147
- ? html` <a .href=${this.backButton}>
148
- <md-icon-button>
157
+ ? html` <md-icon-button title="Back" aria-label="Back" @click=${this._goBack}>
149
158
  <ha-svg-icon .path=${mdiArrowLeft}></ha-svg-icon>
150
159
  </md-icon-button>
151
- </a>`
160
+ <md-icon-button title="Home" aria-label="Home" @click=${this._goHome}>
161
+ <ha-svg-icon .path=${mdiHome}></ha-svg-icon>
162
+ </md-icon-button>`
152
163
  : ""}
153
164
 
154
165
  <div class="title">${this.title ?? ""}</div>
@@ -13,13 +13,14 @@ import "@material/web/list/list";
13
13
  import "@material/web/list/list-item";
14
14
  import { consume } from "@lit/context";
15
15
  import { MatterClient, MatterNode, UpdateSource } from "@matter-server/ws-client";
16
- import { mdiChatProcessing, mdiLink, mdiShareVariant, mdiTrashCan, mdiUpdate, mdiVideo } from "@mdi/js";
16
+ import { mdiChatProcessing, mdiLink, mdiPencil, mdiShareVariant, mdiTrashCan, mdiUpdate, mdiVideo } from "@mdi/js";
17
17
  import { LitElement, css, html, nothing } from "lit";
18
18
  import { customElement, property, state } from "lit/decorators.js";
19
19
  import { clientContext, tickContext } from "../../client/client-context.js";
20
20
  import { DeviceType } from "../../client/models/descriptions.js";
21
21
  import { showAlertDialog, showPromptDialog } from "../../components/dialog-box/show-dialog-box.js";
22
22
  import { showNodeBindingDialog } from "../../components/dialogs/binding/show-node-binding-dialog.js";
23
+ import { showNodeLabelDialog } from "../../components/dialogs/node-label-dialog/show-node-label-dialog.js";
23
24
  import { handleAsync } from "../../util/async-handler.js";
24
25
  import "../../components/ha-svg-icon";
25
26
  import "../camera-overlay.js";
@@ -86,8 +87,19 @@ export class NodeDetails extends LitElement {
86
87
  <md-list>
87
88
  <md-list-item>
88
89
  <ha-svg-icon slot="start" class="device-icon" .path=${getDeviceIcon(this.node)}></ha-svg-icon>
89
- <div slot="headline">
90
+ <div slot="headline" class="node-label-row">
90
91
  <b>${this.node.nodeLabel || "Node Info"}</b>
92
+ ${this.node.available
93
+ ? html`
94
+ <md-icon-button
95
+ @click=${() => this._editNodeLabel()}
96
+ aria-label="Edit node label"
97
+ title="Edit node label"
98
+ >
99
+ <ha-svg-icon .path=${mdiPencil}></ha-svg-icon>
100
+ </md-icon-button>
101
+ `
102
+ : nothing}
91
103
  ${this.node.available ? nothing : html` <span class="status">OFFLINE</span> `}
92
104
  </div>
93
105
  </md-list-item>
@@ -168,6 +180,10 @@ export class NodeDetails extends LitElement {
168
180
  `;
169
181
  }
170
182
 
183
+ private _editNodeLabel() {
184
+ showNodeLabelDialog(this.client, this.node!);
185
+ }
186
+
171
187
  private async _reinterview() {
172
188
  if (
173
189
  !(await showPromptDialog({
@@ -318,6 +334,19 @@ export class NodeDetails extends LitElement {
318
334
  }
319
335
 
320
336
  static override styles = css`
337
+ .node-label-row {
338
+ display: flex;
339
+ align-items: center;
340
+ gap: 8px;
341
+ }
342
+
343
+ .node-label-row md-icon-button {
344
+ width: 24px;
345
+ height: 24px;
346
+ --md-icon-button-state-layer-width: 32px;
347
+ --md-icon-button-state-layer-height: 32px;
348
+ }
349
+
321
350
  .device-icon {
322
351
  --icon-primary-color: var(--md-sys-color-on-surface-variant, #666);
323
352
  }
@@ -291,7 +291,7 @@ class MatterNetworkView extends LitElement {
291
291
  return;
292
292
  }
293
293
 
294
- const found = graph.selectByExtendedAddress(searchValue);
294
+ const found = graph.selectBySearch(searchValue);
295
295
  this._threadAddressSearchStatus = found ? "found" : "not-found";
296
296
  }
297
297
 
@@ -306,8 +306,8 @@ class MatterNetworkView extends LitElement {
306
306
  type="text"
307
307
  .value=${this._threadAddressSearch}
308
308
  @input=${this._handleThreadAddressSearchInput}
309
- placeholder="Search extended address"
310
- title="Find device by Thread extended address (EUI-64)"
309
+ placeholder="Search label, node id, or address"
310
+ title="Find a Thread device by node label, node id, or extended address (EUI-64)"
311
311
  />
312
312
  <button type="submit" class="search-button">Find</button>
313
313
  </form>
@@ -371,7 +371,7 @@ class MatterNetworkView extends LitElement {
371
371
  : html`<div class="thread-search-status ${this._threadAddressSearchStatus}">
372
372
  ${this._threadAddressSearchStatus === "found"
373
373
  ? "Node highlighted."
374
- : "No matching extended address found."}
374
+ : "No matching device found."}
375
375
  </div>`}
376
376
  <thread-graph
377
377
  .nodes=${this.nodes}
@@ -114,36 +114,56 @@ export class ThreadGraph extends BaseNetworkGraph {
114
114
  }
115
115
 
116
116
  /**
117
- * Searches for a Thread node (known or unknown) by extended address and selects it.
118
- * Accepts formats like:
119
- * - AABBCCDDEEFF0011
120
- * - AA:BB:CC:DD:EE:FF:00:11
121
- * - 0xAABBCCDDEEFF0011
117
+ * Searches for a Thread node (known or unknown) and selects it. Matches, in priority order:
118
+ * 1. Extended address (EUI-64), accepting `AABBCCDDEEFF0011`, `AA:BB:...`, or `0x...` forms.
119
+ * 2. Node id (exact).
120
+ * 3. Visible device label (case-insensitive substring).
122
121
  * Returns true when a match is found.
123
122
  */
124
- public selectByExtendedAddress(address: string): boolean {
125
- const normalized = normalizeExtendedAddressInput(address);
126
- if (!normalized) {
123
+ public selectBySearch(query: string): boolean {
124
+ const trimmed = query.trim();
125
+ if (!trimmed) {
127
126
  return false;
128
127
  }
129
128
 
130
- // Search commissioned Thread devices first
131
- for (const node of Object.values(this.nodes)) {
132
- if (getNetworkType(node) !== "thread") {
133
- continue;
129
+ // Only visible nodes are selectable — focusing a hidden node would report a
130
+ // match the user can't see on the graph.
131
+ const threadNodes = Object.values(this.nodes).filter(
132
+ node => getNetworkType(node) === "thread" && !(this.hideOfflineNodes && node.available === false),
133
+ );
134
+
135
+ // 1. Extended address — commissioned devices first, then unknown/external neighbors.
136
+ const normalized = normalizeExtendedAddressInput(trimmed);
137
+ if (normalized) {
138
+ for (const node of threadNodes) {
139
+ const extAddressHex = getThreadExtendedAddressHex(node);
140
+ if (extAddressHex && extAddressHex === normalized) {
141
+ this.selectNode(String(node.node_id));
142
+ return true;
143
+ }
134
144
  }
135
145
 
136
- const extAddressHex = getThreadExtendedAddressHex(node);
137
- if (extAddressHex && extAddressHex === normalized) {
146
+ for (const [unknownId, unknown] of this._unknownDevicesMapCache) {
147
+ if (normalizeExtendedAddressInput(unknown.extAddressHex) === normalized) {
148
+ this.selectNode(unknownId);
149
+ return true;
150
+ }
151
+ }
152
+ }
153
+
154
+ // 2. Node id (exact).
155
+ for (const node of threadNodes) {
156
+ if (String(node.node_id) === trimmed) {
138
157
  this.selectNode(String(node.node_id));
139
158
  return true;
140
159
  }
141
160
  }
142
161
 
143
- // Then search unknown/external Thread devices
144
- for (const [unknownId, unknown] of this._unknownDevicesMapCache) {
145
- if (normalizeExtendedAddressInput(unknown.extAddressHex) === normalized) {
146
- this.selectNode(unknownId);
162
+ // 3. Device label as shown on the graph (case-insensitive substring).
163
+ const needle = trimmed.toLowerCase();
164
+ for (const node of threadNodes) {
165
+ if (getDeviceName(node).toLowerCase().includes(needle)) {
166
+ this.selectNode(String(node.node_id));
147
167
  return true;
148
168
  }
149
169
  }
@@ -258,11 +278,13 @@ export class ThreadGraph extends BaseNetworkGraph {
258
278
  ? `\n${device.networkName}`
259
279
  : "";
260
280
  const label = `${top}${suffix}`;
261
- const isLeader = decodeMeshcopStateBitmap(device.stateBitmapHex)?.threadRoleValue === 3;
281
+ const decodedState = decodeMeshcopStateBitmap(device.stateBitmapHex);
282
+ const isLeader = decodedState?.threadRoleValue === 3;
283
+ const isPrimaryBbr = decodedState?.bbr === true && decodedState.bbrFunction === "primary";
262
284
  graphNodes.push({
263
285
  id: device.id,
264
286
  label,
265
- image: createBorderRouterIconDataUrl(isSelected, isLeader),
287
+ image: createBorderRouterIconDataUrl(isSelected, isLeader, isPrimaryBbr),
266
288
  shape: "image" as const,
267
289
  networkType: "thread" as const,
268
290
  isUnknown: false,
@@ -622,10 +644,12 @@ export class ThreadGraph extends BaseNetworkGraph {
622
644
  }
623
645
  const external = this._unknownDevicesMapCache.get(nodeId);
624
646
  if (external?.kind === "br") {
625
- const isLeader = decodeMeshcopStateBitmap(external.stateBitmapHex)?.threadRoleValue === 3;
647
+ const decodedState = decodeMeshcopStateBitmap(external.stateBitmapHex);
648
+ const isLeader = decodedState?.threadRoleValue === 3;
649
+ const isPrimaryBbr = decodedState?.bbr === true && decodedState.bbrFunction === "primary";
626
650
  this._nodesDataSet.update({
627
651
  id: nodeId,
628
- image: createBorderRouterIconDataUrl(isHighlighted, isLeader),
652
+ image: createBorderRouterIconDataUrl(isHighlighted, isLeader, isPrimaryBbr),
629
653
  });
630
654
  } else if (nodeId.startsWith("unknown_") || nodeId.startsWith("br_")) {
631
655
  this._nodesDataSet.update({