@sukramj/fabric-ui 0.1.2 → 0.2.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.
@@ -12,6 +12,213 @@
12
12
  "common.save": "Save",
13
13
  "common.saving": "Saving…",
14
14
  "common.search": "Search…",
15
+ "controller.acl.add": "Add an entry",
16
+ "controller.acl.add_entry": "Add",
17
+ "controller.acl.any_subject": "every subject",
18
+ "controller.acl.discard": "Discard",
19
+ "controller.acl.empty": "No entry of this controller's fabric",
20
+ "controller.acl.invalid_subject": "A subject is not a hex id.",
21
+ "controller.acl.invalid_target": "The cluster is not a hex id.",
22
+ "controller.acl.lockout": "Lock this controller out?",
23
+ "controller.acl.lockout_body": "The list has no Administer entry for this controller; after the write it can no longer change the node.",
24
+ "controller.acl.mode.case": "CASE",
25
+ "controller.acl.mode.group": "Group",
26
+ "controller.acl.mode.pase": "PASE",
27
+ "controller.acl.mode_label": "Authentication",
28
+ "controller.acl.privilege.administer": "Administer",
29
+ "controller.acl.privilege.manage": "Manage",
30
+ "controller.acl.privilege.operate": "Operate",
31
+ "controller.acl.privilege.proxy_view": "Proxy view",
32
+ "controller.acl.privilege.view": "View",
33
+ "controller.acl.privilege_label": "Privilege",
34
+ "controller.acl.saved": "The ACL is written.",
35
+ "controller.acl.subjects": "Subjects (hex, comma-separated)",
36
+ "controller.acl.target_cluster": "Cluster (hex)",
37
+ "controller.acl.target_endpoint": "Endpoint",
38
+ "controller.acl.whole_node": "the whole node",
39
+ "controller.attributes.attribute": "Attribute id",
40
+ "controller.attributes.cluster": "Cluster",
41
+ "controller.attributes.endpoint": "Endpoint",
42
+ "controller.attributes.hint": "Read one attribute: pick the endpoint and cluster, give the attribute id in hex.",
43
+ "controller.attributes.invalid_json": "The value is not JSON.",
44
+ "controller.attributes.invalid_path": "The cluster or attribute id is not hex.",
45
+ "controller.attributes.read": "Read",
46
+ "controller.attributes.status": "The node answered status {status}.",
47
+ "controller.attributes.value": "Value as JSON",
48
+ "controller.attributes.write": "Write",
49
+ "controller.attributes.write_status": "The node refused the write: status {status}.",
50
+ "controller.attributes.written": "Written.",
51
+ "controller.bindings.add": "Add",
52
+ "controller.bindings.cluster": "Cluster (hex)",
53
+ "controller.bindings.empty": "No binding",
54
+ "controller.bindings.endpoint": "Endpoint",
55
+ "controller.bindings.group": "Group {group}",
56
+ "controller.bindings.invalid": "A target is a node id and an endpoint, or g: and a group id.",
57
+ "controller.bindings.none": "The node has no Binding cluster",
58
+ "controller.bindings.saved": "The bindings are written.",
59
+ "controller.bindings.target": "Node id (hex) or g:<group>",
60
+ "controller.commission.by_code": "Pairing code",
61
+ "controller.commission.by_code_hint": "Enter the QR payload (MT:…) or the manual pairing code printed on the device.",
62
+ "controller.commission.code": "Pairing code",
63
+ "controller.commission.code_placeholder": "MT:… or 11 digits",
64
+ "controller.commission.col_addresses": "Addresses",
65
+ "controller.commission.col_device": "Device",
66
+ "controller.commission.col_discriminator": "Discriminator",
67
+ "controller.commission.col_vendor": "Vendor",
68
+ "controller.commission.discovery": "Devices on the network",
69
+ "controller.commission.done": "{name} is commissioned.",
70
+ "controller.commission.failed": "Commissioning failed",
71
+ "controller.commission.network": "Network for the device",
72
+ "controller.commission.network_none": "No network credential",
73
+ "controller.commission.network_only": "On the network only (no Bluetooth)",
74
+ "controller.commission.none_found": "No commissionable device answered",
75
+ "controller.commission.pair": "Pair",
76
+ "controller.commission.passcode": "Passcode",
77
+ "controller.commission.passcode_invalid": "The passcode is 1 to 99999998.",
78
+ "controller.commission.running": "Commissioning…",
79
+ "controller.commission.search": "Search",
80
+ "controller.commission.searching": "Searching…",
81
+ "controller.commission.start": "Commission",
82
+ "controller.commission.succeeded": "Commissioned",
83
+ "controller.connection.connected": "Connected",
84
+ "controller.connection.disconnected": "Disconnected",
85
+ "controller.connection.reconnecting": "Reconnecting",
86
+ "controller.connection.waiting_for_discovery": "Waiting for discovery",
87
+ "controller.credential.kind_thread": "Thread",
88
+ "controller.credential.kind_wifi": "Wi-Fi",
89
+ "controller.endpoints.empty": "The controller knows no endpoint of this node yet",
90
+ "controller.endpoints.endpoint": "Endpoint {number}",
91
+ "controller.fabrics.col_fabric_id": "Fabric ID",
92
+ "controller.fabrics.own": "this controller",
93
+ "controller.fabrics.remove_confirm": "Remove this administrator's fabric from the node?",
94
+ "controller.fabrics.removed": "The fabric is removed.",
95
+ "controller.icd.awake": "Awake",
96
+ "controller.icd.forget": "Forget without the node",
97
+ "controller.icd.last_check_in": "Last check-in",
98
+ "controller.icd.lit_supported": "Supports LIT",
99
+ "controller.icd.mode.lit": "LIT",
100
+ "controller.icd.mode.sit": "SIT",
101
+ "controller.icd.multi_admin": "Register despite other administrators?",
102
+ "controller.icd.next_check_in": "Next check-in",
103
+ "controller.icd.register": "Register",
104
+ "controller.icd.registered": "Registered",
105
+ "controller.icd.registered_done": "This controller is registered.",
106
+ "controller.icd.unavailable": "Check-in overdue",
107
+ "controller.icd.unregister": "Unregister",
108
+ "controller.icd.unregistered": "Not registered",
109
+ "controller.icd.unregistered_done": "This controller is unregistered.",
110
+ "controller.icd.unsupported": "The node is not an intermittently connected device",
111
+ "controller.node.back": "All nodes",
112
+ "controller.node.tab.acl": "ACL",
113
+ "controller.node.tab.attributes": "Attributes",
114
+ "controller.node.tab.bindings": "Bindings",
115
+ "controller.node.tab.endpoints": "Endpoints",
116
+ "controller.node.tab.fabrics": "Fabrics",
117
+ "controller.node.tab.icd": "ICD",
118
+ "controller.node.tab.overview": "Overview",
119
+ "controller.node.tab.updates": "Updates",
120
+ "controller.node.tab.window": "Window",
121
+ "controller.node.tabs": "Node sections",
122
+ "controller.nodes.col_addresses": "Addresses",
123
+ "controller.nodes.col_connection": "Connection",
124
+ "controller.nodes.col_name": "Node",
125
+ "controller.nodes.col_node_id": "Node ID",
126
+ "controller.nodes.col_vendor": "Vendor",
127
+ "controller.nodes.commission": "Commission a device",
128
+ "controller.nodes.disable": "Disable",
129
+ "controller.nodes.disabled": "Disabled",
130
+ "controller.nodes.disabled_done": "The node is disabled.",
131
+ "controller.nodes.empty": "No node commissioned yet",
132
+ "controller.nodes.enable": "Enable",
133
+ "controller.nodes.enabled": "The node is enabled.",
134
+ "controller.nodes.filter_all": "All",
135
+ "controller.nodes.filter_connection": "Filter by connection",
136
+ "controller.nodes.filter_icd": "Filter by ICD",
137
+ "controller.nodes.filter_vendor": "Filter by vendor",
138
+ "controller.nodes.icd": "ICD",
139
+ "controller.nodes.icd_no": "No ICD",
140
+ "controller.nodes.icd_yes": "ICD only",
141
+ "controller.nodes.interview": "Interview",
142
+ "controller.nodes.interviewed": "The node was read again.",
143
+ "controller.nodes.remove_confirm": "Decommission this node?",
144
+ "controller.nodes.remove_confirm_body": "{name} leaves this controller's fabric; other controllers keep it.",
145
+ "controller.nodes.removed": "The node is decommissioned.",
146
+ "controller.nodes.subscribed": "Subscribed",
147
+ "controller.nodes.window": "Window",
148
+ "controller.overview.addresses": "Addresses",
149
+ "controller.overview.commissioned_at": "Commissioned",
150
+ "controller.overview.connection": "Connection",
151
+ "controller.overview.fabric_index": "Our fabric index on the node",
152
+ "controller.overview.flag.battery_powered": "Battery powered",
153
+ "controller.overview.flag.intermittently_connected": "Intermittently connected",
154
+ "controller.overview.flag.mains_powered": "Mains powered",
155
+ "controller.overview.flag.supports_ethernet": "Ethernet",
156
+ "controller.overview.flag.supports_thread": "Thread",
157
+ "controller.overview.flag.supports_wifi": "Wi-Fi",
158
+ "controller.overview.flag.thread_sleepy_end_device": "Sleepy end device",
159
+ "controller.overview.idle_mode": "Idle mode: {seconds} s",
160
+ "controller.overview.node": "Node",
161
+ "controller.overview.node_id": "Node ID",
162
+ "controller.overview.product": "Product",
163
+ "controller.overview.properties": "Properties",
164
+ "controller.overview.saved": "Saved.",
165
+ "controller.overview.serial": "Serial number",
166
+ "controller.overview.software": "Software",
167
+ "controller.overview.spec_version": "Specification version {version}",
168
+ "controller.overview.subscribe": "Subscribe",
169
+ "controller.overview.unsubscribe": "Stop the subscription",
170
+ "controller.overview.vendor": "Vendor",
171
+ "controller.settings.cap.ble": "Bluetooth LE",
172
+ "controller.settings.cap.credentials": "Credential store",
173
+ "controller.settings.cap.ota": "OTA provider",
174
+ "controller.settings.capabilities": "Capabilities",
175
+ "controller.settings.col_id": "ID",
176
+ "controller.settings.col_kind": "Kind",
177
+ "controller.settings.col_name": "Name",
178
+ "controller.settings.compressed_fabric_id": "Compressed fabric ID",
179
+ "controller.settings.credential_add": "Store",
180
+ "controller.settings.credential_remove": "Remove this credential?",
181
+ "controller.settings.credential_saved": "The credential is stored.",
182
+ "controller.settings.credentials": "Network credentials",
183
+ "controller.settings.credentials_empty": "No credential stored",
184
+ "controller.settings.dataset": "Operational dataset (hex)",
185
+ "controller.settings.fabric_id": "Fabric ID",
186
+ "controller.settings.info": "Controller",
187
+ "controller.settings.label": "Fabric label",
188
+ "controller.settings.label_hint": "Up to 32 characters; the nodes get it in the background.",
189
+ "controller.settings.label_saved": "The label is set.",
190
+ "controller.settings.no_store": "The host stores no network credentials.",
191
+ "controller.settings.node_id": "Node ID",
192
+ "controller.settings.nodes": "Nodes",
193
+ "controller.settings.passphrase": "Passphrase",
194
+ "controller.settings.ssid": "SSID",
195
+ "controller.settings.vendor": "Vendor",
196
+ "controller.settings.write_only": "A passphrase or dataset is written only; it is never shown again.",
197
+ "controller.subtitle": "The nodes this controller commissioned",
198
+ "controller.tab.commission": "Commission",
199
+ "controller.tab.nodes": "Nodes",
200
+ "controller.tab.settings": "Settings",
201
+ "controller.title": "Controller",
202
+ "controller.updates.check": "Check for updates",
203
+ "controller.updates.consent": "Update to {version}",
204
+ "controller.updates.consented": "The update is queued.",
205
+ "controller.updates.none_available": "No update available.",
206
+ "controller.updates.queue_empty": "No update queued for this node",
207
+ "controller.updates.status.in_progress": "In progress",
208
+ "controller.updates.status.queued": "Queued",
209
+ "controller.updates.status.stalled": "Stalled",
210
+ "controller.updates.unavailable": "The controller runs no OTA provider",
211
+ "controller.updates.version": "Version {version}",
212
+ "controller.window.close": "Close window",
213
+ "controller.window.closed": "The window is closed.",
214
+ "controller.window.discriminator": "Discriminator",
215
+ "controller.window.hint": "Open a window so another administrator (another ecosystem) can commission this node too.",
216
+ "controller.window.manual_code": "Manual code",
217
+ "controller.window.minutes": "{minutes} minutes",
218
+ "controller.window.open": "Open window",
219
+ "controller.window.qr_code": "QR payload",
220
+ "controller.window.seconds": "{seconds} s",
221
+ "controller.window.timeout": "Open for",
15
222
  "node.commissioning.closed": "Commissioning window closed",
