@sukramj/fabric-ui 0.1.1 → 0.1.3
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sukramj/fabric-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "The web interface of go-fabric nodes: Svelte 5 pages, UI kit, design tokens with a Home Assistant theme bridge, and the generated API client.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SukramJ",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@tsconfig/svelte": "5.0.8",
|
|
79
79
|
"@types/node": "26.6.5",
|
|
80
80
|
"@types/qrcode": "1.5.6",
|
|
81
|
-
"happy-dom": "
|
|
81
|
+
"happy-dom": "21.0.4",
|
|
82
82
|
"js-yaml": "4.3.2",
|
|
83
83
|
"openapi-typescript": "7.13.0",
|
|
84
84
|
"svelte": "5.57.2",
|
|
@@ -497,6 +497,16 @@ export interface components {
|
|
|
497
497
|
advertising: boolean;
|
|
498
498
|
commissioning_window_open: boolean;
|
|
499
499
|
commissioning_window_duration_seconds?: number;
|
|
500
|
+
/**
|
|
501
|
+
* @description Which window is open — the node's own on its configured credentials, a basic one on fresh credentials, an administrator's enhanced one; absent without a node.
|
|
502
|
+
* @enum {string}
|
|
503
|
+
*/
|
|
504
|
+
window_kind?: "none" | "own" | "basic" | "enhanced";
|
|
505
|
+
/**
|
|
506
|
+
* Format: date-time
|
|
507
|
+
* @description When the open window's timer closes it
|
|
508
|
+
*/
|
|
509
|
+
window_deadline?: string;
|
|
500
510
|
/** @description Whether the host serves a selection. */
|
|
501
511
|
exposure: boolean;
|
|
502
512
|
/** @description The admin token of token mode; absent when the host decides admin rights. */
|
|
@@ -533,9 +543,9 @@ export interface components {
|
|
|
533
543
|
fabrics: components["schemas"]["Fabric"][];
|
|
534
544
|
};
|
|
535
545
|
Pairing: {
|
|
536
|
-
/** @description
|
|
546
|
+
/** @description The discriminator the codes encode: of the window this handler opened on fresh credentials, else the configured one. Absent from servers before 0.1.2 for the configured source. */
|
|
537
547
|
discriminator?: number;
|
|
538
|
-
/** @description
|
|
548
|
+
/** @description The passcode the codes encode: of the window this handler opened on fresh credentials, else the configured one. Absent from servers before 0.1.2 for the configured source. */
|
|
539
549
|
passcode?: number;
|
|
540
550
|
qr_code: string;
|
|
541
551
|
manual_code: string;
|
|
@@ -549,17 +559,20 @@ export interface components {
|
|
|
549
559
|
duration_seconds?: number;
|
|
550
560
|
};
|
|
551
561
|
Window: {
|
|
552
|
-
/** @description
|
|
562
|
+
/** @description The window's discriminator; absent from servers before 0.1.2 for a window on the configured credentials. */
|
|
553
563
|
discriminator?: number;
|
|
554
|
-
/** @description
|
|
564
|
+
/** @description The window's passcode; absent from servers before 0.1.2 for a window on the configured credentials. */
|
|
555
565
|
passcode?: number;
|
|
556
566
|
duration_seconds: number;
|
|
557
567
|
qr_code: string;
|
|
558
568
|
manual_code: string;
|
|
559
569
|
};
|
|
570
|
+
/** @description One cluster server mounted on the endpoint, in its Descriptor's ServerList order. */
|
|
560
571
|
Cluster: {
|
|
561
572
|
id: number;
|
|
573
|
+
/** @description The schema's cluster name; absent for a cluster the schema does not know. */
|
|
562
574
|
name?: string;
|
|
575
|
+
/** @description The schema's ClusterRevision; absent for a cluster the schema does not know. */
|
|
563
576
|
revision?: number;
|
|
564
577
|
};
|
|
565
578
|
Endpoint: {
|
|
@@ -567,6 +580,8 @@ export interface components {
|
|
|
567
580
|
parent_endpoint_id: number;
|
|
568
581
|
device_type: number;
|
|
569
582
|
device_type_name: string;
|
|
583
|
+
/** @description The revision the endpoint's Descriptor serves for its device type; absent for a type the schema does not know. */
|
|
584
|
+
device_type_revision?: number;
|
|
570
585
|
reachable: boolean;
|
|
571
586
|
name: string;
|
|
572
587
|
/** @description Opaque identity of the host's source; `key` is the endpoint's stable key. */
|
|
@@ -666,7 +681,7 @@ export interface components {
|
|
|
666
681
|
label: string;
|
|
667
682
|
/** @description Offer a value filter built from the listed values. */
|
|
668
683
|
filter: boolean;
|
|
669
|
-
/** @description Default false. The field is
|
|
684
|
+
/** @description Default false. The field is list-valued: its values are the item's lists entry for the key (or, deprecated until v0.2.0, the comma split of its fields entry when lists has no such key); shown as chips, filtered by membership, the filter's options built from the individual values. */
|
|
670
685
|
multi?: boolean;
|
|
671
686
|
};
|
|
672
687
|
FilterOption: {
|
|
@@ -677,7 +692,7 @@ export interface components {
|
|
|
677
692
|
field: string;
|
|
678
693
|
label: string;
|
|
679
694
|
options: components["schemas"]["FilterOption"][];
|
|
680
|
-
/** @description Default false. Match by membership in the
|
|
695
|
+
/** @description Default false. Match by membership in the item's list for the field (its lists entry, or, deprecated until v0.2.0, the comma split of its fields entry, values trimmed; an empty value never matches) instead of equality. */
|
|
681
696
|
multi?: boolean;
|
|
682
697
|
};
|
|
683
698
|
ExposureSchema: {
|
|
@@ -700,6 +715,10 @@ export interface components {
|
|
|
700
715
|
fields: {
|
|
701
716
|
[key: string]: string;
|
|
702
717
|
};
|
|
718
|
+
/** @description Optional. The values of list-valued (multi) fields, by field key: one entry per value, no separator semantics, so a value may contain a comma. Wins over the comma-separated fields entry of the same key. */
|
|
719
|
+
lists?: {
|
|
720
|
+
[key: string]: string[];
|
|
721
|
+
};
|
|
703
722
|
verdict: components["schemas"]["Verdict"];
|
|
704
723
|
enabled: boolean;
|
|
705
724
|
/** @description Where the verdict comes from */
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The commissioning window's countdown from the deadline the status
|
|
2
|
+
// reports (window_deadline, on the node's clock), so a reload or a page
|
|
3
|
+
// opened late counts down what is left instead of the full duration.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whole seconds left until deadline at now (milliseconds since the epoch),
|
|
7
|
+
* never negative; null without a deadline or for one that does not parse.
|
|
8
|
+
*/
|
|
9
|
+
export function secondsUntil(deadline: string | undefined, now: number = Date.now()): number | null {
|
|
10
|
+
if (!deadline) return null;
|
|
11
|
+
const t = Date.parse(deadline);
|
|
12
|
+
if (Number.isNaN(t)) return null;
|
|
13
|
+
return Math.max(0, Math.ceil((t - now) / 1000));
|
|
14
|
+
}
|
|
@@ -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}
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
advertised, its counts and the commissioning window. Ported from the
|
|
3
3
|
status card of openccu-loom assets/ui/src/routes/matter/Matter.svelte:47-100
|
|
4
4
|
at b40b289a, without the tabs (the shell or the host navigates). The
|
|
5
|
-
host's configuration warnings come first, in their own card.
|
|
5
|
+
host's configuration warnings come first, in their own card. An open
|
|
6
|
+
window counts down from the deadline the status reports. -->
|
|
6
7
|
<script lang="ts">
|
|
7
8
|
import { onMount } from "svelte";
|
|
8
9
|
import { useFabricUI } from "../../context";
|
|
10
|
+
import { secondsUntil } from "../../countdown";
|
|
9
11
|
import Badge from "../../kit/Badge.svelte";
|
|
10
12
|
import Card from "../../kit/Card.svelte";
|
|
11
13
|
import ErrorState from "../../kit/ErrorState.svelte";
|
|
@@ -14,11 +16,22 @@
|
|
|
14
16
|
|
|
15
17
|
const { t, node } = useFabricUI();
|
|
16
18
|
|
|
19
|
+
let now = $state(Date.now());
|
|
20
|
+
|
|
17
21
|
onMount(() => {
|
|
18
22
|
void node.loadStatus();
|
|
23
|
+
const tick = setInterval(() => (now = Date.now()), 1000);
|
|
24
|
+
return () => clearInterval(tick);
|
|
19
25
|
});
|
|
20
26
|
|
|
21
27
|
const s = $derived(node.status);
|
|
28
|
+
// Seconds left in the open window, from its deadline; the duration it
|
|
29
|
+
// was opened for when the server reports no deadline.
|
|
30
|
+
const windowLeft = $derived(
|
|
31
|
+
s?.commissioning_window_open
|
|
32
|
+
? (secondsUntil(s.window_deadline, now) ?? ((s.commissioning_window_duration_seconds ?? 0) > 0 ? s.commissioning_window_duration_seconds! : null))
|
|
33
|
+
: null,
|
|
34
|
+
);
|
|
22
35
|
</script>
|
|
23
36
|
|
|
24
37
|
{#if node.statusLoading && !s}
|
|
@@ -54,8 +67,8 @@
|
|
|
54
67
|
{#if s.commissioning_window_open}
|
|
55
68
|
<Badge variant="warning">
|
|
56
69
|
{t("node.pair.window_open")}
|
|
57
|
-
{#if
|
|
58
|
-
({
|
|
70
|
+
{#if windowLeft !== null}
|
|
71
|
+
<span data-testid="window-left">({windowLeft}s)</span>
|
|
59
72
|
{/if}
|
|
60
73
|
</Badge>
|
|
61
74
|
{/if}
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
// List-valued item fields (Column.multi, Filter.multi): the provider
|
|
2
|
-
// carries
|
|
2
|
+
// carries the values in `lists[key]`, one entry per value, so a value may
|
|
3
|
+
// contain a comma. The comma-separated `fields[key]` is the deprecated
|
|
4
|
+
// fallback (until v0.2.0), read only when `lists` has no such key.
|
|
3
5
|
|
|
4
|
-
/** The
|
|
6
|
+
/** The part of an item the list helpers read. */
|
|
7
|
+
export type ListSource = {
|
|
8
|
+
fields: Record<string, string>;
|
|
9
|
+
lists?: Record<string, string[]>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The values of a comma-separated field, trimmed, without empty ones.
|
|
14
|
+
*
|
|
15
|
+
* @deprecated The comma form is accepted until v0.2.0; read
|
|
16
|
+
* {@link itemValues}, which prefers `lists`.
|
|
17
|
+
*/
|
|
5
18
|
export function listValues(field: string | undefined): string[] {
|
|
6
19
|
if (!field) return [];
|
|
7
20
|
return field
|
|
@@ -11,22 +24,44 @@ export function listValues(field: string | undefined): string[] {
|
|
|
11
24
|
}
|
|
12
25
|
|
|
13
26
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
27
|
+
* The values of an item's list-valued field: `lists[key]` when the key is
|
|
28
|
+
* present there (empty entries dropped, the rest as given), otherwise the
|
|
29
|
+
* comma split of `fields[key]`.
|
|
30
|
+
*/
|
|
31
|
+
export function itemValues(item: ListSource, key: string): string[] {
|
|
32
|
+
const list = item.lists?.[key];
|
|
33
|
+
if (list) return list.filter((v) => v !== "");
|
|
34
|
+
return listValues(item.fields[key]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether an item matches a filter value on a field: by equality, or, for
|
|
39
|
+
* a list-valued field, by membership in its list. An empty value never
|
|
16
40
|
* matches a list.
|
|
17
41
|
*/
|
|
18
|
-
export function fieldMatches(
|
|
19
|
-
|
|
42
|
+
export function fieldMatches(
|
|
43
|
+
item: ListSource,
|
|
44
|
+
key: string,
|
|
45
|
+
value: string,
|
|
46
|
+
multi: boolean,
|
|
47
|
+
): boolean {
|
|
48
|
+
if (!multi) return item.fields[key] === value;
|
|
20
49
|
const v = value.trim();
|
|
21
|
-
return v !== "" &&
|
|
50
|
+
return v !== "" && itemValues(item, key).includes(v);
|
|
22
51
|
}
|
|
23
52
|
|
|
24
53
|
/** The distinct filter values of a field across items, sorted for display. */
|
|
25
|
-
export function distinctValues(
|
|
54
|
+
export function distinctValues(
|
|
55
|
+
items: ListSource[],
|
|
56
|
+
key: string,
|
|
57
|
+
multi: boolean,
|
|
58
|
+
): string[] {
|
|
26
59
|
const values = new Set<string>();
|
|
27
|
-
for (const
|
|
28
|
-
if (multi) for (const v of
|
|
29
|
-
else if (
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
if (multi) for (const v of itemValues(item, key)) values.add(v);
|
|
62
|
+
else if (item.fields[key]) values.add(item.fields[key]);
|
|
30
63
|
}
|
|
31
|
-
return [...values].sort((a, b) =>
|
|
64
|
+
return [...values].sort((a, b) =>
|
|
65
|
+
a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }),
|
|
66
|
+
);
|
|
32
67
|
}
|
package/src/lib/store.svelte.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// host-readiness gate dropped.
|
|
8
8
|
import { ApiError, type FabricClient, type FabricList, type ExposureSchema, type Item, type Status, type Update, type Window } from "./api/client";
|
|
9
9
|
import { subscribeStream, type StreamEvent } from "./api/stream";
|
|
10
|
+
import { secondsUntil } from "./countdown";
|
|
10
11
|
import type { Translate } from "./i18n";
|
|
11
12
|
|
|
12
13
|
export interface Toast {
|
|
@@ -172,6 +173,9 @@ export function createNodeStore(client: FabricClient, deps: NodeStoreDeps) {
|
|
|
172
173
|
if (!st.enabled || !st.commissioning_window_open) return;
|
|
173
174
|
const p = await client.pairing();
|
|
174
175
|
const duration = p.duration_seconds ?? st.commissioning_window_duration_seconds ?? 0;
|
|
176
|
+
// The deadline says what is left; without one (a server before 0.1.2)
|
|
177
|
+
// the full duration is the upper bound.
|
|
178
|
+
const left = secondsUntil(st.window_deadline) ?? (duration > 0 ? duration : null);
|
|
175
179
|
commissioning = {
|
|
176
180
|
phase: "open",
|
|
177
181
|
window: {
|
|
@@ -181,11 +185,10 @@ export function createNodeStore(client: FabricClient, deps: NodeStoreDeps) {
|
|
|
181
185
|
qr_code: p.qr_code,
|
|
182
186
|
manual_code: p.manual_code,
|
|
183
187
|
},
|
|
184
|
-
|
|
185
|
-
remaining: duration > 0 ? duration : null,
|
|
188
|
+
remaining: left,
|
|
186
189
|
addedFabricLabel: null,
|
|
187
190
|
};
|
|
188
|
-
if (
|
|
191
|
+
if (left !== null && left > 0) startCountdown(left);
|
|
189
192
|
}
|
|
190
193
|
|
|
191
194
|
async function closeWindow() {
|