@sukramj/fabric-ui 0.1.0 → 0.1.2
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 +2 -2
- package/src/lib/api/client.ts +1 -0
- package/src/lib/api/schema.gen.ts +36 -4
- package/src/lib/countdown.ts +14 -0
- package/src/lib/i18n/de.json +3 -0
- package/src/lib/i18n/en.json +3 -0
- package/src/lib/pages/node/HostWarnings.svelte +41 -0
- package/src/lib/pages/node/Selection.svelte +27 -8
- package/src/lib/pages/node/Status.svelte +20 -3
- package/src/lib/pages/node/fields.ts +32 -0
- package/src/lib/store.svelte.ts +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sukramj/fabric-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
package/src/lib/api/client.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { components } from "./schema.gen";
|
|
|
8
8
|
type S = components["schemas"];
|
|
9
9
|
export type Problem = S["Problem"];
|
|
10
10
|
export type Status = S["Status"];
|
|
11
|
+
export type Warning = S["Warning"];
|
|
11
12
|
export type Fabric = S["Fabric"];
|
|
12
13
|
export type FabricList = S["FabricList"];
|
|
13
14
|
export type Pairing = S["Pairing"];
|
|
@@ -497,10 +497,33 @@ 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. */
|
|
503
513
|
csrf_token?: string;
|
|
514
|
+
/** @description The host's findings about its own configuration; absent when the host reports none (no Options.Warnings), empty when it finds nothing. */
|
|
515
|
+
warnings?: components["schemas"]["Warning"][];
|
|
516
|
+
};
|
|
517
|
+
Warning: {
|
|
518
|
+
/** @description The kind of finding, stable across host releases. */
|
|
519
|
+
code: string;
|
|
520
|
+
/** @description What is wrong, in the host's language. */
|
|
521
|
+
message: string;
|
|
522
|
+
/**
|
|
523
|
+
* Format: date-time
|
|
524
|
+
* @description When the host first saw it; absent when unknown.
|
|
525
|
+
*/
|
|
526
|
+
since?: string;
|
|
504
527
|
};
|
|
505
528
|
Fabric: {
|
|
506
529
|
fabric_index: number;
|
|
@@ -520,9 +543,9 @@ export interface components {
|
|
|
520
543
|
fabrics: components["schemas"]["Fabric"][];
|
|
521
544
|
};
|
|
522
545
|
Pairing: {
|
|
523
|
-
/** @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. */
|
|
524
547
|
discriminator?: number;
|
|
525
|
-
/** @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. */
|
|
526
549
|
passcode?: number;
|
|
527
550
|
qr_code: string;
|
|
528
551
|
manual_code: string;
|
|
@@ -536,17 +559,20 @@ export interface components {
|
|
|
536
559
|
duration_seconds?: number;
|
|
537
560
|
};
|
|
538
561
|
Window: {
|
|
539
|
-
/** @description
|
|
562
|
+
/** @description The window's discriminator; absent from servers before 0.1.2 for a window on the configured credentials. */
|
|
540
563
|
discriminator?: number;
|
|
541
|
-
/** @description
|
|
564
|
+
/** @description The window's passcode; absent from servers before 0.1.2 for a window on the configured credentials. */
|
|
542
565
|
passcode?: number;
|
|
543
566
|
duration_seconds: number;
|
|
544
567
|
qr_code: string;
|
|
545
568
|
manual_code: string;
|
|
546
569
|
};
|
|
570
|
+
/** @description One cluster server mounted on the endpoint, in its Descriptor's ServerList order. */
|
|
547
571
|
Cluster: {
|
|
548
572
|
id: number;
|
|
573
|
+
/** @description The schema's cluster name; absent for a cluster the schema does not know. */
|
|
549
574
|
name?: string;
|
|
575
|
+
/** @description The schema's ClusterRevision; absent for a cluster the schema does not know. */
|
|
550
576
|
revision?: number;
|
|
551
577
|
};
|
|
552
578
|
Endpoint: {
|
|
@@ -554,6 +580,8 @@ export interface components {
|
|
|
554
580
|
parent_endpoint_id: number;
|
|
555
581
|
device_type: number;
|
|
556
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;
|
|
557
585
|
reachable: boolean;
|
|
558
586
|
name: string;
|
|
559
587
|
/** @description Opaque identity of the host's source; `key` is the endpoint's stable key. */
|
|
@@ -653,6 +681,8 @@ export interface components {
|
|
|
653
681
|
label: string;
|
|
654
682
|
/** @description Offer a value filter built from the listed values. */
|
|
655
683
|
filter: boolean;
|
|
684
|
+
/** @description Default false. The field is a comma-separated list: shown as chips, filtered by membership, the filter's options built from the individual values. */
|
|
685
|
+
multi?: boolean;
|
|
656
686
|
};
|
|
657
687
|
FilterOption: {
|
|
658
688
|
value: string;
|
|
@@ -662,6 +692,8 @@ export interface components {
|
|
|
662
692
|
field: string;
|
|
663
693
|
label: string;
|
|
664
694
|
options: components["schemas"]["FilterOption"][];
|
|
695
|
+
/** @description Default false. Match by membership in the field's comma-separated list (values trimmed; an empty value never matches) instead of equality. */
|
|
696
|
+
multi?: boolean;
|
|
665
697
|
};
|
|
666
698
|
ExposureSchema: {
|
|
667
699
|
columns: components["schemas"]["Column"][];
|
|
@@ -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
|
+
}
|
package/src/lib/i18n/de.json
CHANGED
|
@@ -152,6 +152,9 @@
|
|
|
152
152
|
"node.tab.selection": "Auswahl",
|
|
153
153
|
"node.tab.status": "Status",
|
|
154
154
|
"node.title": "Knoten",
|
|
155
|
+
"node.warnings.since": "seit {time}",
|
|
156
|
+
"node.warnings.summary": "{count} Konfigurationswarnungen",
|
|
157
|
+
"node.warnings.title": "Konfigurationswarnungen",
|
|
155
158
|
"select.placeholder": "Auswählen…",
|
|
156
159
|
"shell.language": "Sprache"
|
|
157
160
|
}
|
package/src/lib/i18n/en.json
CHANGED
|
@@ -152,6 +152,9 @@
|
|
|
152
152
|
"node.tab.selection": "Selection",
|
|
153
153
|
"node.tab.status": "Status",
|
|
154
154
|
"node.title": "Node",
|
|
155
|
+
"node.warnings.since": "since {time}",
|
|
156
|
+
"node.warnings.summary": "{count} configuration warnings",
|
|
157
|
+
"node.warnings.title": "Configuration warnings",
|
|
155
158
|
"select.placeholder": "Select…",
|
|
156
159
|
"shell.language": "Language"
|
|
157
160
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<!-- The host's findings about its own configuration (Status.warnings):
|
|
2
|
+
a card on the status page, a collapsed list above the selection. -->
|
|
3
|
+
<script lang="ts">
|
|
4
|
+
import { useFabricUI } from "../../context";
|
|
5
|
+
import type { Warning } from "../../api/client";
|
|
6
|
+
import Badge from "../../kit/Badge.svelte";
|
|
7
|
+
import Card from "../../kit/Card.svelte";
|
|
8
|
+
|
|
9
|
+
type Props = { warnings: Warning[]; collapsed?: boolean };
|
|
10
|
+
let { warnings, collapsed = false }: Props = $props();
|
|
11
|
+
|
|
12
|
+
const { t } = useFabricUI();
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
{#snippet list()}
|
|
16
|
+
<ul class="space-y-2">
|
|
17
|
+
{#each warnings as w, i (i)}
|
|
18
|
+
<li class="flex flex-wrap items-center gap-2 text-sm">
|
|
19
|
+
<Badge variant="warning">{w.code}</Badge>
|
|
20
|
+
<span class="text-[var(--ha-primary-text-color)]">{w.message}</span>
|
|
21
|
+
{#if w.since}
|
|
22
|
+
<span class="text-xs text-[var(--ha-secondary-text-color)]">{t("node.warnings.since", { time: new Date(w.since).toLocaleString() })}</span>
|
|
23
|
+
{/if}
|
|
24
|
+
</li>
|
|
25
|
+
{/each}
|
|
26
|
+
</ul>
|
|
27
|
+
{/snippet}
|
|
28
|
+
|
|
29
|
+
{#if warnings.length > 0}
|
|
30
|
+
{#if collapsed}
|
|
31
|
+
<details class="mb-3 rounded-md border border-[var(--ha-divider-color)] px-3 py-2" data-testid="host-warnings">
|
|
32
|
+
<summary class="cursor-pointer text-sm font-medium text-[var(--ha-warning-color)]">{t("node.warnings.summary", { count: warnings.length })}</summary>
|
|
33
|
+
<div class="mt-2">{@render list()}</div>
|
|
34
|
+
</details>
|
|
35
|
+
{:else}
|
|
36
|
+
<Card class="mb-4 p-4" data-testid="host-warnings">
|
|
37
|
+
<h3 class="mb-2 font-medium text-[var(--ha-primary-text-color)]">{t("node.warnings.title")}</h3>
|
|
38
|
+
{@render list()}
|
|
39
|
+
</Card>
|
|
40
|
+
{/if}
|
|
41
|
+
{/if}
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
assets/ui/src/routes/matter/MatterExposureList.svelte:1-584 at
|
|
6
6
|
b40b289a: the host's fixed columns, kind filter and per-item conflict
|
|
7
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
|
|
8
|
+
value filter per filterable column; Preview before Apply is new, and so
|
|
9
|
+
are list-valued fields (Column.multi, Filter.multi) and the host's
|
|
10
|
+
warnings above the list. -->
|
|
9
11
|
<script lang="ts">
|
|
10
12
|
import { onMount } from "svelte";
|
|
11
13
|
import { useFabricUI } from "../../context";
|
|
@@ -24,6 +26,8 @@
|
|
|
24
26
|
import Input from "../../kit/Input.svelte";
|
|
25
27
|
import LoadingState from "../../kit/LoadingState.svelte";
|
|
26
28
|
import Select from "../../kit/Select.svelte";
|
|
29
|
+
import HostWarnings from "./HostWarnings.svelte";
|
|
30
|
+
import { distinctValues, fieldMatches, listValues } from "./fields";
|
|
27
31
|
|
|
28
32
|
const { t, toast, node } = useFabricUI();
|
|
29
33
|
|
|
@@ -32,6 +36,9 @@
|
|
|
32
36
|
const unavailable = $derived(node.status !== null && !node.status.exposure);
|
|
33
37
|
|
|
34
38
|
onMount(() => {
|
|
39
|
+
// The status carries the host's warnings; a host page that mounts the
|
|
40
|
+
// selection alone has not loaded it yet.
|
|
41
|
+
if (node.status === null && !node.statusLoading) void node.loadStatus();
|
|
35
42
|
if (!unavailable) void node.loadExposure();
|
|
36
43
|
});
|
|
37
44
|
|
|
@@ -49,19 +56,21 @@
|
|
|
49
56
|
const schema = $derived(node.schema);
|
|
50
57
|
|
|
51
58
|
// The value filters: the host's fixed filters, then one per filterable
|
|
52
|
-
// column with the distinct values the items carry
|
|
53
|
-
|
|
59
|
+
// column with the distinct values the items carry — for a list-valued
|
|
60
|
+
// column the individual values, not their combinations.
|
|
61
|
+
type ValueFilter = { field: string; label: string; multi: boolean; options: { value: string; label: string }[] };
|
|
54
62
|
const valueFilters = $derived.by<ValueFilter[]>(() => {
|
|
55
63
|
if (!schema) return [];
|
|
56
|
-
const out: ValueFilter[] = schema.filters.map((f) => ({ field: f.field, label: f.label, options: f.options }));
|
|
64
|
+
const out: ValueFilter[] = schema.filters.map((f) => ({ field: f.field, label: f.label, multi: !!f.multi, options: f.options }));
|
|
57
65
|
for (const col of schema.columns) {
|
|
58
66
|
if (!col.filter || out.some((f) => f.field === col.key)) continue;
|
|
59
|
-
const values =
|
|
60
|
-
|
|
61
|
-
out.push({ field: col.key, label: col.label, options: values.map((v) => ({ value: v, label: v })) });
|
|
67
|
+
const values = distinctValues(node.items.map((i) => i.fields[col.key]), !!col.multi);
|
|
68
|
+
out.push({ field: col.key, label: col.label, multi: !!col.multi, options: values.map((v) => ({ value: v, label: v })) });
|
|
62
69
|
}
|
|
63
70
|
return out;
|
|
64
71
|
});
|
|
72
|
+
const multiField = $derived(new Set(valueFilters.filter((f) => f.multi).map((f) => f.field)));
|
|
73
|
+
const multiColumn = $derived(new Set((schema?.columns ?? []).filter((c) => c.multi).map((c) => "field:" + c.key)));
|
|
65
74
|
|
|
66
75
|
// The item as the page shows it: with its pending change applied.
|
|
67
76
|
function current(item: Item): Item {
|
|
@@ -74,7 +83,7 @@
|
|
|
74
83
|
const match = makeTextMatcher(search);
|
|
75
84
|
return node.items.filter((item) => {
|
|
76
85
|
for (const [field, value] of Object.entries(filterValues)) {
|
|
77
|
-
if (value !== ALL && item.fields[field]
|
|
86
|
+
if (value !== ALL && !fieldMatches(item.fields[field], value, multiField.has(field))) return false;
|
|
78
87
|
}
|
|
79
88
|
if (!search.trim()) return true;
|
|
80
89
|
const it = current(item);
|
|
@@ -218,6 +227,15 @@
|
|
|
218
227
|
</button>
|
|
219
228
|
{:else if col.key === "device_type"}
|
|
220
229
|
<span class="text-[var(--ha-secondary-text-color)]">{item.verdict.device_type_name || "—"}</span>
|
|
230
|
+
{:else if multiColumn.has(col.key)}
|
|
231
|
+
{@const values = listValues(item.fields[col.key.slice("field:".length)])}
|
|
232
|
+
{#if values.length === 0}
|
|
233
|
+
<span class="text-[var(--ha-secondary-text-color)]">—</span>
|
|
234
|
+
{:else}
|
|
235
|
+
<span class="inline-flex flex-wrap gap-1">
|
|
236
|
+
{#each values as v, i (i)}<Badge variant="muted">{v}</Badge>{/each}
|
|
237
|
+
</span>
|
|
238
|
+
{/if}
|
|
221
239
|
{:else}
|
|
222
240
|
<span class="text-[var(--ha-secondary-text-color)]">{col.get?.(item) || "—"}</span>
|
|
223
241
|
{/if}
|
|
@@ -230,6 +248,7 @@
|
|
|
230
248
|
{:else if node.exposureError}
|
|
231
249
|
<ErrorState message={node.exposureError} onRetry={() => void node.loadExposure()} />
|
|
232
250
|
{:else if schema}
|
|
251
|
+
<HostWarnings warnings={node.status?.warnings ?? []} collapsed />
|
|
233
252
|
<div class="mb-3 flex flex-col gap-2">
|
|
234
253
|
<div class="flex flex-wrap items-center gap-2">
|
|
235
254
|
<Input placeholder={t("node.selection.search_placeholder")} bind:value={search} class="w-full sm:w-64" />
|
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
<!-- The node's status: whether it runs, listens, is healthy and
|
|
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
|
-
at b40b289a, without the tabs (the shell or the host navigates).
|
|
4
|
+
at b40b289a, without the tabs (the shell or the host navigates). The
|
|
5
|
+
host's configuration warnings come first, in their own card. An open
|
|
6
|
+
window counts down from the deadline the status reports. -->
|
|
5
7
|
<script lang="ts">
|
|
6
8
|
import { onMount } from "svelte";
|
|
7
9
|
import { useFabricUI } from "../../context";
|
|
10
|
+
import { secondsUntil } from "../../countdown";
|
|
8
11
|
import Badge from "../../kit/Badge.svelte";
|
|
9
12
|
import Card from "../../kit/Card.svelte";
|
|
10
13
|
import ErrorState from "../../kit/ErrorState.svelte";
|
|
11
14
|
import LoadingState from "../../kit/LoadingState.svelte";
|
|
15
|
+
import HostWarnings from "./HostWarnings.svelte";
|
|
12
16
|
|
|
13
17
|
const { t, node } = useFabricUI();
|
|
14
18
|
|
|
19
|
+
let now = $state(Date.now());
|
|
20
|
+
|
|
15
21
|
onMount(() => {
|
|
16
22
|
void node.loadStatus();
|
|
23
|
+
const tick = setInterval(() => (now = Date.now()), 1000);
|
|
24
|
+
return () => clearInterval(tick);
|
|
17
25
|
});
|
|
18
26
|
|
|
19
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
|
+
);
|
|
20
35
|
</script>
|
|
21
36
|
|
|
22
37
|
{#if node.statusLoading && !s}
|
|
@@ -24,10 +39,12 @@
|
|
|
24
39
|
{:else if node.statusError && !s}
|
|
25
40
|
<ErrorState message={node.statusError} onRetry={() => void node.loadStatus()} />
|
|
26
41
|
{:else if s && !s.enabled}
|
|
42
|
+
<HostWarnings warnings={s.warnings ?? []} />
|
|
27
43
|
<Card class="p-4">
|
|
28
44
|
<p class="text-sm font-medium text-[var(--ha-secondary-text-color)]">{t("node.status.disabled")}</p>
|
|
29
45
|
</Card>
|
|
30
46
|
{:else if s}
|
|
47
|
+
<HostWarnings warnings={s.warnings ?? []} />
|
|
31
48
|
<Card class="p-4" data-testid="node-status">
|
|
32
49
|
<div class="flex flex-wrap items-center gap-4">
|
|
33
50
|
<span class="font-medium text-[var(--ha-primary-text-color)]">{t("node.status.enabled")}</span>
|
|
@@ -50,8 +67,8 @@
|
|
|
50
67
|
{#if s.commissioning_window_open}
|
|
51
68
|
<Badge variant="warning">
|
|
52
69
|
{t("node.pair.window_open")}
|
|
53
|
-
{#if
|
|
54
|
-
({
|
|
70
|
+
{#if windowLeft !== null}
|
|
71
|
+
<span data-testid="window-left">({windowLeft}s)</span>
|
|
55
72
|
{/if}
|
|
56
73
|
</Badge>
|
|
57
74
|
{/if}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// List-valued item fields (Column.multi, Filter.multi): the provider
|
|
2
|
+
// carries several values as one comma-separated string.
|
|
3
|
+
|
|
4
|
+
/** The values of a comma-separated field, trimmed, without empty ones. */
|
|
5
|
+
export function listValues(field: string | undefined): string[] {
|
|
6
|
+
if (!field) return [];
|
|
7
|
+
return field
|
|
8
|
+
.split(",")
|
|
9
|
+
.map((v) => v.trim())
|
|
10
|
+
.filter((v) => v !== "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether an item's field matches a filter value: by equality, or, for a
|
|
15
|
+
* list-valued field, by membership in its list. An empty value never
|
|
16
|
+
* matches a list.
|
|
17
|
+
*/
|
|
18
|
+
export function fieldMatches(field: string | undefined, value: string, multi: boolean): boolean {
|
|
19
|
+
if (!multi) return field === value;
|
|
20
|
+
const v = value.trim();
|
|
21
|
+
return v !== "" && listValues(field).includes(v);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The distinct filter values of a field across items, sorted for display. */
|
|
25
|
+
export function distinctValues(fields: (string | undefined)[], multi: boolean): string[] {
|
|
26
|
+
const values = new Set<string>();
|
|
27
|
+
for (const f of fields) {
|
|
28
|
+
if (multi) for (const v of listValues(f)) values.add(v);
|
|
29
|
+
else if (f) values.add(f);
|
|
30
|
+
}
|
|
31
|
+
return [...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }));
|
|
32
|
+
}
|
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() {
|