@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.
- package/build/_app/immutable/assets/0.CEQwbG0J.css +1 -0
- package/build/_app/immutable/chunks/C-kqMbRi.js +1 -0
- package/build/_app/immutable/chunks/{CLBx21Hz.js → C3SGj6Qd.js} +2 -2
- package/build/_app/immutable/chunks/{DV9PDoM3.js → C9pP73gT.js} +1 -1
- package/build/_app/immutable/chunks/{CJFJ501n.js → CEBul73-.js} +1 -1
- package/build/_app/immutable/chunks/CHvQYbpl.js +1 -0
- package/build/_app/immutable/chunks/{8zg2gJwV.js → CXuFZWQe.js} +1 -1
- package/build/_app/immutable/chunks/CgIoFKBK.js +1 -0
- package/build/_app/immutable/chunks/D02PB2u7.js +1 -0
- package/build/_app/immutable/chunks/D6XJvoMn.js +404 -0
- package/build/_app/immutable/chunks/DLDgyIn5.js +1164 -0
- package/build/_app/immutable/chunks/{Bq4DTWK2.js → DN7v3Tar.js} +1 -1
- package/build/_app/immutable/entry/{app.BDog2y0J.js → app.BBk9O_OI.js} +2 -2
- package/build/_app/immutable/entry/start.C9qfj9ex.js +1 -0
- package/build/_app/immutable/nodes/0.BBf1ZyjL.js +2 -0
- package/build/_app/immutable/nodes/1.BmVeJAGw.js +1 -0
- package/build/_app/immutable/nodes/{10.D_FCLs0Z.js → 10.DZmZgLvj.js} +1 -1
- package/build/_app/immutable/nodes/{12.DPE6v6Zf.js → 12.CzMvqoYS.js} +2 -2
- package/build/_app/immutable/nodes/15.CRwZaU1B.js +60 -0
- package/build/_app/version.json +1 -1
- package/build/index.html +13 -13
- package/dist/embed/{AnalyticsView-D8z1Nsed.js → AnalyticsView-BWwvQS2I.js} +849 -849
- package/dist/embed/{AnalyticsView-D8z1Nsed.js.map → AnalyticsView-BWwvQS2I.js.map} +1 -1
- package/dist/embed/RedUiEmbedRoot-DAB-6DEr.js +35587 -0
- package/dist/embed/RedUiEmbedRoot-DAB-6DEr.js.map +1 -0
- package/dist/embed/event-BvRbkLjD.js +46 -0
- package/dist/embed/event-BvRbkLjD.js.map +1 -0
- package/dist/embed/index-BVxMkfHe.js +8 -0
- package/dist/embed/index-BVxMkfHe.js.map +1 -0
- package/dist/embed/index-Bgo4yw4C.js +856 -0
- package/dist/embed/index-Bgo4yw4C.js.map +1 -0
- package/dist/embed/{index-client-BeiP-va5.js → index-client-BHUUkI8H.js} +121 -120
- package/dist/embed/{index-client-BeiP-va5.js.map → index-client-BHUUkI8H.js.map} +1 -1
- package/dist/embed/index.js +1 -1
- package/dist/embed/{sigma.esm-qqKBpvin.js → sigma.esm-BOgYLguI.js} +2 -2
- package/dist/embed/{sigma.esm-qqKBpvin.js.map → sigma.esm-BOgYLguI.js.map} +1 -1
- package/dist/embed/window-ZR8_mM8o.js +1889 -0
- package/dist/embed/window-ZR8_mM8o.js.map +1 -0
- package/package.json +5 -3
- package/src/app.css +5 -0
- package/src/lib/ConnectDropdown.svelte +60 -3
- package/src/lib/DevConsole.svelte +155 -0
- package/src/lib/ErrorBoundary.fixture.svelte +11 -0
- package/src/lib/ErrorBoundary.fixtures.svelte.ts +5 -0
- package/src/lib/ErrorBoundary.svelte +78 -0
- package/src/lib/ErrorBoundaryCrashChild.fixture.svelte +9 -0
- package/src/lib/LoadingOverlay.svelte +109 -0
- package/src/lib/StatusBar.svelte +19 -1
- package/src/lib/Workspace.svelte +118 -2
- package/src/lib/connections.svelte.ts +95 -1
- package/src/lib/embed/RedUiEmbedRoot.svelte +13 -8
- package/src/lib/embed/index.ts +34 -1
- package/src/lib/index.ts +7 -0
- package/src/lib/loading.svelte.ts +142 -0
- package/src/lib/reddb/client.ts +133 -15
- package/src/lib/reddb/desktop-error.ts +89 -0
- package/src/lib/reddb/dev-console.ts +186 -0
- package/src/lib/reddb/index.ts +2 -0
- package/src/lib/reddb/local-url-provider.ts +4 -8
- package/src/lib/reddb/tauri-encrypted-store.ts +4 -9
- package/src/lib/tabs.svelte.ts +5 -0
- package/src/lib/workspace-session.ts +81 -0
- package/build/_app/immutable/assets/0.DgbuWrJy.css +0 -1
- package/build/_app/immutable/chunks/85-Fh5Zp.js +0 -404
- package/build/_app/immutable/chunks/CnD4hwxC.js +0 -1106
- package/build/_app/immutable/chunks/DQCYUNtS.js +0 -1
- package/build/_app/immutable/entry/start.D1J45dq5.js +0 -1
- package/build/_app/immutable/nodes/0.BTrC4th2.js +0 -1
- package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +0 -1
- package/build/_app/immutable/nodes/15.CcJlZGxa.js +0 -60
- package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js +0 -31302
- package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js.map +0 -1
- package/dist/embed/index-w1Qo_uW0.js +0 -677
- 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();
|
package/src/lib/reddb/client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
|
599
|
-
|
|
600
|
-
|
|
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();
|
package/src/lib/reddb/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
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: ${
|
|
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: ${
|
|
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: ${
|
|
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
|
-
}
|