@reddb-io/ui 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/build/_app/immutable/assets/0.CEQwbG0J.css +1 -0
  2. package/build/_app/immutable/chunks/C-kqMbRi.js +1 -0
  3. package/build/_app/immutable/chunks/{CLBx21Hz.js → C3SGj6Qd.js} +2 -2
  4. package/build/_app/immutable/chunks/{DV9PDoM3.js → C9pP73gT.js} +1 -1
  5. package/build/_app/immutable/chunks/{CJFJ501n.js → CEBul73-.js} +1 -1
  6. package/build/_app/immutable/chunks/CHvQYbpl.js +1 -0
  7. package/build/_app/immutable/chunks/{8zg2gJwV.js → CXuFZWQe.js} +1 -1
  8. package/build/_app/immutable/chunks/CgIoFKBK.js +1 -0
  9. package/build/_app/immutable/chunks/D02PB2u7.js +1 -0
  10. package/build/_app/immutable/chunks/D6XJvoMn.js +404 -0
  11. package/build/_app/immutable/chunks/DLDgyIn5.js +1164 -0
  12. package/build/_app/immutable/chunks/{Bq4DTWK2.js → DN7v3Tar.js} +1 -1
  13. package/build/_app/immutable/entry/{app.BDog2y0J.js → app.BBk9O_OI.js} +2 -2
  14. package/build/_app/immutable/entry/start.C9qfj9ex.js +1 -0
  15. package/build/_app/immutable/nodes/0.BBf1ZyjL.js +2 -0
  16. package/build/_app/immutable/nodes/1.BmVeJAGw.js +1 -0
  17. package/build/_app/immutable/nodes/{10.D_FCLs0Z.js → 10.DZmZgLvj.js} +1 -1
  18. package/build/_app/immutable/nodes/{12.DPE6v6Zf.js → 12.CzMvqoYS.js} +2 -2
  19. package/build/_app/immutable/nodes/15.CRwZaU1B.js +60 -0
  20. package/build/_app/version.json +1 -1
  21. package/build/index.html +13 -13
  22. package/dist/embed/{AnalyticsView-D8z1Nsed.js → AnalyticsView-BWwvQS2I.js} +849 -849
  23. package/dist/embed/{AnalyticsView-D8z1Nsed.js.map → AnalyticsView-BWwvQS2I.js.map} +1 -1
  24. package/dist/embed/RedUiEmbedRoot-DAB-6DEr.js +35587 -0
  25. package/dist/embed/RedUiEmbedRoot-DAB-6DEr.js.map +1 -0
  26. package/dist/embed/event-BvRbkLjD.js +46 -0
  27. package/dist/embed/event-BvRbkLjD.js.map +1 -0
  28. package/dist/embed/index-BVxMkfHe.js +8 -0
  29. package/dist/embed/index-BVxMkfHe.js.map +1 -0
  30. package/dist/embed/index-Bgo4yw4C.js +856 -0
  31. package/dist/embed/index-Bgo4yw4C.js.map +1 -0
  32. package/dist/embed/{index-client-BeiP-va5.js → index-client-BHUUkI8H.js} +121 -120
  33. package/dist/embed/{index-client-BeiP-va5.js.map → index-client-BHUUkI8H.js.map} +1 -1
  34. package/dist/embed/index.js +1 -1
  35. package/dist/embed/{sigma.esm-qqKBpvin.js → sigma.esm-BOgYLguI.js} +2 -2
  36. package/dist/embed/{sigma.esm-qqKBpvin.js.map → sigma.esm-BOgYLguI.js.map} +1 -1
  37. package/dist/embed/window-ZR8_mM8o.js +1889 -0
  38. package/dist/embed/window-ZR8_mM8o.js.map +1 -0
  39. package/package.json +5 -3
  40. package/src/app.css +5 -0
  41. package/src/lib/ConnectDropdown.svelte +60 -3
  42. package/src/lib/DevConsole.svelte +155 -0
  43. package/src/lib/ErrorBoundary.fixture.svelte +11 -0
  44. package/src/lib/ErrorBoundary.fixtures.svelte.ts +5 -0
  45. package/src/lib/ErrorBoundary.svelte +78 -0
  46. package/src/lib/ErrorBoundaryCrashChild.fixture.svelte +9 -0
  47. package/src/lib/LoadingOverlay.svelte +109 -0
  48. package/src/lib/StatusBar.svelte +19 -1
  49. package/src/lib/Workspace.svelte +118 -2
  50. package/src/lib/connections.svelte.ts +95 -1
  51. package/src/lib/embed/RedUiEmbedRoot.svelte +13 -8
  52. package/src/lib/embed/index.ts +34 -1
  53. package/src/lib/index.ts +7 -0
  54. package/src/lib/loading.svelte.ts +142 -0
  55. package/src/lib/reddb/client.ts +133 -15
  56. package/src/lib/reddb/desktop-error.ts +89 -0
  57. package/src/lib/reddb/dev-console.ts +186 -0
  58. package/src/lib/reddb/index.ts +2 -0
  59. package/src/lib/reddb/local-url-provider.ts +4 -8
  60. package/src/lib/reddb/tauri-encrypted-store.ts +4 -9
  61. package/src/lib/tabs.svelte.ts +5 -0
  62. package/src/lib/workspace-session.ts +81 -0
  63. package/build/_app/immutable/assets/0.DgbuWrJy.css +0 -1
  64. package/build/_app/immutable/chunks/85-Fh5Zp.js +0 -404
  65. package/build/_app/immutable/chunks/CnD4hwxC.js +0 -1106
  66. package/build/_app/immutable/chunks/DQCYUNtS.js +0 -1
  67. package/build/_app/immutable/entry/start.D1J45dq5.js +0 -1
  68. package/build/_app/immutable/nodes/0.BTrC4th2.js +0 -1
  69. package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +0 -1
  70. package/build/_app/immutable/nodes/15.CcJlZGxa.js +0 -60
  71. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js +0 -31302
  72. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js.map +0 -1
  73. package/dist/embed/index-w1Qo_uW0.js +0 -677
  74. package/dist/embed/index-w1Qo_uW0.js.map +0 -1
