@reddb-io/ui 0.2.0 → 0.3.1
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.DgbuWrJy.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/85-Fh5Zp.js +404 -0
- package/build/_app/immutable/chunks/{BHBXmd1Y.js → 8zg2gJwV.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/Bq4DTWK2.js +2 -0
- package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
- package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
- package/build/_app/immutable/chunks/CJFJ501n.js +18 -0
- package/build/_app/immutable/chunks/CLBx21Hz.js +2 -0
- package/build/_app/immutable/chunks/CnD4hwxC.js +1106 -0
- package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
- package/build/_app/immutable/chunks/DQCYUNtS.js +1 -0
- package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
- package/build/_app/immutable/chunks/{C278awGv.js → DV9PDoM3.js} +1 -1
- package/build/_app/immutable/entry/app.BDog2y0J.js +2 -0
- package/build/_app/immutable/entry/start.D1J45dq5.js +1 -0
- package/build/_app/immutable/nodes/0.BTrC4th2.js +1 -0
- package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +1 -0
- package/build/_app/immutable/nodes/10.D_FCLs0Z.js +96 -0
- package/build/_app/immutable/nodes/12.DPE6v6Zf.js +3 -0
- package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/15.CcJlZGxa.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-D8z1Nsed.js +23337 -0
- package/dist/embed/AnalyticsView-D8z1Nsed.js.map +1 -0
- package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js +31302 -0
- package/dist/embed/RedUiEmbedRoot-CtqTd_FG.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-client-BeiP-va5.js +3303 -0
- package/dist/embed/index-client-BeiP-va5.js.map +1 -0
- package/dist/embed/index-w1Qo_uW0.js +677 -0
- package/dist/embed/index-w1Qo_uW0.js.map +1 -0
- package/dist/embed/index.js +1 -1
- package/dist/embed/{sigma.esm-C2pqg5JH.js → sigma.esm-qqKBpvin.js} +2 -2
- package/dist/embed/{sigma.esm-C2pqg5JH.js.map → sigma.esm-qqKBpvin.js.map} +1 -1
- package/package.json +5 -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/GlobalProgressBar.svelte +9 -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 +1137 -226
- package/src/lib/Topbar.svelte +29 -1
- package/src/lib/Workspace.svelte +18 -3
- package/src/lib/activity.svelte.ts +44 -35
- package/src/lib/collection-preview.ts +14 -0
- package/src/lib/connections.svelte.ts +73 -4
- package/src/lib/index.ts +3 -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 +159 -58
- package/src/lib/reddb/cluster-status.ts +306 -0
- package/src/lib/reddb/index.ts +3 -0
- package/src/lib/reddb/local-url-provider.ts +32 -1
- package/src/lib/reddb/redwire-ws-spike.ts +590 -0
- package/src/lib/router.svelte.ts +12 -0
- package/src/lib/settings-authoring-client.ts +336 -0
- package/src/lib/settings-sections.ts +294 -180
- 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/BMc_qoms.js +0 -973
- package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
- package/build/_app/immutable/chunks/CwxFwvow.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/entry/app.CGfmRH-N.js +0 -2
- package/build/_app/immutable/entry/start._LhFJimm.js +0 -1
- package/build/_app/immutable/nodes/0.BYhY6O5-.js +0 -1
- package/build/_app/immutable/nodes/1.4XYGRMul.js +0 -1
- package/build/_app/immutable/nodes/12.DXZfNPNK.js +0 -60
- package/build/_app/immutable/nodes/8.C7OOsMOJ.js +0 -134
- package/dist/embed/RedUiEmbedRoot-Dia9lxfb.js +0 -28570
- package/dist/embed/RedUiEmbedRoot-Dia9lxfb.js.map +0 -1
- package/dist/embed/index-Bq0TT7Ao.js +0 -577
- package/dist/embed/index-Bq0TT7Ao.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
|
@@ -4,57 +4,99 @@
|
|
|
4
4
|
// running flag. Stored separately from the tabs store so the Tab type
|
|
5
5
|
// stays a thin identity record and switching tabs preserves state.
|
|
6
6
|
|
|
7
|
-
import
|
|
7
|
+
import { activity } from "./activity.svelte";
|
|
8
|
+
import type { QueryResult } from "#reddb";
|
|
8
9
|
|
|
9
10
|
export interface QueryTabState {
|
|
10
|
-
sql: string
|
|
11
|
-
result: QueryResult | null
|
|
12
|
-
error: string | null
|
|
13
|
-
running: boolean
|
|
11
|
+
sql: string;
|
|
12
|
+
result: QueryResult | null;
|
|
13
|
+
error: string | null;
|
|
14
|
+
running: boolean;
|
|
15
|
+
runningStartedAt: number | null;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
export
|
|
18
|
+
export interface QueryRunOptions {
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type QueryExecutor = (
|
|
23
|
+
sql: string,
|
|
24
|
+
options?: QueryRunOptions
|
|
25
|
+
) => Promise<QueryResult>;
|
|
17
26
|
|
|
18
27
|
function emptyState(): QueryTabState {
|
|
19
|
-
return {
|
|
28
|
+
return {
|
|
29
|
+
sql: "",
|
|
30
|
+
result: null,
|
|
31
|
+
error: null,
|
|
32
|
+
running: false,
|
|
33
|
+
runningStartedAt: null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function queryActivityLabel(sql: string): string {
|
|
38
|
+
const oneLine = sql.replace(/\s+/g, " ").trim();
|
|
39
|
+
if (oneLine.length <= 80) return `query · ${oneLine}`;
|
|
40
|
+
return `query · ${oneLine.slice(0, 79).trimEnd()}…`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isAbortError(e: unknown): boolean {
|
|
44
|
+
return (
|
|
45
|
+
(e instanceof DOMException && e.name === "AbortError") ||
|
|
46
|
+
(e instanceof Error && e.name === "AbortError")
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatElapsed(ms: number): string {
|
|
51
|
+
if (ms < 1000) return `${ms}ms`;
|
|
52
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
53
|
+
const minutes = Math.floor(ms / 60_000);
|
|
54
|
+
const seconds = Math.floor((ms % 60_000) / 1000);
|
|
55
|
+
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
|
|
20
56
|
}
|
|
21
57
|
|
|
22
58
|
class QueryTabsStore {
|
|
23
59
|
/** Per-tab state keyed by tab id. */
|
|
24
|
-
states = $state<Record<string, QueryTabState>>({})
|
|
60
|
+
states = $state<Record<string, QueryTabState>>({});
|
|
25
61
|
/** Monotonic counter for "Query N" labels — survives close so numbers don't reuse. */
|
|
26
|
-
private counter = 0
|
|
27
|
-
private executor: QueryExecutor | null = null
|
|
62
|
+
private counter = 0;
|
|
63
|
+
private executor: QueryExecutor | null = null;
|
|
64
|
+
private runSeq = 0;
|
|
65
|
+
private inFlight = new Map<
|
|
66
|
+
string,
|
|
67
|
+
{ token: number; controller: AbortController }
|
|
68
|
+
>();
|
|
28
69
|
|
|
29
70
|
setExecutor(executor: QueryExecutor | null): void {
|
|
30
|
-
this.executor = executor
|
|
71
|
+
this.executor = executor;
|
|
31
72
|
}
|
|
32
73
|
|
|
33
74
|
nextLabel(): string {
|
|
34
|
-
this.counter += 1
|
|
35
|
-
return `Query ${this.counter}
|
|
75
|
+
this.counter += 1;
|
|
76
|
+
return `Query ${this.counter}`;
|
|
36
77
|
}
|
|
37
78
|
|
|
38
79
|
ensure(id: string): QueryTabState {
|
|
39
|
-
if (!this.states[id]) this.states[id] = emptyState()
|
|
40
|
-
return this.states[id]
|
|
80
|
+
if (!this.states[id]) this.states[id] = emptyState();
|
|
81
|
+
return this.states[id];
|
|
41
82
|
}
|
|
42
83
|
|
|
43
84
|
setSql(id: string, sql: string): void {
|
|
44
|
-
const s = this.ensure(id)
|
|
45
|
-
s.sql = sql
|
|
85
|
+
const s = this.ensure(id);
|
|
86
|
+
s.sql = sql;
|
|
46
87
|
}
|
|
47
88
|
|
|
48
89
|
get(id: string): QueryTabState | undefined {
|
|
49
|
-
return this.states[id]
|
|
90
|
+
return this.states[id];
|
|
50
91
|
}
|
|
51
92
|
|
|
52
93
|
has(id: string): boolean {
|
|
53
|
-
return this.states[id] !== undefined
|
|
94
|
+
return this.states[id] !== undefined;
|
|
54
95
|
}
|
|
55
96
|
|
|
56
97
|
remove(id: string): void {
|
|
57
|
-
|
|
98
|
+
this.cancel(id);
|
|
99
|
+
delete this.states[id];
|
|
58
100
|
}
|
|
59
101
|
|
|
60
102
|
/**
|
|
@@ -63,9 +105,9 @@ class QueryTabsStore {
|
|
|
63
105
|
* confirmation.
|
|
64
106
|
*/
|
|
65
107
|
isDirty(id: string): boolean {
|
|
66
|
-
const s = this.states[id]
|
|
67
|
-
if (!s) return false
|
|
68
|
-
return s.sql.trim().length > 0
|
|
108
|
+
const s = this.states[id];
|
|
109
|
+
if (!s) return false;
|
|
110
|
+
return s.sql.trim().length > 0;
|
|
69
111
|
}
|
|
70
112
|
|
|
71
113
|
/**
|
|
@@ -74,42 +116,75 @@ class QueryTabsStore {
|
|
|
74
116
|
* running flag guards against re-entry.
|
|
75
117
|
*/
|
|
76
118
|
async run(id: string): Promise<void> {
|
|
77
|
-
const s = this.ensure(id)
|
|
78
|
-
if (s.running) return
|
|
119
|
+
const s = this.ensure(id);
|
|
120
|
+
if (s.running) return;
|
|
79
121
|
if (!this.executor) {
|
|
80
|
-
s.error =
|
|
81
|
-
s.result = null
|
|
82
|
-
return
|
|
122
|
+
s.error = "No connection — connect to a reddb instance first.";
|
|
123
|
+
s.result = null;
|
|
124
|
+
return;
|
|
83
125
|
}
|
|
84
|
-
const sql = s.sql.trim()
|
|
126
|
+
const sql = s.sql.trim();
|
|
85
127
|
if (!sql) {
|
|
86
|
-
s.error =
|
|
87
|
-
s.result = null
|
|
88
|
-
return
|
|
128
|
+
s.error = "Empty query.";
|
|
129
|
+
s.result = null;
|
|
130
|
+
return;
|
|
89
131
|
}
|
|
90
|
-
|
|
91
|
-
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const token = ++this.runSeq;
|
|
134
|
+
this.inFlight.set(id, { token, controller });
|
|
135
|
+
s.running = true;
|
|
136
|
+
s.runningStartedAt = Date.now();
|
|
137
|
+
s.error = null;
|
|
92
138
|
try {
|
|
93
|
-
const r = await
|
|
94
|
-
|
|
95
|
-
|
|
139
|
+
const r = await activity.track(queryActivityLabel(sql), () =>
|
|
140
|
+
this.executor!(sql, { signal: controller.signal })
|
|
141
|
+
);
|
|
142
|
+
if (this.inFlight.get(id)?.token !== token) return;
|
|
143
|
+
s.result = r;
|
|
144
|
+
s.error = r.ok ? null : (r.error ?? "Query failed.");
|
|
96
145
|
} catch (e) {
|
|
97
|
-
|
|
98
|
-
|
|
146
|
+
if (this.inFlight.get(id)?.token !== token) return;
|
|
147
|
+
if (controller.signal.aborted || isAbortError(e)) {
|
|
148
|
+
s.error = null;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
s.error = (e as Error).message;
|
|
152
|
+
s.result = null;
|
|
99
153
|
} finally {
|
|
100
|
-
|
|
154
|
+
if (this.inFlight.get(id)?.token === token) {
|
|
155
|
+
this.inFlight.delete(id);
|
|
156
|
+
s.running = false;
|
|
157
|
+
s.runningStartedAt = null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
cancel(id: string): boolean {
|
|
163
|
+
const run = this.inFlight.get(id);
|
|
164
|
+
if (!run) return false;
|
|
165
|
+
this.inFlight.delete(id);
|
|
166
|
+
run.controller.abort();
|
|
167
|
+
const s = this.states[id];
|
|
168
|
+
if (s) {
|
|
169
|
+
s.running = false;
|
|
170
|
+
s.runningStartedAt = null;
|
|
171
|
+
s.error = null;
|
|
101
172
|
}
|
|
173
|
+
return true;
|
|
102
174
|
}
|
|
103
175
|
|
|
104
176
|
/** Test-only reset. */
|
|
105
177
|
clear(): void {
|
|
106
|
-
this.
|
|
107
|
-
this.
|
|
108
|
-
this.
|
|
178
|
+
for (const run of this.inFlight.values()) run.controller.abort();
|
|
179
|
+
this.inFlight.clear();
|
|
180
|
+
this.states = {};
|
|
181
|
+
this.counter = 0;
|
|
182
|
+
this.executor = null;
|
|
183
|
+
this.runSeq = 0;
|
|
109
184
|
}
|
|
110
185
|
}
|
|
111
186
|
|
|
112
|
-
export const queryTabs = new QueryTabsStore()
|
|
187
|
+
export const queryTabs = new QueryTabsStore();
|
|
113
188
|
|
|
114
189
|
/**
|
|
115
190
|
* Derive a tab title from the SQL text. Defaults to `fallback` (e.g.
|
|
@@ -118,13 +193,17 @@ export const queryTabs = new QueryTabsStore()
|
|
|
118
193
|
*
|
|
119
194
|
* Kept pure & exported for unit tests.
|
|
120
195
|
*/
|
|
121
|
-
export function deriveQueryLabel(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
if (!
|
|
128
|
-
|
|
129
|
-
|
|
196
|
+
export function deriveQueryLabel(
|
|
197
|
+
sql: string,
|
|
198
|
+
fallback: string,
|
|
199
|
+
max = 24
|
|
200
|
+
): string {
|
|
201
|
+
const trimmed = sql.trim();
|
|
202
|
+
if (!trimmed) return fallback;
|
|
203
|
+
const match = trimmed.match(/^\s*select\s+([\s\S]+?)\s+from\b/i);
|
|
204
|
+
if (!match) return fallback;
|
|
205
|
+
const clause = match[1].replace(/\s+/g, " ").trim();
|
|
206
|
+
if (!clause || clause === "*") return fallback;
|
|
207
|
+
if (clause.length <= max) return clause;
|
|
208
|
+
return clause.slice(0, max - 1).trimEnd() + "…";
|
|
130
209
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Analytics v0 surface: metric descriptors (`red.metrics`) + the Prometheus
|
|
2
|
+
// HTTP query adapter (`/api/v1/query[_range]`), plus the bridge the UI needs:
|
|
3
|
+
// given a metric's *kind*, what chart shows it honestly.
|
|
4
|
+
//
|
|
5
|
+
// Pinned against reddb source (2026-06):
|
|
6
|
+
// - descriptor catalog columns: crates/reddb-server/src/runtime/red_schema.rs (red.metrics, #747)
|
|
7
|
+
// - PromQL envelope + subset: docs/operations/metrics-compatibility.md (ADR 0017)
|
|
8
|
+
//
|
|
9
|
+
// Premise (the user's): if we know the *nature* of a metric, we know the
|
|
10
|
+
// chart. A counter is a rate line, a gauge is a point-in-time gauge, a
|
|
11
|
+
// histogram is a P50/P95/P99 percentile curve. `chartKindForMetric` encodes
|
|
12
|
+
// exactly that, and `promQLForMetric` builds the matching query.
|
|
13
|
+
|
|
14
|
+
/** Metric kinds reddb's descriptor catalog validates (immutable per metric). */
|
|
15
|
+
export type MetricKind =
|
|
16
|
+
| "counter"
|
|
17
|
+
| "gauge"
|
|
18
|
+
| "histogram"
|
|
19
|
+
| "ratio"
|
|
20
|
+
| "derived";
|
|
21
|
+
|
|
22
|
+
/** `role` tags the metric's intent — drives grouping, not chart choice. */
|
|
23
|
+
export type MetricRole = "metric" | "operational" | "kpi" | "sli" | string;
|
|
24
|
+
|
|
25
|
+
/** One row of `red.metrics` (the typed descriptor projection, #747). */
|
|
26
|
+
export interface MetricDescriptor {
|
|
27
|
+
path: string;
|
|
28
|
+
kind: MetricKind;
|
|
29
|
+
role: MetricRole;
|
|
30
|
+
/** Reserved in v0 (NULL until the catalog tracks it). */
|
|
31
|
+
unit?: string | null;
|
|
32
|
+
labels?: Record<string, unknown> | null;
|
|
33
|
+
retention_ms?: number | null;
|
|
34
|
+
supports_prometheus_query?: boolean;
|
|
35
|
+
created_at_ms?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The chart family the UI renders for a metric. */
|
|
39
|
+
export type ChartKind =
|
|
40
|
+
| "line" // counter rate / derived series over time
|
|
41
|
+
| "area" // gauge over time
|
|
42
|
+
| "gauge" // gauge / ratio point-in-time dial
|
|
43
|
+
| "percentiles" // histogram → P50/P95/P99 curves
|
|
44
|
+
| "bars"; // fallback categorical
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Map a metric's nature to its chart. This is the heart of eixo 3: the UI
|
|
48
|
+
* never asks the user "what chart?" — the descriptor's `kind` decides.
|
|
49
|
+
*/
|
|
50
|
+
export function chartKindForMetric(kind: MetricKind): ChartKind {
|
|
51
|
+
switch (kind) {
|
|
52
|
+
case "counter":
|
|
53
|
+
return "line"; // rate() over time
|
|
54
|
+
case "gauge":
|
|
55
|
+
return "area"; // point-in-time level, filled
|
|
56
|
+
case "histogram":
|
|
57
|
+
return "percentiles"; // P50/P95/P99 via histogram_quantile
|
|
58
|
+
case "ratio":
|
|
59
|
+
return "gauge"; // 0..1 dial
|
|
60
|
+
case "derived":
|
|
61
|
+
return "line";
|
|
62
|
+
default:
|
|
63
|
+
return "line";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Prometheus HTTP API envelopes (standard shapes) ──────────────────────────
|
|
68
|
+
|
|
69
|
+
/** A single `[unixSeconds, "value"]` sample (Prometheus encodes value as a string). */
|
|
70
|
+
export type PromSample = [number, string];
|
|
71
|
+
|
|
72
|
+
export interface PromVectorEntry {
|
|
73
|
+
metric: Record<string, string>;
|
|
74
|
+
value: PromSample;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PromMatrixEntry {
|
|
78
|
+
metric: Record<string, string>;
|
|
79
|
+
values: PromSample[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface PromResponse {
|
|
83
|
+
status: "success" | "error";
|
|
84
|
+
data?: {
|
|
85
|
+
resultType: "vector" | "matrix" | "scalar" | "string";
|
|
86
|
+
result: PromVectorEntry[] | PromMatrixEntry[];
|
|
87
|
+
};
|
|
88
|
+
errorType?: string;
|
|
89
|
+
error?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A normalised time-series point the chart layer consumes. */
|
|
93
|
+
export interface SeriesPoint {
|
|
94
|
+
/** epoch milliseconds */
|
|
95
|
+
t: number;
|
|
96
|
+
v: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface NormalizedSeries {
|
|
100
|
+
/** A short stable label built from the series' distinguishing labels. */
|
|
101
|
+
label: string;
|
|
102
|
+
labels: Record<string, string>;
|
|
103
|
+
points: SeriesPoint[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function seriesLabel(metric: Record<string, string>): string {
|
|
107
|
+
const name = metric.__name__;
|
|
108
|
+
const rest = Object.entries(metric)
|
|
109
|
+
.filter(([k]) => k !== "__name__")
|
|
110
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
111
|
+
.join(",");
|
|
112
|
+
if (name && rest) return `${name}{${rest}}`;
|
|
113
|
+
if (rest) return `{${rest}}`;
|
|
114
|
+
return name ?? "series";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function toPoint(sample: PromSample): SeriesPoint | null {
|
|
118
|
+
const v = Number(sample[1]);
|
|
119
|
+
if (!Number.isFinite(v)) return null; // Prometheus emits "NaN" for gaps
|
|
120
|
+
return { t: sample[0] * 1000, v };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Normalise a `query_range` (matrix) response into chartable series. */
|
|
124
|
+
export function parsePromMatrix(resp: PromResponse): NormalizedSeries[] {
|
|
125
|
+
if (resp.status !== "success" || resp.data?.resultType !== "matrix")
|
|
126
|
+
return [];
|
|
127
|
+
return (resp.data.result as PromMatrixEntry[]).map((entry) => ({
|
|
128
|
+
label: seriesLabel(entry.metric),
|
|
129
|
+
labels: entry.metric,
|
|
130
|
+
points: entry.values
|
|
131
|
+
.map(toPoint)
|
|
132
|
+
.filter((p): p is SeriesPoint => p !== null),
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Normalise an instant (vector) response into one point per series. */
|
|
137
|
+
export function parsePromVector(resp: PromResponse): NormalizedSeries[] {
|
|
138
|
+
if (resp.status !== "success" || resp.data?.resultType !== "vector")
|
|
139
|
+
return [];
|
|
140
|
+
return (resp.data.result as PromVectorEntry[])
|
|
141
|
+
.map((entry) => {
|
|
142
|
+
const point = toPoint(entry.value);
|
|
143
|
+
return point
|
|
144
|
+
? {
|
|
145
|
+
label: seriesLabel(entry.metric),
|
|
146
|
+
labels: entry.metric,
|
|
147
|
+
points: [point],
|
|
148
|
+
}
|
|
149
|
+
: null;
|
|
150
|
+
})
|
|
151
|
+
.filter((s): s is NormalizedSeries => s !== null);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A PromQL expression plus the label it should be charted under. */
|
|
155
|
+
export interface MetricQuery {
|
|
156
|
+
label: string;
|
|
157
|
+
expr: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface MetricQueryPlan {
|
|
161
|
+
chart: ChartKind;
|
|
162
|
+
/** Range vector window for rate()/histogram_quantile(), e.g. "5m". */
|
|
163
|
+
window: string;
|
|
164
|
+
/** One expr for simple metrics; three (p50/p95/p99) for histograms. */
|
|
165
|
+
queries: MetricQuery[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const PERCENTILES: Array<{ label: string; phi: number }> = [
|
|
169
|
+
{ label: "p50", phi: 0.5 },
|
|
170
|
+
{ label: "p95", phi: 0.95 },
|
|
171
|
+
{ label: "p99", phi: 0.99 },
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build the PromQL query plan for a descriptor. Honours the v0 subset
|
|
176
|
+
* documented in metrics-compatibility.md:
|
|
177
|
+
* - counter → `rate(<path>[w])`
|
|
178
|
+
* - histogram → `histogram_quantile(phi, sum by (le) (rate(<path>_bucket[w])))` ×3
|
|
179
|
+
* - gauge / ratio / derived → bare `<path>`
|
|
180
|
+
*/
|
|
181
|
+
export function promQLForMetric(
|
|
182
|
+
descriptor: Pick<MetricDescriptor, "path" | "kind">,
|
|
183
|
+
opts: { window?: string } = {}
|
|
184
|
+
): MetricQueryPlan {
|
|
185
|
+
const window = opts.window ?? "5m";
|
|
186
|
+
const path = descriptor.path;
|
|
187
|
+
const chart = chartKindForMetric(descriptor.kind);
|
|
188
|
+
|
|
189
|
+
if (descriptor.kind === "histogram") {
|
|
190
|
+
return {
|
|
191
|
+
chart,
|
|
192
|
+
window,
|
|
193
|
+
queries: PERCENTILES.map(({ label, phi }) => ({
|
|
194
|
+
label,
|
|
195
|
+
expr: `histogram_quantile(${phi}, sum by (le) (rate(${path}_bucket[${window}])))`,
|
|
196
|
+
})),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (descriptor.kind === "counter") {
|
|
200
|
+
return {
|
|
201
|
+
chart,
|
|
202
|
+
window,
|
|
203
|
+
queries: [{ label: path, expr: `rate(${path}[${window}])` }],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
// gauge | ratio | derived — read the level directly.
|
|
207
|
+
return { chart, window, queries: [{ label: path, expr: path }] };
|
|
208
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// RedDB's `ASK` — a grounded natural-language query that retrieves context
|
|
2
|
+
// (tables, documents, vectors, graphs, KV) and synthesises an answer with
|
|
3
|
+
// inline `[^N]` citations tied to specific sources (ADR 0013, v1 stable).
|
|
4
|
+
//
|
|
5
|
+
// Pinned against reddb: docs/api/http.md `POST /ai/ask` response shape +
|
|
6
|
+
// .red/adr/0013-ask-grounding-citations.md (citation contract).
|
|
7
|
+
//
|
|
8
|
+
// This is the substrate for the /ask chat (eixo 4): the answer goes in the
|
|
9
|
+
// conversation column, sources_flat becomes the visualization column, and the
|
|
10
|
+
// citations link one to the other.
|
|
11
|
+
|
|
12
|
+
export type AskSourceKind =
|
|
13
|
+
| "table"
|
|
14
|
+
| "document"
|
|
15
|
+
| "vector"
|
|
16
|
+
| "graph_node"
|
|
17
|
+
| "graph_edge"
|
|
18
|
+
| "kv";
|
|
19
|
+
|
|
20
|
+
export interface AskSource {
|
|
21
|
+
kind: AskSourceKind;
|
|
22
|
+
/** `reddb:<collection>/<id>` (+ `#<score>`/`#<edge_id>`/`#<fragment>`). */
|
|
23
|
+
urn: string;
|
|
24
|
+
/** Raw text/JSON view of the retrieved source. */
|
|
25
|
+
content: string;
|
|
26
|
+
/** Retrieval rank score. */
|
|
27
|
+
score?: number;
|
|
28
|
+
// kind-specific extras
|
|
29
|
+
node_label?: string;
|
|
30
|
+
edge_relation?: string;
|
|
31
|
+
version?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AskCitation {
|
|
35
|
+
/** 1-based positional index into `sources_flat`. */
|
|
36
|
+
marker: number;
|
|
37
|
+
/** [start, end) character span of the cited claim in `answer`. */
|
|
38
|
+
span: [number, number];
|
|
39
|
+
urn: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AskValidation {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
warnings: string[];
|
|
45
|
+
errors: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AskResponse {
|
|
49
|
+
answer: string;
|
|
50
|
+
sources_flat: AskSource[];
|
|
51
|
+
citations: AskCitation[];
|
|
52
|
+
validation: AskValidation;
|
|
53
|
+
cache_hit?: boolean;
|
|
54
|
+
provider?: string;
|
|
55
|
+
model?: string;
|
|
56
|
+
prompt_tokens?: number;
|
|
57
|
+
completion_tokens?: number;
|
|
58
|
+
cost_usd?: number;
|
|
59
|
+
mode?: string;
|
|
60
|
+
retry_count?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AskOptions {
|
|
64
|
+
provider?: string;
|
|
65
|
+
model?: string;
|
|
66
|
+
credential?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A rendered piece of an answer: plain text, or a citation marker chip. */
|
|
70
|
+
export type AnswerSegment =
|
|
71
|
+
| { type: "text"; value: string }
|
|
72
|
+
| { type: "cite"; marker: number };
|
|
73
|
+
|
|
74
|
+
const MARKER_RE = /\[\^(\d+)\]/g;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Split an answer into text + citation-marker segments for rendering. `[^N]`
|
|
78
|
+
* becomes a `cite` segment; an escaped `\[^N\]` is emitted as literal text
|
|
79
|
+
* (the backslash dropped), matching the ADR's escape rule. Markers inside
|
|
80
|
+
* fenced code blocks are left as text so we don't turn code into chips.
|
|
81
|
+
*/
|
|
82
|
+
export function parseAnswerSegments(answer: string): AnswerSegment[] {
|
|
83
|
+
const segments: AnswerSegment[] = [];
|
|
84
|
+
// Mask fenced code blocks so markers inside them are never parsed.
|
|
85
|
+
const codeSpans: Array<[number, number]> = [];
|
|
86
|
+
const fence = /```[\s\S]*?```|`[^`\n]*`/g;
|
|
87
|
+
let fm: RegExpExecArray | null;
|
|
88
|
+
while ((fm = fence.exec(answer)) !== null) {
|
|
89
|
+
codeSpans.push([fm.index, fm.index + fm[0].length]);
|
|
90
|
+
}
|
|
91
|
+
const inCode = (i: number) => codeSpans.some(([s, e]) => i >= s && i < e);
|
|
92
|
+
|
|
93
|
+
let last = 0;
|
|
94
|
+
let m: RegExpExecArray | null;
|
|
95
|
+
MARKER_RE.lastIndex = 0;
|
|
96
|
+
while ((m = MARKER_RE.exec(answer)) !== null) {
|
|
97
|
+
const start = m.index;
|
|
98
|
+
const escaped = start > 0 && answer[start - 1] === "\\";
|
|
99
|
+
if (escaped || inCode(start)) continue;
|
|
100
|
+
if (start > last)
|
|
101
|
+
segments.push({ type: "text", value: answer.slice(last, start) });
|
|
102
|
+
segments.push({ type: "cite", marker: Number(m[1]) });
|
|
103
|
+
last = start + m[0].length;
|
|
104
|
+
}
|
|
105
|
+
if (last < answer.length)
|
|
106
|
+
segments.push({ type: "text", value: answer.slice(last) });
|
|
107
|
+
// Render an escaped `\[^N\]` as the literal citation syntax by dropping the
|
|
108
|
+
// backslashes before its brackets.
|
|
109
|
+
return segments.map((seg) =>
|
|
110
|
+
seg.type === "text"
|
|
111
|
+
? { type: "text", value: seg.value.replace(/\\([[\]])/g, "$1") }
|
|
112
|
+
: seg
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Parse the collection + id out of a `reddb:<collection>/<id>` URN. */
|
|
117
|
+
export function parseUrn(
|
|
118
|
+
urn: string
|
|
119
|
+
): { collection: string; id: string } | null {
|
|
120
|
+
const m = /^reddb:([^/]+)\/(.+?)(?:#.*)?$/.exec(urn);
|
|
121
|
+
if (!m) return null;
|
|
122
|
+
return { collection: m[1], id: m[2] };
|
|
123
|
+
}
|