@reddb-io/ui 0.2.0 → 0.3.1

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.
Files changed (96) hide show
  1. package/build/_app/immutable/assets/0.DgbuWrJy.css +1 -0
  2. package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
  3. package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
  4. package/build/_app/immutable/chunks/85-Fh5Zp.js +404 -0
  5. package/build/_app/immutable/chunks/{BHBXmd1Y.js → 8zg2gJwV.js} +1 -1
  6. package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
  7. package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
  8. package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
  9. package/build/_app/immutable/chunks/Bq4DTWK2.js +2 -0
  10. package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
  11. package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
  12. package/build/_app/immutable/chunks/CJFJ501n.js +18 -0
  13. package/build/_app/immutable/chunks/CLBx21Hz.js +2 -0
  14. package/build/_app/immutable/chunks/CnD4hwxC.js +1106 -0
  15. package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
  16. package/build/_app/immutable/chunks/DQCYUNtS.js +1 -0
  17. package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
  18. package/build/_app/immutable/chunks/{C278awGv.js → DV9PDoM3.js} +1 -1
  19. package/build/_app/immutable/entry/app.BDog2y0J.js +2 -0
  20. package/build/_app/immutable/entry/start.D1J45dq5.js +1 -0
  21. package/build/_app/immutable/nodes/0.BTrC4th2.js +1 -0
  22. package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +1 -0
  23. package/build/_app/immutable/nodes/10.D_FCLs0Z.js +96 -0
  24. package/build/_app/immutable/nodes/12.DPE6v6Zf.js +3 -0
  25. package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
  26. package/build/_app/immutable/nodes/15.CcJlZGxa.js +60 -0
  27. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  28. package/build/_app/version.json +1 -1
  29. package/build/index.html +18 -15
  30. package/dist/embed/AnalyticsView-D8z1Nsed.js +23337 -0
  31. package/dist/embed/AnalyticsView-D8z1Nsed.js.map +1 -0
  32. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js +31302 -0
  33. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js.map +1 -0
  34. package/dist/embed/index-0hA141D2.js +85 -0
  35. package/dist/embed/index-0hA141D2.js.map +1 -0
  36. package/dist/embed/index-client-BeiP-va5.js +3303 -0
  37. package/dist/embed/index-client-BeiP-va5.js.map +1 -0
  38. package/dist/embed/index-w1Qo_uW0.js +677 -0
  39. package/dist/embed/index-w1Qo_uW0.js.map +1 -0
  40. package/dist/embed/index.js +1 -1
  41. package/dist/embed/{sigma.esm-C2pqg5JH.js → sigma.esm-qqKBpvin.js} +2 -2
  42. package/dist/embed/{sigma.esm-C2pqg5JH.js.map → sigma.esm-qqKBpvin.js.map} +1 -1
  43. package/package.json +5 -3
  44. package/src/app.css +86 -20
  45. package/src/lib/AnalyticsView.svelte +209 -0
  46. package/src/lib/AskView.svelte +255 -0
  47. package/src/lib/ClusterView.svelte +415 -95
  48. package/src/lib/CommandPalette.svelte +2 -0
  49. package/src/lib/GlobalProgressBar.svelte +9 -0
  50. package/src/lib/MetricChart.svelte +85 -0
  51. package/src/lib/QueryEditor.svelte +48 -7
  52. package/src/lib/ResultsPane.svelte +76 -8
  53. package/src/lib/SchemaTree.svelte +25 -1
  54. package/src/lib/SecurityView.svelte +176 -65
  55. package/src/lib/SettingsView.svelte +1137 -226
  56. package/src/lib/Topbar.svelte +29 -1
  57. package/src/lib/Workspace.svelte +18 -3
  58. package/src/lib/activity.svelte.ts +44 -35
  59. package/src/lib/collection-preview.ts +14 -0
  60. package/src/lib/connections.svelte.ts +73 -4
  61. package/src/lib/index.ts +3 -0
  62. package/src/lib/permission-gate.ts +120 -0
  63. package/src/lib/policies.ts +136 -0
  64. package/src/lib/query-tabs.svelte.ts +132 -53
  65. package/src/lib/reddb/analytics.ts +208 -0
  66. package/src/lib/reddb/ask.ts +123 -0
  67. package/src/lib/reddb/client.ts +159 -58
  68. package/src/lib/reddb/cluster-status.ts +306 -0
  69. package/src/lib/reddb/index.ts +3 -0
  70. package/src/lib/reddb/local-url-provider.ts +32 -1
  71. package/src/lib/reddb/redwire-ws-spike.ts +590 -0
  72. package/src/lib/router.svelte.ts +12 -0
  73. package/src/lib/settings-authoring-client.ts +336 -0
  74. package/src/lib/settings-sections.ts +294 -180
  75. package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
  76. package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
  77. package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
  78. package/build/_app/immutable/chunks/BMc_qoms.js +0 -973
  79. package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
  80. package/build/_app/immutable/chunks/CwxFwvow.js +0 -1
  81. package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
  82. package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
  83. package/build/_app/immutable/entry/app.CGfmRH-N.js +0 -2
  84. package/build/_app/immutable/entry/start._LhFJimm.js +0 -1
  85. package/build/_app/immutable/nodes/0.BYhY6O5-.js +0 -1
  86. package/build/_app/immutable/nodes/1.4XYGRMul.js +0 -1
  87. package/build/_app/immutable/nodes/12.DXZfNPNK.js +0 -60
  88. package/build/_app/immutable/nodes/8.C7OOsMOJ.js +0 -134
  89. package/dist/embed/RedUiEmbedRoot-Dia9lxfb.js +0 -28570
  90. package/dist/embed/RedUiEmbedRoot-Dia9lxfb.js.map +0 -1
  91. package/dist/embed/index-Bq0TT7Ao.js +0 -577
  92. package/dist/embed/index-Bq0TT7Ao.js.map +0 -1
  93. package/dist/embed/index-client-ItCEU64C.js +0 -3087
  94. package/dist/embed/index-client-ItCEU64C.js.map +0 -1
  95. /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
  96. /package/build/_app/immutable/nodes/{10.CAG5_cGP.js → 13.CAG5_cGP.js} +0 -0
