@matter-server/dashboard 0.3.4 → 0.3.5

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 (33) hide show
  1. package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts.map +1 -1
  2. package/dist/esm/components/dialogs/binding/node-binding-dialog.js +44 -17
  3. package/dist/esm/components/dialogs/binding/node-binding-dialog.js.map +1 -1
  4. package/dist/esm/entrypoint/main.js +1 -1
  5. package/dist/esm/entrypoint/main.js.map +1 -1
  6. package/dist/esm/pages/matter-network-view.d.ts +5 -0
  7. package/dist/esm/pages/matter-network-view.d.ts.map +1 -1
  8. package/dist/esm/pages/matter-network-view.js +96 -13
  9. package/dist/esm/pages/matter-network-view.js.map +1 -1
  10. package/dist/esm/pages/network/base-network-graph.d.ts +34 -0
  11. package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -1
  12. package/dist/esm/pages/network/base-network-graph.js +106 -23
  13. package/dist/esm/pages/network/base-network-graph.js.map +1 -1
  14. package/dist/esm/util/matter-status.d.ts +49 -0
  15. package/dist/esm/util/matter-status.d.ts.map +1 -0
  16. package/dist/esm/util/matter-status.js +110 -0
  17. package/dist/esm/util/matter-status.js.map +6 -0
  18. package/dist/web/js/{commission-node-dialog-CcMuttYO.js → commission-node-dialog-DBugVQOl.js} +4 -4
  19. package/dist/web/js/{commission-node-existing-CqTRDMAr.js → commission-node-existing-ts2MN2bQ.js} +2 -2
  20. package/dist/web/js/{commission-node-thread-DgwtTVwK.js → commission-node-thread-CixfNuM3.js} +2 -2
  21. package/dist/web/js/{commission-node-wifi-XaN2SEnE.js → commission-node-wifi-CWC30EYn.js} +2 -2
  22. package/dist/web/js/{dialog-box-COpDD8i7.js → dialog-box-SeMuFiWx.js} +1 -1
  23. package/dist/web/js/{fire_event-mDYWi2sw.js → fire_event-B63oGTcK.js} +1 -1
  24. package/dist/web/js/{log-level-dialog-Bc32kZVw.js → log-level-dialog-J0gFiLLM.js} +1 -1
  25. package/dist/web/js/main.js +2 -2
  26. package/dist/web/js/{matter-dashboard-app-CrBHT4fT.js → matter-dashboard-app-D7YWrgXj.js} +222 -43
  27. package/dist/web/js/{node-binding-dialog-C8fqOJiB.js → node-binding-dialog-DSBwIJcQ.js} +146 -18
  28. package/package.json +6 -6
  29. package/src/components/dialogs/binding/node-binding-dialog.ts +45 -21
  30. package/src/entrypoint/main.ts +2 -0
  31. package/src/pages/matter-network-view.ts +102 -13
  32. package/src/pages/network/base-network-graph.ts +130 -30
  33. package/src/util/matter-status.ts +165 -0
@@ -1,4 +1,4 @@
1
- import { k as i, G as c, H as clientContext, n, a as e, i as i$1, A, j as b, F as handleAsync, t } from './matter-dashboard-app-CrBHT4fT.js';
1
+ import { k as i, G as c, H as clientContext, n, a as e, i as i$1, A, j as b, F as handleAsync, t } from './matter-dashboard-app-D7YWrgXj.js';
2
2
  import { p as preventDefault } from './prevent_default-D-ohDGsN.js';
3
3
  import './main.js';
4
4
 
@@ -116,6 +116,104 @@ class AccessControlEntryDataTransformer {
116
116
  }
117
117
  _staticBlock2();
118
118
 
