@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.
@@ -0,0 +1,156 @@
1
+ <!-- The commissioned nodes: a table with filters by connection, vendor and
2
+ ICD, and the actions an administrator takes on a node — interview,
3
+ open a window for another administrator (on the node's page),
4
+ disable or enable, decommission. A row opens the node's page. -->
5
+ <script lang="ts">
6
+ import { onMount } from "svelte";
7
+ import { useFabricUI } from "../../context";
8
+ import { errorText } from "../../store.svelte";
9
+ import type { ControllerNodeSummary } from "../../api/client";
10
+ import type { DataColumn } from "../../kit/data-table";
11
+ import Badge from "../../kit/Badge.svelte";
12
+ import Button from "../../kit/Button.svelte";
13
+ import Card from "../../kit/Card.svelte";
14
+ import DataTable from "../../kit/DataTable.svelte";
15
+ import ErrorState from "../../kit/ErrorState.svelte";
16
+ import LoadingState from "../../kit/LoadingState.svelte";
17
+ import Select from "../../kit/Select.svelte";
18
+ import { connectionLabel, connectionVariant, nodeName, vendorLabel } from "./format";
19
+
20
+ const { t, toast, confirm, client, controller, routes } = useFabricUI();
21
+
22
+ onMount(() => {
23
+ void controller.loadNodes();
24
+ });
25
+
26
+ let connection = $state("");
27
+ let vendor = $state("");
28
+ let icd = $state("");
29
+ let busy = $state<string | null>(null);
30
+
31
+ const vendors = $derived([...new Set(controller.nodes.map((n) => vendorLabel(n.vendor_name, n.vendor_id)).filter(Boolean))].sort());
32
+
33
+ const rows = $derived(
34
+ controller.nodes.filter(
35
+ (n) =>
36
+ (!connection || n.connection === connection) &&
37
+ (!vendor || vendorLabel(n.vendor_name, n.vendor_id) === vendor) &&
38
+ (!icd || (icd === "yes") === n.icd),
39
+ ),
40
+ );
41
+
42
+ const connectionOptions = $derived([
43
+ { value: "", label: t("controller.nodes.filter_all") },
44
+ ...(["connected", "reconnecting", "waiting_for_discovery", "disconnected"] as const).map((s) => ({ value: s, label: connectionLabel(t, s) })),
45
+ ]);
46
+ const vendorOptions = $derived([{ value: "", label: t("controller.nodes.filter_all") }, ...vendors.map((v) => ({ value: v, label: v }))]);
47
+ const icdOptions = $derived([
48
+ { value: "", label: t("controller.nodes.filter_all") },
49
+ { value: "yes", label: t("controller.nodes.icd_yes") },
50
+ { value: "no", label: t("controller.nodes.icd_no") },
51
+ ]);
52
+
53
+ function open(n: ControllerNodeSummary) {
54
+ location.hash = routes.controllerNode + n.node_id_hex;
55
+ }
56
+
57
+ async function act(n: ControllerNodeSummary, run: () => Promise<void>, done: string) {
58
+ busy = n.node_id_hex;
59
+ try {
60
+ await run();
61
+ toast.success(t(done));
62
+ await controller.loadNodes();
63
+ } catch (err) {
64
+ toast.error(errorText(err));
65
+ } finally {
66
+ busy = null;
67
+ }
68
+ }
69
+
70
+ async function decommission(n: ControllerNodeSummary) {
71
+ const ok = await confirm({
72
+ title: t("controller.nodes.remove_confirm"),
73
+ body: t("controller.nodes.remove_confirm_body", { name: nodeName(n) }),
74
+ destructive: true,
75
+ confirmLabel: t("common.remove"),
76
+ });
77
+ if (ok) await act(n, () => client.decommissionNode(n.node_id_hex), "controller.nodes.removed");
78
+ }
79
+
80
+ const columns: DataColumn<ControllerNodeSummary>[] = $derived([
81
+ { key: "name", label: t("controller.nodes.col_name"), sortable: true, title: true, get: (n) => nodeName(n) },
82
+ { key: "vendor", label: t("controller.nodes.col_vendor"), sortable: true, get: (n) => vendorLabel(n.vendor_name, n.vendor_id) },
83
+ { key: "node_id", label: t("controller.nodes.col_node_id"), sortable: true, get: (n) => n.node_id_hex },
84
+ { key: "connection", label: t("controller.nodes.col_connection"), sortable: true, get: (n) => n.connection },
85
+ { key: "addresses", label: t("controller.nodes.col_addresses"), get: (n) => n.addresses.join(" ") },
86
+ { key: "action", label: "", align: "right", cellClass: "reflow-actions" },
87
+ ]);
88
+ </script>
89
+
90
+ <div class="space-y-4">
91
+ <div class="flex flex-wrap items-center gap-3">
92
+ <Select class="w-auto" ariaLabel={t("controller.nodes.filter_connection")} value={connection} onValueChange={(v) => (connection = v as typeof connection)} options={connectionOptions} />
93
+ <Select class="w-auto" ariaLabel={t("controller.nodes.filter_vendor")} value={vendor} onValueChange={(v) => (vendor = v as typeof vendor)} options={vendorOptions} />
94
+ <Select class="w-auto" ariaLabel={t("controller.nodes.filter_icd")} value={icd} onValueChange={(v) => (icd = v as typeof icd)} options={icdOptions} />
95
+ <a href={routes.controllerCommission} class="ml-auto text-sm font-medium text-[var(--ha-primary-color)] hover:underline">{t("controller.nodes.commission")}</a>
96
+ </div>
97
+ {#if controller.nodesLoading && controller.nodes.length === 0}
98
+ <LoadingState message={t("common.loading")} />
99
+ {:else if controller.nodesError}
100
+ <ErrorState message={controller.nodesError} onRetry={() => void controller.loadNodes()} />
101
+ {:else}
102
+ <Card class="p-4">
103
+ <DataTable
104
+ {rows}
105
+ {columns}
106
+ rowKey={(n) => n.node_id_hex}
107
+ persistKey="controller-nodes"
108
+ search
109
+ searchPlaceholder={t("common.search")}
110
+ emptyMessage={t("controller.nodes.empty")}
111
+ initialSort={{ key: "name", asc: true }}
112
+ onRowClick={open}
113
+ >
114
+ {#snippet cell(n, col)}
115
+ {#if col.key === "name"}
116
+ <span class="inline-flex flex-wrap items-center gap-1 text-[var(--ha-primary-text-color)]">
117
+ {nodeName(n)}
118
+ {#if n.icd}<Badge variant="muted">{t("controller.nodes.icd")}</Badge>{/if}
119
+ {#if n.disabled}<Badge variant="warning">{t("controller.nodes.disabled")}</Badge>{/if}
120
+ </span>
121
+ {:else if col.key === "vendor"}
122
+ <span class="text-[var(--ha-secondary-text-color)]">{vendorLabel(n.vendor_name, n.vendor_id)}</span>
123
+ {:else if col.key === "node_id"}
124
+ <span class="font-mono text-xs text-[var(--ha-secondary-text-color)]">0x{n.node_id_hex}</span>
125
+ {:else if col.key === "connection"}
126
+ <span class="inline-flex flex-wrap items-center gap-1">
127
+ <Badge variant={connectionVariant(n.connection)}>{connectionLabel(t, n.connection)}</Badge>
128
+ {#if n.subscription_active}<Badge variant="default">{t("controller.nodes.subscribed")}</Badge>{/if}
129
+ </span>
130
+ {:else if col.key === "addresses"}
131
+ <span class="font-mono text-xs text-[var(--ha-secondary-text-color)]">{n.addresses.join(", ")}</span>
132
+ {:else if col.key === "action"}
133
+ <span class="inline-flex flex-wrap justify-end gap-1">
134
+ <Button size="sm" variant="ghost" disabled={busy === n.node_id_hex} onclick={() => void act(n, () => client.interviewNode(n.node_id_hex), "controller.nodes.interviewed")}>
135
+ {t("controller.nodes.interview")}
136
+ </Button>
137
+ <a class="inline-flex items-center px-2 text-sm text-[var(--ha-primary-color)] hover:underline" href={`${routes.controllerNode}${n.node_id_hex}?tab=window`}>{t("controller.nodes.window")}</a>
138
+ {#if n.disabled}
139
+ <Button size="sm" variant="ghost" disabled={busy === n.node_id_hex} onclick={() => void act(n, () => client.enableNode(n.node_id_hex), "controller.nodes.enabled")}>
140
+ {t("controller.nodes.enable")}
141
+ </Button>
142
+ {:else}
143
+ <Button size="sm" variant="ghost" disabled={busy === n.node_id_hex} onclick={() => void act(n, () => client.disableNode(n.node_id_hex), "controller.nodes.disabled_done")}>
144
+ {t("controller.nodes.disable")}
145
+ </Button>
146
+ {/if}
147
+ <Button size="sm" variant="ghost" class="text-[var(--ha-error-color)]" disabled={busy === n.node_id_hex} onclick={() => void decommission(n)}>
148
+ {t("common.remove")}
149
+ </Button>
150
+ </span>
151
+ {/if}
152
+ {/snippet}
153
+ </DataTable>
154
+ </Card>
155
+ {/if}
156
+ </div>
@@ -0,0 +1,160 @@
1
+ <!-- The controller's settings: its fabric label, the stored Wi-Fi and
2
+ Thread credentials (names only; a secret is entered once and never
3
+ shown again) and the controller's identity. -->
4
+ <script lang="ts">
5
+ import { onMount } from "svelte";
6
+ import { useFabricUI } from "../../context";
7
+ import { errorText } from "../../store.svelte";
8
+ import type { Credential, CredentialRequest } from "../../api/client";
9
+ import type { DataColumn } from "../../kit/data-table";
10
+ import Badge from "../../kit/Badge.svelte";
11
+ import Button from "../../kit/Button.svelte";
12
+ import Card from "../../kit/Card.svelte";
13
+ import DataTable from "../../kit/DataTable.svelte";
14
+ import ErrorState from "../../kit/ErrorState.svelte";
15
+ import Input from "../../kit/Input.svelte";
16
+ import Select from "../../kit/Select.svelte";
17
+ import { vendorLabel } from "./format";
18
+
19
+ const { t, toast, confirm, client, controller } = useFabricUI();
20
+
21
+ let label = $state("");
22
+ let kind = $state<Credential["kind"]>("wifi");
23
+ let id = $state("");
24
+ let ssid = $state("");
25
+ let secret = $state("");
26
+ let busy = $state(false);
27
+
28
+ onMount(async () => {
29
+ await controller.loadStatus();
30
+ label = controller.status?.label ?? "";
31
+ if (controller.status?.capabilities.credentials) void controller.loadCredentials();
32
+ });
33
+
34
+ async function saveLabel() {
35
+ busy = true;
36
+ try {
37
+ await client.setFabricLabel(label.trim());
38
+ toast.success(t("controller.settings.label_saved"));
39
+ await controller.loadStatus();
40
+ } catch (err) {
41
+ toast.error(errorText(err));
42
+ } finally {
43
+ busy = false;
44
+ }
45
+ }
46
+
47
+ async function addCredential() {
48
+ const req: CredentialRequest = { kind, id: id.trim() };
49
+ if (kind === "wifi") {
50
+ req.ssid = ssid.trim();
51
+ req.passphrase = secret;
52
+ } else {
53
+ req.dataset_hex = secret.replace(/\s+/g, "");
54
+ }
55
+ busy = true;
56
+ try {
57
+ await client.setCredential(req);
58
+ toast.success(t("controller.settings.credential_saved"));
59
+ id = ssid = secret = "";
60
+ await controller.loadCredentials();
61
+ } catch (err) {
62
+ toast.error(errorText(err));
63
+ } finally {
64
+ busy = false;
65
+ }
66
+ }
67
+
68
+ async function removeCredential(c: Credential) {
69
+ const ok = await confirm({ title: t("controller.settings.credential_remove"), body: c.name, destructive: true, confirmLabel: t("common.remove") });
70
+ if (!ok) return;
71
+ try {
72
+ await client.removeCredential(c.kind, c.id);
73
+ await controller.loadCredentials();
74
+ } catch (err) {
75
+ toast.error(errorText(err));
76
+ }
77
+ }
78
+
79
+ const kindOptions = $derived([
80
+ { value: "wifi", label: t("controller.credential.kind_wifi") },
81
+ { value: "thread", label: t("controller.credential.kind_thread") },
82
+ ]);
83
+
84
+ const columns: DataColumn<Credential>[] = $derived([
85
+ { key: "name", label: t("controller.settings.col_name"), title: true, sortable: true, get: (c) => c.name },
86
+ { key: "kind", label: t("controller.settings.col_kind"), sortable: true, get: (c) => c.kind },
87
+ { key: "id", label: t("controller.settings.col_id"), sortable: true, get: (c) => c.id },
88
+ { key: "action", label: "", align: "right", cellClass: "reflow-actions" },
89
+ ]);
90
+
91
+ const s = $derived(controller.status);
92
+ </script>
93
+
94
+ <div class="space-y-6">
95
+ {#if controller.statusError}
96
+ <ErrorState message={controller.statusError} onRetry={() => void controller.loadStatus()} />
97
+ {/if}
98
+ <Card class="p-4">
99
+ <h3 class="mb-2 font-medium text-[var(--ha-primary-text-color)]">{t("controller.settings.label")}</h3>
100
+ <form class="flex flex-wrap items-center gap-2" onsubmit={(e) => { e.preventDefault(); void saveLabel(); }}>
101
+ <Input class="max-w-xs" bind:value={label} maxlength={32} aria-label={t("controller.settings.label")} />
102
+ <Button type="submit" disabled={busy}>{t("common.save")}</Button>
103
+ </form>
104
+ <p class="mt-1 text-xs text-[var(--ha-secondary-text-color)]">{t("controller.settings.label_hint")}</p>
105
+ </Card>
106
+
107
+ <Card class="p-4" data-testid="credentials">
108
+ <h3 class="mb-2 font-medium text-[var(--ha-primary-text-color)]">{t("controller.settings.credentials")}</h3>
109
+ {#if s && !s.capabilities.credentials}
110
+ <p class="text-sm text-[var(--ha-secondary-text-color)]">{t("controller.settings.no_store")}</p>
111
+ {:else}
112
+ {#if controller.credentialsError}
113
+ <ErrorState message={controller.credentialsError} onRetry={() => void controller.loadCredentials()} />
114
+ {:else}
115
+ <DataTable rows={controller.credentials} {columns} rowKey={(c) => `${c.kind}:${c.id}`} emptyMessage={t("controller.settings.credentials_empty")}>
116
+ {#snippet cell(c, col)}
117
+ {#if col.key === "name"}
118
+ {c.name}
119
+ {:else if col.key === "kind"}
120
+ <Badge variant="muted">{t(`controller.credential.kind_${c.kind}`)}</Badge>
121
+ {:else if col.key === "id"}
122
+ <span class="font-mono text-xs">{c.id}</span>
123
+ {:else if col.key === "action"}
124
+ <Button size="sm" variant="ghost" class="text-[var(--ha-error-color)]" onclick={() => void removeCredential(c)}>{t("common.remove")}</Button>
125
+ {/if}
126
+ {/snippet}
127
+ </DataTable>
128
+ {/if}
129
+ <form class="mt-4 flex flex-wrap items-center gap-2" onsubmit={(e) => { e.preventDefault(); void addCredential(); }}>
130
+ <Select class="w-auto" ariaLabel={t("controller.settings.col_kind")} value={kind} onValueChange={(v) => (kind = v as typeof kind)} options={kindOptions} />
131
+ <Input class="w-32" bind:value={id} placeholder={t("controller.settings.col_id")} aria-label={t("controller.settings.col_id")} />
132
+ {#if kind === "wifi"}
133
+ <Input class="w-40" bind:value={ssid} placeholder={t("controller.settings.ssid")} aria-label={t("controller.settings.ssid")} />
134
+ <Input class="w-48" type="password" autocomplete="new-password" bind:value={secret} placeholder={t("controller.settings.passphrase")} aria-label={t("controller.settings.passphrase")} />
135
+ {:else}
136
+ <Input class="w-72 font-mono" type="password" autocomplete="off" bind:value={secret} placeholder={t("controller.settings.dataset")} aria-label={t("controller.settings.dataset")} />
137
+ {/if}
138
+ <Button type="submit" disabled={busy || !id.trim()}>{t("controller.settings.credential_add")}</Button>
139
+ </form>
140
+ <p class="mt-1 text-xs text-[var(--ha-secondary-text-color)]">{t("controller.settings.write_only")}</p>
141
+ {/if}
142
+ </Card>
143
+
144
+ {#if s}
145
+ <Card class="p-4" data-testid="controller-info">
146
+ <h3 class="mb-2 font-medium text-[var(--ha-primary-text-color)]">{t("controller.settings.info")}</h3>
147
+ <dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
148
+ <dt class="text-[var(--ha-secondary-text-color)]">{t("controller.settings.fabric_id")}</dt><dd class="font-mono">0x{s.fabric_id_hex}</dd>
149
+ <dt class="text-[var(--ha-secondary-text-color)]">{t("controller.settings.compressed_fabric_id")}</dt><dd class="font-mono">{s.compressed_fabric_id_hex}</dd>
150
+ <dt class="text-[var(--ha-secondary-text-color)]">{t("controller.settings.node_id")}</dt><dd class="font-mono">0x{s.node_id_hex}</dd>
151
+ <dt class="text-[var(--ha-secondary-text-color)]">{t("controller.settings.vendor")}</dt><dd>{vendorLabel(s.vendor_name, s.vendor_id)}</dd>
152
+ <dt class="text-[var(--ha-secondary-text-color)]">{t("controller.settings.nodes")}</dt><dd>{s.node_count}</dd>
153
+ <dt class="text-[var(--ha-secondary-text-color)]">{t("controller.settings.capabilities")}</dt>
154
+ <dd class="flex flex-wrap gap-1">
155
+ {#each (["ota", "credentials", "ble"] as const).filter((k) => s.capabilities[k]) as k (k)}<Badge variant="muted">{t(`controller.settings.cap.${k}`)}</Badge>{/each}
156
+ </dd>
157
+ </dl>
158
+ </Card>
159
+ {/if}
160
+ </div>
@@ -0,0 +1,49 @@
1
+ // Helpers the controller pages share: a node's display name, the badge of
2
+ // a connection state, ids as the contract carries them.
3
+ import type { ConnectionState, ControllerNodeSummary } from "../../api/client";
4
+ import type { Translate } from "../../i18n";
5
+
6
+ /** A node's name: its label, else its product, else its id. */
7
+ export function nodeName(n: ControllerNodeSummary): string {
8
+ return n.node_label || n.product_name || `0x${n.node_id_hex}`;
9
+ }
10
+
11
+ /** The vendor of a node or a device: the name the server sends, else the id. */
12
+ export function vendorLabel(name: string | undefined, id: number | undefined): string {
13
+ if (name) return name;
14
+ if (id === undefined) return "";
15
+ return `0x${id.toString(16).toUpperCase().padStart(4, "0")}`;
16
+ }
17
+
18
+ export type BadgeVariant = "default" | "success" | "warning" | "danger" | "muted";
19
+
20
+ /** The badge colour of a connection state. */
21
+ export function connectionVariant(s: ConnectionState): BadgeVariant {
22
+ switch (s) {
23
+ case "connected":
24
+ return "success";
25
+ case "reconnecting":
26
+ return "warning";
27
+ case "waiting_for_discovery":
28
+ return "muted";
29
+ default:
30
+ return "danger";
31
+ }
32
+ }
33
+
34
+ /** A connection state's label. */
35
+ export function connectionLabel(t: Translate, s: ConnectionState): string {
36
+ return t(`controller.connection.${s}`);
37
+ }
38
+
39
+ /** A 32-bit id as "0x0006", the contract's hex form. */
40
+ export function hexId(v: number): string {
41
+ return `0x${v.toString(16).toUpperCase().padStart(4, "0")}`;
42
+ }
43
+
44
+ /** Parses "0x0006", "6" or "0006" as hex; null when it is not hex. */
45
+ export function parseHexId(s: string): number | null {
46
+ const t = s.trim().replace(/^0x/i, "");
47
+ if (!/^[0-9a-f]{1,8}$/i.test(t)) return null;
48
+ return parseInt(t, 16);
49
+ }
@@ -0,0 +1,151 @@
1
+ <!-- The node's access control entries of our fabric: read, edit, write
2
+ back as one list. Saving a list without an Administer entry for this
3
+ controller locks it out; the page asks first. -->
4
+ <script lang="ts">
5
+ import { onMount } from "svelte";
6
+ import { useFabricUI } from "../../../context";
7
+ import { errorText } from "../../../store.svelte";
8
+ import type { AclEntry } from "../../../api/client";
9
+ import Badge from "../../../kit/Badge.svelte";
10
+ import Button from "../../../kit/Button.svelte";
11
+ import Card from "../../../kit/Card.svelte";
12
+ import EmptyState from "../../../kit/EmptyState.svelte";
13
+ import ErrorState from "../../../kit/ErrorState.svelte";
14
+ import Input from "../../../kit/Input.svelte";
15
+ import LoadingState from "../../../kit/LoadingState.svelte";
16
+ import Select from "../../../kit/Select.svelte";
17
+ import { hexId, parseHexId } from "../format";
18
+
19
+ let { nodeId }: { nodeId: string } = $props();
20
+ const { t, toast, confirm, client, controller } = useFabricUI();
21
+
22
+ let entries = $state<AclEntry[] | null>(null);
23
+ let error = $state<string | null>(null);
24
+ let dirty = $state(false);
25
+ let busy = $state(false);
26
+
27
+ let privilege = $state<AclEntry["privilege"]>("operate");
28
+ let authMode = $state<AclEntry["auth_mode"]>("case");
29
+ let subjects = $state("");
30
+ let targetCluster = $state("");
31
+ let targetEndpoint = $state("");
32
+
33
+ const PRIVILEGES = ["view", "proxy_view", "operate", "manage", "administer"] as const;
34
+ const MODES = ["pase", "case", "group"] as const;
35
+
36
+ async function load() {
37
+ error = null;
38
+ try {
39
+ entries = (await client.nodeAcl(nodeId)).entries;
40
+ dirty = false;
41
+ } catch (err) {
42
+ error = errorText(err);
43
+ }
44
+ }
45
+ onMount(() => void load());
46
+
47
+ function add() {
48
+ const subs = subjects.split(/[\s,]+/).filter(Boolean);
49
+ if (subs.some((s) => !/^(0x)?[0-9a-f]{1,16}$/i.test(s))) {
50
+ toast.error(t("controller.acl.invalid_subject"));
51
+ return;
52
+ }
53
+ const target: AclEntry["targets"][number] = {};
54
+ if (targetCluster.trim()) {
55
+ const c = parseHexId(targetCluster);
56
+ if (c === null) {
57
+ toast.error(t("controller.acl.invalid_target"));
58
+ return;
59
+ }
60
+ target.cluster_hex = hexId(c);
61
+ }
62
+ if (targetEndpoint.trim()) target.endpoint = Number(targetEndpoint);
63
+ const e: AclEntry = {
64
+ privilege,
65
+ auth_mode: authMode,
66
+ subjects_hex: subs.map((s) => s.replace(/^0x/i, "").toUpperCase().padStart(16, "0")),
67
+ targets: Object.keys(target).length ? [target] : [],
68
+ };
69
+ entries = [...(entries ?? []), e];
70
+ subjects = targetCluster = targetEndpoint = "";
71
+ dirty = true;
72
+ }
73
+
74
+ function remove(i: number) {
75
+ entries = (entries ?? []).filter((_, j) => j !== i);
76
+ dirty = true;
77
+ }
78
+
79
+ // Does the list keep this controller's Administer access?
80
+ function keepsUs(list: AclEntry[]): boolean {
81
+ const own = controller.status?.node_id_hex;
82
+ return list.some((e) => e.privilege === "administer" && e.auth_mode === "case" && (!own || e.subjects_hex.length === 0 || e.subjects_hex.includes(own)));
83
+ }
84
+
85
+ async function save() {
86
+ if (!entries) return;
87
+ if (!keepsUs(entries)) {
88
+ const ok = await confirm({ title: t("controller.acl.lockout"), body: t("controller.acl.lockout_body"), destructive: true, confirmLabel: t("common.save") });
89
+ if (!ok) return;
90
+ }
91
+ busy = true;
92
+ try {
93
+ const res = await client.setNodeAcl(nodeId, entries);
94
+ if (res.status === 0) toast.success(t("controller.acl.saved"));
95
+ else toast.error(t("controller.attributes.write_status", { status: hexId(res.status) }));
96
+ await load();
97
+ } catch (err) {
98
+ toast.error(errorText(err));
99
+ } finally {
100
+ busy = false;
101
+ }
102
+ }
103
+
104
+ const privilegeOptions = $derived(PRIVILEGES.map((p) => ({ value: p, label: t(`controller.acl.privilege.${p}`) })));
105
+ const modeOptions = $derived(MODES.map((m) => ({ value: m, label: t(`controller.acl.mode.${m}`) })));
106
+ </script>
107
+
108
+ {#if error}
109
+ <ErrorState message={error} onRetry={() => void load()} />
110
+ {:else if !entries}
111
+ <LoadingState message={t("common.loading")} />
112
+ {:else}
113
+ <div class="space-y-4">
114
+ <Card class="p-4">
115
+ {#if entries.length === 0}
116
+ <EmptyState message={t("controller.acl.empty")} />
117
+ {:else}
118
+ <ul class="divide-y divide-[var(--ha-divider-color)]" data-testid="acl-entries">
119
+ {#each entries as e, i (i)}
120
+ <li class="flex flex-wrap items-center gap-2 py-2 text-sm">
121
+ <Badge variant={e.privilege === "administer" ? "warning" : "default"}>{t(`controller.acl.privilege.${e.privilege}`)}</Badge>
122
+ <Badge variant="muted">{t(`controller.acl.mode.${e.auth_mode}`)}</Badge>
123
+ <span class="font-mono text-xs">{e.subjects_hex.length ? e.subjects_hex.map((s) => "0x" + s).join(", ") : t("controller.acl.any_subject")}</span>
124
+ <span class="text-xs text-[var(--ha-secondary-text-color)]">
125
+ {e.targets.length
126
+ ? e.targets.map((x) => [x.endpoint !== undefined ? t("controller.endpoints.endpoint", { number: x.endpoint }) : "", x.cluster_hex ?? "", x.device_type_hex ?? ""].filter(Boolean).join(" ")).join("; ")
127
+ : t("controller.acl.whole_node")}
128
+ </span>
129
+ <Button size="sm" variant="ghost" class="ml-auto text-[var(--ha-error-color)]" onclick={() => remove(i)}>{t("common.remove")}</Button>
130
+ </li>
131
+ {/each}
132
+ </ul>
133
+ {/if}
134
+ </Card>
135
+ <Card class="p-4">
136
+ <h3 class="mb-2 font-medium text-[var(--ha-primary-text-color)]">{t("controller.acl.add")}</h3>
137
+ <div class="flex flex-wrap items-center gap-2">
138
+ <Select class="w-auto" ariaLabel={t("controller.acl.privilege_label")} value={privilege} onValueChange={(v) => (privilege = v as typeof privilege)} options={privilegeOptions} />
139
+ <Select class="w-auto" ariaLabel={t("controller.acl.mode_label")} value={authMode} onValueChange={(v) => (authMode = v as typeof authMode)} options={modeOptions} />
140
+ <Input class="w-64 font-mono" bind:value={subjects} placeholder={t("controller.acl.subjects")} aria-label={t("controller.acl.subjects")} />
141
+ <Input class="w-28 font-mono" bind:value={targetCluster} placeholder={t("controller.acl.target_cluster")} aria-label={t("controller.acl.target_cluster")} />
142
+ <Input class="w-24" bind:value={targetEndpoint} placeholder={t("controller.acl.target_endpoint")} aria-label={t("controller.acl.target_endpoint")} inputmode="numeric" />
143
+ <Button variant="outline" onclick={add}>{t("controller.acl.add_entry")}</Button>
144
+ </div>
145
+ <div class="mt-3 flex gap-2">
146
+ <Button disabled={!dirty || busy} onclick={() => void save()}>{t("common.save")}</Button>
147
+ <Button variant="ghost" disabled={!dirty || busy} onclick={() => void load()}>{t("controller.acl.discard")}</Button>
148
+ </div>
149
+ </Card>
150
+ </div>
151
+ {/if}
@@ -0,0 +1,100 @@
1
+ <!-- A targeted attribute reader: one concrete path at a time (no
2
+ inventory), the value as JSON; an administrator can write the value
3
+ back. -->
4
+ <script lang="ts">
5
+ import { useFabricUI } from "../../../context";
6
+ import { errorText } from "../../../store.svelte";
7
+ import type { AttributeValue, ControllerNodeDetail } from "../../../api/client";
8
+ import Button from "../../../kit/Button.svelte";
9
+ import Card from "../../../kit/Card.svelte";
10
+ import Input from "../../../kit/Input.svelte";
11
+ import Select from "../../../kit/Select.svelte";
12
+ import { hexId, parseHexId } from "../format";
13
+
14
+ let { nodeId, detail }: { nodeId: string; detail: ControllerNodeDetail } = $props();
15
+ const { t, toast, client } = useFabricUI();
16
+
17
+ // svelte-ignore state_referenced_locally
18
+ let endpoint = $state(String(detail.endpoints[0]?.endpoint ?? 0));
19
+ let cluster = $state("");
20
+ let attribute = $state("0x0000");
21
+ let result = $state<AttributeValue | null>(null);
22
+ let edit = $state("");
23
+ let busy = $state(false);
24
+
25
+ const endpointOptions = $derived(detail.endpoints.map((e) => ({ value: String(e.endpoint), label: t("controller.endpoints.endpoint", { number: e.endpoint }) })));
26
+ const clusterOptions = $derived(
27
+ (detail.endpoints.find((e) => String(e.endpoint) === endpoint)?.clusters ?? []).map((c) => ({ value: c.id_hex, label: `${c.name || c.id_hex} (${c.id_hex})` })),
28
+ );
29
+
30
+ function path() {
31
+ const c = parseHexId(cluster);
32
+ const a = parseHexId(attribute);
33
+ if (c === null || a === null) return null;
34
+ return { endpoint: Number(endpoint), cluster_hex: hexId(c), attribute_hex: hexId(a) };
35
+ }
36
+
37
+ async function read() {
38
+ const p = path();
39
+ if (!p) {
40
+ toast.error(t("controller.attributes.invalid_path"));
41
+ return;
42
+ }
43
+ busy = true;
44
+ try {
45
+ const res = await client.readAttributes(nodeId, [p]);
46
+ result = res.values[0] ?? null;
47
+ edit = result && result.status === undefined ? JSON.stringify(result.value) : "";
48
+ } catch (err) {
49
+ toast.error(errorText(err));
50
+ } finally {
51
+ busy = false;
52
+ }
53
+ }
54
+
55
+ async function write() {
56
+ const p = path();
57
+ if (!p) return;
58
+ let value: unknown;
59
+ try {
60
+ value = JSON.parse(edit);
61
+ } catch {
62
+ toast.error(t("controller.attributes.invalid_json"));
63
+ return;
64
+ }
65
+ busy = true;
66
+ try {
67
+ const res = await client.writeAttribute(nodeId, { ...p, value });
68
+ if (res.status === 0) toast.success(t("controller.attributes.written"));
69
+ else toast.error(t("controller.attributes.write_status", { status: hexId(res.status) }));
70
+ await read();
71
+ } catch (err) {
72
+ toast.error(errorText(err));
73
+ } finally {
74
+ busy = false;
75
+ }
76
+ }
77
+ </script>
78
+
79
+ <Card class="p-4">
80
+ <p class="mb-3 text-sm text-[var(--ha-secondary-text-color)]">{t("controller.attributes.hint")}</p>
81
+ <form class="flex flex-wrap items-center gap-2" onsubmit={(e) => { e.preventDefault(); void read(); }}>
82
+ <Select class="w-auto" ariaLabel={t("controller.attributes.endpoint")} value={endpoint} onValueChange={(v) => (endpoint = v as typeof endpoint)} options={endpointOptions} />
83
+ <Select class="w-auto" ariaLabel={t("controller.attributes.cluster")} value={cluster} onValueChange={(v) => (cluster = v as typeof cluster)} options={clusterOptions} />
84
+ <Input class="w-28 font-mono" bind:value={attribute} aria-label={t("controller.attributes.attribute")} placeholder="0x0000" />
85
+ <Button type="submit" disabled={busy || !cluster}>{t("controller.attributes.read")}</Button>
86
+ </form>
87
+ {#if result}
88
+ <div class="mt-4" data-testid="attribute-result">
89
+ {#if result.status !== undefined}
90
+ <p class="text-sm text-[var(--ha-error-color)]">{t("controller.attributes.status", { status: hexId(result.status) })}</p>
91
+ {:else}
92
+ <pre class="overflow-x-auto rounded bg-[var(--ha-secondary-background-color)] p-2 font-mono text-xs">{JSON.stringify(result.value, null, 2)}</pre>
93
+ <div class="mt-2 flex flex-wrap items-center gap-2">
94
+ <Input class="max-w-md font-mono" bind:value={edit} aria-label={t("controller.attributes.value")} />
95
+ <Button variant="outline" disabled={busy} onclick={() => void write()}>{t("controller.attributes.write")}</Button>
96
+ </div>
97
+ {/if}
98
+ </div>
99
+ {/if}
100
+ </Card>