@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.
- package/build/_app/immutable/assets/0.BWZTMKZW.css +1 -0
- package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
- package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
- package/build/_app/immutable/chunks/{C9rXzYLu.js → B-yl15ta.js} +1 -1
- package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
- package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
- package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
- package/build/_app/immutable/chunks/Bgb6fHGb.js +18 -0
- package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
- package/build/_app/immutable/chunks/C-kqMbRi.js +1 -0
- package/build/_app/immutable/chunks/C3SGj6Qd.js +2 -0
- package/build/_app/immutable/chunks/{C278awGv.js → C7kf23SQ.js} +1 -1
- package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
- package/build/_app/immutable/chunks/CHvQYbpl.js +1 -0
- package/build/_app/immutable/chunks/CO7K4bD2.js +1164 -0
- package/build/_app/immutable/chunks/CP65IkJc.js +1 -0
- package/build/_app/immutable/chunks/CxnvJShw.js +404 -0
- package/build/_app/immutable/chunks/D02PB2u7.js +1 -0
- package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
- package/build/_app/immutable/chunks/DN7v3Tar.js +2 -0
- package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
- package/build/_app/immutable/entry/app.BeX4Tmu5.js +2 -0
- package/build/_app/immutable/entry/start.D7y1MZex.js +1 -0
- package/build/_app/immutable/nodes/0.C0e5KQVb.js +2 -0
- package/build/_app/immutable/nodes/1.ceiVuZbB.js +1 -0
- package/build/_app/immutable/nodes/10.DeFqKRR2.js +96 -0
- package/build/_app/immutable/nodes/12.CzMvqoYS.js +3 -0
- package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/15.CRDBqbiH.js +60 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -1
- package/build/index.html +18 -15
- package/dist/embed/AnalyticsView-SKyGxeAx.js +23337 -0
- package/dist/embed/AnalyticsView-SKyGxeAx.js.map +1 -0
- package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js +31960 -0
- package/dist/embed/RedUiEmbedRoot-BzVzGAFK.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-0hA141D2.js +85 -0
- package/dist/embed/index-0hA141D2.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-BY6fc1oQ.js +856 -0
- package/dist/embed/index-BY6fc1oQ.js.map +1 -0
- package/dist/embed/index-client-BHUUkI8H.js +3304 -0
- package/dist/embed/index-client-BHUUkI8H.js.map +1 -0
- package/dist/embed/index.js +1 -1
- package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-BawyGZ3s.js} +2 -2
- package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-BawyGZ3s.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 +7 -3
- package/src/app.css +86 -20
- package/src/lib/AnalyticsView.svelte +209 -0
- package/src/lib/AskView.svelte +255 -0
- package/src/lib/ClusterView.svelte +415 -95
- package/src/lib/CommandPalette.svelte +2 -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/GlobalProgressBar.svelte +9 -0
- package/src/lib/LoadingOverlay.svelte +109 -0
- package/src/lib/MetricChart.svelte +85 -0
- package/src/lib/QueryEditor.svelte +48 -7
- package/src/lib/ResultsPane.svelte +76 -8
- package/src/lib/SchemaTree.svelte +25 -1
- package/src/lib/SecurityView.svelte +176 -65
- package/src/lib/SettingsView.svelte +1111 -254
- package/src/lib/StatusBar.svelte +19 -1
- package/src/lib/Topbar.svelte +29 -1
- package/src/lib/Workspace.svelte +136 -5
- package/src/lib/activity.svelte.ts +44 -35
- package/src/lib/collection-preview.ts +14 -0
- package/src/lib/connections.svelte.ts +167 -4
- package/src/lib/embed/RedUiEmbedRoot.svelte +13 -8
- package/src/lib/embed/index.ts +34 -1
- package/src/lib/index.ts +10 -0
- package/src/lib/loading.svelte.ts +142 -0
- package/src/lib/permission-gate.ts +120 -0
- package/src/lib/policies.ts +136 -0
- package/src/lib/query-tabs.svelte.ts +132 -53
- package/src/lib/reddb/analytics.ts +208 -0
- package/src/lib/reddb/ask.ts +123 -0
- package/src/lib/reddb/client.ts +292 -73
- package/src/lib/reddb/cluster-status.ts +306 -0
- package/src/lib/reddb/desktop-error.ts +89 -0
- package/src/lib/reddb/dev-console.ts +186 -0
- package/src/lib/reddb/index.ts +5 -0
- package/src/lib/reddb/local-url-provider.ts +28 -1
- package/src/lib/reddb/redwire-ws-spike.ts +590 -0
- package/src/lib/reddb/tauri-encrypted-store.ts +4 -9
- package/src/lib/router.svelte.ts +12 -0
- package/src/lib/settings-authoring-client.ts +336 -0
- package/src/lib/settings-sections.ts +292 -196
- package/src/lib/tabs.svelte.ts +5 -0
- package/src/lib/workspace-session.ts +81 -0
- package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
- package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
- package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
- package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
- package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
- package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
- package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
- package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
- package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
- package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
- package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
- package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
- package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
- package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
- package/dist/embed/index-DGhgtnaK.js +0 -577
- package/dist/embed/index-DGhgtnaK.js.map +0 -1
- package/dist/embed/index-client-ItCEU64C.js +0 -3087
- package/dist/embed/index-client-ItCEU64C.js.map +0 -1
- /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
- /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();
|
package/src/lib/reddb/index.ts
CHANGED
|
@@ -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
|
-
|
|
194
|
+
clientUrl,
|
|
168
195
|
token ? { headers: { Authorization: `Bearer ${token}` } } : undefined
|
|
169
196
|
);
|
|
170
197
|
const ping = await client.ping();
|