119
+ /**
120
+ * @license
121
+ * Copyright 2025-2026 Open Home Foundation
122
+ * SPDX-License-Identifier: Apache-2.0
123
+ */
124
+ const MatterStatusNames = {
125
+ 0: "Success",
126
+ 1: "Failure",
127
+ 125: "InvalidSubscription",
128
+ 126: "UnsupportedAccess",
129
+ 127: "UnsupportedEndpoint",
130
+ 128: "InvalidAction",
131
+ 129: "UnsupportedCommand",
132
+ 133: "InvalidCommand",
133
+ 134: "UnsupportedAttribute",
134
+ 135: "ConstraintError",
135
+ 136: "UnsupportedWrite",
136
+ 137: "ResourceExhausted",
137
+ 139: "NotFound",
138
+ 140: "UnreportableAttribute",
139
+ 141: "InvalidDataType",
140
+ 143: "UnsupportedRead",
141
+ 146: "DataVersionMismatch",
142
+ 148: "Timeout",
143
+ 155: "UnsupportedNode",
144
+ 156: "Busy",
145
+ 157: "AccessRestricted",
146
+ 195: "UnsupportedCluster",
147
+ 197: "NoUpstreamSubscription",
148
+ 198: "NeedsTimedInteraction",
149
+ 199: "UnsupportedEvent",
150
+ 200: "PathsExhausted",
151
+ 201: "TimedRequestMismatch",
152
+ 202: "FailsafeRequired",
153
+ 203: "InvalidInState",
154
+ 204: "NoCommandResponse",
155
+ 205: "TermsAndConditionsChanged",
156
+ 206: "MaintenanceRequired",
157
+ 207: "DynamicConstraintError",
158
+ 208: "AlreadyExists",
159
+ 209: "InvalidTransportType"
160
+ };
161
+ function getMatterStatusName(status) {
162
+ return MatterStatusNames[status] ?? `Unknown(${status})`;
163
+ }
164
+ function analyzeBatchResults(results) {
165
+ if (!results || results.length === 0) {
166
+ return {
167
+ outcome: "all_failed",
168
+ successCount: 0,
169
+ failureCount: 0,
170
+ errorCounts: {},
171
+ message: "No response/results returned"
172
+ };
173
+ }
174
+ let successCount = 0;
175
+ let failureCount = 0;
176
+ const errorCounts = {};
177
+ for (const result of results) {
178
+ if (result.status === 0) {
179
+ successCount++;
180
+ } else {
181
+ failureCount++;
182
+ errorCounts[result.status] = (errorCounts[result.status] ?? 0) + 1;
183
+ }
184
+ }
185
+ let outcome;
186
+ if (failureCount === 0) {
187
+ outcome = "all_success";
188
+ } else if (successCount === 0) {
189
+ outcome = "all_failed";
190
+ } else {
191
+ outcome = "partial";
192
+ }
193
+ const message = formatBatchMessage(outcome, successCount, failureCount, errorCounts);
194
+ return {
195
+ outcome,
196
+ successCount,
197
+ failureCount,
198
+ errorCounts,
199
+ message
200
+ };
201
+ }
202
+ function formatBatchMessage(outcome, successCount, failureCount, errorCounts) {
203
+ const entryWord = count => count === 1 ? "entry" : "entries";
204
+ if (outcome === "all_success") {
205
+ return "Write successful";
206
+ }
207
+ const errorParts = Object.entries(errorCounts).map(([code, count]) => {
208
+ const statusCode = parseInt(code, 10);
209
+ return `${count}x ${getMatterStatusName(statusCode)} (${code})`;
210
+ }).join(", ");
211
+ if (outcome === "all_failed") {
212
+ return `All ${failureCount} ${entryWord(failureCount)} failed: ${errorParts}`;
213
+ }
214
+ return `Partial failure: ${successCount} ${entryWord(successCount)} succeeded, ${failureCount} failed (${errorParts}). Please verify.`;
215
+ }
216
+
119
217
  var __defProp = Object.defineProperty;