package/src/lib/index.ts CHANGED
@@ -9,6 +9,13 @@ export { default as ConnectDropdown } from "./ConnectDropdown.svelte";
9
9
  export { default as EmptyState } from "./EmptyState.svelte";
10
10
  export { default as GlobalProgressBar } from "./GlobalProgressBar.svelte";
11
11
  export { default as LiveChanges } from "./LiveChanges.svelte";
12
+ export { default as LoadingOverlay } from "./LoadingOverlay.svelte";
13
+ export {
14
+ loading,
15
+ formatElapsed,
16
+ type LoadingStep,
17
+ type LoadingStepStatus,
18
+ } from "./loading.svelte";
12
19
  export { default as MasterPasswordDialog } from "./MasterPasswordDialog.svelte";
13
20
  export { default as PageHeader } from "./PageHeader.svelte";
14
21
  export { default as PendingChangesPanel } from "./PendingChangesPanel.svelte";
@@ -0,0 +1,142 @@
1
+ // Connect/boot loading model (#127). Slow opens (sidecar spawn, readiness,
2
+ // handshake, initial schema/stats fetch) get real, timestamped feedback: a
3
+ // started-at, an ordered log of steps (each with a label, optional detail, and
4
+ // its own start time + outcome), and a "current" step that stays on screen —
5
+ // with its elapsed time — while it's still running (a stalled step reads AS
6
+ // stalled). Live-or-empty applies: steps are appended by the actual connect
7
+ // sequence, never synthesized to fake progress.
8
+ //
9
+ // `$state` is compiled here (`.svelte.ts`), so the model is reactive and the
10
+ // overlay re-renders as steps land. Writes are wrapped in `untrack()` because
11
+ // the connect sequence commonly runs from inside a Svelte `$effect` (the
12
+ // layout's auto-refresh); an untracked write keeps our own mutations from
13
+ // re-firing that effect.
14
+
15
+ import { untrack } from "svelte";
16
+
17
+ export type LoadingStepStatus = "active" | "done" | "error";
18
+
19
+ export interface LoadingStep {
20
+ id: number;
21
+ label: string;
22
+ detail?: string;
23
+ /** Absolute wall-clock time the step was appended. */
24
+ startedAt: number;
25
+ /** Wall-clock duration once the step resolves; null while still active. */
26
+ durationMs: number | null;
27
+ status: LoadingStepStatus;
28
+ error?: string;
29
+ }
30
+
31
+ /**
32
+ * Format a millisecond elapsed value for the overlay badge and per-step timing.
33
+ * Sub-second reads as fractional seconds ("0.4s"); longer as one-decimal
34
+ * seconds ("12.3s"). Kept pure so the render is unit-testable without a clock.
35
+ */
36
+ export function formatElapsed(ms: number): string {
37
+ const clamped = Math.max(0, ms);
38
+ return `${(clamped / 1000).toFixed(1)}s`;
39
+ }
40
+
41
+ class LoadingStore {
42
+ /** Wall-clock time the current open began; null when nothing is opening. */
43
+ startedAt = $state<number | null>(null);
44
+ /** Headline for what's being opened, e.g. "Opening Docker primary". */
45
+ label = $state<string | null>(null);
46
+ /** Ordered, timestamped step log for the current open. */
47
+ steps = $state<LoadingStep[]>([]);
48
+ private nextId = 1;
49
+
50
+ /** True while an open is in progress. Gates the overlay. */
51
+ get active(): boolean {
52
+ return this.startedAt !== null;
53
+ }
54
+
55
+ /** The step currently on screen — the last appended one, if any. */
56
+ get current(): LoadingStep | null {
57
+ return this.steps.length ? this.steps[this.steps.length - 1] : null;
58
+ }
59
+
60
+ /** Elapsed time since the open began, for the ticking badge. */
61
+ elapsedMs(now = Date.now()): number {
62
+ return this.startedAt === null ? 0 : Math.max(0, now - this.startedAt);
63
+ }
64
+
65
+ /**
66
+ * Begin a fresh open. Resets the log and stamps the start. Any subsequent
67
+ * `step()` calls append to this open until `succeed`/`fail` finishes it.
68
+ */
69
+ begin(label: string, now = Date.now()): void {
70
+ untrack(() => {
71
+ this.startedAt = now;
72
+ this.label = label;
73
+ this.steps = [];
74
+ this.nextId = 1;
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Append a step and mark it the current (active) one, closing out the
80
+ * previous active step as done. No-op if no open is in progress — steps are
81
+ * only ever real work inside an open.
82
+ */
83
+ step(label: string, detail?: string, now = Date.now()): void {
84
+ untrack(() => {
85
+ if (this.startedAt === null) return;
86
+ const closed = this.steps.map((s) =>
87
+ s.status === "active"
88
+ ? { ...s, status: "done" as const, durationMs: now - s.startedAt }
89
+ : s
90
+ );
91
+ this.steps = [
92
+ ...closed,
93
+ {
94
+ id: this.nextId++,
95
+ label,
96
+ ...(detail ? { detail } : {}),
97
+ startedAt: now,
98
+ durationMs: null,
99
+ status: "active",
100
+ },
101
+ ];
102
+ });
103
+ }
104
+
105
+ /** Finish the open successfully: close the active step and hide the overlay. */
106
+ succeed(now = Date.now()): void {
107
+ untrack(() => {
108
+ this.#closeActive(now);
109
+ this.startedAt = null;
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Finish the open with a failure: mark the active step errored and hide the
115
+ * overlay (the Connect flow surfaces the error separately).
116
+ */
117
+ fail(error?: string, now = Date.now()): void {
118
+ untrack(() => {
119
+ this.steps = this.steps.map((s) =>
120
+ s.status === "active"
121
+ ? {
122
+ ...s,
123
+ status: "error" as const,
124
+ durationMs: now - s.startedAt,
125
+ ...(error ? { error } : {}),
126
+ }
127
+ : s
128
+ );
129
+ this.startedAt = null;
130
+ });
131
+ }
132
+
133
+ #closeActive(now: number): void {
134
+ this.steps = this.steps.map((s) =>
135
+ s.status === "active"
136
+ ? { ...s, status: "done" as const, durationMs: now - s.startedAt }
137
+ : s
138
+ );
139
+ }
140
+ }
141
+
142
+ export const loading = new LoadingStore();
@@ -15,6 +15,7 @@ import type {
15
15
  import type { ClusterStatus, ReplicationStatus } from "./cluster-status";
16
16
  import type { MetricDescriptor, PromResponse } from "./analytics";
17
17
  import type { AskResponse, AskOptions } from "./ask";
18
+ import { devConsole, sanitizePayload } from "./dev-console";
18
19
 
19
20
  export interface QueryRow {
20
21
  values: Record<string, unknown>;
@@ -293,6 +294,25 @@ async function* parseNdjsonFrames(
293
294
 
294
295
  const SELECT_RE = /^\s*select\b/i;
295
296
 
297
+ /**
298
+ * Pull a row count out of a parsed response for the developer console (#128).
299
+ * Recognises the `/query` envelope (`record_count`, else the length of
300
+ * `result.records`) and a bare array; returns `undefined` when the shape
301
+ * carries no notion of rows. Purely structural — never throws.
302
+ */
303
+ export function extractRowCount(data: unknown): number | undefined {
304
+ if (Array.isArray(data)) return data.length;
305
+ if (data && typeof data === "object") {
306
+ const d = data as {
307
+ record_count?: unknown;
308
+ result?: { records?: unknown };
309
+ };
310
+ if (typeof d.record_count === "number") return d.record_count;
311
+ if (Array.isArray(d.result?.records)) return d.result.records.length;
312
+ }
313
+ return undefined;
314
+ }
315
+
296
316
  /**
297
317
  * Extract a human-readable message from any thrown value. Tauri's `invoke` and
298
318
  * the HTTP plugin reject with plain strings or bare objects (not `Error`), so
@@ -374,14 +394,66 @@ export class RedClient {
374
394
  Object.assign(headers, this.headers as Record<string, string> | undefined);
375
395
  Object.assign(headers, init?.headers as Record<string, string> | undefined);
376
396
 
377
- const res = await this.fetcher(url, { ...init, headers });
397
+ // Developer console instrumentation (#128). Every HTTP call the client
398
+ // makes is timed and logged from this single seam, so every Surface is
399
+ // covered without touching each caller. The request body is sanitized
400
+ // before it lands in an entry — a copy action can never leak a secret.
401
+ const verb = init?.method ?? "GET";
402
+ const startedAt = Date.now();
403
+ const start = performance.now();
404
+ const payload = sanitizePayload(init?.body);
405
+ const elapsed = () => Math.round(performance.now() - start);
406
+
407
+ let res: Response;
408
+ try {
409
+ res = await this.fetcher(url, { ...init, headers });
410
+ } catch (e) {
411
+ devConsole.record({
412
+ kind: "http",
413
+ verb,
414
+ target: path,
415
+ startedAt,
416
+ durationMs: elapsed(),
417
+ ok: false,
418
+ payload,
419
+ error: errorText(e),
420
+ });
421
+ throw e;
422
+ }
423
+
378
424
  if (!res.ok) {
379
425
  const body = await res.text().catch(() => "");
380
- throw new Error(
381
- `${init?.method ?? "GET"} ${path} → ${res.status} ${body.slice(0, 200)}`
382
- );
426
+ const message = `${verb} ${path} → ${res.status} ${body.slice(0, 200)}`;
427
+ devConsole.record({
428
+ kind: "http",
429
+ verb,
430
+ target: path,
431
+ startedAt,
432
+ durationMs: elapsed(),
433
+ ok: false,
434
+ status: res.status,
435
+ payload,
436
+ error: message,
437
+ });
438
+ throw new Error(message);
383
439
  }
384
- return res.json() as Promise<T>;
440
+
441
+ const data = (await res.json()) as T;
442
+ const rowCount = extractRowCount(data);
443
+ devConsole.record({
444
+ // A response carrying a row count is a query round-trip; everything else
445
+ // is a plain HTTP call. This is how `/query` entries mark themselves.
446
+ kind: rowCount === undefined ? "http" : "query",
447
+ verb,
448
+ target: path,
449
+ startedAt,
450
+ durationMs: elapsed(),
451
+ ok: true,
452
+ status: res.status,
453
+ ...(rowCount === undefined ? {} : { rowCount }),
454
+ payload,
455
+ });
456
+ return data;
385
457
  }
386
458
 
387
459
  async stats(): Promise<Stats> {
@@ -588,17 +660,51 @@ export class RedClient {
588
660
  };
589
661
  Object.assign(headers, this.headers as Record<string, string> | undefined);
590
662
 
591
- const res = await this.fetcher(`${this.baseUrl}/query/stream`, {
592
- method: "POST",
593
- headers,
594
- body: JSON.stringify({ query }),
595
- signal: opts.signal,
596
- });
663
+ // Developer console instrumentation (#128). The streaming endpoint skips
664
+ // `json()`, so it logs its own query round-trip here (verb `POST`, target
665
+ // `/query/stream`) with the collected row count.
666
+ const startedAt = Date.now();
667
+ const start = performance.now();
668
+ const body = JSON.stringify({ query });
669
+ const payload = sanitizePayload(body);
670
+ const elapsed = () => Math.round(performance.now() - start);
671
+
672
+ let res: Response;
673
+ try {
674
+ res = await this.fetcher(`${this.baseUrl}/query/stream`, {
675
+ method: "POST",
676
+ headers,
677
+ body,
678
+ signal: opts.signal,
679
+ });
680
+ } catch (e) {
681
+ devConsole.record({
682
+ kind: "query",
683
+ verb: "POST",
684
+ target: "/query/stream",
685
+ startedAt,
686
+ durationMs: elapsed(),
687
+ ok: false,
688
+ payload,
689
+ error: errorText(e),
690
+ });
691
+ throw e;
692
+ }
597
693
  if (!res.ok || !res.body) {
598
- const body = res.body ? await res.text().catch(() => "") : "";
599
- throw new Error(
600
- `POST /query/stream → ${res.status} ${body.slice(0, 200)}`
601
- );
694
+ const errBody = res.body ? await res.text().catch(() => "") : "";
695
+ const message = `POST /query/stream → ${res.status} ${errBody.slice(0, 200)}`;
696
+ devConsole.record({
697
+ kind: "query",
698
+ verb: "POST",
699
+ target: "/query/stream",
700
+ startedAt,
701
+ durationMs: elapsed(),
702
+ ok: false,
703
+ status: res.status,
704
+ payload,
705
+ error: message,
706
+ });
707
+ throw new Error(message);
602
708
  }
603
709
 
604
710
  const out: StreamedQueryResult = {
@@ -626,6 +732,18 @@ export class RedClient {
626
732
  }
627
733
  }
628
734
 
735
+ devConsole.record({
736
+ kind: "query",
737
+ verb: "POST",
738
+ target: "/query/stream",
739
+ startedAt,
740
+ durationMs: elapsed(),
741
+ ok: true,
742
+ status: res.status,
743
+ rowCount: out.rowCount,
744
+ payload,
745
+ });
746
+
629
747
  // We stopped early with more rows on the server — release the pinned cursor.
630
748
  if (out.truncated && out.cursor) {
631
749
  await this.cancelQueryStream(out.cursor).catch(() => {
@@ -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();
@@ -4,6 +4,7 @@ export * from "./cluster-status";
4
4
  export * from "./analytics";
5
5
  export * from "./ask";
6
6
  export * from "./client";
7
+ export * from "./dev-console";
7
8
  export * from "./connection-provider";
8
9
  export * from "./local-url-provider";
9
10
  export * from "./injected-client-provider";
@@ -15,3 +16,4 @@ export * from "./cdc-stream-client";
15
16
  export * from "./secure-store";
16
17
  export * from "./web-encrypted-store";
17
18
  export * from "./tauri-encrypted-store";
19
+ export * from "./desktop-error";
@@ -181,14 +181,10 @@ export class LocalUrlProvider implements ConnectionProvider {
181
181
  try {
182
182
  clientUrl = await this.embeddedResolver(target.url);
183
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);
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);
192
188
  throw new UnreachableConnectionError(target.id, reason);
193
189
  }
194
190
  }
@@ -1,4 +1,5 @@
1
1
  import { type EncryptedStore, SecureStoreError } from './secure-store'
2
+ import { desktopErrorMessage } from './desktop-error'
2
3
 
3
4
  export type TauriInvoke = <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
4
5
 
@@ -35,7 +36,7 @@ export class TauriEncryptedStore implements EncryptedStore {
35
36
  try {
36
37
  await this.invoke<void>('keychain_set', { service: this.service, key, value })
37
38
  } catch (e) {
38
- throw new SecureStoreError('BACKEND_FAILURE', `keychain_set failed: ${stringifyError(e)}`)
39
+ throw new SecureStoreError('BACKEND_FAILURE', `keychain_set failed: ${desktopErrorMessage(e)}`)
39
40
  }
40
41
  }
41
42
 
@@ -44,7 +45,7 @@ export class TauriEncryptedStore implements EncryptedStore {
44
45
  const v = await this.invoke<string | null>('keychain_get', { service: this.service, key })
45
46
  return v ?? null
46
47
  } catch (e) {
47
- throw new SecureStoreError('BACKEND_FAILURE', `keychain_get failed: ${stringifyError(e)}`)
48
+ throw new SecureStoreError('BACKEND_FAILURE', `keychain_get failed: ${desktopErrorMessage(e)}`)
48
49
  }
49
50
  }
50
51
 
@@ -52,13 +53,7 @@ export class TauriEncryptedStore implements EncryptedStore {
52
53
  try {
53
54
  await this.invoke<void>('keychain_delete', { service: this.service, key })
54
55
  } catch (e) {
55
- throw new SecureStoreError('BACKEND_FAILURE', `keychain_delete failed: ${stringifyError(e)}`)
56
+ throw new SecureStoreError('BACKEND_FAILURE', `keychain_delete failed: ${desktopErrorMessage(e)}`)
56
57
  }
57
58
  }
58
59
  }
59
-
60
- function stringifyError(e: unknown): string {
61
- if (e instanceof Error) return e.message
62
- if (typeof e === 'string') return e
63
- try { return JSON.stringify(e) } catch { return String(e) }
64
- }