@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
package/src/lib/reddb/client.ts
CHANGED
|
@@ -7,6 +7,15 @@ import {
|
|
|
7
7
|
type RawCapabilities,
|
|
8
8
|
type ServerCapabilities,
|
|
9
9
|
} from "./server-capabilities";
|
|
10
|
+
import type {
|
|
11
|
+
PermissionCheck,
|
|
12
|
+
PermissionDecision,
|
|
13
|
+
PermissionResource,
|
|
14
|
+
} from "../permission-gate";
|
|
15
|
+
import type { ClusterStatus, ReplicationStatus } from "./cluster-status";
|
|
16
|
+
import type { MetricDescriptor, PromResponse } from "./analytics";
|
|
17
|
+
import type { AskResponse, AskOptions } from "./ask";
|
|
18
|
+
import { devConsole, sanitizePayload } from "./dev-console";
|
|
10
19
|
|
|
11
20
|
export interface QueryRow {
|
|
12
21
|
values: Record<string, unknown>;
|
|
@@ -104,59 +113,6 @@ export interface Stats {
|
|
|
104
113
|
read_only_reason?: string;
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
export interface ClusterStatus {
|
|
108
|
-
ok?: boolean;
|
|
109
|
-
deployment?: {
|
|
110
|
-
mode?: "embedded" | "server" | "docker" | "replicated" | string;
|
|
111
|
-
file_path?: string;
|
|
112
|
-
process_kind?: string;
|
|
113
|
-
container_id?: string;
|
|
114
|
-
image?: string;
|
|
115
|
-
configured_by?: string;
|
|
116
|
-
};
|
|
117
|
-
storage?: {
|
|
118
|
-
capacity_bytes?: number;
|
|
119
|
-
used_bytes?: number;
|
|
120
|
-
free_bytes?: number;
|
|
121
|
-
};
|
|
122
|
-
wal?: {
|
|
123
|
-
size_bytes?: number;
|
|
124
|
-
lsn?: number;
|
|
125
|
-
oldest_lsn?: number;
|
|
126
|
-
};
|
|
127
|
-
throughput?: {
|
|
128
|
-
requests_per_second?: number;
|
|
129
|
-
reads_per_second?: number;
|
|
130
|
-
writes_per_second?: number;
|
|
131
|
-
avg_response_ms?: number;
|
|
132
|
-
};
|
|
133
|
-
system?: {
|
|
134
|
-
cpu_usage_percent?: number;
|
|
135
|
-
memory_used_bytes?: number;
|
|
136
|
-
memory_total_bytes?: number;
|
|
137
|
-
};
|
|
138
|
-
replication?: {
|
|
139
|
-
replica_count?: number;
|
|
140
|
-
lag_ms?: number;
|
|
141
|
-
lag_lsn?: number;
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export interface ReplicationStatus {
|
|
146
|
-
ok: boolean;
|
|
147
|
-
role: "primary" | "replica" | "standalone";
|
|
148
|
-
state?: "healthy" | "lagging" | "disconnected";
|
|
149
|
-
primary_addr?: string;
|
|
150
|
-
/** primary perspective */
|
|
151
|
-
wal_lsn?: number;
|
|
152
|
-
oldest_lsn?: number;
|
|
153
|
-
replica_count?: number;
|
|
154
|
-
/** replica perspective */
|
|
155
|
-
last_applied_lsn?: number;
|
|
156
|
-
last_seen_primary_lsn?: number;
|
|
157
|
-
last_seen_oldest_lsn?: number;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
116
|
export interface Whoami {
|
|
161
117
|
ok: boolean;
|
|
162
118
|
authenticated: boolean;
|
|
@@ -199,6 +155,12 @@ export interface AuthPolicy {
|
|
|
199
155
|
created_at?: number;
|
|
200
156
|
}
|
|
201
157
|
|
|
158
|
+
interface AuthCanResponse {
|
|
159
|
+
ok?: boolean;
|
|
160
|
+
results?: Array<Partial<PermissionDecision>>;
|
|
161
|
+
error?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
202
164
|
export interface ChangeEvent {
|
|
203
165
|
lsn: number;
|
|
204
166
|
timestamp: number;
|
|
@@ -280,6 +242,18 @@ function unsupportedCollectionMetadataError(baseUrl: string): Error {
|
|
|
280
242
|
);
|
|
281
243
|
}
|
|
282
244
|
|
|
245
|
+
function isPermissionResource(value: unknown): value is PermissionResource {
|
|
246
|
+
if (!value || typeof value !== "object") return false;
|
|
247
|
+
const candidate = value as Partial<PermissionResource>;
|
|
248
|
+
return (
|
|
249
|
+
typeof candidate.kind === "string" &&
|
|
250
|
+
candidate.kind.length > 0 &&
|
|
251
|
+
typeof candidate.name === "string" &&
|
|
252
|
+
candidate.name.length > 0 &&
|
|
253
|
+
(candidate.tenant === undefined || typeof candidate.tenant === "string")
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
283
257
|
/** One NDJSON frame from `POST /query/stream`. Discriminated by its single key. */
|
|
284
258
|
type NdjsonFrame =
|
|
285
259
|
| { descriptor: { columns: string[]; schema_fingerprint?: string } }
|
|
@@ -320,6 +294,46 @@ async function* parseNdjsonFrames(
|
|
|
320
294
|
|
|
321
295
|
const SELECT_RE = /^\s*select\b/i;
|
|
322
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
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Extract a human-readable message from any thrown value. Tauri's `invoke` and
|
|
318
|
+
* the HTTP plugin reject with plain strings or bare objects (not `Error`), so
|
|
319
|
+
* `(e as Error).message` is often `undefined` — this never returns that.
|
|
320
|
+
*/
|
|
321
|
+
export function errorText(e: unknown): string {
|
|
322
|
+
if (e instanceof Error) return e.message || String(e);
|
|
323
|
+
if (typeof e === "string") return e;
|
|
324
|
+
if (e && typeof e === "object") {
|
|
325
|
+
const m = (e as { message?: unknown }).message;
|
|
326
|
+
if (typeof m === "string" && m) return m;
|
|
327
|
+
try {
|
|
328
|
+
const s = JSON.stringify(e);
|
|
329
|
+
if (s && s !== "{}") return s;
|
|
330
|
+
} catch {
|
|
331
|
+
/* circular — fall through */
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return String(e);
|
|
335
|
+
}
|
|
336
|
+
|
|
323
337
|
/**
|
|
324
338
|
* Run a query, preferring the bounded-memory NDJSON stream for a plain SELECT
|
|
325
339
|
* and falling back to the buffered `POST /query` for everything else — and for
|
|
@@ -334,7 +348,9 @@ export async function runQueryPreferStream(
|
|
|
334
348
|
sql: string,
|
|
335
349
|
opts: { maxRows?: number; signal?: AbortSignal } = {}
|
|
336
350
|
): Promise<QueryResult> {
|
|
337
|
-
|
|
351
|
+
const queryOpts = opts.signal ? { signal: opts.signal } : undefined;
|
|
352
|
+
if (!SELECT_RE.test(sql))
|
|
353
|
+
return queryOpts ? client.query(sql, queryOpts) : client.query(sql);
|
|
338
354
|
try {
|
|
339
355
|
const s = await client.queryStreamCollect(sql, opts);
|
|
340
356
|
return {
|
|
@@ -348,8 +364,14 @@ export async function runQueryPreferStream(
|
|
|
348
364
|
streamed: true,
|
|
349
365
|
truncated: s.truncated,
|
|
350
366
|
};
|
|
351
|
-
} catch {
|
|
352
|
-
|
|
367
|
+
} catch (e) {
|
|
368
|
+
if (
|
|
369
|
+
opts.signal?.aborted ||
|
|
370
|
+
(e instanceof Error && e.name === "AbortError")
|
|
371
|
+
) {
|
|
372
|
+
throw e;
|
|
373
|
+
}
|
|
374
|
+
return queryOpts ? client.query(sql, queryOpts) : client.query(sql);
|
|
353
375
|
}
|
|
354
376
|
}
|
|
355
377
|
|
|
@@ -372,14 +394,66 @@ export class RedClient {
|
|
|
372
394
|
Object.assign(headers, this.headers as Record<string, string> | undefined);
|
|
373
395
|
Object.assign(headers, init?.headers as Record<string, string> | undefined);
|
|
374
396
|
|
|
375
|
-
|
|
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
|
+
|
|
376
424
|
if (!res.ok) {
|
|
377
425
|
const body = await res.text().catch(() => "");
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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);
|
|
381
439
|
}
|
|
382
|
-
|
|
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;
|
|
383
457
|
}
|
|
384
458
|
|
|
385
459
|
async stats(): Promise<Stats> {
|
|
@@ -390,6 +464,60 @@ export class RedClient {
|
|
|
390
464
|
return this.json<ClusterStatus>("/cluster/status");
|
|
391
465
|
}
|
|
392
466
|
|
|
467
|
+
/**
|
|
468
|
+
* List registered metric descriptors from the typed `red.metrics`
|
|
469
|
+
* projection (reddb #747). One row per `CREATE METRIC`, carrying the
|
|
470
|
+
* `kind` (counter|gauge|histogram|ratio|derived) the UI uses to auto-select
|
|
471
|
+
* a chart. Returns `[]` when the server has no analytics surface.
|
|
472
|
+
*/
|
|
473
|
+
async metricsCatalog(): Promise<MetricDescriptor[]> {
|
|
474
|
+
const r = await this.query(
|
|
475
|
+
"SELECT path, kind, role, unit, supports_prometheus_query, created_at_ms FROM red.metrics"
|
|
476
|
+
);
|
|
477
|
+
return r.result.records.map((rec) => {
|
|
478
|
+
const v = rec.values;
|
|
479
|
+
return {
|
|
480
|
+
path: String(v.path ?? ""),
|
|
481
|
+
kind: v.kind as MetricDescriptor["kind"],
|
|
482
|
+
role: String(v.role ?? "metric"),
|
|
483
|
+
unit: (v.unit as string | null) ?? null,
|
|
484
|
+
supports_prometheus_query: v.supports_prometheus_query !== false,
|
|
485
|
+
created_at_ms:
|
|
486
|
+
typeof v.created_at_ms === "number" ? v.created_at_ms : undefined,
|
|
487
|
+
} satisfies MetricDescriptor;
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Prometheus instant query (`GET /api/v1/query`). Returns the raw
|
|
493
|
+
* Prometheus-shaped envelope; callers normalise with `parsePromVector`.
|
|
494
|
+
*/
|
|
495
|
+
async promQuery(query: string, time?: number): Promise<PromResponse> {
|
|
496
|
+
const qs = new URLSearchParams({ query });
|
|
497
|
+
if (time !== undefined) qs.set("time", String(time));
|
|
498
|
+
return this.json<PromResponse>(`/api/v1/query?${qs}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Prometheus range query (`GET /api/v1/query_range`). `start`/`end` are unix
|
|
503
|
+
* seconds, `step` is the resolution in seconds. Normalise with
|
|
504
|
+
* `parsePromMatrix`.
|
|
505
|
+
*/
|
|
506
|
+
async promQueryRange(
|
|
507
|
+
query: string,
|
|
508
|
+
start: number,
|
|
509
|
+
end: number,
|
|
510
|
+
step: number
|
|
511
|
+
): Promise<PromResponse> {
|
|
512
|
+
const qs = new URLSearchParams({
|
|
513
|
+
query,
|
|
514
|
+
start: String(start),
|
|
515
|
+
end: String(end),
|
|
516
|
+
step: String(step),
|
|
517
|
+
});
|
|
518
|
+
return this.json<PromResponse>(`/api/v1/query_range?${qs}`);
|
|
519
|
+
}
|
|
520
|
+
|
|
393
521
|
async ping(): Promise<
|
|
394
522
|
{ ok: true; rtt_ms: number } | { ok: false; error: string }
|
|
395
523
|
> {
|
|
@@ -398,7 +526,7 @@ export class RedClient {
|
|
|
398
526
|
await this.json("/stats");
|
|
399
527
|
return { ok: true, rtt_ms: Math.round(performance.now() - start) };
|
|
400
528
|
} catch (e) {
|
|
401
|
-
return { ok: false, error: (e
|
|
529
|
+
return { ok: false, error: errorText(e) };
|
|
402
530
|
}
|
|
403
531
|
}
|
|
404
532
|
|
|
@@ -497,10 +625,14 @@ export class RedClient {
|
|
|
497
625
|
);
|
|
498
626
|
}
|
|
499
627
|
|
|
500
|
-
async query(
|
|
628
|
+
async query(
|
|
629
|
+
query: string,
|
|
630
|
+
opts: { signal?: AbortSignal } = {}
|
|
631
|
+
): Promise<QueryResult> {
|
|
501
632
|
return this.json<QueryResult>("/query", {
|
|
502
633
|
method: "POST",
|
|
503
634
|
body: JSON.stringify({ query }),
|
|
635
|
+
signal: opts.signal,
|
|
504
636
|
});
|
|
505
637
|
}
|
|
506
638
|
|
|
@@ -528,17 +660,51 @@ export class RedClient {
|
|
|
528
660
|
};
|
|
529
661
|
Object.assign(headers, this.headers as Record<string, string> | undefined);
|
|
530
662
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
});
|
|
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
|
+
}
|
|
537
693
|
if (!res.ok || !res.body) {
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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);
|
|
542
708
|
}
|
|
543
709
|
|
|
544
710
|
const out: StreamedQueryResult = {
|
|
@@ -566,6 +732,18 @@ export class RedClient {
|
|
|
566
732
|
}
|
|
567
733
|
}
|
|
568
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
|
+
|
|
569
747
|
// We stopped early with more rows on the server — release the pinned cursor.
|
|
570
748
|
if (out.truncated && out.cursor) {
|
|
571
749
|
await this.cancelQueryStream(out.cursor).catch(() => {
|
|
@@ -654,6 +832,20 @@ export class RedClient {
|
|
|
654
832
|
return this.json<Whoami>("/auth/whoami");
|
|
655
833
|
}
|
|
656
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Grounded natural-language query (`POST /ai/ask`, ADR 0013). Returns the
|
|
837
|
+
* synthesised answer plus the `sources_flat` it was grounded on and the
|
|
838
|
+
* `[^N]` citations linking claims to sources. Requires the server's AI
|
|
839
|
+
* provider to be configured; an unconfigured server answers with an error
|
|
840
|
+
* status (surfaced by `json()`).
|
|
841
|
+
*/
|
|
842
|
+
async ask(question: string, opts: AskOptions = {}): Promise<AskResponse> {
|
|
843
|
+
return this.json<AskResponse>("/ai/ask", {
|
|
844
|
+
method: "POST",
|
|
845
|
+
body: JSON.stringify({ question, ...opts }),
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
657
849
|
async users(): Promise<AuthUser[]> {
|
|
658
850
|
const r = await this.json<{ ok: boolean; users: AuthUser[] }>(
|
|
659
851
|
"/auth/users"
|
|
@@ -675,6 +867,33 @@ export class RedClient {
|
|
|
675
867
|
return r.policies ?? [];
|
|
676
868
|
}
|
|
677
869
|
|
|
870
|
+
async authCan(checks: PermissionCheck[]): Promise<PermissionDecision[]> {
|
|
871
|
+
const r = await this.json<AuthCanResponse>("/auth/can", {
|
|
872
|
+
method: "POST",
|
|
873
|
+
body: JSON.stringify({ checks }),
|
|
874
|
+
});
|
|
875
|
+
if (!Array.isArray(r.results)) {
|
|
876
|
+
throw new Error(r.error ?? "POST /auth/can returned no results");
|
|
877
|
+
}
|
|
878
|
+
return checks.map((check, index) => {
|
|
879
|
+
const result = r.results?.[index] ?? {};
|
|
880
|
+
const resource = isPermissionResource(result.resource)
|
|
881
|
+
? result.resource
|
|
882
|
+
: check.resource;
|
|
883
|
+
return {
|
|
884
|
+
action:
|
|
885
|
+
typeof result.action === "string" ? result.action : check.action,
|
|
886
|
+
resource,
|
|
887
|
+
current_tenant:
|
|
888
|
+
typeof result.current_tenant === "string"
|
|
889
|
+
? result.current_tenant
|
|
890
|
+
: check.current_tenant,
|
|
891
|
+
allowed: result.allowed === true,
|
|
892
|
+
reason: typeof result.reason === "string" ? result.reason : undefined,
|
|
893
|
+
};
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
678
897
|
async changes(
|
|
679
898
|
sinceLsn?: number,
|
|
680
899
|
opts: { collection?: string; limit?: number } = {}
|