120
218
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
121
219
  var __decorateClass = (decorators, target, key, kind) => {
@@ -192,14 +290,23 @@ let NodeBindingDialog = class extends i$1 {
192
290
  const entries = rawEntries ? Object.values(rawEntries).map(v => AccessControlEntryDataTransformer.transform(v)) : [];
193
291
  entries.push(entry);
194
292
  const apiEntries = entries.map(e => this.toAccessControlEntry(e));
195
- const result = await this.client.setACLEntry(targetNodeId, apiEntries);
196
- if (result && result.length > 0) {
197
- return result[0].status === 0;
293
+ const results = await this.client.setACLEntry(targetNodeId, apiEntries);
294
+ const batchResult = analyzeBatchResults(results);
295
+ if (batchResult.outcome !== "all_success") {
296
+ console.error(`Set ACL entry: ${batchResult.message}`);
198
297
  }
199
- return true;
298
+ return batchResult;
200
299
  } catch (err) {
201
- console.error("add acl error:", err);
202
- return false;
300
+ console.error("Add ACL error:", err);
301
+ return {
302
+ outcome: "all_failed",
303
+ successCount: 0,
304
+ failureCount: 1,
305
+ errorCounts: {
306
+ 1: 1
307
+ },
308
+ message: `Exception: ${err instanceof Error ? err.message : String(err)}`
309
+ };
203
310
  }
204
311
  }
205
312
  /** Convert local BindingEntryStruct to API BindingTarget (without fabricIndex) */
@@ -230,14 +337,23 @@ let NodeBindingDialog = class extends i$1 {
230
337
  bindings.push(bindingEntry);
231
338
  try {
232
339
  const apiBindings = bindings.map(b => this.toBindingTarget(b));
233
- const result = await this.client.setNodeBinding(this.getNodeIdAsNumber(), endpoint, apiBindings);
234
- if (result && result.length > 0) {
235
- return result[0].status === 0;
340
+ const results = await this.client.setNodeBinding(this.getNodeIdAsNumber(), endpoint, apiBindings);
341
+ const batchResult = analyzeBatchResults(results);
342
+ if (batchResult.outcome !== "all_success") {
343
+ console.error(`Set binding: ${batchResult.message}`);
236
344
  }
237
- return true;
345
+ return batchResult;
238
346
  } catch (err) {
239
- console.log("add bindings error:", err);
240
- return false;
347
+ console.error("Add bindings error:", err);
348
+ return {
349
+ outcome: "all_failed",
350
+ successCount: 0,
351
+ failureCount: 1,
352
+ errorCounts: {
353
+ 1: 1
354
+ },
355
+ message: `Exception: ${err instanceof Error ? err.message : String(err)}`
356
+ };
241
357
  }
242
358
  }
243
359
  async addBindingHandler() {
@@ -271,11 +387,16 @@ let NodeBindingDialog = class extends i$1 {
271
387
  fabricIndex: 0
272
388
  // Placeholder - server will use correct fabric index
273
389
  };
274
- const result_acl = await this.add_target_acl(targetNodeId, acl_entry);
275
- if (!result_acl) {
276
- alert("add target acl error!");
390
+ const aclResult = await this.add_target_acl(targetNodeId, acl_entry);
391
+ if (aclResult.outcome === "all_failed") {
392
+ alert(`Failed to add ACL entry:
393
+ ${aclResult.message}`);
277
394
  return;
278
395
  }
396
+ if (aclResult.outcome === "partial") {
397
+ alert(`ACL entry partially failed:
398
+ ${aclResult.message}`);
399
+ }
279
400
  const endpoint = this.endpoint;
280
401
  const bindingEntry = {
281
402
  node: targetNodeId,
@@ -285,12 +406,19 @@ let NodeBindingDialog = class extends i$1 {
285
406
  fabricIndex: void 0
286
407
  // Server will use correct fabric index
287
408
  };
288
- const result_binding = await this.add_bindings(endpoint, bindingEntry);
289
- if (result_binding) {
409
+ const bindingResult = await this.add_bindings(endpoint, bindingEntry);
410
+ if (bindingResult.outcome === "all_success") {
290
411
  this._targetNodeId.value = "";
291
412
  this._targetEndpoint.value = "";
292
413
  this._targetCluster.value = "";
293
414
  this.requestUpdate();
415
+ } else if (bindingResult.outcome === "partial") {
416
+ alert(`Binding partially failed:
417
+ ${bindingResult.message}`);
418
+ this.requestUpdate();
419
+ } else {
420
+ alert(`Failed to add binding:
421
+ ${bindingResult.message}`);
294
422
  }
295
423
  }
296
424
  _close() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matter-server/dashboard",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Dashboard for OHF Matter Server",
5
5
  "bugs": {
6
6
  "url": "https://github.com/matter-js/matterjs-server/issues"
@@ -22,8 +22,8 @@
22
22
  "bundle": "rollup -c"
23
23
  },
24
24
  "devDependencies": {
25
- "@babel/preset-env": "^7.28.6",
26
- "@matter/main": "0.16.9-alpha.0-20260201-8748390b1",
25
+ "@babel/preset-env": "^7.29.0",
26
+ "@matter/main": "0.16.9-alpha.0-20260204-fd5b6ed86",
27
27
  "@rollup/plugin-babel": "^6.1.0",
28
28
  "@rollup/plugin-commonjs": "^29.0.0",
29
29
  "rollup-plugin-copy": "^3.5.0",
@@ -31,14 +31,14 @@
31
31
  "@rollup/plugin-node-resolve": "^16.0.3",
32
32
  "@rollup/plugin-terser": "^0.4.4",
33
33
  "@rollup/plugin-typescript": "^12.3.0",
34
- "rollup": "^4.57.0",
34
+ "rollup": "^4.57.1",
35
35
  "serve": "^14.2.5"
36
36
  },
37
37
  "dependencies": {
38
38
  "@lit/context": "^1.1.6",
39
39
  "@material/web": "^2.4.1",
40
- "@matter-server/ws-client": "0.3.4",
41
- "@matter-server/custom-clusters": "0.3.4",
40
+ "@matter-server/ws-client": "0.3.5",
41
+ "@matter-server/custom-clusters": "0.3.5",
42
42
  "@mdi/js": "^7.4.47",
43
43
  "lit": "^3.3.2",
44
44
  "tslib": "^2.8.1",
@@ -28,6 +28,7 @@ import {
28
28
 
29
29
  import { consume } from "@lit/context";
30
30
  import { clientContext } from "../../../client/client-context.js";
31
+ import { analyzeBatchResults, type MatterBatchResult } from "../../../util/matter-status.js";
31
32
 
32
33
  @customElement("node-binding-dialog")
33
34
  export class NodeBindingDialog extends LitElement {
@@ -137,7 +138,7 @@ export class NodeBindingDialog extends LitElement {
137
138
  console.error(`Binding deletion failed: ${errorMessage}`);
138
139
  }
139
140
 
140
- private async add_target_acl(targetNodeId: number, entry: AccessControlEntryStruct) {
141
+ private async add_target_acl(targetNodeId: number, entry: AccessControlEntryStruct): Promise<MatterBatchResult> {
141
142
  try {
142
143
  // Fetch existing ACL entries and transform to local struct format
143
144
  const rawEntries = this.client.nodes[targetNodeId]?.attributes["0/31/0"] as InputType[] | undefined;
@@ -148,15 +149,22 @@ export class NodeBindingDialog extends LitElement {
148
149
 
149
150
  // Convert to API format (without fabricIndex - server handles it)
150
151
  const apiEntries = entries.map(e => this.toAccessControlEntry(e));
151
- const result = await this.client.setACLEntry(targetNodeId, apiEntries);
152
- // Check first result status if available
153
- if (result && result.length > 0) {
154
- return result[0].status === 0;
152
+ const results = await this.client.setACLEntry(targetNodeId, apiEntries);
153
+
154
+ const batchResult = analyzeBatchResults(results);
155
+ if (batchResult.outcome !== "all_success") {
156
+ console.error(`Set ACL entry: ${batchResult.message}`);
155
157
  }
156
- return true; // Assume success if no error thrown
158
+ return batchResult;
157
159
  } catch (err) {
158
- console.error("add acl error:", err);
159
- return false;
160
+ console.error("Add ACL error:", err);
161
+ return {
162
+ outcome: "all_failed",
163
+ successCount: 0,
164
+ failureCount: 1,
165
+ errorCounts: { 1: 1 },
166
+ message: `Exception: ${err instanceof Error ? err.message : String(err)}`,
167
+ };
160
168
  }
161
169
  }
162
170
 
@@ -185,21 +193,28 @@ export class NodeBindingDialog extends LitElement {
185
193
  };
186
194
  }
187
195
 
188
- private async add_bindings(endpoint: number, bindingEntry: BindingEntryStruct) {
196
+ private async add_bindings(endpoint: number, bindingEntry: BindingEntryStruct): Promise<MatterBatchResult> {
189
197
  const bindings = this.fetchBindingEntry();
190
198
  bindings.push(bindingEntry);
191
199
  try {
192
200
  // Convert to API format (without fabricIndex - server handles it)
193
201
  const apiBindings = bindings.map(b => this.toBindingTarget(b));
194
- const result = await this.client.setNodeBinding(this.getNodeIdAsNumber(), endpoint, apiBindings);
195
- // Check first result status if available
196
- if (result && result.length > 0) {
197
- return result[0].status === 0;
202
+ const results = await this.client.setNodeBinding(this.getNodeIdAsNumber(), endpoint, apiBindings);
203
+
204
+ const batchResult = analyzeBatchResults(results);
205
+ if (batchResult.outcome !== "all_success") {
206
+ console.error(`Set binding: ${batchResult.message}`);
198
207
  }
199
- return true; // Assume success if no error thrown
208
+ return batchResult;
200
209
  } catch (err) {
201
- console.log("add bindings error:", err);
202
- return false;
210
+ console.error("Add bindings error:", err);
211
+ return {
212
+ outcome: "all_failed",
213
+ successCount: 0,
214
+ failureCount: 1,
215
+ errorCounts: { 1: 1 },
216
+ message: `Exception: ${err instanceof Error ? err.message : String(err)}`,
217
+ };
203
218
  }
204
219
  }
205
220
 
@@ -243,11 +258,15 @@ export class NodeBindingDialog extends LitElement {
243
258
  fabricIndex: 0, // Placeholder - server will use correct fabric index
244
259
  };
245
260
 
246
- const result_acl = await this.add_target_acl(targetNodeId, acl_entry);
247
- if (!result_acl) {
248
- alert("add target acl error!");
261
+ const aclResult = await this.add_target_acl(targetNodeId, acl_entry);
262
+ if (aclResult.outcome === "all_failed") {
263
+ alert(`Failed to add ACL entry:\n${aclResult.message}`);
249
264
  return;
250
265
  }
266
+ if (aclResult.outcome === "partial") {
267
+ alert(`ACL entry partially failed:\n${aclResult.message}`);
268
+ // Continue with binding attempt since some ACL entries succeeded
269
+ }
251
270
 
252
271
  const endpoint = this.endpoint;
253
272
  // Note: fabricIndex is assigned by the server based on the device's fabric table
@@ -259,13 +278,18 @@ export class NodeBindingDialog extends LitElement {
259
278
  fabricIndex: undefined, // Server will use correct fabric index
260
279
  };
261
280
 
262
- const result_binding = await this.add_bindings(endpoint, bindingEntry);
281
+ const bindingResult = await this.add_bindings(endpoint, bindingEntry);
263
282
 
264
- if (result_binding) {
283
+ if (bindingResult.outcome === "all_success") {
265
284
  this._targetNodeId.value = "";
266
285
  this._targetEndpoint.value = "";
267
286
  this._targetCluster.value = "";
268
287
  this.requestUpdate();
288
+ } else if (bindingResult.outcome === "partial") {
289
+ alert(`Binding partially failed:\n${bindingResult.message}`);
290
+ this.requestUpdate(); // Update UI to show what succeeded
291
+ } else {
292
+ alert(`Failed to add binding:\n${bindingResult.message}`);
269
293
  }
270
294
  }
271
295
 
@@ -13,7 +13,9 @@ async function main() {
13
13
  let url = "";
14
14
 
15
15
  // Detect if we're running in the (production) webserver included in the matter server or not.
16
+ // Priority: 1) Server-injected flag (for reverse proxy setups), 2) URL-based detection
16
17
  const isProductionServer =
18
+ (window as unknown as { __MATTERJS_PRODUCTION_MODE__?: boolean }).__MATTERJS_PRODUCTION_MODE__ === true ||
17
19
  location.origin.includes(":5580") ||
18
20
  location.href.includes("hassio_ingress") ||
19
21
  location.href.includes("/api/ingress/");
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { MatterClient, MatterNode } from "@matter-server/ws-client";
8
- import { mdiFitToScreen } from "@mdi/js";
8
+ import { mdiFitToScreen, mdiMagnifyMinus, mdiMagnifyPlus, mdiPause, mdiPlay } from "@mdi/js";
9
9
  import { css, html, LitElement } from "lit";
10
10
  import { customElement, property, query, state } from "lit/decorators.js";
11
11
  import "../components/ha-svg-icon";
@@ -51,6 +51,9 @@ class MatterNetworkView extends LitElement {
51
51
  @state()
52
52
  private _selectedNodeId: number | string | null = null;
53
53
 
54
+ @state()
55
+ private _physicsEnabled = true;
56
+
54
57
  private _initialSelectionApplied = false;
55
58
  private _selectRetryTimer?: ReturnType<typeof setTimeout>;
56
59
 
@@ -127,16 +130,67 @@ class MatterNetworkView extends LitElement {
127
130
  }
128
131
  }
129
132
 
133
+ private _handleZoomIn(): void {
134
+ if (this.networkType === "thread") {
135
+ this._threadGraph?.zoomIn();
136
+ } else {
137
+ this._wifiGraph?.zoomIn();
138
+ }
139
+ }
140
+
141
+ private _handleZoomOut(): void {
142
+ if (this.networkType === "thread") {
143
+ this._threadGraph?.zoomOut();
144
+ } else {
145
+ this._wifiGraph?.zoomOut();
146
+ }
147
+ }
148
+
149
+ private _handleTogglePhysics(): void {
150
+ const newState = !this._physicsEnabled;
151
+ this._physicsEnabled = newState;
152
+ // Keep both graphs in sync so switching between Thread and WiFi
153
+ // does not cause a mismatch between the UI button and graph state
154
+ this._threadGraph?.setPhysicsEnabled(newState);
155
+ this._wifiGraph?.setPhysicsEnabled(newState);
156
+ }
157
+
158
+ private _handlePhysicsChanged(event: CustomEvent<{ enabled: boolean }>): void {
159
+ // Update UI state when graph auto-freezes and keep both graphs in sync
160
+ this._physicsEnabled = event.detail.enabled;
161
+ this._threadGraph?.setPhysicsEnabled(event.detail.enabled);
162
+ this._wifiGraph?.setPhysicsEnabled(event.detail.enabled);
163
+ }
164
+
130
165
  private _renderThreadView() {
131
166
  return html`
132
167
  <div class="graph-section">
133
168
  <div class="graph-header">
134
169
  <h2>Thread Network Mesh</h2>
135
- <button class="fit-button" @click=${this._handleFitToScreen} title="Fit to screen">
136
- <ha-svg-icon .path=${mdiFitToScreen}></ha-svg-icon>
137
- </button>
170
+ <div class="graph-controls">
171
+ <button class="control-button" @click=${this._handleZoomIn} title="Zoom in">
172
+ <ha-svg-icon .path=${mdiMagnifyPlus}></ha-svg-icon>
173
+ </button>
174
+ <button class="control-button" @click=${this._handleZoomOut} title="Zoom out">
175
+ <ha-svg-icon .path=${mdiMagnifyMinus}></ha-svg-icon>
176
+ </button>
177
+ <button class="control-button" @click=${this._handleFitToScreen} title="Fit to screen">
178
+ <ha-svg-icon .path=${mdiFitToScreen}></ha-svg-icon>
179
+ </button>
180
+ <button
181
+ class="control-button ${this._physicsEnabled ? "" : "active"}"
182
+ @click=${this._handleTogglePhysics}
183
+ title="${this._physicsEnabled ? "Freeze layout" : "Unfreeze layout"}"
184
+ >
185
+ <ha-svg-icon .path=${this._physicsEnabled ? mdiPause : mdiPlay}></ha-svg-icon>
186
+ </button>
187
+ </div>
138
188
  </div>
139
- <thread-graph .nodes=${this.nodes} @node-selected=${this._handleNodeSelected}></thread-graph>
189
+ <thread-graph
190
+ .nodes=${this.nodes}
191
+ @node-selected=${this._handleNodeSelected}
192
+ @physics-changed=${this._handlePhysicsChanged}
193
+ ></thread-graph>
140
194
  </div>
141
195
  `;
142
196
  }
@@ -146,11 +200,30 @@ class MatterNetworkView extends LitElement {
146
200
  <div class="graph-section">
147
201
  <div class="graph-header">
148
202
  <h2>WiFi Network</h2>
149
- <button class="fit-button" @click=${this._handleFitToScreen} title="Fit to screen">
150
- <ha-svg-icon .path=${mdiFitToScreen}></ha-svg-icon>
151
- </button>
203
+ <div class="graph-controls">
204
+ <button class="control-button" @click=${this._handleZoomIn} title="Zoom in">
205
+ <ha-svg-icon .path=${mdiMagnifyPlus}></ha-svg-icon>
206
+ </button>
207
+ <button class="control-button" @click=${this._handleZoomOut} title="Zoom out">
208
+ <ha-svg-icon .path=${mdiMagnifyMinus}></ha-svg-icon>
209
+ </button>
210
+ <button class="control-button" @click=${this._handleFitToScreen} title="Fit to screen">
211
+ <ha-svg-icon .path=${mdiFitToScreen}></ha-svg-icon>
212
+ </button>
213
+ <button
214
+ class="control-button ${this._physicsEnabled ? "" : "active"}"
215
+ @click=${this._handleTogglePhysics}
216
+ title="${this._physicsEnabled ? "Freeze layout" : "Unfreeze layout"}"
217
+ >
218
+ <ha-svg-icon .path=${this._physicsEnabled ? mdiPause : mdiPlay}></ha-svg-icon>
219
+ </button>
220
+ </div>
152
221
  </div>
153
- <wifi-graph .nodes=${this.nodes} @node-selected=${this._handleNodeSelected}></wifi-graph>
222
+ <wifi-graph
223
+ .nodes=${this.nodes}
224
+ @node-selected=${this._handleNodeSelected}
225
+ @physics-changed=${this._handlePhysicsChanged}
226
+ ></wifi-graph>
154
227
  </div>
155
228
  `;
156
229
  }
@@ -245,7 +318,12 @@ class MatterNetworkView extends LitElement {
245
318
  color: var(--md-sys-color-on-background, #333);
246
319
  }
247
320
 
248
- .fit-button {
321
+ .graph-controls {
322
+ display: flex;
323
+ gap: 4px;
324
+ }
325
+
326
+ .control-button {
249
327
  background: none;
250
328
  border: 1px solid var(--md-sys-color-outline-variant, #ccc);
251
329
  border-radius: 4px;
@@ -254,17 +332,28 @@ class MatterNetworkView extends LitElement {
254
332
  display: flex;
255
333
  align-items: center;
256
334
  justify-content: center;
257
- transition: background-color 0.2s;
335
+ transition:
336
+ background-color 0.2s,
337
+ border-color 0.2s;
258
338
  }
259
339
 
260
- .fit-button:hover {
340
+ .control-button:hover {
261
341
  background-color: var(--md-sys-color-surface-container-high, #e8e8e8);
262
342
  }
263
343
 
264
- .fit-button ha-svg-icon {
344
+ .control-button.active {
345
+ background-color: var(--md-sys-color-primary-container, #e8def8);
346
+ border-color: var(--md-sys-color-primary, #6750a4);
347
+ }
348
+
349
+ .control-button ha-svg-icon {
265
350
  --icon-primary-color: var(--md-sys-color-on-surface-variant, #666);
266
351
  }
267
352
 
353
+ .control-button.active ha-svg-icon {
354
+ --icon-primary-color: var(--md-sys-color-on-primary-container, #21005d);
355
+ }
356
+
268
357
  .graph-section thread-graph,
269
358
  .graph-section wifi-graph {
270
359
  flex: 1 1 0;