@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.
Files changed (96) hide show
  1. package/build/_app/immutable/assets/0.DgbuWrJy.css +1 -0
  2. package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
  3. package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
  4. package/build/_app/immutable/chunks/85-Fh5Zp.js +404 -0
  5. package/build/_app/immutable/chunks/{BHBXmd1Y.js → 8zg2gJwV.js} +1 -1
  6. package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
  7. package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
  8. package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
  9. package/build/_app/immutable/chunks/Bq4DTWK2.js +2 -0
  10. package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
  11. package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
  12. package/build/_app/immutable/chunks/CJFJ501n.js +18 -0
  13. package/build/_app/immutable/chunks/CLBx21Hz.js +2 -0
  14. package/build/_app/immutable/chunks/CnD4hwxC.js +1106 -0
  15. package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
  16. package/build/_app/immutable/chunks/DQCYUNtS.js +1 -0
  17. package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
  18. package/build/_app/immutable/chunks/{C278awGv.js → DV9PDoM3.js} +1 -1
  19. package/build/_app/immutable/entry/app.BDog2y0J.js +2 -0
  20. package/build/_app/immutable/entry/start.D1J45dq5.js +1 -0
  21. package/build/_app/immutable/nodes/0.BTrC4th2.js +1 -0
  22. package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +1 -0
  23. package/build/_app/immutable/nodes/10.D_FCLs0Z.js +96 -0
  24. package/build/_app/immutable/nodes/12.DPE6v6Zf.js +3 -0
  25. package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
  26. package/build/_app/immutable/nodes/15.CcJlZGxa.js +60 -0
  27. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  28. package/build/_app/version.json +1 -1
  29. package/build/index.html +18 -15
  30. package/dist/embed/AnalyticsView-D8z1Nsed.js +23337 -0
  31. package/dist/embed/AnalyticsView-D8z1Nsed.js.map +1 -0
  32. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js +31302 -0
  33. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js.map +1 -0
  34. package/dist/embed/index-0hA141D2.js +85 -0
  35. package/dist/embed/index-0hA141D2.js.map +1 -0
  36. package/dist/embed/index-client-BeiP-va5.js +3303 -0
  37. package/dist/embed/index-client-BeiP-va5.js.map +1 -0
  38. package/dist/embed/index-w1Qo_uW0.js +677 -0
  39. package/dist/embed/index-w1Qo_uW0.js.map +1 -0
  40. package/dist/embed/index.js +1 -1
  41. package/dist/embed/{sigma.esm-C2pqg5JH.js → sigma.esm-qqKBpvin.js} +2 -2
  42. package/dist/embed/{sigma.esm-C2pqg5JH.js.map → sigma.esm-qqKBpvin.js.map} +1 -1
  43. package/package.json +5 -3
  44. package/src/app.css +86 -20
  45. package/src/lib/AnalyticsView.svelte +209 -0
  46. package/src/lib/AskView.svelte +255 -0
  47. package/src/lib/ClusterView.svelte +415 -95
  48. package/src/lib/CommandPalette.svelte +2 -0
  49. package/src/lib/GlobalProgressBar.svelte +9 -0
  50. package/src/lib/MetricChart.svelte +85 -0
  51. package/src/lib/QueryEditor.svelte +48 -7
  52. package/src/lib/ResultsPane.svelte +76 -8
  53. package/src/lib/SchemaTree.svelte +25 -1
  54. package/src/lib/SecurityView.svelte +176 -65
  55. package/src/lib/SettingsView.svelte +1137 -226
  56. package/src/lib/Topbar.svelte +29 -1
  57. package/src/lib/Workspace.svelte +18 -3
  58. package/src/lib/activity.svelte.ts +44 -35
  59. package/src/lib/collection-preview.ts +14 -0
  60. package/src/lib/connections.svelte.ts +73 -4
  61. package/src/lib/index.ts +3 -0
  62. package/src/lib/permission-gate.ts +120 -0
  63. package/src/lib/policies.ts +136 -0
  64. package/src/lib/query-tabs.svelte.ts +132 -53
  65. package/src/lib/reddb/analytics.ts +208 -0
  66. package/src/lib/reddb/ask.ts +123 -0
  67. package/src/lib/reddb/client.ts +159 -58
  68. package/src/lib/reddb/cluster-status.ts +306 -0
  69. package/src/lib/reddb/index.ts +3 -0
  70. package/src/lib/reddb/local-url-provider.ts +32 -1
  71. package/src/lib/reddb/redwire-ws-spike.ts +590 -0
  72. package/src/lib/router.svelte.ts +12 -0
  73. package/src/lib/settings-authoring-client.ts +336 -0
  74. package/src/lib/settings-sections.ts +294 -180
  75. package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
  76. package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
  77. package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
  78. package/build/_app/immutable/chunks/BMc_qoms.js +0 -973
  79. package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
  80. package/build/_app/immutable/chunks/CwxFwvow.js +0 -1
  81. package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
  82. package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
  83. package/build/_app/immutable/entry/app.CGfmRH-N.js +0 -2
  84. package/build/_app/immutable/entry/start._LhFJimm.js +0 -1
  85. package/build/_app/immutable/nodes/0.BYhY6O5-.js +0 -1
  86. package/build/_app/immutable/nodes/1.4XYGRMul.js +0 -1
  87. package/build/_app/immutable/nodes/12.DXZfNPNK.js +0 -60
  88. package/build/_app/immutable/nodes/8.C7OOsMOJ.js +0 -134
  89. package/dist/embed/RedUiEmbedRoot-Dia9lxfb.js +0 -28570
  90. package/dist/embed/RedUiEmbedRoot-Dia9lxfb.js.map +0 -1
  91. package/dist/embed/index-Bq0TT7Ao.js +0 -577
  92. package/dist/embed/index-Bq0TT7Ao.js.map +0 -1
  93. package/dist/embed/index-client-ItCEU64C.js +0 -3087
  94. package/dist/embed/index-client-ItCEU64C.js.map +0 -1
  95. /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
  96. /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 type { QueryResult } from '#reddb'
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 type QueryExecutor = (sql: string) => Promise<QueryResult>
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 { sql: '', result: null, error: null, running: false }
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
- delete this.states[id]
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 = 'No connection — connect to a reddb instance first.'
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 = 'Empty query.'
87
- s.result = null
88
- return
128
+ s.error = "Empty query.";
129
+ s.result = null;
130
+ return;
89
131
  }
90
- s.running = true
91
- s.error = null
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 this.executor(sql)
94
- s.result = r
95
- s.error = r.ok ? null : (r.error ?? 'Query failed.')
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
- s.error = (e as Error).message
98
- s.result = null
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
- s.running = false
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.states = {}
107
- this.counter = 0
108
- this.executor = null
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(sql: string, fallback: string, max = 24): string {
122
- const trimmed = sql.trim()
123
- if (!trimmed) return fallback
124
- const match = trimmed.match(/^\s*select\s+([\s\S]+?)\s+from\b/i)
125
- if (!match) return fallback
126
- const clause = match[1].replace(/\s+/g, ' ').trim()
127
- if (!clause || clause === '*') return fallback
128
- if (clause.length <= max) return clause
129
- return clause.slice(0, max - 1).trimEnd() + '…'
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
+ }