@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.
- package/README.md +14 -1
- package/package.json +1 -1
- package/src/lib/api/client.ts +134 -1
- package/src/lib/api/schema.gen.ts +1838 -264
- package/src/lib/api/stream.ts +40 -1
- package/src/lib/context.ts +15 -2
- package/src/lib/controller-store.svelte.ts +175 -0
- package/src/lib/i18n/de.json +212 -1
- package/src/lib/i18n/en.json +212 -1
- package/src/lib/index.ts +1 -0
- package/src/lib/pages/controller/Commission.svelte +180 -0
- package/src/lib/pages/controller/Node.svelte +91 -0
- package/src/lib/pages/controller/Nodes.svelte +156 -0
- package/src/lib/pages/controller/Settings.svelte +160 -0
- package/src/lib/pages/controller/format.ts +49 -0
- package/src/lib/pages/controller/node/Acl.svelte +151 -0
- package/src/lib/pages/controller/node/Attributes.svelte +100 -0
- package/src/lib/pages/controller/node/Bindings.svelte +114 -0
- package/src/lib/pages/controller/node/Endpoints.svelte +49 -0
- package/src/lib/pages/controller/node/Fabrics.svelte +83 -0
- package/src/lib/pages/controller/node/Icd.svelte +92 -0
- package/src/lib/pages/controller/node/Overview.svelte +81 -0
- package/src/lib/pages/controller/node/Updates.svelte +75 -0
- package/src/lib/pages/controller/node/Window.svelte +80 -0
- package/src/lib/pages/index.ts +7 -1
- package/src/lib/pages/node/Selection.svelte +11 -6
- package/src/lib/pages/node/fields.ts +47 -12
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<!-- The binding targets of every endpoint with a Binding cluster: add a
|
|
2
|
+
unicast (node and endpoint) or group target, remove one, write the
|
|
3
|
+
endpoint's list back. -->
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import { onMount } from "svelte";
|
|
6
|
+
import { useFabricUI } from "../../../context";
|
|
7
|
+
import { errorText } from "../../../store.svelte";
|
|
8
|
+
import type { BindingTarget, ControllerNodeDetail, EndpointBindings } from "../../../api/client";
|
|
9
|
+
import Button from "../../../kit/Button.svelte";
|
|
10
|
+
import Card from "../../../kit/Card.svelte";
|
|
11
|
+
import EmptyState from "../../../kit/EmptyState.svelte";
|
|
12
|
+
import ErrorState from "../../../kit/ErrorState.svelte";
|
|
13
|
+
import Input from "../../../kit/Input.svelte";
|
|
14
|
+
import LoadingState from "../../../kit/LoadingState.svelte";
|
|
15
|
+
import { hexId, parseHexId } from "../format";
|
|
16
|
+
|
|
17
|
+
let { nodeId, detail }: { nodeId: string; detail: ControllerNodeDetail } = $props();
|
|
18
|
+
const { t, toast, client } = useFabricUI();
|
|
19
|
+
|
|
20
|
+
let lists = $state<EndpointBindings[] | null>(null);
|
|
21
|
+
let error = $state<string | null>(null);
|
|
22
|
+
let drafts = $state<Record<number, { target: string; endpoint: string; cluster: string }>>({});
|
|
23
|
+
|
|
24
|
+
const hasBinding = $derived(detail.endpoints.some((e) => e.clusters.some((c) => c.id === 0x001e)));
|
|
25
|
+
|
|
26
|
+
async function load() {
|
|
27
|
+
error = null;
|
|
28
|
+
try {
|
|
29
|
+
lists = (await client.nodeBindings(nodeId)).endpoints;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
error = errorText(err);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
onMount(() => void load());
|
|
35
|
+
|
|
36
|
+
function describe(b: BindingTarget): string {
|
|
37
|
+
const where = b.group !== undefined ? t("controller.bindings.group", { group: b.group }) : `0x${b.node_id_hex ?? ""} · ${t("controller.endpoints.endpoint", { number: b.endpoint ?? 0 })}`;
|
|
38
|
+
return b.cluster_hex ? `${where} · ${b.cluster_hex}` : where;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function write(eb: EndpointBindings, bindings: BindingTarget[]) {
|
|
42
|
+
try {
|
|
43
|
+
const res = await client.setNodeBindings(nodeId, eb.endpoint, bindings.map(({ fabric_index: _f, ...b }) => b));
|
|
44
|
+
if (res.status === 0) toast.success(t("controller.bindings.saved"));
|
|
45
|
+
else toast.error(t("controller.attributes.write_status", { status: hexId(res.status) }));
|
|
46
|
+
await load();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
toast.error(errorText(err));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function add(eb: EndpointBindings) {
|
|
53
|
+
const d = drafts[eb.endpoint] ?? { target: "", endpoint: "", cluster: "" };
|
|
54
|
+
const b: BindingTarget = {};
|
|
55
|
+
const target = d.target.trim();
|
|
56
|
+
if (/^g:\d+$/i.test(target)) b.group = Number(target.slice(2));
|
|
57
|
+
else if (/^(0x)?[0-9a-f]{1,16}$/i.test(target) && d.endpoint.trim()) {
|
|
58
|
+
b.node_id_hex = target.replace(/^0x/i, "").toUpperCase().padStart(16, "0");
|
|
59
|
+
b.endpoint = Number(d.endpoint);
|
|
60
|
+
} else {
|
|
61
|
+
toast.error(t("controller.bindings.invalid"));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (d.cluster.trim()) {
|
|
65
|
+
const c = parseHexId(d.cluster);
|
|
66
|
+
if (c === null) {
|
|
67
|
+
toast.error(t("controller.bindings.invalid"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
b.cluster_hex = hexId(c);
|
|
71
|
+
}
|
|
72
|
+
drafts = { ...drafts, [eb.endpoint]: { target: "", endpoint: "", cluster: "" } };
|
|
73
|
+
await write(eb, [...eb.bindings, b]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function draft(ep: number, key: "target" | "endpoint" | "cluster", value: string) {
|
|
77
|
+
const d = drafts[ep] ?? { target: "", endpoint: "", cluster: "" };
|
|
78
|
+
drafts = { ...drafts, [ep]: { ...d, [key]: value } };
|
|
79
|
+
}
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
{#if !hasBinding}
|
|
83
|
+
<EmptyState message={t("controller.bindings.none")} />
|
|
84
|
+
{:else if error}
|
|
85
|
+
<ErrorState message={error} onRetry={() => void load()} />
|
|
86
|
+
{:else if !lists}
|
|
87
|
+
<LoadingState message={t("common.loading")} />
|
|
88
|
+
{:else}
|
|
89
|
+
<div class="space-y-4">
|
|
90
|
+
{#each lists as eb (eb.endpoint)}
|
|
91
|
+
<Card class="p-4" data-testid={`bindings-${eb.endpoint}`}>
|
|
92
|
+
<h3 class="mb-2 font-medium text-[var(--ha-primary-text-color)]">{t("controller.endpoints.endpoint", { number: eb.endpoint })}</h3>
|
|
93
|
+
{#if eb.bindings.length === 0}
|
|
94
|
+
<p class="text-sm text-[var(--ha-secondary-text-color)]">{t("controller.bindings.empty")}</p>
|
|
95
|
+
{:else}
|
|
96
|
+
<ul class="space-y-1">
|
|
97
|
+
{#each eb.bindings as b, i (i)}
|
|
98
|
+
<li class="flex items-center gap-2 text-sm">
|
|
99
|
+
<span class="font-mono text-xs">{describe(b)}</span>
|
|
100
|
+
<Button size="sm" variant="ghost" class="ml-auto text-[var(--ha-error-color)]" onclick={() => void write(eb, eb.bindings.filter((_, j) => j !== i))}>{t("common.remove")}</Button>
|
|
101
|
+
</li>
|
|
102
|
+
{/each}
|
|
103
|
+
</ul>
|
|
104
|
+
{/if}
|
|
105
|
+
<div class="mt-3 flex flex-wrap items-center gap-2">
|
|
106
|
+
<Input class="w-56 font-mono" placeholder={t("controller.bindings.target")} aria-label={t("controller.bindings.target")} value={drafts[eb.endpoint]?.target ?? ""} oninput={(e) => draft(eb.endpoint, "target", (e.currentTarget as HTMLInputElement).value)} />
|
|
107
|
+
<Input class="w-24" placeholder={t("controller.bindings.endpoint")} aria-label={t("controller.bindings.endpoint")} inputmode="numeric" value={drafts[eb.endpoint]?.endpoint ?? ""} oninput={(e) => draft(eb.endpoint, "endpoint", (e.currentTarget as HTMLInputElement).value)} />
|
|
108
|
+
<Input class="w-28 font-mono" placeholder={t("controller.bindings.cluster")} aria-label={t("controller.bindings.cluster")} value={drafts[eb.endpoint]?.cluster ?? ""} oninput={(e) => draft(eb.endpoint, "cluster", (e.currentTarget as HTMLInputElement).value)} />
|
|
109
|
+
<Button variant="outline" onclick={() => void add(eb)}>{t("controller.bindings.add")}</Button>
|
|
110
|
+
</div>
|
|
111
|
+
</Card>
|
|
112
|
+
{/each}
|
|
113
|
+
</div>
|
|
114
|
+
{/if}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!-- A node's endpoints as a tree: parts under their parent, each with its
|
|
2
|
+
device types and server clusters. -->
|
|
3
|
+
<script lang="ts">
|
|
4
|
+
import { useFabricUI } from "../../../context";
|
|
5
|
+
import type { ControllerNodeDetail, NodeEndpoint } from "../../../api/client";
|
|
6
|
+
import Badge from "../../../kit/Badge.svelte";
|
|
7
|
+
import Card from "../../../kit/Card.svelte";
|
|
8
|
+
import EmptyState from "../../../kit/EmptyState.svelte";
|
|
9
|
+
|
|
10
|
+
let { detail }: { detail: ControllerNodeDetail } = $props();
|
|
11
|
+
const { t } = useFabricUI();
|
|
12
|
+
|
|
13
|
+
// Depth first, parts under their parent, each level in number order.
|
|
14
|
+
const ordered = $derived.by(() => {
|
|
15
|
+
const out: { ep: NodeEndpoint; depth: number }[] = [];
|
|
16
|
+
const children = (parent: number | undefined) => detail.endpoints.filter((e) => e.parent === parent);
|
|
17
|
+
const walk = (ep: NodeEndpoint, depth: number) => {
|
|
18
|
+
out.push({ ep, depth });
|
|
19
|
+
for (const c of children(ep.endpoint)) walk(c, depth + 1);
|
|
20
|
+
};
|
|
21
|
+
for (const top of children(undefined)) walk(top, 0);
|
|
22
|
+
return out;
|
|
23
|
+
});
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
{#if detail.endpoints.length === 0}
|
|
27
|
+
<EmptyState message={t("controller.endpoints.empty")} />
|
|
28
|
+
{:else}
|
|
29
|
+
<div class="space-y-3">
|
|
30
|
+
{#each ordered as { ep, depth } (ep.endpoint)}
|
|
31
|
+
<Card class="p-4" style={`margin-left: ${depth * 1.5}rem`} data-testid={`endpoint-${ep.endpoint}`}>
|
|
32
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
33
|
+
<span class="font-medium text-[var(--ha-primary-text-color)]">{t("controller.endpoints.endpoint", { number: ep.endpoint })}</span>
|
|
34
|
+
{#if ep.name}<span class="text-sm text-[var(--ha-secondary-text-color)]">{ep.name}</span>{/if}
|
|
35
|
+
{#each ep.device_types as dt (dt.id)}
|
|
36
|
+
<Badge title={dt.id_hex}>{dt.name || dt.id_hex}{dt.revision ? ` r${dt.revision}` : ""}</Badge>
|
|
37
|
+
{/each}
|
|
38
|
+
</div>
|
|
39
|
+
{#if ep.clusters.length > 0}
|
|
40
|
+
<ul class="mt-2 flex flex-wrap gap-1">
|
|
41
|
+
{#each ep.clusters as c (c.id)}
|
|
42
|
+
<li><Badge variant="muted" title={`${c.id_hex} r${c.revision}${c.features.length ? " · " + c.features.join(", ") : ""}`}>{c.name || c.id_hex}</Badge></li>
|
|
43
|
+
{/each}
|
|
44
|
+
</ul>
|
|
45
|
+
{/if}
|
|
46
|
+
</Card>
|
|
47
|
+
{/each}
|
|
48
|
+
</div>
|
|
49
|
+
{/if}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!-- Every fabric on the node, ours marked; removing another
|
|
2
|
+
administrator's. Ours goes with decommissioning. -->
|
|
3
|
+
<script lang="ts">
|
|
4
|
+
import { onMount } from "svelte";
|
|
5
|
+
import { useFabricUI } from "../../../context";
|
|
6
|
+
import { errorText } from "../../../store.svelte";
|
|
7
|
+
import type { NodeFabric } from "../../../api/client";
|
|
8
|
+
import type { DataColumn } from "../../../kit/data-table";
|
|
9
|
+
import Badge from "../../../kit/Badge.svelte";
|
|
10
|
+
import Button from "../../../kit/Button.svelte";
|
|
11
|
+
import Card from "../../../kit/Card.svelte";
|
|
12
|
+
import DataTable from "../../../kit/DataTable.svelte";
|
|
13
|
+
import ErrorState from "../../../kit/ErrorState.svelte";
|
|
14
|
+
import LoadingState from "../../../kit/LoadingState.svelte";
|
|
15
|
+
import { vendorLabel } from "../format";
|
|
16
|
+
|
|
17
|
+
let { nodeId }: { nodeId: string } = $props();
|
|
18
|
+
const { t, toast, confirm, client } = useFabricUI();
|
|
19
|
+
|
|
20
|
+
let fabrics = $state<NodeFabric[] | null>(null);
|
|
21
|
+
let error = $state<string | null>(null);
|
|
22
|
+
|
|
23
|
+
async function load() {
|
|
24
|
+
error = null;
|
|
25
|
+
try {
|
|
26
|
+
fabrics = (await client.nodeFabrics(nodeId)).fabrics;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
error = errorText(err);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
onMount(() => void load());
|
|
32
|
+
|
|
33
|
+
async function remove(f: NodeFabric) {
|
|
34
|
+
const ok = await confirm({
|
|
35
|
+
title: t("controller.fabrics.remove_confirm"),
|
|
36
|
+
body: f.label || vendorLabel(f.vendor_name, f.vendor_id),
|
|
37
|
+
destructive: true,
|
|
38
|
+
confirmLabel: t("common.remove"),
|
|
39
|
+
});
|
|
40
|
+
if (!ok) return;
|
|
41
|
+
try {
|
|
42
|
+
await client.removeNodeFabric(nodeId, f.fabric_index);
|
|
43
|
+
toast.success(t("controller.fabrics.removed"));
|
|
44
|
+
await load();
|
|
45
|
+
} catch (err) {
|
|
46
|
+
toast.error(errorText(err));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const columns: DataColumn<NodeFabric>[] = $derived([
|
|
51
|
+
{ key: "vendor", label: t("node.fabrics.col_vendor"), title: true, sortable: true, get: (f) => vendorLabel(f.vendor_name, f.vendor_id) },
|
|
52
|
+
{ key: "label", label: t("node.fabrics.col_label"), sortable: true, get: (f) => f.label },
|
|
53
|
+
{ key: "fabric", label: t("node.fabrics.col_fabric"), sortable: true, get: (f) => f.fabric_index },
|
|
54
|
+
{ key: "fabric_id", label: t("controller.fabrics.col_fabric_id"), get: (f) => f.fabric_id_hex },
|
|
55
|
+
{ key: "action", label: "", align: "right", cellClass: "reflow-actions" },
|
|
56
|
+
]);
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
{#if error}
|
|
60
|
+
<ErrorState message={error} onRetry={() => void load()} />
|
|
61
|
+
{:else if !fabrics}
|
|
62
|
+
<LoadingState message={t("common.loading")} />
|
|
63
|
+
{:else}
|
|
64
|
+
<Card class="p-4">
|
|
65
|
+
<DataTable rows={fabrics} {columns} rowKey={(f) => String(f.fabric_index)} emptyMessage={t("node.fabrics.empty")} initialSort={{ key: "fabric", asc: true }}>
|
|
66
|
+
{#snippet cell(f, col)}
|
|
67
|
+
{#if col.key === "vendor"}
|
|
68
|
+
<span class="inline-flex items-center gap-1">{vendorLabel(f.vendor_name, f.vendor_id)}{#if f.own}<Badge>{t("controller.fabrics.own")}</Badge>{/if}</span>
|
|
69
|
+
{:else if col.key === "label"}
|
|
70
|
+
{f.label}
|
|
71
|
+
{:else if col.key === "fabric"}
|
|
72
|
+
{f.fabric_index}
|
|
73
|
+
{:else if col.key === "fabric_id"}
|
|
74
|
+
<span class="font-mono text-xs">0x{f.fabric_id_hex}</span>
|
|
75
|
+
{:else if col.key === "action"}
|
|
76
|
+
{#if !f.own}
|
|
77
|
+
<Button size="sm" variant="ghost" class="text-[var(--ha-error-color)]" onclick={() => void remove(f)}>{t("common.remove")}</Button>
|
|
78
|
+
{/if}
|
|
79
|
+
{/if}
|
|
80
|
+
{/snippet}
|
|
81
|
+
</DataTable>
|
|
82
|
+
</Card>
|
|
83
|
+
{/if}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<!-- The node's ICD icd; registering this controller as its check-in
|
|
2
|
+
client, or unregistering it. -->
|
|
3
|
+
<script lang="ts">
|
|
4
|
+
import { onMount } from "svelte";
|
|
5
|
+
import { useFabricUI } from "../../../context";
|
|
6
|
+
import { errorText } from "../../../store.svelte";
|
|
7
|
+
import type { IcdState } from "../../../api/client";
|
|
8
|
+
import Badge from "../../../kit/Badge.svelte";
|
|
9
|
+
import Button from "../../../kit/Button.svelte";
|
|
10
|
+
import Card from "../../../kit/Card.svelte";
|
|
11
|
+
import EmptyState from "../../../kit/EmptyState.svelte";
|
|
12
|
+
import ErrorState from "../../../kit/ErrorState.svelte";
|
|
13
|
+
import LoadingState from "../../../kit/LoadingState.svelte";
|
|
14
|
+
|
|
15
|
+
let { nodeId }: { nodeId: string } = $props();
|
|
16
|
+
const { t, toast, confirm, client } = useFabricUI();
|
|
17
|
+
|
|
18
|
+
let icd = $state<IcdState | null>(null);
|
|
19
|
+
let error = $state<string | null>(null);
|
|
20
|
+
let busy = $state(false);
|
|
21
|
+
|
|
22
|
+
async function load() {
|
|
23
|
+
error = null;
|
|
24
|
+
try {
|
|
25
|
+
icd = await client.nodeIcd(nodeId);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
error = errorText(err);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
onMount(() => void load());
|
|
31
|
+
|
|
32
|
+
async function register(allowMultiAdmin = false) {
|
|
33
|
+
busy = true;
|
|
34
|
+
try {
|
|
35
|
+
icd = await client.registerNodeIcd(nodeId, allowMultiAdmin);
|
|
36
|
+
toast.success(t("controller.icd.registered_done"));
|
|
37
|
+
} catch (err) {
|
|
38
|
+
// Other administrators' vendors may not support LIT: ask, then retry.
|
|
39
|
+
if (!allowMultiAdmin && (err as { status?: number }).status === 409) {
|
|
40
|
+
busy = false;
|
|
41
|
+
const ok = await confirm({ title: t("controller.icd.multi_admin"), body: errorText(err), confirmLabel: t("controller.icd.register") });
|
|
42
|
+
if (ok) await register(true);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
toast.error(errorText(err));
|
|
46
|
+
} finally {
|
|
47
|
+
busy = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function unregister(force: boolean) {
|
|
52
|
+
busy = true;
|
|
53
|
+
try {
|
|
54
|
+
icd = await client.unregisterNodeIcd(nodeId, force);
|
|
55
|
+
toast.success(t("controller.icd.unregistered_done"));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
toast.error(errorText(err));
|
|
58
|
+
} finally {
|
|
59
|
+
busy = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
{#if error}
|
|
65
|
+
<ErrorState message={error} onRetry={() => void load()} />
|
|
66
|
+
{:else if !icd}
|
|
67
|
+
<LoadingState message={t("common.loading")} />
|
|
68
|
+
{:else if !icd.supported}
|
|
69
|
+
<EmptyState message={t("controller.icd.unsupported")} />
|
|
70
|
+
{:else}
|
|
71
|
+
<Card class="p-4" data-testid="node-icd">
|
|
72
|
+
<div class="flex flex-wrap gap-1">
|
|
73
|
+
<Badge variant={icd.registered ? "success" : "muted"}>{icd.registered ? t("controller.icd.registered") : t("controller.icd.unregistered")}</Badge>
|
|
74
|
+
{#if icd.operating_mode}<Badge>{t(`controller.icd.mode.${icd.operating_mode}`)}</Badge>{/if}
|
|
75
|
+
{#if icd.lit_supported}<Badge variant="muted">{t("controller.icd.lit_supported")}</Badge>{/if}
|
|
76
|
+
{#if icd.awake}<Badge variant="success">{t("controller.icd.awake")}</Badge>{/if}
|
|
77
|
+
{#if icd.available === false}<Badge variant="warning">{t("controller.icd.unavailable")}</Badge>{/if}
|
|
78
|
+
</div>
|
|
79
|
+
<dl class="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
|
|
80
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.icd.next_check_in")}</dt><dd>{icd.next_check_in ? new Date(icd.next_check_in).toLocaleString() : "–"}</dd>
|
|
81
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.icd.last_check_in")}</dt><dd>{icd.last_check_in ? new Date(icd.last_check_in).toLocaleString() : "–"}</dd>
|
|
82
|
+
</dl>
|
|
83
|
+
<div class="mt-3 flex flex-wrap gap-2">
|
|
84
|
+
{#if icd.registered}
|
|
85
|
+
<Button variant="outline" disabled={busy} onclick={() => void unregister(false)}>{t("controller.icd.unregister")}</Button>
|
|
86
|
+
<Button variant="outline-destructive" disabled={busy} onclick={() => void unregister(true)}>{t("controller.icd.forget")}</Button>
|
|
87
|
+
{:else}
|
|
88
|
+
<Button disabled={busy} onclick={() => void register()}>{t("controller.icd.register")}</Button>
|
|
89
|
+
{/if}
|
|
90
|
+
</div>
|
|
91
|
+
</Card>
|
|
92
|
+
{/if}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<!-- A node's record, properties and connection. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { useFabricUI } from "../../../context";
|
|
4
|
+
import { errorText } from "../../../store.svelte";
|
|
5
|
+
import type { ControllerNodeDetail } from "../../../api/client";
|
|
6
|
+
import Badge from "../../../kit/Badge.svelte";
|
|
7
|
+
import Button from "../../../kit/Button.svelte";
|
|
8
|
+
import Card from "../../../kit/Card.svelte";
|
|
9
|
+
import { connectionLabel, connectionVariant, vendorLabel } from "../format";
|
|
10
|
+
|
|
11
|
+
let { detail }: { detail: ControllerNodeDetail } = $props();
|
|
12
|
+
const { t, toast, client } = useFabricUI();
|
|
13
|
+
|
|
14
|
+
const n = $derived(detail.node);
|
|
15
|
+
const r = $derived(detail.record);
|
|
16
|
+
const p = $derived(detail.properties);
|
|
17
|
+
|
|
18
|
+
const flags = $derived(
|
|
19
|
+
(
|
|
20
|
+
[
|
|
21
|
+
["supports_thread", p.supports_thread],
|
|
22
|
+
["supports_wifi", p.supports_wifi],
|
|
23
|
+
["supports_ethernet", p.supports_ethernet],
|
|
24
|
+
["mains_powered", p.mains_powered],
|
|
25
|
+
["battery_powered", p.battery_powered],
|
|
26
|
+
["intermittently_connected", p.intermittently_connected],
|
|
27
|
+
["thread_sleepy_end_device", p.thread_sleepy_end_device ?? false],
|
|
28
|
+
] as const
|
|
29
|
+
).filter(([, on]) => on).map(([k]) => k),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
let busy = $state(false);
|
|
33
|
+
async function setAutoSubscribe(on: boolean) {
|
|
34
|
+
busy = true;
|
|
35
|
+
try {
|
|
36
|
+
await client.setNodeAutoSubscribe(n.node_id_hex, on);
|
|
37
|
+
toast.success(t("controller.overview.saved"));
|
|
38
|
+
} catch (err) {
|
|
39
|
+
toast.error(errorText(err));
|
|
40
|
+
} finally {
|
|
41
|
+
busy = false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<div class="grid gap-4 md:grid-cols-2">
|
|
47
|
+
<Card class="p-4" data-testid="node-overview">
|
|
48
|
+
<h3 class="mb-2 font-medium text-[var(--ha-primary-text-color)]">{t("controller.overview.node")}</h3>
|
|
49
|
+
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
|
|
50
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.overview.node_id")}</dt><dd class="font-mono">0x{n.node_id_hex}</dd>
|
|
51
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.overview.vendor")}</dt><dd>{vendorLabel(n.vendor_name, n.vendor_id)}</dd>
|
|
52
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.overview.product")}</dt><dd>{n.product_name ?? ""}</dd>
|
|
53
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.overview.software")}</dt><dd>{p.software_version ?? ""}</dd>
|
|
54
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.overview.serial")}</dt><dd>{p.serial_number ?? ""}</dd>
|
|
55
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.overview.connection")}</dt>
|
|
56
|
+
<dd><Badge variant={connectionVariant(n.connection)}>{connectionLabel(t, n.connection)}</Badge>
|
|
57
|
+
{#if n.subscription_active}<Badge>{t("controller.nodes.subscribed")}</Badge>{/if}
|
|
58
|
+
{#if n.disabled}<Badge variant="warning">{t("controller.nodes.disabled")}</Badge>{/if}</dd>
|
|
59
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.overview.addresses")}</dt><dd class="font-mono text-xs">{r.addresses.join(", ")}</dd>
|
|
60
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.overview.fabric_index")}</dt><dd>{r.fabric_index_on_peer ?? "–"}</dd>
|
|
61
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.overview.commissioned_at")}</dt><dd>{r.commissioned_at ? new Date(r.commissioned_at).toLocaleString() : "–"}</dd>
|
|
62
|
+
</dl>
|
|
63
|
+
<div class="mt-3 flex flex-wrap gap-2">
|
|
64
|
+
<Button size="sm" variant="outline" disabled={busy} onclick={() => void setAutoSubscribe(!n.subscription_active)}>
|
|
65
|
+
{n.subscription_active ? t("controller.overview.unsubscribe") : t("controller.overview.subscribe")}
|
|
66
|
+
</Button>
|
|
67
|
+
</div>
|
|
68
|
+
</Card>
|
|
69
|
+
<Card class="p-4">
|
|
70
|
+
<h3 class="mb-2 font-medium text-[var(--ha-primary-text-color)]">{t("controller.overview.properties")}</h3>
|
|
71
|
+
<div class="flex flex-wrap gap-1">
|
|
72
|
+
{#each flags as f (f)}<Badge variant="muted">{t(`controller.overview.flag.${f}`)}</Badge>{/each}
|
|
73
|
+
</div>
|
|
74
|
+
{#if p.idle_mode_duration_seconds}
|
|
75
|
+
<p class="mt-2 text-sm text-[var(--ha-secondary-text-color)]">{t("controller.overview.idle_mode", { seconds: p.idle_mode_duration_seconds })}</p>
|
|
76
|
+
{/if}
|
|
77
|
+
{#if p.specification_version}
|
|
78
|
+
<p class="mt-1 text-sm text-[var(--ha-secondary-text-color)]">{t("controller.overview.spec_version", { version: `0x${p.specification_version.toString(16).toUpperCase().padStart(8, "0")}` })}</p>
|
|
79
|
+
{/if}
|
|
80
|
+
</Card>
|
|
81
|
+
</div>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<!-- The node's entries of the update queue, an available update to
|
|
2
|
+
consent to, and the check. -->
|
|
3
|
+
<script lang="ts">
|
|
4
|
+
import { onMount } from "svelte";
|
|
5
|
+
import { useFabricUI } from "../../../context";
|
|
6
|
+
import { errorText } from "../../../store.svelte";
|
|
7
|
+
import type { AvailableUpdate } from "../../../api/client";
|
|
8
|
+
import Badge from "../../../kit/Badge.svelte";
|
|
9
|
+
import Button from "../../../kit/Button.svelte";
|
|
10
|
+
import Card from "../../../kit/Card.svelte";
|
|
11
|
+
import EmptyState from "../../../kit/EmptyState.svelte";
|
|
12
|
+
|
|
13
|
+
let { nodeId }: { nodeId: string } = $props();
|
|
14
|
+
const { t, toast, client, controller } = useFabricUI();
|
|
15
|
+
|
|
16
|
+
let available = $state<AvailableUpdate[]>([]);
|
|
17
|
+
let busy = $state(false);
|
|
18
|
+
|
|
19
|
+
onMount(() => void controller.loadUpdates());
|
|
20
|
+
|
|
21
|
+
const queue = $derived((controller.updates?.queue ?? []).filter((u) => u.node_id_hex === nodeId));
|
|
22
|
+
|
|
23
|
+
async function check() {
|
|
24
|
+
busy = true;
|
|
25
|
+
try {
|
|
26
|
+
available = (await client.checkUpdates()).updates.filter((u) => u.node_id_hex === nodeId);
|
|
27
|
+
if (available.length === 0) toast.info(t("controller.updates.none_available"));
|
|
28
|
+
} catch (err) {
|
|
29
|
+
toast.error(errorText(err));
|
|
30
|
+
} finally {
|
|
31
|
+
busy = false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function consent(u: AvailableUpdate) {
|
|
36
|
+
busy = true;
|
|
37
|
+
try {
|
|
38
|
+
await client.consentUpdate({ node_id_hex: u.node_id_hex, vendor_id: u.vendor_id, product_id: u.product_id, target_software_version: u.software_version });
|
|
39
|
+
toast.success(t("controller.updates.consented"));
|
|
40
|
+
available = available.filter((a) => a !== u);
|
|
41
|
+
await controller.loadUpdates();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
toast.error(errorText(err));
|
|
44
|
+
} finally {
|
|
45
|
+
busy = false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
{#if controller.updates && !controller.updates.available}
|
|
51
|
+
<EmptyState message={t("controller.updates.unavailable")} />
|
|
52
|
+
{:else}
|
|
53
|
+
<Card class="p-4" data-testid="node-updates">
|
|
54
|
+
{#if queue.length === 0}
|
|
55
|
+
<p class="text-sm text-[var(--ha-secondary-text-color)]">{t("controller.updates.queue_empty")}</p>
|
|
56
|
+
{:else}
|
|
57
|
+
<ul class="space-y-1 text-sm">
|
|
58
|
+
{#each queue as u (u.target_software_version)}
|
|
59
|
+
<li class="flex flex-wrap items-center gap-2">
|
|
60
|
+
<span>{t("controller.updates.version", { version: u.target_software_version })}</span>
|
|
61
|
+
<Badge>{t(`controller.updates.status.${u.status}`)}</Badge>
|
|
62
|
+
{#if u.progress_status}<span class="text-xs text-[var(--ha-secondary-text-color)]">{u.progress_status}</span>{/if}
|
|
63
|
+
{#if u.transferred !== undefined && u.length}<span class="text-xs">{Math.round((u.transferred / u.length) * 100)}%</span>{/if}
|
|
64
|
+
</li>
|
|
65
|
+
{/each}
|
|
66
|
+
</ul>
|
|
67
|
+
{/if}
|
|
68
|
+
<div class="mt-3 flex flex-wrap items-center gap-2">
|
|
69
|
+
<Button variant="outline" disabled={busy} onclick={() => void check()}>{t("controller.updates.check")}</Button>
|
|
70
|
+
{#each available as u (u.software_version)}
|
|
71
|
+
<Button disabled={busy} onclick={() => void consent(u)}>{t("controller.updates.consent", { version: u.software_version_string || String(u.software_version) })}</Button>
|
|
72
|
+
{/each}
|
|
73
|
+
</div>
|
|
74
|
+
</Card>
|
|
75
|
+
{/if}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<!-- A commissioning window on the node for another administrator: the
|
|
2
|
+
codes, the QR code (renderQrSvg) and the timeout; closing it. The
|
|
3
|
+
passcode stays on this page; it is not stored. -->
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import { useFabricUI } from "../../../context";
|
|
6
|
+
import { errorText } from "../../../store.svelte";
|
|
7
|
+
import { renderQrSvg } from "../../../qr";
|
|
8
|
+
import type { NodeWindow } from "../../../api/client";
|
|
9
|
+
import Button from "../../../kit/Button.svelte";
|
|
10
|
+
import Card from "../../../kit/Card.svelte";
|
|
11
|
+
import Select from "../../../kit/Select.svelte";
|
|
12
|
+
|
|
13
|
+
let { nodeId }: { nodeId: string } = $props();
|
|
14
|
+
const { t, toast, client } = useFabricUI();
|
|
15
|
+
|
|
16
|
+
let win = $state<NodeWindow | null>(null);
|
|
17
|
+
let timeout = $state("900");
|
|
18
|
+
let busy = $state(false);
|
|
19
|
+
|
|
20
|
+
const options = $derived(["180", "300", "600", "900"].map((s) => ({ value: s, label: t("controller.window.minutes", { minutes: Number(s) / 60 }) })));
|
|
21
|
+
const qrSvg = $derived(win ? renderQrSvg(win.qr_code) : null);
|
|
22
|
+
|
|
23
|
+
async function open() {
|
|
24
|
+
busy = true;
|
|
25
|
+
try {
|
|
26
|
+
win = await client.openNodeWindow(nodeId, Number(timeout));
|
|
27
|
+
} catch (err) {
|
|
28
|
+
toast.error(errorText(err));
|
|
29
|
+
} finally {
|
|
30
|
+
busy = false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function close() {
|
|
35
|
+
busy = true;
|
|
36
|
+
try {
|
|
37
|
+
await client.closeNodeWindow(nodeId);
|
|
38
|
+
win = null;
|
|
39
|
+
toast.success(t("controller.window.closed"));
|
|
40
|
+
} catch (err) {
|
|
41
|
+
toast.error(errorText(err));
|
|
42
|
+
} finally {
|
|
43
|
+
busy = false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function copy(v: string) {
|
|
48
|
+
try {
|
|
49
|
+
await navigator.clipboard.writeText(v);
|
|
50
|
+
toast.success(t("node.pair.copied"));
|
|
51
|
+
} catch {
|
|
52
|
+
toast.error(t("node.pair.copy_failed"));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<Card class="p-4">
|
|
58
|
+
<p class="mb-3 text-sm text-[var(--ha-secondary-text-color)]">{t("controller.window.hint")}</p>
|
|
59
|
+
{#if !win}
|
|
60
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
61
|
+
<Select class="w-auto" ariaLabel={t("controller.window.timeout")} value={timeout} onValueChange={(v) => (timeout = v as typeof timeout)} {options} />
|
|
62
|
+
<Button disabled={busy} onclick={() => void open()}>{t("controller.window.open")}</Button>
|
|
63
|
+
</div>
|
|
64
|
+
{:else}
|
|
65
|
+
<div class="flex flex-wrap gap-6" data-testid="node-window">
|
|
66
|
+
<div class="w-48">{@html qrSvg}</div>
|
|
67
|
+
<dl class="grid grid-cols-[auto_1fr] content-start gap-x-4 gap-y-1 text-sm">
|
|
68
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.window.manual_code")}</dt>
|
|
69
|
+
<dd class="font-mono">{win.manual_code} <Button size="sm" variant="ghost" onclick={() => void copy(win!.manual_code)}>{t("common.copy")}</Button></dd>
|
|
70
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.window.qr_code")}</dt>
|
|
71
|
+
<dd class="font-mono text-xs">{win.qr_code}</dd>
|
|
72
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.window.discriminator")}</dt>
|
|
73
|
+
<dd>{win.discriminator}</dd>
|
|
74
|
+
<dt class="text-[var(--ha-secondary-text-color)]">{t("controller.window.timeout")}</dt>
|
|
75
|
+
<dd>{t("controller.window.seconds", { seconds: win.timeout_seconds })}</dd>
|
|
76
|
+
</dl>
|
|
77
|
+
</div>
|
|
78
|
+
<Button class="mt-4" variant="outline-destructive" disabled={busy} onclick={() => void close()}>{t("controller.window.close")}</Button>
|
|
79
|
+
{/if}
|
|
80
|
+
</Card>
|
package/src/lib/pages/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The page families. Each page reads the fabric-ui context
|
|
2
2
|
// (provideFabricUI) and renders nothing host-specific.
|
|
3
3
|
export { default as NodeStatus } from "./node/Status.svelte";
|
|
4
4
|
export { default as NodePair } from "./node/Pair.svelte";
|
|
@@ -6,3 +6,9 @@ export { default as NodeFabrics } from "./node/Fabrics.svelte";
|
|
|
6
6
|
export { default as NodeSelection } from "./node/Selection.svelte";
|
|
7
7
|
export { default as NodeEndpoints } from "./node/Endpoints.svelte";
|
|
8
8
|
export { default as NodeDiagnostics } from "./node/Diagnostics.svelte";
|
|
9
|
+
// The controller family (wave U2). ControllerNode takes the node's
|
|
10
|
+
// 16-digit hex id as `nodeId` and, optionally, the first `tab`.
|
|
11
|
+
export { default as ControllerNodes } from "./controller/Nodes.svelte";
|
|
12
|
+
export { default as ControllerCommission } from "./controller/Commission.svelte";
|
|
13
|
+
export { default as ControllerNode } from "./controller/Node.svelte";
|
|
14
|
+
export { default as ControllerSettings } from "./controller/Settings.svelte";
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import LoadingState from "../../kit/LoadingState.svelte";
|
|
28
28
|
import Select from "../../kit/Select.svelte";
|
|
29
29
|
import HostWarnings from "./HostWarnings.svelte";
|
|
30
|
-
import { distinctValues, fieldMatches,
|
|
30
|
+
import { distinctValues, fieldMatches, itemValues } from "./fields";
|
|
31
31
|
|
|
32
32
|
const { t, toast, node } = useFabricUI();
|
|
33
33
|
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
const out: ValueFilter[] = schema.filters.map((f) => ({ field: f.field, label: f.label, multi: !!f.multi, options: f.options }));
|
|
65
65
|
for (const col of schema.columns) {
|
|
66
66
|
if (!col.filter || out.some((f) => f.field === col.key)) continue;
|
|
67
|
-
const values = distinctValues(node.items
|
|
67
|
+
const values = distinctValues(node.items, col.key, !!col.multi);
|
|
68
68
|
out.push({ field: col.key, label: col.label, multi: !!col.multi, options: values.map((v) => ({ value: v, label: v })) });
|
|
69
69
|
}
|
|
70
70
|
return out;
|
|
@@ -83,11 +83,11 @@
|
|
|
83
83
|
const match = makeTextMatcher(search);
|
|
84
84
|
return node.items.filter((item) => {
|
|
85
85
|
for (const [field, value] of Object.entries(filterValues)) {
|
|
86
|
-
if (value !== ALL && !fieldMatches(item
|
|
86
|
+
if (value !== ALL && !fieldMatches(item, field, value, multiField.has(field))) return false;
|
|
87
87
|
}
|
|
88
88
|
if (!search.trim()) return true;
|
|
89
89
|
const it = current(item);
|
|
90
|
-
return [it.name, it.group, it.verdict.device_type_name, ...Object.values(it.fields)].some((v) => v && match(v));
|
|
90
|
+
return [it.name, it.group, it.verdict.device_type_name, ...Object.values(it.fields), ...Object.values(it.lists ?? {}).flat()].some((v) => v && match(v));
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
@@ -184,7 +184,12 @@
|
|
|
184
184
|
{ key: "select", label: t("node.selection.col_select") },
|
|
185
185
|
{ key: "state", label: t("node.selection.col_state"), align: "center", get: (i) => stateInfo(i).label },
|
|
186
186
|
{ key: "name", label: t("node.selection.col_name"), sortable: true, title: true, get: (i) => current(i).name },
|
|
187
|
-
...(schema?.columns ?? []).map((c) => ({
|
|
187
|
+
...(schema?.columns ?? []).map((c) => ({
|
|
188
|
+
key: "field:" + c.key,
|
|
189
|
+
label: c.label,
|
|
190
|
+
sortable: true,
|
|
191
|
+
get: (i: Item) => (c.multi ? itemValues(i, c.key).join(", ") : (i.fields[c.key] ?? "")),
|
|
192
|
+
})),
|
|
188
193
|
{ key: "device_type", label: t("node.selection.col_device_type"), sortable: true, get: (i) => i.verdict.device_type_name || "—" },
|
|
189
194
|
]);
|
|
190
195
|
</script>
|
|
@@ -228,7 +233,7 @@
|
|
|
228
233
|
{:else if col.key === "device_type"}
|
|
229
234
|
<span class="text-[var(--ha-secondary-text-color)]">{item.verdict.device_type_name || "—"}</span>
|
|
230
235
|
{:else if multiColumn.has(col.key)}
|
|
231
|
-
{@const values =
|
|
236
|
+
{@const values = itemValues(item, col.key.slice("field:".length))}
|
|
232
237
|
{#if values.length === 0}
|
|
233
238
|
<span class="text-[var(--ha-secondary-text-color)]">—</span>
|
|
234
239
|
{:else}
|