@reddb-io/ui 0.3.0 → 0.3.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.
Files changed (121) hide show
  1. package/build/_app/immutable/assets/0.BWZTMKZW.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/{C9rXzYLu.js → B-yl15ta.js} +1 -1
  5. package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
  6. package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
  7. package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
  8. package/build/_app/immutable/chunks/Bgb6fHGb.js +18 -0
  9. package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
  10. package/build/_app/immutable/chunks/C-kqMbRi.js +1 -0
  11. package/build/_app/immutable/chunks/C3SGj6Qd.js +2 -0
  12. package/build/_app/immutable/chunks/{C278awGv.js → C7kf23SQ.js} +1 -1
  13. package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
  14. package/build/_app/immutable/chunks/CHvQYbpl.js +1 -0
  15. package/build/_app/immutable/chunks/CO7K4bD2.js +1164 -0
  16. package/build/_app/immutable/chunks/CP65IkJc.js +1 -0
  17. package/build/_app/immutable/chunks/CxnvJShw.js +404 -0
  18. package/build/_app/immutable/chunks/D02PB2u7.js +1 -0
  19. package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
  20. package/build/_app/immutable/chunks/DN7v3Tar.js +2 -0
  21. package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
  22. package/build/_app/immutable/entry/app.BeX4Tmu5.js +2 -0
  23. package/build/_app/immutable/entry/start.D7y1MZex.js +1 -0
  24. package/build/_app/immutable/nodes/0.C0e5KQVb.js +2 -0
  25. package/build/_app/immutable/nodes/1.ceiVuZbB.js +1 -0
  26. package/build/_app/immutable/nodes/10.DeFqKRR2.js +96 -0
  27. package/build/_app/immutable/nodes/12.CzMvqoYS.js +3 -0
  28. package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
  29. package/build/_app/immutable/nodes/15.CRDBqbiH.js +60 -0
  30. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  31. package/build/_app/version.json +1 -1
  32. package/build/index.html +18 -15
  33. package/dist/embed/AnalyticsView-SKyGxeAx.js +23337 -0
  34. package/dist/embed/AnalyticsView-SKyGxeAx.js.map +1 -0
  35. package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js +31960 -0
  36. package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js.map +1 -0
  37. package/dist/embed/event-BvRbkLjD.js +46 -0
  38. package/dist/embed/event-BvRbkLjD.js.map +1 -0
  39. package/dist/embed/index-0hA141D2.js +85 -0
  40. package/dist/embed/index-0hA141D2.js.map +1 -0
  41. package/dist/embed/index-BVxMkfHe.js +8 -0
  42. package/dist/embed/index-BVxMkfHe.js.map +1 -0
  43. package/dist/embed/index-BY6fc1oQ.js +856 -0
  44. package/dist/embed/index-BY6fc1oQ.js.map +1 -0
  45. package/dist/embed/index-client-BHUUkI8H.js +3304 -0
  46. package/dist/embed/index-client-BHUUkI8H.js.map +1 -0
  47. package/dist/embed/index.js +1 -1
  48. package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-BawyGZ3s.js} +2 -2
  49. package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-BawyGZ3s.js.map} +1 -1
  50. package/dist/embed/window-ZR8_mM8o.js +1889 -0
  51. package/dist/embed/window-ZR8_mM8o.js.map +1 -0
  52. package/package.json +7 -3
  53. package/src/app.css +86 -20
  54. package/src/lib/AnalyticsView.svelte +209 -0
  55. package/src/lib/AskView.svelte +255 -0
  56. package/src/lib/ClusterView.svelte +415 -95
  57. package/src/lib/CommandPalette.svelte +2 -0
  58. package/src/lib/ConnectDropdown.svelte +60 -3
  59. package/src/lib/DevConsole.svelte +155 -0
  60. package/src/lib/ErrorBoundary.fixture.svelte +11 -0
  61. package/src/lib/ErrorBoundary.fixtures.svelte.ts +5 -0
  62. package/src/lib/ErrorBoundary.svelte +78 -0
  63. package/src/lib/ErrorBoundaryCrashChild.fixture.svelte +9 -0
  64. package/src/lib/GlobalProgressBar.svelte +9 -0
  65. package/src/lib/LoadingOverlay.svelte +109 -0
  66. package/src/lib/MetricChart.svelte +85 -0
  67. package/src/lib/QueryEditor.svelte +48 -7
  68. package/src/lib/ResultsPane.svelte +76 -8
  69. package/src/lib/SchemaTree.svelte +25 -1
  70. package/src/lib/SecurityView.svelte +176 -65
  71. package/src/lib/SettingsView.svelte +1111 -254
  72. package/src/lib/StatusBar.svelte +19 -1
  73. package/src/lib/Topbar.svelte +29 -1
  74. package/src/lib/Workspace.svelte +136 -5
  75. package/src/lib/activity.svelte.ts +44 -35
  76. package/src/lib/collection-preview.ts +14 -0
  77. package/src/lib/connections.svelte.ts +167 -4
  78. package/src/lib/embed/RedUiEmbedRoot.svelte +13 -8
  79. package/src/lib/embed/index.ts +34 -1
  80. package/src/lib/index.ts +10 -0
  81. package/src/lib/loading.svelte.ts +142 -0
  82. package/src/lib/permission-gate.ts +120 -0
  83. package/src/lib/policies.ts +136 -0
  84. package/src/lib/query-tabs.svelte.ts +132 -53
  85. package/src/lib/reddb/analytics.ts +208 -0
  86. package/src/lib/reddb/ask.ts +123 -0
  87. package/src/lib/reddb/client.ts +292 -73
  88. package/src/lib/reddb/cluster-status.ts +306 -0
  89. package/src/lib/reddb/desktop-error.ts +89 -0
  90. package/src/lib/reddb/dev-console.ts +186 -0
  91. package/src/lib/reddb/index.ts +5 -0
  92. package/src/lib/reddb/local-url-provider.ts +28 -1
  93. package/src/lib/reddb/redwire-ws-spike.ts +590 -0
  94. package/src/lib/reddb/tauri-encrypted-store.ts +4 -9
  95. package/src/lib/router.svelte.ts +12 -0
  96. package/src/lib/settings-authoring-client.ts +336 -0
  97. package/src/lib/settings-sections.ts +292 -196
  98. package/src/lib/tabs.svelte.ts +5 -0
  99. package/src/lib/workspace-session.ts +81 -0
  100. package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
  101. package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
  102. package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
  103. package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
  104. package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
  105. package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
  106. package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
  107. package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
  108. package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
  109. package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
  110. package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
  111. package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
  112. package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
  113. package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
  114. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
  115. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
  116. package/dist/embed/index-DGhgtnaK.js +0 -577
  117. package/dist/embed/index-DGhgtnaK.js.map +0 -1
  118. package/dist/embed/index-client-ItCEU64C.js +0 -3087
  119. package/dist/embed/index-client-ItCEU64C.js.map +0 -1
  120. /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
  121. /package/build/_app/immutable/nodes/{10.CAG5_cGP.js → 13.CAG5_cGP.js} +0 -0
