@sukramj/fabric-ui 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +91 -2
- package/package.json +92 -4
- package/src/lib/api/client.ts +154 -0
- package/src/lib/api/index.ts +3 -0
- package/src/lib/api/schema.gen.ts +1159 -0
- package/src/lib/api/stream.ts +54 -0
- package/src/lib/context.ts +96 -0
- package/src/lib/i18n/de.json +157 -0
- package/src/lib/i18n/en.json +157 -0
- package/src/lib/i18n/index.ts +50 -0
- package/src/lib/index.ts +10 -0
- package/src/lib/kit/Badge.svelte +26 -0
- package/src/lib/kit/Button.svelte +49 -0
- package/src/lib/kit/Card.svelte +16 -0
- package/src/lib/kit/DataTable.svelte +239 -0
- package/src/lib/kit/EmptyState.svelte +24 -0
- package/src/lib/kit/ErrorState.svelte +22 -0
- package/src/lib/kit/Icon.svelte +12 -0
- package/src/lib/kit/Input.svelte +22 -0
- package/src/lib/kit/LoadingState.svelte +15 -0
- package/src/lib/kit/PageHeader.svelte +32 -0
- package/src/lib/kit/PageShell.svelte +16 -0
- package/src/lib/kit/Select.svelte +49 -0
- package/src/lib/kit/Spinner.svelte +59 -0
- package/src/lib/kit/Tabs.svelte +63 -0
- package/src/lib/kit/data-table.ts +24 -0
- package/src/lib/kit/icons.ts +41 -0
- package/src/lib/kit/index.ts +21 -0
- package/src/lib/kit/tabs.ts +13 -0
- package/src/lib/kit/utils.ts +49 -0
- package/src/lib/pages/index.ts +8 -0
- package/src/lib/pages/node/Diagnostics.svelte +205 -0
- package/src/lib/pages/node/Endpoints.svelte +82 -0
- package/src/lib/pages/node/Fabrics.svelte +169 -0
- package/src/lib/pages/node/Pair.svelte +186 -0
- package/src/lib/pages/node/Selection.svelte +388 -0
- package/src/lib/pages/node/Status.svelte +63 -0
- package/src/lib/qr.ts +58 -0
- package/src/lib/store.svelte.ts +293 -0
- package/src/lib/theme/ha-bridge.ts +156 -0
- package/src/lib/theme/index.ts +1 -0
- package/src/lib/theme/tokens.css +292 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Class composition and text matching for the kit. Ported from
|
|
2
|
+
// openccu-loom assets/ui/src/lib/utils.ts:1-65 at b40b289a.
|
|
3
|
+
import { clsx, type ClassValue } from "clsx";
|
|
4
|
+
import { twMerge } from "tailwind-merge";
|
|
5
|
+
|
|
6
|
+
/** Tailwind class composition: conditional classes, conflicts resolved. */
|
|
7
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
8
|
+
return twMerge(clsx(inputs));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A case-insensitive predicate over a free-text search term. The literal
|
|
13
|
+
* substring always matches; when the term is also a valid regular
|
|
14
|
+
* expression it matches by that too, so `lamp|light` finds both while a
|
|
15
|
+
* pasted name with parentheses still finds itself.
|
|
16
|
+
*/
|
|
17
|
+
export function makeTextMatcher(term: string): (text: string) => boolean {
|
|
18
|
+
const q = term.trim();
|
|
19
|
+
if (!q) return () => true;
|
|
20
|
+
let re: RegExp | null = null;
|
|
21
|
+
try {
|
|
22
|
+
re = new RegExp(q, "i");
|
|
23
|
+
} catch {
|
|
24
|
+
re = null;
|
|
25
|
+
}
|
|
26
|
+
const lower = q.toLowerCase();
|
|
27
|
+
return (text: string) => {
|
|
28
|
+
if (text.toLowerCase().includes(lower)) return true;
|
|
29
|
+
return re ? re.test(text) : false;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Reads a string from localStorage, `fallback` when absent or unavailable. */
|
|
34
|
+
export function loadLS(key: string, fallback = ""): string {
|
|
35
|
+
try {
|
|
36
|
+
return localStorage.getItem(key) ?? fallback;
|
|
37
|
+
} catch {
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Best-effort write to localStorage. */
|
|
43
|
+
export function saveLS(key: string, value: string): void {
|
|
44
|
+
try {
|
|
45
|
+
localStorage.setItem(key, value);
|
|
46
|
+
} catch {
|
|
47
|
+
// storage unavailable — the value simply does not persist.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The node page family. Each page reads the fabric-ui context
|
|
2
|
+
// (provideFabricUI) and renders nothing host-specific.
|
|
3
|
+
export { default as NodeStatus } from "./node/Status.svelte";
|
|
4
|
+
export { default as NodePair } from "./node/Pair.svelte";
|
|
5
|
+
export { default as NodeFabrics } from "./node/Fabrics.svelte";
|
|
6
|
+
export { default as NodeSelection } from "./node/Selection.svelte";
|
|
7
|
+
export { default as NodeEndpoints } from "./node/Endpoints.svelte";
|
|
8
|
+
export { default as NodeDiagnostics } from "./node/Diagnostics.svelte";
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<!-- Diagnostics: discovery (the live DNS-SD advertisement and its
|
|
2
|
+
findings), the connected controllers and the session-id occupancy,
|
|
3
|
+
ecosystem compatibility, and the recent events. Ported from openccu-loom
|
|
4
|
+
assets/ui/src/routes/matter/MatterDiagnostics.svelte:1-352 at b40b289a;
|
|
5
|
+
the endpoint inspector moved to the Endpoints page. -->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { onMount } from "svelte";
|
|
8
|
+
import { useFabricUI } from "../../context";
|
|
9
|
+
import { errorText } from "../../store.svelte";
|
|
10
|
+
import type { Compatibility, DiagnosticEvent, Mdns, Occupancy, Session } 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 EmptyState from "../../kit/EmptyState.svelte";
|
|
17
|
+
import ErrorState from "../../kit/ErrorState.svelte";
|
|
18
|
+
import LoadingState from "../../kit/LoadingState.svelte";
|
|
19
|
+
|
|
20
|
+
const { t, client } = useFabricUI();
|
|
21
|
+
|
|
22
|
+
let loading = $state(true);
|
|
23
|
+
let error = $state<string | null>(null);
|
|
24
|
+
let sessions = $state<Session[]>([]);
|
|
25
|
+
let occupancy = $state<Occupancy | null>(null);
|
|
26
|
+
let mdns = $state<Mdns | null>(null);
|
|
27
|
+
let compat = $state<Compatibility | null>(null);
|
|
28
|
+
let events = $state<DiagnosticEvent[]>([]);
|
|
29
|
+
|
|
30
|
+
async function load() {
|
|
31
|
+
loading = true;
|
|
32
|
+
error = null;
|
|
33
|
+
try {
|
|
34
|
+
const [s, m, c, ev] = await Promise.all([client.sessions(), client.mdns(), client.compatibility(), client.events()]);
|
|
35
|
+
sessions = s.sessions;
|
|
36
|
+
occupancy = s.occupancy;
|
|
37
|
+
mdns = m;
|
|
38
|
+
compat = c;
|
|
39
|
+
events = ev.events;
|
|
40
|
+
} catch (err) {
|
|
41
|
+
error = errorText(err);
|
|
42
|
+
} finally {
|
|
43
|
+
loading = false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
onMount(load);
|
|
48
|
+
|
|
49
|
+
function age(seconds: number): string {
|
|
50
|
+
if (seconds < 60) return t("node.diag.age_seconds", { n: seconds });
|
|
51
|
+
if (seconds < 3600) return t("node.diag.age_minutes", { n: Math.floor(seconds / 60) });
|
|
52
|
+
return t("node.diag.age_hours", { n: Math.floor(seconds / 3600) });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A controller that stopped talking leaves its session open, so the
|
|
56
|
+
// peer-side age separates a live controller from a departed one; under
|
|
57
|
+
// five minutes is ordinary quiet (loom MatterDiagnostics.svelte:60-70).
|
|
58
|
+
function peerVariant(seconds: number): "success" | "warning" | "danger" {
|
|
59
|
+
if (seconds < 300) return "success";
|
|
60
|
+
if (seconds < 1800) return "warning";
|
|
61
|
+
return "danger";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const errorFindings = $derived((mdns?.findings ?? []).filter((f) => f.severity === "error"));
|
|
65
|
+
const warningFindings = $derived((mdns?.findings ?? []).filter((f) => f.severity === "warning"));
|
|
66
|
+
|
|
67
|
+
const sessionColumns: DataColumn<Session>[] = $derived([
|
|
68
|
+
{ key: "session_id", label: t("node.diag.col_session"), sortable: true, numeric: true, align: "left", get: (s) => s.session_id },
|
|
69
|
+
{ key: "fabric_index", label: t("node.diag.col_fabric"), sortable: true, numeric: true, get: (s) => s.fabric_index },
|
|
70
|
+
// Sorted on the seconds, not the rendered age.
|
|
71
|
+
{ key: "peer_idle", label: t("node.diag.col_peer_idle"), sortable: true, get: (s) => s.peer_idle_seconds },
|
|
72
|
+
{ key: "subscriptions", label: t("node.diag.col_subscriptions"), sortable: true, numeric: true, get: (s) => s.subscriptions },
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const eventClass = (sev: string) =>
|
|
76
|
+
sev === "error"
|
|
77
|
+
? "bg-[color-mix(in_srgb,var(--ha-error-color)_15%,transparent)] text-[var(--ha-error-color)]"
|
|
78
|
+
: sev === "warning"
|
|
79
|
+
? "bg-[color-mix(in_srgb,var(--ha-warning-color)_15%,transparent)] text-[var(--ha-warning-color)]"
|
|
80
|
+
: "bg-[var(--ha-secondary-background-color)] text-[var(--ha-secondary-text-color)]";
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
{#snippet sessionCell(s: Session, col: DataColumn<Session>)}
|
|
84
|
+
{#if col.key === "session_id"}
|
|
85
|
+
{s.session_id}
|
|
86
|
+
{#if s.is_pase}<Badge variant="muted">{t("node.diag.pase")}</Badge>{/if}
|
|
87
|
+
{:else if col.key === "fabric_index"}
|
|
88
|
+
{s.fabric_index}
|
|
89
|
+
{:else if col.key === "peer_idle"}
|
|
90
|
+
<Badge variant={peerVariant(s.peer_idle_seconds)}>{age(s.peer_idle_seconds)}</Badge>
|
|
91
|
+
{:else if col.key === "subscriptions"}
|
|
92
|
+
{s.subscriptions}
|
|
93
|
+
{#if s.subscriptions === 0 && !s.is_pase}
|
|
94
|
+
<span class="ml-2 text-xs text-[var(--ha-warning-color)]">{t("node.diag.no_subscriptions")}</span>
|
|
95
|
+
{/if}
|
|
96
|
+
{/if}
|
|
97
|
+
{/snippet}
|
|
98
|
+
|
|
99
|
+
<div class="space-y-6">
|
|
100
|
+
<div class="flex items-center justify-between">
|
|
101
|
+
<h2 class="text-lg font-semibold text-[var(--ha-primary-text-color)]">{t("node.diag.title")}</h2>
|
|
102
|
+
<Button variant="outline" onclick={() => void load()} disabled={loading}>{t("common.refresh")}</Button>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{#if loading}
|
|
106
|
+
<LoadingState />
|
|
107
|
+
{:else if error}
|
|
108
|
+
<ErrorState message={error} onRetry={() => void load()} />
|
|
109
|
+
{:else}
|
|
110
|
+
<Card class="p-4">
|
|
111
|
+
<h3 class="font-medium text-[var(--ha-primary-text-color)]">{t("node.diag.discovery")}</h3>
|
|
112
|
+
{#if !mdns?.advertising}
|
|
113
|
+
<p class="mt-2 text-sm text-[var(--ha-secondary-text-color)]">{t("node.diag.not_advertising")}</p>
|
|
114
|
+
{:else}
|
|
115
|
+
{#if errorFindings.length === 0 && warningFindings.length === 0}
|
|
116
|
+
<p class="mt-2 text-sm text-[var(--ha-success-color)]">{t("node.diag.discovery_ok")}</p>
|
|
117
|
+
{/if}
|
|
118
|
+
<ul class="mt-2 space-y-2">
|
|
119
|
+
{#each [...errorFindings, ...warningFindings] as finding (finding.code + (finding.service ?? ""))}
|
|
120
|
+
<li class="flex flex-wrap items-start gap-2 text-sm">
|
|
121
|
+
<Badge variant={finding.severity === "error" ? "danger" : "warning"}>{t(`node.diag.severity.${finding.severity}`)}</Badge>
|
|
122
|
+
<span class="min-w-[16rem] flex-1 text-[var(--ha-primary-text-color)]">{finding.message}</span>
|
|
123
|
+
</li>
|
|
124
|
+
{/each}
|
|
125
|
+
</ul>
|
|
126
|
+
<div class="mt-3 space-y-1 text-xs text-[var(--ha-secondary-text-color)]">
|
|
127
|
+
{#each mdns.services as svc (svc.service_type + svc.instance_name)}
|
|
128
|
+
<div>
|
|
129
|
+
<span class="font-mono">{svc.service_type}</span>
|
|
130
|
+
· {t("node.diag.port")} {svc.port} · {svc.addresses.join(", ")}
|
|
131
|
+
{#if svc.subtypes.length > 0}· {svc.subtypes.join(" ")}{/if}
|
|
132
|
+
</div>
|
|
133
|
+
{/each}
|
|
134
|
+
</div>
|
|
135
|
+
{/if}
|
|
136
|
+
</Card>
|
|
137
|
+
|
|
138
|
+
<Card class="p-4">
|
|
139
|
+
<h3 class="font-medium text-[var(--ha-primary-text-color)]">{t("node.diag.sessions")}</h3>
|
|
140
|
+
<p class="mt-1 text-xs text-[var(--ha-secondary-text-color)]">{t("node.diag.sessions_hint")}</p>
|
|
141
|
+
<!-- An id staked by a handshake that never completed holds its slot
|
|
142
|
+
and appears in no row below; this line makes a filling table visible. -->
|
|
143
|
+
{#if occupancy}
|
|
144
|
+
<p class="mt-1 text-xs text-[var(--ha-secondary-text-color)]" data-testid="occupancy">
|
|
145
|
+
{t("node.diag.sessions_occupancy", { live: occupancy.live, reserved: occupancy.reserved, free: occupancy.free, capacity: occupancy.capacity })}
|
|
146
|
+
</p>
|
|
147
|
+
{/if}
|
|
148
|
+
<div class="mt-3">
|
|
149
|
+
<DataTable
|
|
150
|
+
rows={sessions}
|
|
151
|
+
columns={sessionColumns}
|
|
152
|
+
rowKey={(s) => String(s.session_id)}
|
|
153
|
+
cell={sessionCell}
|
|
154
|
+
persistKey="node-sessions"
|
|
155
|
+
initialSort={{ key: "session_id", asc: true }}
|
|
156
|
+
emptyMessage={t("node.diag.no_sessions")}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
</Card>
|
|
160
|
+
|
|
161
|
+
{#if compat}
|
|
162
|
+
<Card class="p-4">
|
|
163
|
+
<h3 class="font-medium text-[var(--ha-primary-text-color)]">{t("node.diag.compatibility")}</h3>
|
|
164
|
+
<div class="mt-2 flex flex-wrap gap-2">
|
|
165
|
+
{#each compat.ecosystems as eco (eco.fabric_index)}
|
|
166
|
+
<Badge variant="muted">{t(`node.diag.ecosystem.${eco.ecosystem}`)}</Badge>
|
|
167
|
+
{/each}
|
|
168
|
+
</div>
|
|
169
|
+
{#if compat.findings.length === 0}
|
|
170
|
+
<p class="mt-3 text-sm text-[var(--ha-success-color)]">{t("node.diag.compat_ok")}</p>
|
|
171
|
+
{:else}
|
|
172
|
+
<ul class="mt-3 space-y-2">
|
|
173
|
+
{#each compat.findings as finding (finding.ecosystem + finding.code)}
|
|
174
|
+
<li class="flex flex-wrap items-start gap-2 text-sm">
|
|
175
|
+
<Badge variant="warning">{t(`node.diag.ecosystem.${finding.ecosystem}`)}</Badge>
|
|
176
|
+
<span class="min-w-[16rem] flex-1 text-[var(--ha-primary-text-color)]">{finding.message}</span>
|
|
177
|
+
</li>
|
|
178
|
+
{/each}
|
|
179
|
+
</ul>
|
|
180
|
+
{/if}
|
|
181
|
+
</Card>
|
|
182
|
+
{/if}
|
|
183
|
+
|
|
184
|
+
<!-- Inside the loaded branch: "nothing recorded" is a claim about the
|
|
185
|
+
node and must not be printed under a failed fetch. -->
|
|
186
|
+
<Card class="p-4">
|
|
187
|
+
<h3 class="font-medium text-[var(--ha-primary-text-color)]">{t("node.diag.events")}</h3>
|
|
188
|
+
<p class="mt-1 text-xs text-[var(--ha-secondary-text-color)]">{t("node.diag.events_hint")}</p>
|
|
189
|
+
{#if events.length === 0}
|
|
190
|
+
<EmptyState class="mt-3" message={t("node.diag.events_empty")} />
|
|
191
|
+
{:else}
|
|
192
|
+
<ul class="mt-3 space-y-2">
|
|
193
|
+
<!-- Keyed by position: two events can share second and message. -->
|
|
194
|
+
{#each events as event, i (i)}
|
|
195
|
+
<li class="flex gap-3 text-sm" data-testid="event">
|
|
196
|
+
<span class="shrink-0 font-mono text-xs text-[var(--ha-secondary-text-color)]">{new Date(event.at).toLocaleTimeString()}</span>
|
|
197
|
+
<span class="shrink-0 rounded px-1.5 py-0.5 text-xs {eventClass(event.severity)}">{t(`node.diag.kind_${event.kind}`)}</span>
|
|
198
|
+
<span class="text-[var(--ha-primary-text-color)]">{event.message}</span>
|
|
199
|
+
</li>
|
|
200
|
+
{/each}
|
|
201
|
+
</ul>
|
|
202
|
+
{/if}
|
|
203
|
+
</Card>
|
|
204
|
+
{/if}
|
|
205
|
+
</div>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<!-- The bridged endpoints as a controller sees them, without commissioning
|
|
2
|
+
one first. Ported from the endpoint card of openccu-loom
|
|
3
|
+
assets/ui/src/routes/matter/MatterDiagnostics.svelte:229-260 at
|
|
4
|
+
b40b289a: the host's name for the endpoint, its parent, its device
|
|
5
|
+
type and clusters. -->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { onMount } from "svelte";
|
|
8
|
+
import { useFabricUI } from "../../context";
|
|
9
|
+
import { errorText } from "../../store.svelte";
|
|
10
|
+
import type { Endpoint } from "../../api/client";
|
|
11
|
+
import Badge from "../../kit/Badge.svelte";
|
|
12
|
+
import Button from "../../kit/Button.svelte";
|
|
13
|
+
import Card from "../../kit/Card.svelte";
|
|
14
|
+
import EmptyState from "../../kit/EmptyState.svelte";
|
|
15
|
+
import ErrorState from "../../kit/ErrorState.svelte";
|
|
16
|
+
import LoadingState from "../../kit/LoadingState.svelte";
|
|
17
|
+
|
|
18
|
+
const { t, client } = useFabricUI();
|
|
19
|
+
|
|
20
|
+
let loading = $state(true);
|
|
21
|
+
let error = $state<string | null>(null);
|
|
22
|
+
let endpoints = $state<Endpoint[]>([]);
|
|
23
|
+
|
|
24
|
+
async function load() {
|
|
25
|
+
loading = true;
|
|
26
|
+
error = null;
|
|
27
|
+
try {
|
|
28
|
+
endpoints = (await client.endpoints()).endpoints;
|
|
29
|
+
} catch (err) {
|
|
30
|
+
error = errorText(err);
|
|
31
|
+
} finally {
|
|
32
|
+
loading = false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
onMount(load);
|
|
37
|
+
|
|
38
|
+
const hex = (n: number, w = 4) => `0x${n.toString(16).toUpperCase().padStart(w, "0")}`;
|
|
39
|
+
const topLevel = $derived(new Set(endpoints.map((e) => e.endpoint_id)));
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<Card class="p-4">
|
|
43
|
+
<div class="flex items-center justify-between gap-2">
|
|
44
|
+
<h3 class="font-medium text-[var(--ha-primary-text-color)]">{t("node.endpoints.title")}</h3>
|
|
45
|
+
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>{t("common.refresh")}</Button>
|
|
46
|
+
</div>
|
|
47
|
+
<p class="mt-1 text-xs text-[var(--ha-secondary-text-color)]">{t("node.endpoints.hint")}</p>
|
|
48
|
+
{#if loading}
|
|
49
|
+
<LoadingState />
|
|
50
|
+
{:else if error}
|
|
51
|
+
<ErrorState class="mt-3" message={error} onRetry={() => void load()} />
|
|
52
|
+
{:else if endpoints.length === 0}
|
|
53
|
+
<EmptyState class="mt-3" message={t("node.endpoints.empty")} />
|
|
54
|
+
{:else}
|
|
55
|
+
<ul class="mt-3 space-y-2">
|
|
56
|
+
{#each endpoints as ep (ep.endpoint_id)}
|
|
57
|
+
<li class="rounded border border-[var(--ha-divider-color)] p-3" data-testid="endpoint">
|
|
58
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
59
|
+
<span class="font-mono text-sm text-[var(--ha-secondary-text-color)]">#{ep.endpoint_id}</span>
|
|
60
|
+
<span class="font-medium text-[var(--ha-primary-text-color)]">{ep.name}</span>
|
|
61
|
+
<Badge variant={ep.reachable ? "success" : "muted"}>
|
|
62
|
+
{ep.reachable ? t("node.endpoints.reachable") : t("node.endpoints.unreachable")}
|
|
63
|
+
</Badge>
|
|
64
|
+
<span class="text-xs text-[var(--ha-secondary-text-color)]">{ep.device_type_name || hex(ep.device_type)}</span>
|
|
65
|
+
{#if topLevel.has(ep.parent_endpoint_id)}
|
|
66
|
+
<span class="text-xs text-[var(--ha-secondary-text-color)]">{t("node.endpoints.part_of", { id: ep.parent_endpoint_id })}</span>
|
|
67
|
+
{/if}
|
|
68
|
+
</div>
|
|
69
|
+
{#if ep.clusters.length > 0}
|
|
70
|
+
<div class="mt-2 flex flex-wrap gap-1">
|
|
71
|
+
{#each ep.clusters as cluster (cluster.id)}
|
|
72
|
+
<span class="rounded bg-[var(--ha-secondary-background-color)] px-2 py-0.5 text-xs text-[var(--ha-secondary-text-color)]">
|
|
73
|
+
{cluster.name || hex(cluster.id)}
|
|
74
|
+
</span>
|
|
75
|
+
{/each}
|
|
76
|
+
</div>
|
|
77
|
+
{/if}
|
|
78
|
+
</li>
|
|
79
|
+
{/each}
|
|
80
|
+
</ul>
|
|
81
|
+
{/if}
|
|
82
|
+
</Card>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<!-- The commissioned fabrics, removing one, and the maintenance actions
|
|
2
|
+
(reassemble, remove all pairings). Ported from openccu-loom
|
|
3
|
+
assets/ui/src/routes/matter/MatterFabrics.svelte:1-209 at b40b289a:
|
|
4
|
+
the vendor name from the server, the node id from its exact hex twin,
|
|
5
|
+
the confirmations before every destructive action. -->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { onMount } from "svelte";
|
|
8
|
+
import { useFabricUI } from "../../context";
|
|
9
|
+
import { errorText } from "../../store.svelte";
|
|
10
|
+
import type { Fabric } 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 ErrorState from "../../kit/ErrorState.svelte";
|
|
17
|
+
import LoadingState from "../../kit/LoadingState.svelte";
|
|
18
|
+
|
|
19
|
+
const { t, toast, confirm, client, node, routes } = useFabricUI();
|
|
20
|
+
|
|
21
|
+
onMount(() => {
|
|
22
|
+
void node.loadFabrics();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// The server owns the vendor table, so one list cannot drift from the
|
|
26
|
+
// other; the raw id is the fallback.
|
|
27
|
+
function vendorName(f: Fabric): string {
|
|
28
|
+
return f.vendor_name || `0x${f.vendor_id.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Controllers print node ids as 16-digit hex; the exact value travels in
|
|
32
|
+
// node_id_hex, the number is only a fallback.
|
|
33
|
+
function nodeIdHex(f: Fabric): string {
|
|
34
|
+
if (f.node_id_hex) return `0x${f.node_id_hex.toUpperCase()}`;
|
|
35
|
+
return `0x${f.node_id.toString(16).toUpperCase().padStart(16, "0")}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A JSON number carries 53 bits: without the hex twin an id above that
|
|
39
|
+
// was rounded before it reached the page.
|
|
40
|
+
function nodeIdExact(f: Fabric): boolean {
|
|
41
|
+
return Boolean(f.node_id_hex) || Number.isSafeInteger(f.node_id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let busy = $state(false);
|
|
45
|
+
|
|
46
|
+
async function reassemble() {
|
|
47
|
+
busy = true;
|
|
48
|
+
try {
|
|
49
|
+
await client.reassemble();
|
|
50
|
+
toast.success(t("node.maint.reassemble_done"));
|
|
51
|
+
await node.loadStatus();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
toast.error(errorText(err));
|
|
54
|
+
} finally {
|
|
55
|
+
busy = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function factoryReset() {
|
|
60
|
+
const ok = await confirm({
|
|
61
|
+
title: t("node.maint.reset_confirm"),
|
|
62
|
+
body: t("node.maint.reset_confirm_body"),
|
|
63
|
+
destructive: true,
|
|
64
|
+
confirmLabel: t("node.maint.reset_confirm_label"),
|
|
65
|
+
});
|
|
66
|
+
if (!ok) return;
|
|
67
|
+
busy = true;
|
|
68
|
+
try {
|
|
69
|
+
await client.factoryReset();
|
|
70
|
+
toast.success(t("node.maint.reset_done"));
|
|
71
|
+
await node.loadFabrics();
|
|
72
|
+
await node.loadStatus();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
toast.error(errorText(err));
|
|
75
|
+
} finally {
|
|
76
|
+
busy = false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function unpair(f: Fabric) {
|
|
81
|
+
const ok = await confirm({
|
|
82
|
+
title: t("node.fabric.unpair_confirm"),
|
|
83
|
+
body: f.label || t("node.fabric.label_unknown"),
|
|
84
|
+
destructive: true,
|
|
85
|
+
confirmLabel: t("common.remove"),
|
|
86
|
+
});
|
|
87
|
+
if (!ok) return;
|
|
88
|
+
try {
|
|
89
|
+
await client.removeFabric(f.fabric_index);
|
|
90
|
+
toast.success(t("node.fabric.unpaired"));
|
|
91
|
+
await node.loadFabrics();
|
|
92
|
+
await node.loadStatus();
|
|
93
|
+
} catch (err) {
|
|
94
|
+
toast.error(errorText(err));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const columns: DataColumn<Fabric>[] = $derived([
|
|
99
|
+
{ key: "vendor", label: t("node.fabrics.col_vendor"), sortable: true, title: true, get: (f) => vendorName(f) },
|
|
100
|
+
{ key: "label", label: t("node.fabrics.col_label"), sortable: true, get: (f) => f.label || t("node.fabric.label_unknown") },
|
|
101
|
+
{ key: "fabric", label: t("node.fabrics.col_fabric"), sortable: true, get: (f) => f.fabric_index },
|
|
102
|
+
// Sorted on the zero-padded hex: fixed width makes lexical order numeric.
|
|
103
|
+
{ key: "node_id", label: t("node.fabrics.col_node_id"), sortable: true, get: (f) => nodeIdHex(f) },
|
|
104
|
+
{ key: "action", label: "", align: "right", cellClass: "reflow-actions" },
|
|
105
|
+
]);
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
<div class="space-y-6">
|
|
109
|
+
{#if node.fabricsLoading && node.fabrics.length === 0}
|
|
110
|
+
<LoadingState message={t("common.loading")} />
|
|
111
|
+
{:else if node.fabricsError}
|
|
112
|
+
<ErrorState message={node.fabricsError} onRetry={() => void node.loadFabrics()} />
|
|
113
|
+
{:else}
|
|
114
|
+
<Card class="p-4">
|
|
115
|
+
<DataTable
|
|
116
|
+
rows={node.fabrics}
|
|
117
|
+
{columns}
|
|
118
|
+
rowKey={(f) => String(f.fabric_index)}
|
|
119
|
+
persistKey="node-fabrics"
|
|
120
|
+
emptyMessage={t("node.fabrics.empty")}
|
|
121
|
+
emptyIcon="mdi:link"
|
|
122
|
+
initialSort={{ key: "vendor", asc: true }}
|
|
123
|
+
>
|
|
124
|
+
{#snippet cell(fabric, col)}
|
|
125
|
+
{#if col.key === "vendor"}
|
|
126
|
+
<span class="text-[var(--ha-primary-text-color)]">{vendorName(fabric)}</span>
|
|
127
|
+
{:else if col.key === "label"}
|
|
128
|
+
<span class={fabric.label ? "text-[var(--ha-primary-text-color)]" : "text-[var(--ha-secondary-text-color)]"}>
|
|
129
|
+
{fabric.label || t("node.fabric.label_unknown")}
|
|
130
|
+
</span>
|
|
131
|
+
{:else if col.key === "fabric"}
|
|
132
|
+
<span class="text-[var(--ha-secondary-text-color)]">{fabric.fabric_index}</span>
|
|
133
|
+
{:else if col.key === "node_id"}
|
|
134
|
+
<span class="inline-flex flex-wrap items-center gap-1">
|
|
135
|
+
<span class="font-mono text-xs text-[var(--ha-secondary-text-color)]">{nodeIdHex(fabric)}</span>
|
|
136
|
+
{#if !nodeIdExact(fabric)}
|
|
137
|
+
<Badge variant="warning" title={t("node.fabrics.node_id_rounded_hint")}>{t("node.fabrics.node_id_rounded")}</Badge>
|
|
138
|
+
{/if}
|
|
139
|
+
</span>
|
|
140
|
+
{:else if col.key === "action"}
|
|
141
|
+
<Button size="sm" variant="ghost" class="text-[var(--ha-error-color)]" onclick={() => void unpair(fabric)}>
|
|
142
|
+
{t("common.remove")}
|
|
143
|
+
</Button>
|
|
144
|
+
{/if}
|
|
145
|
+
{/snippet}
|
|
146
|
+
</DataTable>
|
|
147
|
+
</Card>
|
|
148
|
+
{/if}
|
|
149
|
+
|
|
150
|
+
<Card class="p-4">
|
|
151
|
+
<h3 class="mb-2 font-medium text-[var(--ha-primary-text-color)]">{t("node.maint.title")}</h3>
|
|
152
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
153
|
+
<Button variant="outline" onclick={() => void reassemble()} disabled={busy}>{t("node.maint.reassemble")}</Button>
|
|
154
|
+
<span class="text-xs text-[var(--ha-secondary-text-color)]">{t("node.maint.reassemble_hint")}</span>
|
|
155
|
+
</div>
|
|
156
|
+
<div class="mt-3 flex flex-wrap items-center gap-3">
|
|
157
|
+
<Button variant="outline-destructive" onclick={() => void factoryReset()} disabled={busy}>{t("node.maint.reset")}</Button>
|
|
158
|
+
<span class="text-xs text-[var(--ha-secondary-text-color)]">{t("node.maint.reset_hint")}</span>
|
|
159
|
+
</div>
|
|
160
|
+
</Card>
|
|
161
|
+
|
|
162
|
+
<div class="rounded-[var(--ha-radius-card)] border border-[var(--ha-divider-color)] bg-[var(--ha-secondary-background-color)] p-4">
|
|
163
|
+
<h3 class="mb-2 font-medium text-[var(--ha-primary-text-color)]">{t("node.fabric.share")}</h3>
|
|
164
|
+
<p class="text-sm text-[var(--ha-secondary-text-color)]">{t("node.fabric.share_hint")}</p>
|
|
165
|
+
<a href={routes.pair} data-testid="share-link" class="mt-3 inline-flex items-center text-sm font-medium text-[var(--ha-primary-color)] hover:underline">
|
|
166
|
+
{t("node.fabric.share_go")}
|
|
167
|
+
</a>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|