@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,186 @@
|
|
|
1
|
+
<!-- Pairing: open a commissioning window, show its QR code, manual code and
|
|
2
|
+
countdown, close it, and report the controller that paired. Ported from
|
|
3
|
+
openccu-loom assets/ui/src/routes/matter/MatterPair.svelte:1-279 at
|
|
4
|
+
b40b289a; the host-readiness gate becomes the node's own "no endpoint
|
|
5
|
+
exposed yet" hint, and the colours are the theme tokens. -->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { onMount } from "svelte";
|
|
8
|
+
import { useFabricUI } from "../../context";
|
|
9
|
+
import { errorText } from "../../store.svelte";
|
|
10
|
+
import { renderQrSvg } from "../../qr";
|
|
11
|
+
import Button from "../../kit/Button.svelte";
|
|
12
|
+
import Card from "../../kit/Card.svelte";
|
|
13
|
+
import Select from "../../kit/Select.svelte";
|
|
14
|
+
|
|
15
|
+
const { t, toast, node } = useFabricUI();
|
|
16
|
+
|
|
17
|
+
let hydrateLoading = $state(true);
|
|
18
|
+
let hydrateError = $state<string | null>(null);
|
|
19
|
+
let opening = $state(false);
|
|
20
|
+
let closing = $state(false);
|
|
21
|
+
let selectedDuration = $state(300);
|
|
22
|
+
|
|
23
|
+
// A refresh, a tab switch or a window opened elsewhere shows the codes
|
|
24
|
+
// and the countdown instead of the empty form.
|
|
25
|
+
onMount(async () => {
|
|
26
|
+
try {
|
|
27
|
+
await node.hydrateCommissioning();
|
|
28
|
+
} catch (err) {
|
|
29
|
+
hydrateError = errorText(err);
|
|
30
|
+
} finally {
|
|
31
|
+
hydrateLoading = false;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// $derived, not a const: the labels follow a live locale switch.
|
|
36
|
+
const durationOptions = $derived([
|
|
37
|
+
{ label: `5 ${t("node.pair.minutes")}`, value: "300" },
|
|
38
|
+
{ label: `10 ${t("node.pair.minutes")}`, value: "600" },
|
|
39
|
+
{ label: `15 ${t("node.pair.minutes")}`, value: "900" },
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const phase = $derived(node.commissioning.phase);
|
|
43
|
+
const win = $derived(node.commissioning.window);
|
|
44
|
+
const remaining = $derived(node.commissioning.remaining);
|
|
45
|
+
const addedLabel = $derived(node.commissioning.addedFabricLabel);
|
|
46
|
+
|
|
47
|
+
// Opening a window on a node controllers already hold adds a controller;
|
|
48
|
+
// the card says which of the two the operator is doing.
|
|
49
|
+
const fabricCount = $derived(node.status?.fabric_count ?? 0);
|
|
50
|
+
const alreadyPaired = $derived(fabricCount > 0);
|
|
51
|
+
const noEndpoint = $derived(node.status !== null && node.status.enabled && node.status.endpoint_count === 0);
|
|
52
|
+
|
|
53
|
+
// The clipboard API is unavailable on a page served over plain HTTP (an
|
|
54
|
+
// Ingress, a bare LAN address), so a refusal has to surface.
|
|
55
|
+
async function copy(value: string) {
|
|
56
|
+
try {
|
|
57
|
+
await navigator.clipboard.writeText(value);
|
|
58
|
+
toast.success(t("node.pair.copied"));
|
|
59
|
+
} catch {
|
|
60
|
+
toast.error(t("node.pair.copy_failed"));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function openWindow() {
|
|
65
|
+
opening = true;
|
|
66
|
+
try {
|
|
67
|
+
await node.openWindow(selectedDuration);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
toast.error(errorText(err));
|
|
70
|
+
} finally {
|
|
71
|
+
opening = false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function closeWindow() {
|
|
76
|
+
closing = true;
|
|
77
|
+
try {
|
|
78
|
+
await node.closeWindow();
|
|
79
|
+
} catch (err) {
|
|
80
|
+
toast.error(errorText(err));
|
|
81
|
+
} finally {
|
|
82
|
+
closing = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const RING_R = 54;
|
|
87
|
+
const RING_C = 2 * Math.PI * RING_R;
|
|
88
|
+
const ringOffset = $derived(!remaining || !win || !win.duration_seconds ? 0 : RING_C * (1 - remaining / win.duration_seconds));
|
|
89
|
+
const qrSvg = $derived(win?.qr_code ? renderQrSvg(win.qr_code) : null);
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<div class="max-w-lg">
|
|
93
|
+
{#if hydrateLoading}
|
|
94
|
+
<p class="text-sm text-[var(--ha-secondary-text-color)]">{t("node.pair.loading")}</p>
|
|
95
|
+
{:else if hydrateError}
|
|
96
|
+
<p class="text-sm text-[var(--ha-error-color)]">{t("node.pair.load_error")}</p>
|
|
97
|
+
{:else if phase === "idle"}
|
|
98
|
+
<Card class="p-6">
|
|
99
|
+
<h2 class="mb-4 text-base font-semibold text-[var(--ha-primary-text-color)]">{t("node.pair.window_open_duration")}</h2>
|
|
100
|
+
{#if alreadyPaired}
|
|
101
|
+
<p class="mb-4 text-sm text-[var(--ha-secondary-text-color)]">{t("node.pair.already_paired", { count: fabricCount })}</p>
|
|
102
|
+
{/if}
|
|
103
|
+
{#if noEndpoint}
|
|
104
|
+
<p class="mb-4 text-sm font-medium text-[var(--ha-secondary-text-color)]">{t("node.pair.waiting")}</p>
|
|
105
|
+
{/if}
|
|
106
|
+
<div class="mb-4 flex flex-wrap items-center gap-3">
|
|
107
|
+
<Select
|
|
108
|
+
class="w-auto"
|
|
109
|
+
value={String(selectedDuration)}
|
|
110
|
+
onValueChange={(v) => (selectedDuration = Number(v))}
|
|
111
|
+
ariaLabel={t("node.pair.window_open_duration")}
|
|
112
|
+
options={durationOptions}
|
|
113
|
+
/>
|
|
114
|
+
<Button class="w-full sm:w-auto" disabled={opening} onclick={() => void openWindow()}>
|
|
115
|
+
{opening ? t("common.saving") : alreadyPaired ? t("node.pair.add_controller") : t("node.pair.window_open")}
|
|
116
|
+
</Button>
|
|
117
|
+
</div>
|
|
118
|
+
<p class="text-xs text-[var(--ha-secondary-text-color)]">{t("node.pair.qr_caption")}</p>
|
|
119
|
+
</Card>
|
|
120
|
+
{:else if phase === "open" && win}
|
|
121
|
+
<Card class="p-6">
|
|
122
|
+
<h2 class="mb-4 text-base font-semibold text-[var(--ha-primary-text-color)]">{t("node.pair.qr_caption")}</h2>
|
|
123
|
+
<div class="flex flex-col items-center gap-4">
|
|
124
|
+
{#if remaining !== null}
|
|
125
|
+
<div class="relative flex items-center justify-center">
|
|
126
|
+
<svg width="120" height="120" viewBox="0 0 120 120" aria-hidden="true">
|
|
127
|
+
<circle cx="60" cy="60" r={RING_R} fill="none" stroke="var(--ha-divider-color)" stroke-width="8" />
|
|
128
|
+
<circle
|
|
129
|
+
cx="60"
|
|
130
|
+
cy="60"
|
|
131
|
+
r={RING_R}
|
|
132
|
+
fill="none"
|
|
133
|
+
stroke="var(--ha-primary-color)"
|
|
134
|
+
stroke-width="8"
|
|
135
|
+
stroke-dasharray={RING_C}
|
|
136
|
+
stroke-dashoffset={ringOffset}
|
|
137
|
+
stroke-linecap="round"
|
|
138
|
+
transform="rotate(-90 60 60)"
|
|
139
|
+
style="transition: stroke-dashoffset 1s linear;"
|
|
140
|
+
/>
|
|
141
|
+
</svg>
|
|
142
|
+
<span class="absolute text-xl font-semibold text-[var(--ha-primary-text-color)]">{remaining}s</span>
|
|
143
|
+
</div>
|
|
144
|
+
{/if}
|
|
145
|
+
{#if qrSvg}
|
|
146
|
+
<div class="max-w-full rounded border border-[var(--ha-divider-color)] p-2" aria-label={t("node.pair.qr_caption")} data-testid="pair-qr">
|
|
147
|
+
<!-- The SVG is built locally from the payload; nothing user-supplied is injected. -->
|
|
148
|
+
<div class="h-auto max-w-full">{@html qrSvg}</div>
|
|
149
|
+
</div>
|
|
150
|
+
{/if}
|
|
151
|
+
<div class="text-center">
|
|
152
|
+
<p class="mb-1 text-xs text-[var(--ha-secondary-text-color)]">{t("node.pair.manual_code")}</p>
|
|
153
|
+
<div class="flex items-center justify-center gap-2">
|
|
154
|
+
<p class="overflow-x-auto whitespace-nowrap font-mono text-lg font-semibold tracking-widest text-[var(--ha-primary-text-color)]">
|
|
155
|
+
{win.manual_code}
|
|
156
|
+
</p>
|
|
157
|
+
<Button variant="ghost" size="sm" aria-label={t("node.pair.copy_manual_code")} onclick={() => void copy(win.manual_code)}>
|
|
158
|
+
{t("common.copy")}
|
|
159
|
+
</Button>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
<details class="w-full">
|
|
163
|
+
<summary class="cursor-pointer text-xs text-[var(--ha-secondary-text-color)]">{t("node.pair.qr_payload")}</summary>
|
|
164
|
+
<div class="mt-1 flex items-start gap-2">
|
|
165
|
+
<p class="break-all font-mono text-xs text-[var(--ha-secondary-text-color)]">{win.qr_code}</p>
|
|
166
|
+
<Button variant="ghost" size="sm" aria-label={t("node.pair.copy_qr_payload")} onclick={() => void copy(win.qr_code)}>
|
|
167
|
+
{t("common.copy")}
|
|
168
|
+
</Button>
|
|
169
|
+
</div>
|
|
170
|
+
</details>
|
|
171
|
+
<Button variant="outline" size="sm" disabled={closing} onclick={() => void closeWindow()}>
|
|
172
|
+
{closing ? t("common.saving") : t("node.pair.close_window")}
|
|
173
|
+
</Button>
|
|
174
|
+
</div>
|
|
175
|
+
</Card>
|
|
176
|
+
{:else if phase === "success"}
|
|
177
|
+
<Card class="p-6 text-center">
|
|
178
|
+
<div class="mb-3 text-4xl text-[var(--ha-success-color)]" aria-hidden="true">✓</div>
|
|
179
|
+
<h2 class="mb-2 text-base font-semibold text-[var(--ha-primary-text-color)]">{t("node.pair.success")}</h2>
|
|
180
|
+
{#if addedLabel}
|
|
181
|
+
<p class="mb-4 text-sm text-[var(--ha-secondary-text-color)]">{addedLabel}</p>
|
|
182
|
+
{/if}
|
|
183
|
+
<Button onclick={() => node.resetCommissioning()}>{t("common.close")}</Button>
|
|
184
|
+
</Card>
|
|
185
|
+
{/if}
|
|
186
|
+
</div>
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
<!-- The host's selection: which items the node exposes. Generic over the
|
|
2
|
+
provider's schema — its columns, its filters, its groups — with search,
|
|
3
|
+
bulk selection, a dirty map, a preview of the verdicts before anything
|
|
4
|
+
is written, and the save/discard bar. Ported from openccu-loom
|
|
5
|
+
assets/ui/src/routes/matter/MatterExposureList.svelte:1-584 at
|
|
6
|
+
b40b289a: the host's fixed columns, kind filter and per-item conflict
|
|
7
|
+
hints become the schema's columns and filters; the class chips become a
|
|
8
|
+
value filter per filterable column; Preview before Apply is new. -->
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import { onMount } from "svelte";
|
|
11
|
+
import { useFabricUI } from "../../context";
|
|
12
|
+
import { errorText } from "../../store.svelte";
|
|
13
|
+
import type { Item, VerdictState } from "../../api/client";
|
|
14
|
+
import type { DataColumn } from "../../kit/data-table";
|
|
15
|
+
import type { IconName } from "../../kit/icons";
|
|
16
|
+
import { makeTextMatcher } from "../../kit/utils";
|
|
17
|
+
import Badge from "../../kit/Badge.svelte";
|
|
18
|
+
import Button from "../../kit/Button.svelte";
|
|
19
|
+
import Card from "../../kit/Card.svelte";
|
|
20
|
+
import DataTable from "../../kit/DataTable.svelte";
|
|
21
|
+
import EmptyState from "../../kit/EmptyState.svelte";
|
|
22
|
+
import ErrorState from "../../kit/ErrorState.svelte";
|
|
23
|
+
import Icon from "../../kit/Icon.svelte";
|
|
24
|
+
import Input from "../../kit/Input.svelte";
|
|
25
|
+
import LoadingState from "../../kit/LoadingState.svelte";
|
|
26
|
+
import Select from "../../kit/Select.svelte";
|
|
27
|
+
|
|
28
|
+
const { t, toast, node } = useFabricUI();
|
|
29
|
+
|
|
30
|
+
// A host without a selection says so in its status; the page then
|
|
31
|
+
// loads nothing.
|
|
32
|
+
const unavailable = $derived(node.status !== null && !node.status.exposure);
|
|
33
|
+
|
|
34
|
+
onMount(() => {
|
|
35
|
+
if (!unavailable) void node.loadExposure();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const ALL = "__all__";
|
|
39
|
+
let search = $state("");
|
|
40
|
+
let filterValues = $state<Record<string, string>>({});
|
|
41
|
+
let selected = $state<Set<string>>(new Set());
|
|
42
|
+
let drawer = $state<Item | null>(null);
|
|
43
|
+
let drawerName = $state("");
|
|
44
|
+
let drawerEnabled = $state(false);
|
|
45
|
+
let previewItems = $state<Item[] | null>(null);
|
|
46
|
+
let previewing = $state(false);
|
|
47
|
+
let saving = $state(false);
|
|
48
|
+
|
|
49
|
+
const schema = $derived(node.schema);
|
|
50
|
+
|
|
51
|
+
// The value filters: the host's fixed filters, then one per filterable
|
|
52
|
+
// column with the distinct values the items carry.
|
|
53
|
+
type ValueFilter = { field: string; label: string; options: { value: string; label: string }[] };
|
|
54
|
+
const valueFilters = $derived.by<ValueFilter[]>(() => {
|
|
55
|
+
if (!schema) return [];
|
|
56
|
+
const out: ValueFilter[] = schema.filters.map((f) => ({ field: f.field, label: f.label, options: f.options }));
|
|
57
|
+
for (const col of schema.columns) {
|
|
58
|
+
if (!col.filter || out.some((f) => f.field === col.key)) continue;
|
|
59
|
+
const values = [...new Set(node.items.map((i) => i.fields[col.key]).filter((v): v is string => !!v))];
|
|
60
|
+
values.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }));
|
|
61
|
+
out.push({ field: col.key, label: col.label, options: values.map((v) => ({ value: v, label: v })) });
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The item as the page shows it: with its pending change applied.
|
|
67
|
+
function current(item: Item): Item {
|
|
68
|
+
const p = node.pending.get(item.key);
|
|
69
|
+
if (!p) return item;
|
|
70
|
+
return { ...item, enabled: p.enabled, name: p.name ?? item.name };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const filtered = $derived.by(() => {
|
|
74
|
+
const match = makeTextMatcher(search);
|
|
75
|
+
return node.items.filter((item) => {
|
|
76
|
+
for (const [field, value] of Object.entries(filterValues)) {
|
|
77
|
+
if (value !== ALL && item.fields[field] !== value) return false;
|
|
78
|
+
}
|
|
79
|
+
if (!search.trim()) return true;
|
|
80
|
+
const it = current(item);
|
|
81
|
+
return [it.name, it.group, it.verdict.device_type_name, ...Object.values(it.fields)].some((v) => v && match(v));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
type StateInfo = { icon: IconName; color: string; label: string };
|
|
86
|
+
|
|
87
|
+
function stateInfo(item: Item): StateInfo {
|
|
88
|
+
const it = current(item);
|
|
89
|
+
const s = it.verdict.state;
|
|
90
|
+
if (s === "unmappable") return { icon: "mdi:cancel", color: "text-[var(--ha-error-color)]", label: t("node.selection.state_unmappable") };
|
|
91
|
+
if (it.enabled && s === "mappable") return { icon: "mdi:check-circle", color: "text-[var(--ha-success-color)]", label: t("node.selection.state_exposed") };
|
|
92
|
+
if (it.enabled && s === "partially_mappable") return { icon: "mdi:alert-circle", color: "text-[var(--ha-warning-color)]", label: t("node.selection.state_partial") };
|
|
93
|
+
return { icon: "mdi:circle-outline", color: "text-[var(--ha-disabled-text-color)]", label: t("node.selection.state_available") };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function verdictHint(s: VerdictState): string {
|
|
97
|
+
if (s === "unmappable") return t("node.selection.unmappable_hint");
|
|
98
|
+
if (s === "partially_mappable") return t("node.selection.partially_mappable_hint");
|
|
99
|
+
return t("node.selection.mappable_hint");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const legend = $derived<StateInfo[]>([
|
|
103
|
+
{ icon: "mdi:check-circle", color: "text-[var(--ha-success-color)]", label: t("node.selection.state_exposed") },
|
|
104
|
+
{ icon: "mdi:alert-circle", color: "text-[var(--ha-warning-color)]", label: t("node.selection.state_partial") },
|
|
105
|
+
{ icon: "mdi:circle-outline", color: "text-[var(--ha-disabled-text-color)]", label: t("node.selection.state_available") },
|
|
106
|
+
{ icon: "mdi:cancel", color: "text-[var(--ha-error-color)]", label: t("node.selection.state_unmappable") },
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const bulkable = (item: Item) => item.verdict.state !== "unmappable";
|
|
110
|
+
|
|
111
|
+
function toggleSelect(item: Item) {
|
|
112
|
+
if (!bulkable(item)) return;
|
|
113
|
+
const next = new Set(selected);
|
|
114
|
+
if (next.has(item.key)) next.delete(item.key);
|
|
115
|
+
else next.add(item.key);
|
|
116
|
+
selected = next;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function selectAll() {
|
|
120
|
+
selected = new Set(filtered.filter(bulkable).map((i) => i.key));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function bulkSet(enabled: boolean) {
|
|
124
|
+
for (const item of filtered) if (selected.has(item.key)) node.markDirty(item, { enabled });
|
|
125
|
+
selected = new Set();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function openDrawer(item: Item) {
|
|
129
|
+
const it = current(item);
|
|
130
|
+
drawer = item;
|
|
131
|
+
drawerName = it.name;
|
|
132
|
+
drawerEnabled = it.enabled;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function saveDrawer() {
|
|
136
|
+
if (!drawer) return;
|
|
137
|
+
node.markDirty(drawer, { enabled: drawerEnabled, name: drawerName });
|
|
138
|
+
drawer = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function preview() {
|
|
142
|
+
previewing = true;
|
|
143
|
+
try {
|
|
144
|
+
previewItems = await node.preview();
|
|
145
|
+
} catch (err) {
|
|
146
|
+
toast.error(errorText(err));
|
|
147
|
+
} finally {
|
|
148
|
+
previewing = false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function apply() {
|
|
153
|
+
saving = true;
|
|
154
|
+
try {
|
|
155
|
+
const applied = await node.saveBulk();
|
|
156
|
+
previewItems = null;
|
|
157
|
+
toast.success(t("node.selection.saved_toast", { count: applied }));
|
|
158
|
+
} catch (err) {
|
|
159
|
+
toast.error(errorText(err));
|
|
160
|
+
} finally {
|
|
161
|
+
saving = false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function discard() {
|
|
166
|
+
node.discardDirty();
|
|
167
|
+
previewItems = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const before = (key: string) => node.items.find((i) => i.key === key);
|
|
171
|
+
const verdictMoved = (a: Item | undefined, b: Item) =>
|
|
172
|
+
!a || a.verdict.state !== b.verdict.state || a.verdict.device_type !== b.verdict.device_type || a.verdict.reason !== b.verdict.reason;
|
|
173
|
+
|
|
174
|
+
const columns: DataColumn<Item>[] = $derived([
|
|
175
|
+
{ key: "select", label: t("node.selection.col_select") },
|
|
176
|
+
{ key: "state", label: t("node.selection.col_state"), align: "center", get: (i) => stateInfo(i).label },
|
|
177
|
+
{ key: "name", label: t("node.selection.col_name"), sortable: true, title: true, get: (i) => current(i).name },
|
|
178
|
+
...(schema?.columns ?? []).map((c) => ({ key: "field:" + c.key, label: c.label, sortable: true, get: (i: Item) => i.fields[c.key] ?? "" })),
|
|
179
|
+
{ key: "device_type", label: t("node.selection.col_device_type"), sortable: true, get: (i) => i.verdict.device_type_name || "—" },
|
|
180
|
+
]);
|
|
181
|
+
</script>
|
|
182
|
+
|
|
183
|
+
{#snippet groupHeader(group: string, rows: Item[])}
|
|
184
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
185
|
+
<span class="font-semibold text-[var(--ha-primary-text-color)]">{group || "—"}</span>
|
|
186
|
+
<span
|
|
187
|
+
class="ml-auto rounded-full bg-[var(--ha-card-background-color)] px-2 py-0.5 text-[10px] tabular-nums text-[var(--ha-secondary-text-color)]"
|
|
188
|
+
aria-label={t("node.selection.group_count", { count: rows.length })}>{rows.length}</span
|
|
189
|
+
>
|
|
190
|
+
</div>
|
|
191
|
+
{/snippet}
|
|
192
|
+
|
|
193
|
+
{#snippet itemCell(item: Item, col: DataColumn<Item>)}
|
|
194
|
+
{#if col.key === "select"}
|
|
195
|
+
<label class="flex items-center justify-center">
|
|
196
|
+
<input
|
|
197
|
+
type="checkbox"
|
|
198
|
+
checked={selected.has(item.key)}
|
|
199
|
+
disabled={!bulkable(item)}
|
|
200
|
+
title={bulkable(item) ? undefined : t("node.selection.unmappable_checkbox_title")}
|
|
201
|
+
onclick={(e) => {
|
|
202
|
+
e.stopPropagation();
|
|
203
|
+
toggleSelect(item);
|
|
204
|
+
}}
|
|
205
|
+
class="h-5 w-5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
|
206
|
+
aria-label={t("node.selection.select_row")}
|
|
207
|
+
/>
|
|
208
|
+
</label>
|
|
209
|
+
{:else if col.key === "state"}
|
|
210
|
+
{@const si = stateInfo(item)}
|
|
211
|
+
<span class="inline-flex justify-center"><Icon name={si.icon} size={18} class={si.color} aria-label={si.label} title={si.label} /></span>
|
|
212
|
+
{:else if col.key === "name"}
|
|
213
|
+
<button type="button" class="text-left hover:underline" onclick={() => openDrawer(item)}>
|
|
214
|
+
<span class="text-[var(--ha-primary-text-color)]">{current(item).name}</span>
|
|
215
|
+
{#if node.pending.has(item.key)}
|
|
216
|
+
<span class="ml-1 text-xs text-[var(--ha-primary-color)]">{t("common.modified")}</span>
|
|
217
|
+
{/if}
|
|
218
|
+
</button>
|
|
219
|
+
{:else if col.key === "device_type"}
|
|
220
|
+
<span class="text-[var(--ha-secondary-text-color)]">{item.verdict.device_type_name || "—"}</span>
|
|
221
|
+
{:else}
|
|
222
|
+
<span class="text-[var(--ha-secondary-text-color)]">{col.get?.(item) || "—"}</span>
|
|
223
|
+
{/if}
|
|
224
|
+
{/snippet}
|
|
225
|
+
|
|
226
|
+
{#if unavailable}
|
|
227
|
+
<EmptyState message={t("node.selection.unavailable")} />
|
|
228
|
+
{:else if node.exposureLoading && !schema}
|
|
229
|
+
<LoadingState message={t("common.loading")} />
|
|
230
|
+
{:else if node.exposureError}
|
|
231
|
+
<ErrorState message={node.exposureError} onRetry={() => void node.loadExposure()} />
|
|
232
|
+
{:else if schema}
|
|
233
|
+
<div class="mb-3 flex flex-col gap-2">
|
|
234
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
235
|
+
<Input placeholder={t("node.selection.search_placeholder")} bind:value={search} class="w-full sm:w-64" />
|
|
236
|
+
{#each valueFilters as f (f.field)}
|
|
237
|
+
<Select
|
|
238
|
+
class="w-auto"
|
|
239
|
+
value={filterValues[f.field] ?? ALL}
|
|
240
|
+
ariaLabel={f.label}
|
|
241
|
+
onValueChange={(v) => (filterValues = { ...filterValues, [f.field]: v })}
|
|
242
|
+
options={[{ value: ALL, label: `${f.label}: ${t("node.selection.filter_all")}` }, ...f.options]}
|
|
243
|
+
/>
|
|
244
|
+
{/each}
|
|
245
|
+
</div>
|
|
246
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
247
|
+
{#if selected.size > 0}
|
|
248
|
+
<Button size="sm" onclick={() => bulkSet(true)}>{t("node.selection.bulk_expose")}</Button>
|
|
249
|
+
<Button size="sm" variant="outline" onclick={() => bulkSet(false)}>{t("node.selection.bulk_hide")}</Button>
|
|
250
|
+
<Button size="sm" variant="ghost" onclick={() => (selected = new Set())}>{t("common.cancel")}</Button>
|
|
251
|
+
{:else}
|
|
252
|
+
<Button size="sm" variant="ghost" onclick={selectAll}>{t("node.selection.select_all")}</Button>
|
|
253
|
+
{/if}
|
|
254
|
+
{#if node.hasDirty}
|
|
255
|
+
<div class="ml-auto flex flex-wrap items-center gap-2" data-testid="save-bar">
|
|
256
|
+
<span class="text-xs text-[var(--ha-secondary-text-color)]">{t("node.selection.pending", { count: node.pending.size })}</span>
|
|
257
|
+
<Button size="sm" disabled={previewing} onclick={() => void preview()}>{t("node.selection.preview")}</Button>
|
|
258
|
+
<Button size="sm" variant="outline" onclick={discard}>{t("node.selection.discard")}</Button>
|
|
259
|
+
</div>
|
|
260
|
+
{/if}
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
{#if previewItems}
|
|
265
|
+
<Card class="mb-4 p-4" data-testid="preview">
|
|
266
|
+
<h3 class="font-medium text-[var(--ha-primary-text-color)]">{t("node.selection.preview_title")}</h3>
|
|
267
|
+
<p class="mt-1 text-xs text-[var(--ha-secondary-text-color)]">{t("node.selection.preview_hint")}</p>
|
|
268
|
+
<ul class="mt-3 space-y-2">
|
|
269
|
+
{#each previewItems as after (after.key)}
|
|
270
|
+
{@const was = before(after.key)}
|
|
271
|
+
<li class="flex flex-wrap items-center gap-2 text-sm">
|
|
272
|
+
<span class="font-medium text-[var(--ha-primary-text-color)]">{after.name}</span>
|
|
273
|
+
<Badge variant={after.enabled ? "success" : "muted"}>
|
|
274
|
+
{after.enabled ? t("node.selection.state_exposed") : t("node.selection.state_available")}
|
|
275
|
+
</Badge>
|
|
276
|
+
<span class="text-xs text-[var(--ha-secondary-text-color)]">{after.verdict.device_type_name || "—"} · {verdictHint(after.verdict.state)}</span>
|
|
277
|
+
{#if verdictMoved(was, after)}
|
|
278
|
+
<Badge variant="warning">{t("node.selection.preview_changed")}</Badge>
|
|
279
|
+
{/if}
|
|
280
|
+
{#if after.verdict.reason}
|
|
281
|
+
<span class="w-full text-xs text-[var(--ha-secondary-text-color)]">{after.verdict.reason}</span>
|
|
282
|
+
{/if}
|
|
283
|
+
</li>
|
|
284
|
+
{/each}
|
|
285
|
+
</ul>
|
|
286
|
+
<div class="mt-4 flex gap-2">
|
|
287
|
+
<Button size="sm" disabled={saving} onclick={() => void apply()}>{saving ? t("common.saving") : t("node.selection.apply")}</Button>
|
|
288
|
+
<Button size="sm" variant="outline" onclick={() => (previewItems = null)}>{t("node.selection.back")}</Button>
|
|
289
|
+
</div>
|
|
290
|
+
</Card>
|
|
291
|
+
{/if}
|
|
292
|
+
|
|
293
|
+
{#if filtered.length === 0}
|
|
294
|
+
<EmptyState message={t("node.selection.empty")} icon="mdi:list-checks" />
|
|
295
|
+
{:else}
|
|
296
|
+
<Card class="p-4">
|
|
297
|
+
<div class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[var(--ha-secondary-text-color)]">
|
|
298
|
+
<span class="font-semibold">{t("node.selection.legend")}:</span>
|
|
299
|
+
{#each legend as li (li.label)}
|
|
300
|
+
<span class="inline-flex items-center gap-1"><Icon name={li.icon} size={14} class={li.color} /><span>{li.label}</span></span>
|
|
301
|
+
{/each}
|
|
302
|
+
</div>
|
|
303
|
+
<DataTable
|
|
304
|
+
rows={filtered}
|
|
305
|
+
{columns}
|
|
306
|
+
rowKey={(i) => i.key}
|
|
307
|
+
cell={itemCell}
|
|
308
|
+
groupBy={schema.groups ? (i) => i.group : undefined}
|
|
309
|
+
groupHeader={schema.groups ? groupHeader : undefined}
|
|
310
|
+
rowClass={(i) => (selected.has(i.key) ? "bg-[var(--ha-secondary-background-color)]" : "")}
|
|
311
|
+
persistKey="node-selection"
|
|
312
|
+
emptyMessage={t("node.selection.empty")}
|
|
313
|
+
/>
|
|
314
|
+
</Card>
|
|
315
|
+
{/if}
|
|
316
|
+
{:else}
|
|
317
|
+
<EmptyState message={t("node.selection.unavailable")} />
|
|
318
|
+
{/if}
|
|
319
|
+
|
|
320
|
+
{#if drawer}
|
|
321
|
+
{@const item = current(drawer)}
|
|
322
|
+
<div
|
|
323
|
+
class="fixed inset-0 z-40 bg-black/30"
|
|
324
|
+
onclick={() => (drawer = null)}
|
|
325
|
+
onkeydown={(e) => {
|
|
326
|
+
if (e.key === "Escape") drawer = null;
|
|
327
|
+
}}
|
|
328
|
+
role="presentation"
|
|
329
|
+
tabindex="-1"
|
|
330
|
+
></div>
|
|
331
|
+
<aside
|
|
332
|
+
class="fixed right-0 top-0 z-50 flex h-full w-full max-w-sm flex-col overflow-y-auto border-l border-[var(--ha-divider-color)] bg-[var(--ha-card-background-color)]"
|
|
333
|
+
aria-label={t("node.selection.drawer_aria")}
|
|
334
|
+
>
|
|
335
|
+
<div class="flex items-center justify-between border-b border-[var(--ha-divider-color)] p-4">
|
|
336
|
+
<h2 class="text-base font-semibold text-[var(--ha-primary-text-color)]">{drawer.name}</h2>
|
|
337
|
+
<button type="button" class="rounded-md p-2 hover:bg-[var(--ha-secondary-background-color)]" onclick={() => (drawer = null)} aria-label={t("common.close")}>✕</button>
|
|
338
|
+
</div>
|
|
339
|
+
<div class="flex-1 space-y-4 p-4">
|
|
340
|
+
<div>
|
|
341
|
+
<label for="fui-drawer-name" class="mb-1 block text-xs font-semibold text-[var(--ha-secondary-text-color)]">{t("node.selection.name")}</label>
|
|
342
|
+
<Input id="fui-drawer-name" bind:value={drawerName} placeholder={drawer.name} />
|
|
343
|
+
</div>
|
|
344
|
+
<div class="flex items-center gap-3">
|
|
345
|
+
<input
|
|
346
|
+
type="checkbox"
|
|
347
|
+
id="fui-drawer-enabled"
|
|
348
|
+
bind:checked={drawerEnabled}
|
|
349
|
+
disabled={item.verdict.state === "unmappable"}
|
|
350
|
+
title={item.verdict.state === "unmappable" ? t("node.selection.unmappable_checkbox_title") : undefined}
|
|
351
|
+
class="cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
|
352
|
+
/>
|
|
353
|
+
<label for="fui-drawer-enabled" class="text-sm text-[var(--ha-primary-text-color)]">{t("node.selection.exposed")}</label>
|
|
354
|
+
</div>
|
|
355
|
+
<div>
|
|
356
|
+
<p class="mb-1 text-xs font-semibold text-[var(--ha-secondary-text-color)]">{t("node.selection.drawer_state")}</p>
|
|
357
|
+
<p class="text-sm text-[var(--ha-primary-text-color)]">{verdictHint(item.verdict.state)}</p>
|
|
358
|
+
{#if item.verdict.reason}
|
|
359
|
+
<p class="mt-1 text-xs text-[var(--ha-secondary-text-color)]">{item.verdict.reason}</p>
|
|
360
|
+
{/if}
|
|
361
|
+
</div>
|
|
362
|
+
{#if item.source}
|
|
363
|
+
<div>
|
|
364
|
+
<p class="mb-1 text-xs font-semibold text-[var(--ha-secondary-text-color)]">{t("node.selection.drawer_source")}</p>
|
|
365
|
+
<p class="text-sm text-[var(--ha-primary-text-color)]">{item.source}</p>
|
|
366
|
+
</div>
|
|
367
|
+
{/if}
|
|
368
|
+
{#if item.verdict.device_type}
|
|
369
|
+
<div>
|
|
370
|
+
<p class="mb-1 text-xs font-semibold text-[var(--ha-secondary-text-color)]">{t("node.selection.drawer_device_type")}</p>
|
|
371
|
+
<p class="text-sm text-[var(--ha-primary-text-color)]">{item.verdict.device_type_name}</p>
|
|
372
|
+
</div>
|
|
373
|
+
{/if}
|
|
374
|
+
{#if item.verdict.clusters.length > 0}
|
|
375
|
+
<div>
|
|
376
|
+
<p class="mb-1 text-xs font-semibold text-[var(--ha-secondary-text-color)]">{t("node.selection.drawer_clusters")}</p>
|
|
377
|
+
<p class="font-mono text-sm text-[var(--ha-primary-text-color)]">
|
|
378
|
+
{item.verdict.clusters.map((c) => `0x${c.toString(16).toUpperCase().padStart(4, "0")}`).join(" · ")}
|
|
379
|
+
</p>
|
|
380
|
+
</div>
|
|
381
|
+
{/if}
|
|
382
|
+
</div>
|
|
383
|
+
<div class="flex gap-2 border-t border-[var(--ha-divider-color)] p-4">
|
|
384
|
+
<Button size="sm" onclick={saveDrawer}>{t("common.save")}</Button>
|
|
385
|
+
<Button size="sm" variant="outline" onclick={() => (drawer = null)}>{t("common.cancel")}</Button>
|
|
386
|
+
</div>
|
|
387
|
+
</aside>
|
|
388
|
+
{/if}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!-- The node's status: whether it runs, listens, is healthy and
|
|
2
|
+
advertised, its counts and the commissioning window. Ported from the
|
|
3
|
+
status card of openccu-loom assets/ui/src/routes/matter/Matter.svelte:47-100
|
|
4
|
+
at b40b289a, without the tabs (the shell or the host navigates). -->
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import { onMount } from "svelte";
|
|
7
|
+
import { useFabricUI } from "../../context";
|
|
8
|
+
import Badge from "../../kit/Badge.svelte";
|
|
9
|
+
import Card from "../../kit/Card.svelte";
|
|
10
|
+
import ErrorState from "../../kit/ErrorState.svelte";
|
|
11
|
+
import LoadingState from "../../kit/LoadingState.svelte";
|
|
12
|
+
|
|
13
|
+
const { t, node } = useFabricUI();
|
|
14
|
+
|
|
15
|
+
onMount(() => {
|
|
16
|
+
void node.loadStatus();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const s = $derived(node.status);
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
{#if node.statusLoading && !s}
|
|
23
|
+
<LoadingState message={t("common.loading")} />
|
|
24
|
+
{:else if node.statusError && !s}
|
|
25
|
+
<ErrorState message={node.statusError} onRetry={() => void node.loadStatus()} />
|
|
26
|
+
{:else if s && !s.enabled}
|
|
27
|
+
<Card class="p-4">
|
|
28
|
+
<p class="text-sm font-medium text-[var(--ha-secondary-text-color)]">{t("node.status.disabled")}</p>
|
|
29
|
+
</Card>
|
|
30
|
+
{:else if s}
|
|
31
|
+
<Card class="p-4" data-testid="node-status">
|
|
32
|
+
<div class="flex flex-wrap items-center gap-4">
|
|
33
|
+
<span class="font-medium text-[var(--ha-primary-text-color)]">{t("node.status.enabled")}</span>
|
|
34
|
+
<Badge variant={s.listening ? "success" : "muted"}>
|
|
35
|
+
{s.listening ? t("node.status.listening") : t("node.status.not_listening")}
|
|
36
|
+
</Badge>
|
|
37
|
+
{#if s.healthy !== undefined}
|
|
38
|
+
<Badge variant={s.healthy ? "success" : "danger"} title={s.health_note}>
|
|
39
|
+
{s.healthy ? t("node.status.healthy") : t("node.status.unhealthy")}
|
|
40
|
+
</Badge>
|
|
41
|
+
{/if}
|
|
42
|
+
<span class="text-sm text-[var(--ha-secondary-text-color)]">{t("node.status.endpoints", { count: s.endpoint_count })}</span>
|
|
43
|
+
<span class="text-sm text-[var(--ha-secondary-text-color)]">{t("node.status.fabrics", { count: s.fabric_count })}</span>
|
|
44
|
+
{#if s.enabled_count !== undefined}
|
|
45
|
+
<span class="text-sm text-[var(--ha-secondary-text-color)]">{t("node.status.exposed", { count: s.enabled_count })}</span>
|
|
46
|
+
{/if}
|
|
47
|
+
{#if s.advertising}
|
|
48
|
+
<Badge variant="default">{t("node.status.advertising")}</Badge>
|
|
49
|
+
{/if}
|
|
50
|
+
{#if s.commissioning_window_open}
|
|
51
|
+
<Badge variant="warning">
|
|
52
|
+
{t("node.pair.window_open")}
|
|
53
|
+
{#if (s.commissioning_window_duration_seconds ?? 0) > 0}
|
|
54
|
+
({s.commissioning_window_duration_seconds}s)
|
|
55
|
+
{/if}
|
|
56
|
+
</Badge>
|
|
57
|
+
{/if}
|
|
58
|
+
</div>
|
|
59
|
+
{#if s.listen_addr}
|
|
60
|
+
<p class="mt-2 text-xs text-[var(--ha-secondary-text-color)]">{t("node.status.listen_addr", { addr: s.listen_addr })}</p>
|
|
61
|
+
{/if}
|
|
62
|
+
</Card>
|
|
63
|
+
{/if}
|