@@ -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
+ }
@@ -0,0 +1,89 @@
1
+ // The single classifier for desktop-command failures (#115).
2
+ //
3
+ // Tauri's `invoke` rejects with whatever the Rust `Err` serialized to. Every
4
+ // desktop command now returns a structured `{ code, message, detail? }` shape
5
+ // (see apps/desktop/src-tauri/src/lib.rs), but older builds and Tauri's own
6
+ // plugin/runtime failures can still reject with a bare string or an `Error`.
7
+ // This module is the ONE place that inspects the raw thrown value and
8
+ // normalizes it, so the standalone-Surface adapters never re-implement the
9
+ // `e instanceof Error ? … : String(e)` coercion dance.
10
+
11
+ /** The serializable error shape returned by Rust desktop commands. */
12
+ export interface DesktopCommandError {
13
+ /** Stable machine token, e.g. `KEYCHAIN`, `EMBEDDED_TIMEOUT`, `SIDECAR_SPAWN`. */
14
+ code: string;
15
+ /** Human-readable summary. */
16
+ message: string;
17
+ /** Underlying cause (OS/keyring/serde message) when the command had one. */
18
+ detail?: string;
19
+ }
20
+
21
+ /** Code used for legacy string errors and anything not shaped as a command error. */
22
+ export const UNKNOWN_DESKTOP_ERROR_CODE = "UNKNOWN";
23
+
24
+ /** Type guard for the structured shape a desktop command rejects with. */
25
+ export function isDesktopCommandError(x: unknown): x is DesktopCommandError {
26
+ return (
27
+ typeof x === "object" &&
28
+ x !== null &&
29
+ typeof (x as DesktopCommandError).code === "string" &&
30
+ typeof (x as DesktopCommandError).message === "string"
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Normalize any value thrown by a desktop command into a `DesktopCommandError`.
36
+ * Structured errors pass through (with an empty/absent `detail` dropped); bare
37
+ * strings, `Error` instances, and everything else collapse to the `UNKNOWN`
38
+ * code so callers always get a `{ code, message }` pair.
39
+ */
40
+ export function classifyDesktopError(e: unknown): DesktopCommandError {
41
+ if (isDesktopCommandError(e)) {
42
+ const detail = e.detail;
43
+ return {
44
+ code: e.code,
45
+ message: e.message,
46
+ ...(typeof detail === "string" && detail.length > 0 ? { detail } : {}),
47
+ };
48
+ }
49
+ if (typeof e === "string") {
50
+ return { code: UNKNOWN_DESKTOP_ERROR_CODE, message: e };
51
+ }
52
+ if (e instanceof Error) {
53
+ return { code: UNKNOWN_DESKTOP_ERROR_CODE, message: e.message || String(e) };
54
+ }
55
+ return { code: UNKNOWN_DESKTOP_ERROR_CODE, message: String(e) };
56
+ }
57
+
58
+ /** Human-readable one-liner for a classified desktop error. */
59
+ export function desktopErrorMessage(e: unknown): string {
60
+ const c = classifyDesktopError(e);
61
+ return c.detail ? `${c.message}: ${c.detail}` : c.message;
62
+ }
63
+
64
+ /**
65
+ * An `Error` carrying the classified `code`/`detail`, with `message` set to the
66
+ * human-readable one-liner. Adapters throw this so downstream consumers (e.g.
67
+ * the connection provider) can read `.message` without any coercion, while
68
+ * still being able to branch on `.code`.
69
+ */
70
+ export class DesktopError extends Error {
71
+ readonly code: string;
72
+ readonly detail?: string;
73
+
74
+ constructor(classified: DesktopCommandError) {
75
+ super(
76
+ classified.detail
77
+ ? `${classified.message}: ${classified.detail}`
78
+ : classified.message
79
+ );
80
+ this.name = "DesktopError";
81
+ this.code = classified.code;
82
+ this.detail = classified.detail;
83
+ }
84
+
85
+ /** Classify a raw thrown value and wrap it. */
86
+ static from(e: unknown): DesktopError {
87
+ return e instanceof DesktopError ? e : new DesktopError(classifyDesktopError(e));
88
+ }
89
+ }
@@ -0,0 +1,186 @@
1
+ // Instrumented developer console (#128). A deliberately framework-agnostic,
2
+ // rune-free observable store so the data layer (`RedClient`, transports) can
3
+ // log every query and HTTP call *without* importing Svelte — the client seam
4
+ // must not depend on the UI runtime. The Svelte panel bridges this store into
5
+ // reactivity by subscribing and copying the snapshot into `$state`.
6
+ //
7
+ // Every entry is one round-trip the client made: the verb (HTTP method /
8
+ // statement head), the target (path or endpoint), how long it took, how many
9
+ // rows came back, and a *sanitized* payload — secrets are redacted before an
10
+ // entry is ever stored, so a copy action can never leak a token.
11
+
12
+ /** What kind of round-trip an entry records. */
13
+ export type ConsoleEntryKind = "query" | "http";
14
+
15
+ export interface ConsoleEntry {
16
+ /** Monotonic id, assigned on record. Stable for keying/dedup in the panel. */
17
+ id: number;
18
+ kind: ConsoleEntryKind;
19
+ /** HTTP method or statement head, e.g. `POST`, `GET`, `SELECT`. */
20
+ verb: string;
21
+ /** Endpoint path or logical target the call hit, e.g. `/query`. */
22
+ target: string;
23
+ /** Absolute wall-clock time the call started. */
24
+ startedAt: number;
25
+ /** Round-trip duration in milliseconds. */
26
+ durationMs: number;
27
+ /** True when the call succeeded (2xx / resolved). */
28
+ ok: boolean;
29
+ /** HTTP status code when one is known. */
30
+ status?: number;
31
+ /** Rows returned, when the response carries a row/record count. */
32
+ rowCount?: number;
33
+ /** Sanitized request payload or query text — copyable, never contains secrets. */
34
+ payload?: string;
35
+ /** Error text when the call failed. */
36
+ error?: string;
37
+ }
38
+
39
+ /** A record as handed to the store — the store owns `id`. */
40
+ export type ConsoleEntryInput = Omit<ConsoleEntry, "id">;
41
+
42
+ /**
43
+ * A per-entry tap on the console. Unlike `subscribe` (which observes the whole
44
+ * snapshot for rendering), a sink receives each entry exactly once, as it is
45
+ * recorded — the shape an embedding host wants for mirroring red-ui's traffic
46
+ * into its own developer console.
47
+ */
48
+ export type ConsoleSink = (entry: ConsoleEntry) => void;
49
+
50
+ type Listener = (entries: readonly ConsoleEntry[]) => void;
51
+
52
+ /** Keys whose values are always masked, matched case-insensitively as substrings. */
53
+ const SENSITIVE_KEY_RE =
54
+ /pass(word)?|secret|token|api[-_]?key|authorization|auth|credential|cookie|session|bearer|private[-_]?key|mfa/i;
55
+
56
+ const REDACTED = "«redacted»";
57
+
58
+ /**
59
+ * Recursively redact sensitive values from an arbitrary structure, returning a
60
+ * plain clone safe to stringify. A value under a sensitive key is replaced with
61
+ * a marker rather than dropped, so the reader still sees *that* a field existed.
62
+ * Depth- and breadth-bounded so a pathological payload can't blow the console.
63
+ */
64
+ export function redactSecrets(value: unknown, depth = 0): unknown {
65
+ if (depth > 6) return "«…»";
66
+ if (Array.isArray(value)) {
67
+ return value.slice(0, 100).map((v) => redactSecrets(v, depth + 1));
68
+ }
69
+ if (value && typeof value === "object") {
70
+ const out: Record<string, unknown> = {};
71
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
72
+ out[k] = SENSITIVE_KEY_RE.test(k)
73
+ ? REDACTED
74
+ : redactSecrets(v, depth + 1);
75
+ }
76
+ return out;
77
+ }
78
+ return value;
79
+ }
80
+
81
+ /**
82
+ * Turn a request body (a JSON string, an object, or anything else) into a
83
+ * sanitized, human-readable string for the entry's copyable payload. Parses
84
+ * JSON so keys can be redacted; falls back to a truncated raw string when the
85
+ * body isn't JSON. Returns `undefined` for an absent body.
86
+ */
87
+ export function sanitizePayload(body: unknown): string | undefined {
88
+ if (body === undefined || body === null) return undefined;
89
+ let parsed: unknown = body;
90
+ if (typeof body === "string") {
91
+ if (body.length === 0) return undefined;
92
+ try {
93
+ parsed = JSON.parse(body);
94
+ } catch {
95
+ return body.length > 2000 ? `${body.slice(0, 2000)}…` : body;
96
+ }
97
+ }
98
+ try {
99
+ const json = JSON.stringify(redactSecrets(parsed), null, 2);
100
+ return json.length > 4000 ? `${json.slice(0, 4000)}…` : json;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * The developer console log. A bounded ring of entries with a minimal
108
+ * subscribe/emit surface — no framework, no runes. `record` is the single
109
+ * write path the client seam calls; `subscribe` is how the panel observes.
110
+ */
111
+ export class DevConsoleStore {
112
+ private entries: ConsoleEntry[] = [];
113
+ private readonly listeners = new Set<Listener>();
114
+ private readonly sinks = new Set<ConsoleSink>();
115
+ private nextId = 1;
116
+
117
+ /** Cap on retained entries; oldest are dropped past this. */
118
+ constructor(private readonly capacity = 500) {}
119
+
120
+ /** Append one round-trip. Assigns the id, trims to capacity, notifies. */
121
+ record(input: ConsoleEntryInput): ConsoleEntry {
122
+ const entry: ConsoleEntry = { ...input, id: this.nextId++ };
123
+ this.entries.push(entry);
124
+ if (this.entries.length > this.capacity) {
125
+ this.entries.splice(0, this.entries.length - this.capacity);
126
+ }
127
+ this.emit();
128
+ for (const sink of this.sinks) {
129
+ try {
130
+ sink(entry);
131
+ } catch {
132
+ // A host-owned sink must never be able to break the client seam.
133
+ }
134
+ }
135
+ return entry;
136
+ }
137
+
138
+ /**
139
+ * Tap every future entry. Entries recorded before the sink was added are
140
+ * not replayed — a host mirrors live traffic, it doesn't import history.
141
+ * Returns an unsubscribe.
142
+ */
143
+ addSink(sink: ConsoleSink): () => void {
144
+ this.sinks.add(sink);
145
+ return () => {
146
+ this.sinks.delete(sink);
147
+ };
148
+ }
149
+
150
+ /** A frozen, newest-last snapshot of the log. */
151
+ snapshot(): readonly ConsoleEntry[] {
152
+ return this.entries.slice();
153
+ }
154
+
155
+ /** Number of retained entries. */
156
+ get size(): number {
157
+ return this.entries.length;
158
+ }
159
+
160
+ /** Drop every entry and notify. */
161
+ clear(): void {
162
+ if (this.entries.length === 0) return;
163
+ this.entries = [];
164
+ this.emit();
165
+ }
166
+
167
+ /**
168
+ * Observe the log. The listener fires immediately with the current snapshot
169
+ * and again on every change. Returns an unsubscribe.
170
+ */
171
+ subscribe(listener: Listener): () => void {
172
+ this.listeners.add(listener);
173
+ listener(this.snapshot());
174
+ return () => {
175
+ this.listeners.delete(listener);
176
+ };
177
+ }
178
+
179
+ private emit(): void {
180
+ const snap = this.snapshot();
181
+ for (const listener of this.listeners) listener(snap);
182
+ }
183
+ }
184
+
185
+ /** The singleton the client seam records into and the panel observes. */
186
+ export const devConsole = new DevConsoleStore();
@@ -1,6 +1,10 @@
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";
7
+ export * from "./dev-console";
4
8
  export * from "./connection-provider";
5
9
  export * from "./local-url-provider";
6
10
  export * from "./injected-client-provider";
@@ -12,3 +16,4 @@ export * from "./cdc-stream-client";
12
16
  export * from "./secure-store";
13
17
  export * from "./web-encrypted-store";
14
18
  export * from "./tauri-encrypted-store";
19
+ export * from "./desktop-error";
@@ -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,26 @@ 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
+ // The resolver (the standalone-Surface adapter) already classified the
185
+ // command failure into an Error with a human-readable message — no
186
+ // defensive shape-coercion here; just carry the reason through.
187
+ const reason = e instanceof Error ? e.message : String(e);
188
+ throw new UnreachableConnectionError(target.id, reason);
189
+ }
190
+ }
191
+
165
192
  const token = this.consumeHandoffToken(target.url);
166
193
  const client = this.clientFactory(
167
- target.url,
194
+ clientUrl,
168
195
  token ? { headers: { Authorization: `Bearer ${token}` } } : undefined
169
196
  );
170
197
  const ping = await client.ping();