@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sukramj/fabric-ui",
3
- "version": "0.1.0",
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": "20.14.6",
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",
@@ -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 Only for a window this handler opened on fresh credentials; the codes carry it. */
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 Only for a window this handler opened on fresh credentials. */
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 Absent for a window on the configured credentials. */
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 Absent for a window on the configured credentials. */
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
+ }
@@ -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
  }
@@ -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
- type ValueFilter = { field: string; label: string; options: { value: string; label: string }[] };
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 = [...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 })) });
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] !== value) return false;
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 (s.commissioning_window_duration_seconds ?? 0) > 0}
54
- ({s.commissioning_window_duration_seconds}s)
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
+ }
@@ -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
- // Without a server-side remaining time the full duration is the upper bound.
185
- remaining: duration > 0 ? duration : null,
188
+ remaining: left,
186
189
  addedFabricLabel: null,
187
190
  };
188
- if (duration > 0) startCountdown(duration);
191
+ if (left !== null && left > 0) startCountdown(left);
189
192
  }
190
193
 
191
194
  async function closeWindow() {