@@ -7,6 +7,14 @@ import {
7
7
  type RawCapabilities,
8
8
  type ServerCapabilities,
9
9
  } from "./server-capabilities";
10
+ import type {
11
+ PermissionCheck,
12
+ PermissionDecision,
13
+ PermissionResource,
14
+ } from "../permission-gate";
15
+ import type { ClusterStatus, ReplicationStatus } from "./cluster-status";
16
+ import type { MetricDescriptor, PromResponse } from "./analytics";
17
+ import type { AskResponse, AskOptions } from "./ask";
10
18
 
11
19
  export interface QueryRow {
12
20
  values: Record<string, unknown>;
@@ -104,59 +112,6 @@ export interface Stats {
104
112
  read_only_reason?: string;
105
113
  }
106
114
 
107
- export interface ClusterStatus {
108
- ok?: boolean;
109
- deployment?: {
110
- mode?: "embedded" | "server" | "docker" | "replicated" | string;
111
- file_path?: string;
112
- process_kind?: string;
113
- container_id?: string;
114
- image?: string;
115
- configured_by?: string;
116
- };
117
- storage?: {
118
- capacity_bytes?: number;
119
- used_bytes?: number;
120
- free_bytes?: number;
121
- };
122
- wal?: {
123
- size_bytes?: number;
124
- lsn?: number;
125
- oldest_lsn?: number;
126
- };
127
- throughput?: {
128
- requests_per_second?: number;
129
- reads_per_second?: number;
130
- writes_per_second?: number;
131
- avg_response_ms?: number;
132
- };
133
- system?: {
134
- cpu_usage_percent?: number;
135
- memory_used_bytes?: number;
136
- memory_total_bytes?: number;
137
- };
138
- replication?: {
139
- replica_count?: number;
140
- lag_ms?: number;
141
- lag_lsn?: number;
142
- };
143
- }
144
-
145
- export interface ReplicationStatus {
146
- ok: boolean;
147
- role: "primary" | "replica" | "standalone";
148
- state?: "healthy" | "lagging" | "disconnected";
149
- primary_addr?: string;
150
- /** primary perspective */
151
- wal_lsn?: number;
152
- oldest_lsn?: number;
153
- replica_count?: number;
154
- /** replica perspective */
155
- last_applied_lsn?: number;
156
- last_seen_primary_lsn?: number;
157
- last_seen_oldest_lsn?: number;
158
- }
159
-
160
115
  export interface Whoami {
161
116
  ok: boolean;
162
117
  authenticated: boolean;
@@ -199,6 +154,12 @@ export interface AuthPolicy {
199
154
  created_at?: number;
200
155
  }
201
156
 
157
+ interface AuthCanResponse {
158
+ ok?: boolean;
159
+ results?: Array<Partial<PermissionDecision>>;
160
+ error?: string;
161
+ }
162
+
202
163
  export interface ChangeEvent {
203
164
  lsn: number;
204
165
  timestamp: number;
@@ -280,6 +241,18 @@ function unsupportedCollectionMetadataError(baseUrl: string): Error {
280
241
  );
281
242
  }
282
243
 
244
+ function isPermissionResource(value: unknown): value is PermissionResource {
245
+ if (!value || typeof value !== "object") return false;
246
+ const candidate = value as Partial<PermissionResource>;
247
+ return (
248
+ typeof candidate.kind === "string" &&
249
+ candidate.kind.length > 0 &&
250
+ typeof candidate.name === "string" &&
251
+ candidate.name.length > 0 &&
252
+ (candidate.tenant === undefined || typeof candidate.tenant === "string")
253
+ );
254
+ }
255
+
283
256
  /** One NDJSON frame from `POST /query/stream`. Discriminated by its single key. */
284
257
  type NdjsonFrame =
285
258
  | { descriptor: { columns: string[]; schema_fingerprint?: string } }
@@ -320,6 +293,27 @@ async function* parseNdjsonFrames(
320
293
 
321
294
  const SELECT_RE = /^\s*select\b/i;
322
295
 
296
+ /**
297
+ * Extract a human-readable message from any thrown value. Tauri's `invoke` and
298
+ * the HTTP plugin reject with plain strings or bare objects (not `Error`), so
299
+ * `(e as Error).message` is often `undefined` — this never returns that.
300
+ */
301
+ export function errorText(e: unknown): string {
302
+ if (e instanceof Error) return e.message || String(e);
303
+ if (typeof e === "string") return e;
304
+ if (e && typeof e === "object") {
305
+ const m = (e as { message?: unknown }).message;
306
+ if (typeof m === "string" && m) return m;
307
+ try {
308
+ const s = JSON.stringify(e);
309
+ if (s && s !== "{}") return s;
310
+ } catch {
311
+ /* circular — fall through */
312
+ }
313
+ }
314
+ return String(e);
315
+ }
316
+
323
317
  /**
324
318
  * Run a query, preferring the bounded-memory NDJSON stream for a plain SELECT
325
319
  * and falling back to the buffered `POST /query` for everything else — and for
@@ -334,7 +328,9 @@ export async function runQueryPreferStream(
334
328
  sql: string,
335
329
  opts: { maxRows?: number; signal?: AbortSignal } = {}
336
330
  ): Promise<QueryResult> {
337
- if (!SELECT_RE.test(sql)) return client.query(sql);
331
+ const queryOpts = opts.signal ? { signal: opts.signal } : undefined;
332
+ if (!SELECT_RE.test(sql))
333
+ return queryOpts ? client.query(sql, queryOpts) : client.query(sql);
338
334
  try {
339
335
  const s = await client.queryStreamCollect(sql, opts);
340
336
  return {
@@ -348,8 +344,14 @@ export async function runQueryPreferStream(
348
344
  streamed: true,
349
345
  truncated: s.truncated,
350
346
  };
351
- } catch {
352
- return client.query(sql);
347
+ } catch (e) {
348
+ if (
349
+ opts.signal?.aborted ||
350
+ (e instanceof Error && e.name === "AbortError")
351
+ ) {
352
+ throw e;
353
+ }
354
+ return queryOpts ? client.query(sql, queryOpts) : client.query(sql);
353
355
  }
354
356
  }
355
357
 
@@ -390,6 +392,60 @@ export class RedClient {
390
392
  return this.json<ClusterStatus>("/cluster/status");
391
393
  }
392
394
 
395
+ /**
396
+ * List registered metric descriptors from the typed `red.metrics`
397
+ * projection (reddb #747). One row per `CREATE METRIC`, carrying the
398
+ * `kind` (counter|gauge|histogram|ratio|derived) the UI uses to auto-select
399
+ * a chart. Returns `[]` when the server has no analytics surface.
400
+ */
401
+ async metricsCatalog(): Promise<MetricDescriptor[]> {
402
+ const r = await this.query(
403
+ "SELECT path, kind, role, unit, supports_prometheus_query, created_at_ms FROM red.metrics"
404
+ );
405
+ return r.result.records.map((rec) => {
406
+ const v = rec.values;
407
+ return {
408
+ path: String(v.path ?? ""),
409
+ kind: v.kind as MetricDescriptor["kind"],
410
+ role: String(v.role ?? "metric"),
411
+ unit: (v.unit as string | null) ?? null,
412
+ supports_prometheus_query: v.supports_prometheus_query !== false,
413
+ created_at_ms:
414
+ typeof v.created_at_ms === "number" ? v.created_at_ms : undefined,
415
+ } satisfies MetricDescriptor;
416
+ });
417
+ }
418
+
419
+ /**
420
+ * Prometheus instant query (`GET /api/v1/query`). Returns the raw
421
+ * Prometheus-shaped envelope; callers normalise with `parsePromVector`.
422
+ */
423
+ async promQuery(query: string, time?: number): Promise<PromResponse> {
424
+ const qs = new URLSearchParams({ query });
425
+ if (time !== undefined) qs.set("time", String(time));
426
+ return this.json<PromResponse>(`/api/v1/query?${qs}`);
427
+ }
428
+
429
+ /**
430
+ * Prometheus range query (`GET /api/v1/query_range`). `start`/`end` are unix
431
+ * seconds, `step` is the resolution in seconds. Normalise with
432
+ * `parsePromMatrix`.
433
+ */
434
+ async promQueryRange(
435
+ query: string,
436
+ start: number,
437
+ end: number,
438
+ step: number
439
+ ): Promise<PromResponse> {
440
+ const qs = new URLSearchParams({
441
+ query,
442
+ start: String(start),
443
+ end: String(end),
444
+ step: String(step),
445
+ });
446
+ return this.json<PromResponse>(`/api/v1/query_range?${qs}`);
447
+ }
448
+
393
449
  async ping(): Promise<
394
450
  { ok: true; rtt_ms: number } | { ok: false; error: string }
395
451
  > {
@@ -398,7 +454,7 @@ export class RedClient {
398
454
  await this.json("/stats");
399
455
  return { ok: true, rtt_ms: Math.round(performance.now() - start) };
400
456
  } catch (e) {
401
- return { ok: false, error: (e as Error).message };
457
+ return { ok: false, error: errorText(e) };
402
458
  }
403
459
  }
404
460
 
@@ -497,10 +553,14 @@ export class RedClient {
497
553
  );
498
554
  }
499
555
 
500
- async query(query: string): Promise<QueryResult> {
556
+ async query(
557
+ query: string,
558
+ opts: { signal?: AbortSignal } = {}
559
+ ): Promise<QueryResult> {
501
560
  return this.json<QueryResult>("/query", {
502
561
  method: "POST",
503
562
  body: JSON.stringify({ query }),
563
+ signal: opts.signal,
504
564
  });
505
565
  }
506
566
 
@@ -654,6 +714,20 @@ export class RedClient {
654
714
  return this.json<Whoami>("/auth/whoami");
655
715
  }
656
716
 
717
+ /**
718
+ * Grounded natural-language query (`POST /ai/ask`, ADR 0013). Returns the
719
+ * synthesised answer plus the `sources_flat` it was grounded on and the
720
+ * `[^N]` citations linking claims to sources. Requires the server's AI
721
+ * provider to be configured; an unconfigured server answers with an error
722
+ * status (surfaced by `json()`).
723
+ */
724
+ async ask(question: string, opts: AskOptions = {}): Promise<AskResponse> {
725
+ return this.json<AskResponse>("/ai/ask", {
726
+ method: "POST",
727
+ body: JSON.stringify({ question, ...opts }),
728
+ });
729
+ }
730
+
657
731
  async users(): Promise<AuthUser[]> {
658
732
  const r = await this.json<{ ok: boolean; users: AuthUser[] }>(
659
733
  "/auth/users"
@@ -675,6 +749,33 @@ export class RedClient {
675
749
  return r.policies ?? [];
676
750
  }
677
751
 
752
+ async authCan(checks: PermissionCheck[]): Promise<PermissionDecision[]> {
753
+ const r = await this.json<AuthCanResponse>("/auth/can", {
754
+ method: "POST",
755
+ body: JSON.stringify({ checks }),
756
+ });
757
+ if (!Array.isArray(r.results)) {
758
+ throw new Error(r.error ?? "POST /auth/can returned no results");
759
+ }
760
+ return checks.map((check, index) => {
761
+ const result = r.results?.[index] ?? {};
762
+ const resource = isPermissionResource(result.resource)
763
+ ? result.resource
764
+ : check.resource;
765
+ return {
766
+ action:
767
+ typeof result.action === "string" ? result.action : check.action,
768
+ resource,
769
+ current_tenant:
770
+ typeof result.current_tenant === "string"
771
+ ? result.current_tenant
772
+ : check.current_tenant,
773
+ allowed: result.allowed === true,
774
+ reason: typeof result.reason === "string" ? result.reason : undefined,
775
+ };
776
+ });
777
+ }
778
+
678
779
  async changes(
679
780
  sinceLsn?: number,
680
781
  opts: { collection?: string; limit?: number } = {}
@@ -0,0 +1,306 @@
1
+ // Normalised view of reddb's `/cluster/status` and `/replication/status`
2
+ // payloads, plus the topology classification the UI hangs per-mode telemetry
3
+ // off of.
4
+ //
5
+ // Shapes are pinned against the reddb source of truth — do NOT infer them
6
+ // from older UI code, which predates the #738 cluster-status contract:
7
+ // - cluster/status: crates/reddb-server/src/presentation/cluster_status_json.rs
8
+ // - replication/status: crates/reddb-server/src/server/handlers_replication.rs
9
+ //
10
+ // Honesty contract (#738): any field reddb cannot measure in the current
11
+ // build is serialised as an `{ "available": false, "reason": "<token>" }`
12
+ // envelope rather than a fabricated default. `Measurable<T>` models that — a
13
+ // field is either its natural value or that envelope.
14
+
15
+ /** Server's honest "cannot measure this" envelope (#738). */
16
+ export interface Unavailable {
17
+ available: false;
18
+ reason: string;
19
+ }
20
+
21
+ /** A value the server may or may not be able to measure in this build. */
22
+ export type Measurable<T> = T | Unavailable;
23
+
24
+ export function isUnavailable(value: unknown): value is Unavailable {
25
+ return (
26
+ !!value &&
27
+ typeof value === "object" &&
28
+ (value as { available?: unknown }).available === false
29
+ );
30
+ }
31
+
32
+ /** The measured value, or `undefined` when the server marked it unavailable. */
33
+ export function measured<T>(
34
+ value: Measurable<T> | null | undefined
35
+ ): T | undefined {
36
+ if (value === null || value === undefined || isUnavailable(value))
37
+ return undefined;
38
+ return value;
39
+ }
40
+
41
+ /** The stable reason token when a field is unavailable, else `undefined`. */
42
+ export function reasonOf(value: unknown): string | undefined {
43
+ return isUnavailable(value) ? value.reason : undefined;
44
+ }
45
+
46
+ export type DeploymentShape = "embedded" | "file" | "server" | "serverless";
47
+ export type ProcessRole = "standalone" | "primary" | "replica";
48
+ /** Lifecycle phase, `crates/reddb-server/src/runtime/lifecycle.rs`. */
49
+ export type LifecyclePhase =
50
+ | "starting"
51
+ | "ready"
52
+ | "draining"
53
+ | "shutting_down"
54
+ | "stopped";
55
+
56
+ export interface TransportListener {
57
+ transport: string;
58
+ bind_addr: string;
59
+ explicit: boolean;
60
+ reason?: string;
61
+ }
62
+
63
+ export interface ClusterStatusReplica {
64
+ id: string;
65
+ last_acked_lsn: number;
66
+ last_sent_lsn: number;
67
+ last_durable_lsn: number;
68
+ last_seen_at_unix_ms: number;
69
+ /** primary head LSN − this replica's last ack. */
70
+ lag_records: number;
71
+ region: string | null;
72
+ }
73
+
74
+ /** Real `/cluster/status` envelope (#738). */
75
+ export interface ClusterStatus {
76
+ snapshot_at_unix_ms?: number;
77
+ version?: string;
78
+ phase?: LifecyclePhase | string;
79
+ uptime_secs?: number;
80
+ started_at_unix_ms?: number;
81
+ /** Present once the process reached `ready`. */
82
+ ready_at_unix_ms?: number;
83
+ read_only?: boolean;
84
+ deployment?: {
85
+ shape?: Measurable<DeploymentShape>;
86
+ process_role?: Measurable<ProcessRole>;
87
+ container?: Measurable<Record<string, unknown>>;
88
+ };
89
+ transports?: {
90
+ active?: TransportListener[];
91
+ failed?: TransportListener[];
92
+ };
93
+ connections?: {
94
+ active: number;
95
+ idle: number;
96
+ total_checkouts: number;
97
+ max: Measurable<number>;
98
+ };
99
+ storage?: {
100
+ db_size_bytes?: Measurable<number>;
101
+ remote_backend?: string | null;
102
+ paged_mode?: boolean;
103
+ encryption_at_rest?: { state: string; error?: string };
104
+ };
105
+ wal?: {
106
+ current_lsn: number;
107
+ last_archived_lsn: number;
108
+ archive_lag_records: number;
109
+ bytes: Measurable<number>;
110
+ };
111
+ system?: {
112
+ pid: number;
113
+ cpu_cores: number;
114
+ os: string;
115
+ arch: string;
116
+ hostname: string;
117
+ total_memory_bytes?: Measurable<number>;
118
+ available_memory_bytes?: Measurable<number>;
119
+ cpu_usage?: Measurable<number>;
120
+ ram_usage?: Measurable<number>;
121
+ };
122
+ throughput?: Measurable<{
123
+ requests_per_second?: number;
124
+ reads_per_second?: number;
125
+ writes_per_second?: number;
126
+ }>;
127
+ latency?: Measurable<{ avg_response_ms?: number }>;
128
+ last_error?: Measurable<{ message?: string; at_unix_ms?: number }>;
129
+ replication?: {
130
+ role?: Measurable<ProcessRole>;
131
+ commit_policy?: string;
132
+ replica_count?: number;
133
+ replicas?: ClusterStatusReplica[];
134
+ apply_health?: Measurable<string>;
135
+ degraded?: Measurable<boolean>;
136
+ apply_errors?: Record<string, number>;
137
+ };
138
+ /** Defensive: older servers may still wrap responses in `{ ok }`. */
139
+ ok?: boolean;
140
+ }
141
+
142
+ /** Per-replica view from a primary's `/replication/status`. */
143
+ export interface ReplicationReplica {
144
+ id: string;
145
+ last_acked_lsn: number;
146
+ last_sent_lsn: number;
147
+ /** primary head − this replica's ack (record distance). */
148
+ lag_lsn: number;
149
+ /** wall-clock seconds since the primary last heard from the replica. */
150
+ lag_seconds: number;
151
+ rebootstrapping: boolean;
152
+ }
153
+
154
+ export interface ReplicationSlot {
155
+ id: string;
156
+ restart_lsn: number;
157
+ confirmed_lsn: number;
158
+ invalidated: boolean;
159
+ invalidation_reason?: string;
160
+ }
161
+
162
+ /** Real `/replication/status` envelope, primary + replica perspectives. */
163
+ export interface ReplicationStatus {
164
+ ok: boolean;
165
+ role: ProcessRole;
166
+ current_term?: number;
167
+ is_leader?: boolean;
168
+ leader?: string;
169
+ state?:
170
+ | "idle"
171
+ | "connecting"
172
+ | "healthy"
173
+ | "lagging"
174
+ | "disconnected"
175
+ | string;
176
+ // primary perspective
177
+ wal_lsn?: number;
178
+ commit_watermark?: number;
179
+ oldest_lsn?: number;
180
+ replica_count?: number;
181
+ replicas?: ReplicationReplica[];
182
+ full_resync_count?: number;
183
+ partial_resync_count?: number;
184
+ replication_lag_lsn?: number;
185
+ safe_replay_lsn?: number;
186
+ retention_floor_lsn?: number;
187
+ slots?: ReplicationSlot[];
188
+ // replica perspective
189
+ primary_addr?: string;
190
+ last_applied_lsn?: number;
191
+ last_seen_primary_lsn?: number;
192
+ last_seen_oldest_lsn?: number;
193
+ last_error?: string;
194
+ }
195
+
196
+ /**
197
+ * The four user-facing topologies, plus `server` (a single networked node that
198
+ * is neither serverless nor part of a replication set) and `unknown`. Derived
199
+ * from the orthogonal axes the server exposes: deployment *shape* (where it
200
+ * runs) × process *role* (its replication relationship).
201
+ */
202
+ export type NodeTopology =
203
+ | "embedded"
204
+ | "serverless"
205
+ | "replicated"
206
+ | "server"
207
+ | "unknown";
208
+
209
+ /**
210
+ * Classify a node's topology. Role wins over shape: a node in a replication
211
+ * relationship is `replicated` regardless of where it runs. Falls back to the
212
+ * preset's configured role when the server exposes neither (older builds).
213
+ */
214
+ export function topologyOf(
215
+ cluster?: ClusterStatus,
216
+ replication?: ReplicationStatus,
217
+ presetRole?: string
218
+ ): NodeTopology {
219
+ const role =
220
+ measured(cluster?.deployment?.process_role) ??
221
+ measured(cluster?.replication?.role) ??
222
+ replication?.role;
223
+ if (role === "primary" || role === "replica") return "replicated";
224
+
225
+ const shape = measured(cluster?.deployment?.shape);
226
+ if (shape === "serverless") return "serverless";
227
+ if (shape === "embedded" || shape === "file") return "embedded";
228
+ if (shape === "server") return "server";
229
+
230
+ // Server exposed nothing usable — lean on how the preset was configured.
231
+ if (presetRole === "primary" || presetRole === "replica") return "replicated";
232
+ if (presetRole === "embedded") return "embedded";
233
+ if (presetRole === "standalone" || presetRole === "server") return "server";
234
+ return "unknown";
235
+ }
236
+
237
+ /**
238
+ * Cold-start duration in ms for a freshly-booted node: `ready_at - started_at`.
239
+ * `null` when either timestamp is missing (e.g. not yet ready). This is the
240
+ * serverless hero metric the user asked for — boot time.
241
+ */
242
+ export function bootDurationMs(cluster?: ClusterStatus): number | null {
243
+ const started = cluster?.started_at_unix_ms;
244
+ const ready = cluster?.ready_at_unix_ms;
245
+ if (started === undefined || ready === undefined) return null;
246
+ const delta = ready - started;
247
+ return delta >= 0 ? delta : null;
248
+ }
249
+
250
+ /**
251
+ * Worst wall-clock replication lag across replicas, in seconds, from a
252
+ * primary's `/replication/status`. `null` when no replicas are observed or the
253
+ * field is absent. This answers "how long is replication taking".
254
+ */
255
+ export function maxReplicaLagSeconds(
256
+ replication?: ReplicationStatus
257
+ ): number | null {
258
+ const replicas = replication?.replicas;
259
+ if (!replicas || replicas.length === 0) return null;
260
+ let worst = 0;
261
+ let seen = false;
262
+ for (const r of replicas) {
263
+ if (typeof r.lag_seconds === "number" && Number.isFinite(r.lag_seconds)) {
264
+ worst = Math.max(worst, r.lag_seconds);
265
+ seen = true;
266
+ }
267
+ }
268
+ return seen ? worst : null;
269
+ }
270
+
271
+ /** One unavailable field the server explicitly declined to measure. */
272
+ export interface UnavailableField {
273
+ /** Dotted path into the payload, e.g. `system.cpu_usage`. */
274
+ path: string;
275
+ reason: string;
276
+ }
277
+
278
+ /**
279
+ * Walk the cluster-status payload and collect every field the server marked
280
+ * `{ available: false }`, with its reason. This replaces the UI's previously
281
+ * hand-maintained "not exposed" list — the server is now the source of truth
282
+ * for what it cannot measure and why (#738).
283
+ */
284
+ export function unavailableFields(cluster?: ClusterStatus): UnavailableField[] {
285
+ if (!cluster) return [];
286
+ const out: UnavailableField[] = [];
287
+ const walk = (value: unknown, path: string, depth: number) => {
288
+ if (isUnavailable(value)) {
289
+ out.push({ path, reason: value.reason });
290
+ return;
291
+ }
292
+ if (
293
+ depth >= 4 ||
294
+ !value ||
295
+ typeof value !== "object" ||
296
+ Array.isArray(value)
297
+ ) {
298
+ return;
299
+ }
300
+ for (const [key, child] of Object.entries(value)) {
301
+ walk(child, path ? `${path}.${key}` : key, depth + 1);
302
+ }
303
+ };
304
+ walk(cluster, "", 0);
305
+ return out;
306
+ }
@@ -1,5 +1,8 @@
1
1
  export * from "./uri";
2
2
  export * from "./types";
3
+ export * from "./cluster-status";
4
+ export * from "./analytics";
5
+ export * from "./ask";
3
6
  export * from "./client";
4
7
  export * from "./connection-provider";
5
8
  export * from "./local-url-provider";
@@ -50,6 +50,14 @@ export interface LocalUrlProviderOptions {
50
50
  * auto-connect without the Connect flow. Tokens must never be placed here.
51
51
  */
52
52
  bootParams?: BootParams;
53
+ /**
54
+ * Resolve a `file://` connection to a reachable URL (#embedded-file). Only a
55
+ * Tauri Surface supplies this: it spawns a local file-backed reddb sidecar
56
+ * and returns its `http://127.0.0.1:<port>` URL, which the HTTP client then
57
+ * talks to. Absent on the browser Surface, where `file://` is unreachable by
58
+ * design. Receives the original `file://…` string, returns the client URL.
59
+ */
60
+ embeddedResolver?: (fileUrl: string) => Promise<string>;
53
61
  }
54
62
 
55
63
  export interface HandoffTokenConsumer {
@@ -102,6 +110,7 @@ export class LocalUrlProvider implements ConnectionProvider {
102
110
  private readonly historyMax: number;
103
111
  private readonly supportedTransports: Transport[];
104
112
  private readonly boot: BootParams | null;
113
+ private readonly embeddedResolver?: (fileUrl: string) => Promise<string>;
105
114
  private readonly handoffTokens = new Map<string, string>();
106
115
  private active: ActiveConnection | null = null;
107
116
 
@@ -120,6 +129,7 @@ export class LocalUrlProvider implements ConnectionProvider {
120
129
  (hasBootEndpoint(opts.bootParams) || hasBootRoute(opts.bootParams))
121
130
  ? { ...opts.bootParams }
122
131
  : null;
132
+ this.embeddedResolver = opts.embeddedResolver;
123
133
  }
124
134
 
125
135
  transports(): Transport[] {
@@ -162,9 +172,30 @@ export class LocalUrlProvider implements ConnectionProvider {
162
172
  const target = this.resolve(id);
163
173
  if (!target) throw new UnknownConnectionError(id);
164
174
 
175
+ // A `file://` target is opened by the Surface's embedded resolver (Tauri
176
+ // spawns a local file-backed reddb and hands back its http URL). The
177
+ // displayed connection keeps the `file://` identity; only the client talks
178
+ // to the resolved local URL.
179
+ let clientUrl = target.url;
180
+ if (this.embeddedResolver && /^file:\/\//i.test(target.url)) {
181
+ try {
182
+ clientUrl = await this.embeddedResolver(target.url);
183
+ } catch (e) {
184
+ // Tauri's `invoke` rejects with a plain string (the Rust `Err`), not an
185
+ // Error — so read the message defensively instead of assuming `.message`.
186
+ const reason =
187
+ e instanceof Error
188
+ ? e.message
189
+ : typeof e === "string"
190
+ ? e
191
+ : String(e);
192
+ throw new UnreachableConnectionError(target.id, reason);
193
+ }
194
+ }
195
+
165
196
  const token = this.consumeHandoffToken(target.url);
166
197
  const client = this.clientFactory(
167
- target.url,
198
+ clientUrl,
168
199
  token ? { headers: { Authorization: `Bearer ${token}` } } : undefined
169
200
  );
170
201
  const ping = await client.ping();