16
223
  "node.diag.age_hours": "{n}h",
17
224
  "node.diag.age_minutes": "{n}min",
@@ -156,5 +363,9 @@
156
363
  "node.warnings.summary": "{count} configuration warnings",
157
364
  "node.warnings.title": "Configuration warnings",
158
365
  "select.placeholder": "Select…",
159
- "shell.language": "Language"
366
+ "shell.families": "Page families",
367
+ "shell.family.controller": "Controller",
368
+ "shell.family.node": "Node",
369
+ "shell.language": "Language",
370
+ "shell.no_family": "This host serves neither a node nor a controller."
160
371
  }
package/src/lib/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./pages";
5
5
  export * from "./context";
6
6
  export * from "./api";
7
7
  export { createNodeStore, errorText, type NodeStore, type Commissioning, type CommissioningPhase } from "./store.svelte";
8
+ export { createControllerStore, type ControllerStore, type CommissionProgress, type CommissionPhase } from "./controller-store.svelte";
8
9
  export { catalogs, createTranslator, defaultT, detectLocale, format, catalogKeys, type Catalog, type Translate, type Vars } from "./i18n";
9
10
  export * from "./theme";
10
11
  export { renderQrSvg } from "./qr";
@@ -0,0 +1,180 @@
1
+ <!-- Commissioning: a QR payload or a manual pairing code, or a device a
2
+ discovery found and its passcode; the stored credential that puts a
3
+ Wi-Fi or Thread device on its network; the progress from the stream
4
+ and the result. The camera is not used (wave U2). The code and the
5
+ passcode go to the admin route only and are cleared after the call. -->
6
+ <script lang="ts">
7
+ import { onMount } from "svelte";
8
+ import { useFabricUI } from "../../context";
9
+ import { errorText } from "../../store.svelte";
10
+ import type { CommissionRequest, ControllerNodeDetail, Discovered } from "../../api/client";
11
+ import type { DataColumn } from "../../kit/data-table";
12
+ import Badge from "../../kit/Badge.svelte";
13
+ import Button from "../../kit/Button.svelte";
14
+ import Card from "../../kit/Card.svelte";
15
+ import DataTable from "../../kit/DataTable.svelte";
16
+ import Input from "../../kit/Input.svelte";
17
+ import Select from "../../kit/Select.svelte";
18
+ import Spinner from "../../kit/Spinner.svelte";
19
+ import { nodeName, vendorLabel } from "./format";
20
+
21
+ const { t, toast, client, controller, routes } = useFabricUI();
22
+
23
+ let code = $state("");
24
+ let network = $state("");
25
+ let networkOnly = $state(false);
26
+ let running = $state(false);
27
+ let result = $state<ControllerNodeDetail | null>(null);
28
+ let failure = $state<string | null>(null);
29
+
30
+ let devices = $state<Discovered[]>([]);
31
+ let searching = $state(false);
32
+ let searched = $state(false);
33
+ let passcodes = $state<Record<string, string>>({});
34
+
35
+ onMount(() => {
36
+ controller.resetProgress();
37
+ if (controller.status?.capabilities.credentials) void controller.loadCredentials();
38
+ });
39
+
40
+ const networkOptions = $derived([
41
+ { value: "", label: t("controller.commission.network_none") },
42
+ ...controller.credentials.map((c) => ({ value: `${c.kind}:${c.id}`, label: `${t(`controller.credential.kind_${c.kind}`)} · ${c.name}` })),
43
+ ]);
44
+
45
+ function withNetwork(req: CommissionRequest): CommissionRequest {
46
+ const [kind, ...rest] = network.split(":");
47
+ const id = rest.join(":");
48
+ if (kind === "wifi") req.wifi_id = id;
49
+ if (kind === "thread") req.thread_id = id;
50
+ if (networkOnly) req.network_only = true;
51
+ return req;
52
+ }
53
+
54
+ async function commission(req: CommissionRequest) {
55
+ running = true;
56
+ result = null;
57
+ failure = null;
58
+ try {
59
+ result = await client.commission(withNetwork(req));
60
+ toast.success(t("controller.commission.done", { name: nodeName(result.node) }));
61
+ void controller.loadNodes();
62
+ } catch (err) {
63
+ failure = errorText(err);
64
+ } finally {
65
+ running = false;
66
+ }
67
+ }
68
+
69
+ async function byCode() {
70
+ const c = code.trim();
71
+ if (!c) return;
72
+ await commission({ code: c });
73
+ code = "";
74
+ }
75
+
76
+ async function search() {
77
+ searching = true;
78
+ try {
79
+ devices = (await client.discover({ timeout: 5 })).devices;
80
+ searched = true;
81
+ } catch (err) {
82
+ toast.error(errorText(err));
83
+ } finally {
84
+ searching = false;
85
+ }
86
+ }
87
+
88
+ async function pair(d: Discovered) {
89
+ const pin = Number((passcodes[d.instance_name] ?? "").trim());
90
+ if (!Number.isInteger(pin) || pin < 1 || pin > 99999998) {
91
+ toast.error(t("controller.commission.passcode_invalid"));
92
+ return;
93
+ }
94
+ await commission({ discovered: { instance_name: d.instance_name, long_discriminator: d.long_discriminator }, passcode: pin });
95
+ passcodes = { ...passcodes, [d.instance_name]: "" };
96
+ devices = devices.filter((x) => x.instance_name !== d.instance_name);
97
+ }
98
+
99
+ const columns: DataColumn<Discovered>[] = $derived([
100
+ { key: "device", label: t("controller.commission.col_device"), title: true, get: (d) => d.device_name || d.instance_name },
101
+ { key: "vendor", label: t("controller.commission.col_vendor"), get: (d) => vendorLabel(d.vendor_name, d.vendor_id) },
102
+ { key: "discriminator", label: t("controller.commission.col_discriminator"), numeric: true, get: (d) => d.long_discriminator },
103
+ { key: "addresses", label: t("controller.commission.col_addresses"), get: (d) => d.addresses.join(" ") },
104
+ { key: "action", label: "", align: "right", cellClass: "reflow-actions" },
105
+ ]);
106
+
107
+ const phase = $derived(controller.progress.phase);
108
+ </script>
109
+
110
+ <div class="space-y-6">
111
+ <Card class="p-4">
112
+ <h3 class="mb-1 font-medium text-[var(--ha-primary-text-color)]">{t("controller.commission.by_code")}</h3>
113
+ <p class="mb-3 text-sm text-[var(--ha-secondary-text-color)]">{t("controller.commission.by_code_hint")}</p>
114
+ <form class="flex flex-wrap items-center gap-3" onsubmit={(e) => { e.preventDefault(); void byCode(); }}>
115
+ <Input class="max-w-sm font-mono" bind:value={code} placeholder={t("controller.commission.code_placeholder")} aria-label={t("controller.commission.code")} autocomplete="off" />
116
+ <Button type="submit" disabled={running || !code.trim()}>{t("controller.commission.start")}</Button>
117
+ </form>
118
+ <div class="mt-3 flex flex-wrap items-center gap-3">
119
+ <Select class="w-auto" ariaLabel={t("controller.commission.network")} value={network} onValueChange={(v) => (network = v as typeof network)} options={networkOptions} />
120
+ <label class="inline-flex items-center gap-2 text-sm text-[var(--ha-secondary-text-color)]">
121
+ <input type="checkbox" bind:checked={networkOnly} />
122
+ {t("controller.commission.network_only")}
123
+ </label>
124
+ </div>
125
+ </Card>
126
+
127
+ {#if running || phase !== "idle" || result || failure}
128
+ <Card class="p-4" data-testid="commission-progress">
129
+ {#if running}
130
+ <span class="inline-flex items-center gap-2 text-sm text-[var(--ha-secondary-text-color)]"><Spinner size={16} />{t("controller.commission.running")}</span>
131
+ {:else if result}
132
+ <p class="text-sm text-[var(--ha-primary-text-color)]">
133
+ <Badge variant="success">{t("controller.commission.succeeded")}</Badge>
134
+ <a class="ml-2 font-medium text-[var(--ha-primary-color)] hover:underline" href={routes.controllerNode + result.node.node_id_hex}>{nodeName(result.node)}</a>
135
+ </p>
136
+ {:else if failure}
137
+ <p class="text-sm text-[var(--ha-error-color)]"><Badge variant="danger">{t("controller.commission.failed")}</Badge> {failure}</p>
138
+ {:else if phase === "failed"}
139
+ <p class="text-sm text-[var(--ha-error-color)]">{t("controller.commission.failed")}</p>
140
+ {/if}
141
+ </Card>
142
+ {/if}
143
+
144
+ <Card class="p-4">
145
+ <div class="mb-3 flex flex-wrap items-center gap-3">
146
+ <h3 class="font-medium text-[var(--ha-primary-text-color)]">{t("controller.commission.discovery")}</h3>
147
+ <Button variant="outline" size="sm" disabled={searching} onclick={() => void search()}>
148
+ {searching ? t("controller.commission.searching") : t("controller.commission.search")}
149
+ </Button>
150
+ </div>
151
+ {#if searched}
152
+ <DataTable rows={devices} {columns} rowKey={(d) => d.instance_name} emptyMessage={t("controller.commission.none_found")}>
153
+ {#snippet cell(d, col)}
154
+ {#if col.key === "device"}
155
+ <span class="text-[var(--ha-primary-text-color)]">{d.device_name || d.instance_name}</span>
156
+ {:else if col.key === "vendor"}
157
+ <span class="text-[var(--ha-secondary-text-color)]">{vendorLabel(d.vendor_name, d.vendor_id)}</span>
158
+ {:else if col.key === "discriminator"}
159
+ <span class="font-mono text-xs">{d.long_discriminator}</span>
160
+ {:else if col.key === "addresses"}
161
+ <span class="font-mono text-xs text-[var(--ha-secondary-text-color)]">{d.addresses.join(", ")}</span>
162
+ {:else if col.key === "action"}
163
+ <span class="inline-flex items-center gap-2">
164
+ <Input
165
+ class="w-32 font-mono"
166
+ inputmode="numeric"
167
+ autocomplete="off"
168
+ aria-label={t("controller.commission.passcode")}
169
+ placeholder={t("controller.commission.passcode")}
170
+ value={passcodes[d.instance_name] ?? ""}
171
+ oninput={(e) => (passcodes = { ...passcodes, [d.instance_name]: (e.currentTarget as HTMLInputElement).value })}
172
+ />
173
+ <Button size="sm" disabled={running} onclick={() => void pair(d)}>{t("controller.commission.pair")}</Button>
174
+ </span>
175
+ {/if}
176
+ {/snippet}
177
+ </DataTable>
178
+ {/if}
179
+ </Card>
180
+ </div>
@@ -0,0 +1,91 @@
1
+ <!-- One node: its record and properties, endpoints, a targeted attribute
2
+ reader, ACL, bindings, a commissioning window for another
3
+ administrator, ICD, the fabrics on it and its pending updates — one
4
+ tab each. The page reloads the node when the stream names it. -->
5
+ <script lang="ts">
6
+ import { untrack } from "svelte";
7
+ import { useFabricUI } from "../../context";
8
+ import { errorText } from "../../store.svelte";
9
+ import type { ControllerNodeDetail } from "../../api/client";
10
+ import ErrorState from "../../kit/ErrorState.svelte";
11
+ import LoadingState from "../../kit/LoadingState.svelte";
12
+ import Tabs from "../../kit/Tabs.svelte";
13
+ import { nodeName } from "./format";
14
+ import Overview from "./node/Overview.svelte";
15
+ import Endpoints from "./node/Endpoints.svelte";
16
+ import Attributes from "./node/Attributes.svelte";
17
+ import Acl from "./node/Acl.svelte";
18
+ import Bindings from "./node/Bindings.svelte";
19
+ import Window from "./node/Window.svelte";
20
+ import Icd from "./node/Icd.svelte";
21
+ import Fabrics from "./node/Fabrics.svelte";
22
+ import Updates from "./node/Updates.svelte";
23
+
24
+ let { nodeId, tab = "overview" }: { nodeId: string; tab?: string } = $props();
25
+
26
+ const { t, client, controller, routes } = useFabricUI();
27
+
28
+ const TABS = ["overview", "endpoints", "attributes", "acl", "bindings", "window", "icd", "fabrics", "updates"] as const;
29
+ type Tab = (typeof TABS)[number];
30
+ const isTab = (v: string): v is Tab => (TABS as readonly string[]).includes(v);
31
+
32
+ // svelte-ignore state_referenced_locally
33
+ let active = $state<Tab>(isTab(tab) ? tab : "overview");
34
+ let detail = $state<ControllerNodeDetail | null>(null);
35
+ let error = $state<string | null>(null);
36
+
37
+ async function load() {
38
+ error = null;
39
+ try {
40
+ detail = await client.controllerNode(nodeId);
41
+ } catch (err) {
42
+ error = errorText(err);
43
+ }
44
+ }
45
+
46
+ $effect(() => {
47
+ void nodeId;
48
+ untrack(() => void load());
49
+ });
50
+
51
+ // An event about this node reloads it.
52
+ $effect(() => {
53
+ void controller.revision;
54
+ if (untrack(() => controller.changed) === nodeId && untrack(() => detail) !== null) untrack(() => void load());
55
+ });
56
+
57
+ const items = $derived(TABS.map((k) => ({ key: k, label: t(`controller.node.tab.${k}`) })));
58
+ </script>
59
+
60
+ <div class="space-y-4">
61
+ <a href={routes.controllerNodes} class="text-sm text-[var(--ha-primary-color)] hover:underline">← {t("controller.node.back")}</a>
62
+ {#if error}
63
+ <ErrorState message={error} onRetry={() => void load()} />
64
+ {:else if !detail}
65
+ <LoadingState message={t("common.loading")} />
66
+ {:else}
67
+ <h2 class="text-lg font-medium text-[var(--ha-primary-text-color)]" data-testid="node-title">{nodeName(detail.node)}</h2>
68
+ <Tabs {items} {active} onSelect={(k) => (active = isTab(k) ? k : "overview")} ariaLabel={t("controller.node.tabs")} />
69
+ <div class="mt-2">
70
+ {#if active === "overview"}
71
+ <Overview {detail} />
72
+ {:else if active === "endpoints"}
73
+ <Endpoints {detail} />
74
+ {:else if active === "attributes"}
75
+ <Attributes {nodeId} {detail} />
76
+ {:else if active === "acl"}
77
+ <Acl {nodeId} />
78
+ {:else if active === "bindings"}
79
+ <Bindings {nodeId} {detail} />
80
+ {:else if active === "window"}
81
+ <Window {nodeId} />
82
+ {:else if active === "icd"}
83
+ <Icd {nodeId} />
84
+ {:else if active === "fabrics"}
85
+ <Fabrics {nodeId} />
86
+ {:else if active === "updates"}
87
+ <Updates {nodeId} />
88
+ {/if}
89
+ </div>
90
+ {/if}
91
+ </div>