@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddb-io/ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "description": "RedDB UI components and standalone web application.",
6
6
  "type": "module",
@@ -51,18 +51,20 @@
51
51
  "@sigma/edge-curve": "^3.1.0",
52
52
  "@sigma/node-border": "^3.0.0",
53
53
  "@tauri-apps/api": "^2.5.0",
54
+ "@tauri-apps/plugin-http": "^2.5.9",
54
55
  "@xyflow/svelte": "^1.2.0",
55
56
  "bits-ui": "^2.18.1",
56
57
  "clsx": "^2.1.1",
57
58
  "graphology": "^0.26.0",
58
59
  "graphology-communities-louvain": "^2.0.2",
59
60
  "graphology-layout-forceatlas2": "^0.10.1",
61
+ "layerchart": "^1.0.13",
60
62
  "lucide-svelte": "^0.475.0",
61
63
  "sigma": "^3.0.3",
62
64
  "tailwind-merge": "^3.6.0",
63
65
  "tailwind-variants": "^2.1.0",
64
- "@reddb-io/ui-kit": "0.2.0",
65
- "@reddb-io/ui-mcp": "0.2.0"
66
+ "@reddb-io/ui-kit": "0.3.1",
67
+ "@reddb-io/ui-mcp": "0.3.1"
66
68
  },
67
69
  "devDependencies": {
68
70
  "@sveltejs/adapter-static": "^3.0.8",
package/src/app.css CHANGED
@@ -1,6 +1,6 @@
1
- @import 'tailwindcss';
2
- @import '@fontsource-variable/inter';
3
- @import '@fontsource-variable/jetbrains-mono';
1
+ @import "tailwindcss";
2
+ @import "@fontsource-variable/inter";
3
+ @import "@fontsource-variable/jetbrains-mono";
4
4
 
5
5
  /* ============================================================================
6
6
  Design tokens. Linear/Vercel vibe — surgical accent, contemporary type.
@@ -51,8 +51,8 @@
51
51
  (#e11d48) keeps the same reddb identity while clearing 4.5:1 against
52
52
  white surfaces and against white button labels. */
53
53
  --color-accent: #e11d48;
54
- --color-accent-soft: rgba(225, 29, 72, 0.10);
55
- --color-accent-glow: rgba(225, 29, 72, 0.30);
54
+ --color-accent-soft: rgba(225, 29, 72, 0.1);
55
+ --color-accent-glow: rgba(225, 29, 72, 0.3);
56
56
 
57
57
  /* Semantic — darker variants so they stay AA on white surfaces */
58
58
  --color-ok: #16a34a;
@@ -90,8 +90,10 @@
90
90
  --text-2xl: 28px;
91
91
  --text-2xl--line-height: 1.15;
92
92
 
93
- --font-sans: 'Inter Variable', 'Inter', system-ui, -apple-system, sans-serif;
94
- --font-mono: 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, 'SF Mono', 'Menlo', monospace;
93
+ --font-sans: "Inter Variable", "Inter", system-ui, -apple-system, sans-serif;
94
+ --font-mono:
95
+ "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, "SF Mono",
96
+ "Menlo", monospace;
95
97
 
96
98
  /* ease-out-expo for ambient transitions, ease-out-quart for snappier UI */
97
99
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
@@ -99,11 +101,12 @@
99
101
 
100
102
  /* Shadows — light variant: smaller alpha, slate tint */
101
103
  --shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08), 0 0 0 1px var(--color-line-2);
102
- --shadow-lg: 0 16px 48px rgba(15, 23, 42, 0.14), 0 0 0 1px var(--color-line-2);
104
+ --shadow-lg:
105
+ 0 16px 48px rgba(15, 23, 42, 0.14), 0 0 0 1px var(--color-line-2);
103
106
  }
104
107
 
105
108
  /* Dark theme — opt-in via `data-theme="dark"` on the document root. */
106
- :root[data-theme='dark'] {
109
+ :root[data-theme="dark"] {
107
110
  --color-bg-0: #050607;
108
111
  --color-bg-1: #0d0f13;
109
112
  --color-bg-2: #181b21;
@@ -162,15 +165,18 @@
162
165
  --radius: var(--radius-md);
163
166
  }
164
167
 
165
- * { box-sizing: border-box; }
168
+ * {
169
+ box-sizing: border-box;
170
+ }
166
171
 
167
- html, body {
172
+ html,
173
+ body {
168
174
  background: var(--color-bg-0);
169
175
  color: var(--color-fg-0);
170
176
  font-family: var(--font-sans);
171
177
  font-size: 13px;
172
178
  line-height: 1.5;
173
- font-feature-settings: 'cv11', 'ss01', 'ss03';
179
+ font-feature-settings: "cv11", "ss01", "ss03";
174
180
  letter-spacing: -0.005em;
175
181
  margin: 0;
176
182
  -webkit-font-smoothing: antialiased;
@@ -214,25 +220,85 @@
214
220
  /* Ambient grid — 96px squares; lines use line-1 so the texture
215
221
  reads as "barely there" in both themes. */
216
222
  body::before {
217
- content: '';
223
+ content: "";
218
224
  position: fixed;
219
225
  inset: 0;
220
226
  background-image:
221
- radial-gradient(ellipse 80% 50% at 50% -10%, var(--color-ambient-glow), transparent),
227
+ radial-gradient(
228
+ ellipse 80% 50% at 50% -10%,
229
+ var(--color-ambient-glow),
230
+ transparent
231
+ ),
222
232
  linear-gradient(var(--color-line-1) 1px, transparent 1px),
223
233
  linear-gradient(90deg, var(--color-line-1) 1px, transparent 1px);
224
- background-size: 100% 100%, 96px 96px, 96px 96px;
234
+ background-size:
235
+ 100% 100%,
236
+ 96px 96px,
237
+ 96px 96px;
225
238
  opacity: 0.35;
226
239
  pointer-events: none;
227
240
  z-index: 0;
228
241
  }
229
242
 
230
- ::selection { background: var(--color-accent-soft); color: var(--color-fg-0); }
243
+ ::selection {
244
+ background: var(--color-accent-soft);
245
+ color: var(--color-fg-0);
246
+ }
231
247
 
232
248
  /* Default scrollbar styling — border matches page bg so the thumb
233
249
  "floats" off the surface in both themes. */
234
- ::-webkit-scrollbar { width: 10px; height: 10px; }
235
- ::-webkit-scrollbar-track { background: transparent; }
236
- ::-webkit-scrollbar-thumb { background: var(--color-line-2); border-radius: 8px; border: 2px solid var(--color-bg-0); }
237
- ::-webkit-scrollbar-thumb:hover { background: var(--color-line-3); }
250
+ ::-webkit-scrollbar {
251
+ width: 10px;
252
+ height: 10px;
253
+ }
254
+ ::-webkit-scrollbar-track {
255
+ background: transparent;
256
+ }
257
+ ::-webkit-scrollbar-thumb {
258
+ background: var(--color-line-2);
259
+ border-radius: 8px;
260
+ border: 2px solid var(--color-bg-0);
261
+ }
262
+ ::-webkit-scrollbar-thumb:hover {
263
+ background: var(--color-line-3);
264
+ }
265
+ }
266
+
267
+ .global-progress {
268
+ position: absolute;
269
+ inset: 0 0 auto;
270
+ z-index: 30;
271
+ height: 2px;
272
+ overflow: hidden;
273
+ pointer-events: none;
274
+ background: var(--color-accent-soft);
275
+ }
276
+
277
+ .global-progress__bar {
278
+ width: 42%;
279
+ height: 100%;
280
+ background: var(--color-accent);
281
+ box-shadow: 0 0 14px var(--color-accent-glow);
282
+ transform-origin: left center;
283
+ animation: global-progress-slide 1.05s var(--ease-snap) infinite;
284
+ }
285
+
286
+ @keyframes global-progress-slide {
287
+ 0% {
288
+ transform: translateX(-110%) scaleX(0.7);
289
+ }
290
+ 50% {
291
+ transform: translateX(90%) scaleX(1);
292
+ }
293
+ 100% {
294
+ transform: translateX(245%) scaleX(0.85);
295
+ }
296
+ }
297
+
298
+ @media (prefers-reduced-motion: reduce) {
299
+ .global-progress__bar {
300
+ width: 100%;
301
+ animation: none;
302
+ opacity: 0.85;
303
+ }
238
304
  }
@@ -0,0 +1,209 @@
1
+ <script lang="ts">
2
+ import { Card, Badge } from '@reddb-io/ui-kit'
3
+ import PageHeader from '$lib/PageHeader.svelte'
4
+ import EmptyState from '$lib/EmptyState.svelte'
5
+ import MetricChart from '$lib/MetricChart.svelte'
6
+ import { activity } from '$lib/activity.svelte'
7
+ import { connection } from '$lib/connections.svelte'
8
+ import { secureStore } from '$lib/secureStore.svelte'
9
+ import {
10
+ promQLForMetric,
11
+ parsePromMatrix,
12
+ type MetricDescriptor,
13
+ type MetricKind,
14
+ type NormalizedSeries,
15
+ type ChartKind,
16
+ } from '#reddb'
17
+ import { LineChart, Lock, RefreshCw } from 'lucide-svelte'
18
+
19
+ let metrics = $state<MetricDescriptor[]>([])
20
+ let selectedPath = $state<string | null>(null)
21
+ let series = $state<NormalizedSeries[]>([])
22
+ let chartKind = $state<ChartKind>('line')
23
+ let windowLabel = $state('5m')
24
+ let loading = $state(false)
25
+ let chartLoading = $state(false)
26
+ let catalogError = $state<string | null>(null)
27
+ let seriesError = $state<string | null>(null)
28
+
29
+ const activeUrl = $derived(connection.active.url)
30
+ const connected = $derived(connection.connected)
31
+ const selected = $derived(metrics.find((m) => m.path === selectedPath) ?? null)
32
+
33
+ // A metric's kind tells the chart what it is — surfaced as a tone so the
34
+ // catalog reads at a glance.
35
+ const KIND_TONE: Record<MetricKind, 'ok' | 'info' | 'warn' | 'accent' | 'neutral'> = {
36
+ counter: 'info',
37
+ gauge: 'ok',
38
+ histogram: 'accent',
39
+ ratio: 'warn',
40
+ derived: 'neutral',
41
+ }
42
+
43
+ async function refresh() {
44
+ const client = connection.client
45
+ if (!client || secureStore.locked || !connected) {
46
+ metrics = []
47
+ selectedPath = null
48
+ series = []
49
+ return
50
+ }
51
+ loading = true
52
+ catalogError = null
53
+ try {
54
+ metrics = await activity.track('analytics · catalog', () => client.metricsCatalog())
55
+ if (metrics.length && !metrics.some((m) => m.path === selectedPath)) {
56
+ void selectMetric(metrics[0])
57
+ }
58
+ } catch (e) {
59
+ catalogError = (e as Error).message
60
+ metrics = []
61
+ }
62
+ loading = false
63
+ }
64
+
65
+ async function selectMetric(descriptor: MetricDescriptor) {
66
+ selectedPath = descriptor.path
67
+ const client = connection.client
68
+ if (!client) return
69
+ const plan = promQLForMetric(descriptor)
70
+ chartKind = plan.chart
71
+ windowLabel = plan.window
72
+ chartLoading = true
73
+ seriesError = null
74
+ series = []
75
+ // Last hour at 30s resolution — enough to read a trend without flooding.
76
+ const end = Math.floor(Date.now() / 1000)
77
+ const start = end - 3600
78
+ const step = 30
79
+ try {
80
+ const collected: NormalizedSeries[] = []
81
+ for (const q of plan.queries) {
82
+ const resp = await activity.track(`analytics · ${q.label}`, () =>
83
+ client.promQueryRange(q.expr, start, end, step),
84
+ )
85
+ const normalized = parsePromMatrix(resp)
86
+ for (const s of normalized) {
87
+ // For multi-query plans (percentiles) the query label IS the series
88
+ // identity; for single-query plans keep the metric's own labels.
89
+ collected.push(plan.queries.length > 1 ? { ...s, label: q.label } : s)
90
+ }
91
+ }
92
+ // Guard against a metric that exists in the catalog but was never
93
+ // written to a metrics collection (PromQL returns empty).
94
+ if (selectedPath === descriptor.path) series = collected
95
+ } catch (e) {
96
+ seriesError = (e as Error).message
97
+ series = []
98
+ } finally {
99
+ if (selectedPath === descriptor.path) chartLoading = false
100
+ }
101
+ }
102
+
103
+ $effect(() => {
104
+ if (secureStore.locked) return
105
+ void activeUrl
106
+ void connected
107
+ refresh()
108
+ })
109
+ </script>
110
+
111
+ <div class="flex h-full flex-col overflow-hidden bg-bg-0 text-fg-1">
112
+ <PageHeader
113
+ eyebrow="Observability"
114
+ title="Analytics"
115
+ subtitle="Metrics chart themselves by nature — a counter is a rate, a histogram is P50/P95/P99, a gauge is a level."
116
+ >
117
+ {#snippet actions()}
118
+ <button
119
+ type="button"
120
+ onclick={refresh}
121
+ disabled={loading || secureStore.locked || !connected}
122
+ class="inline-flex h-7 items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
123
+ >
124
+ <RefreshCw class="size-3.5 {loading ? 'animate-spin' : ''}" />
125
+ refresh
126
+ </button>
127
+ {/snippet}
128
+ </PageHeader>
129
+
130
+ {#if secureStore.locked}
131
+ <div class="p-6"><EmptyState icon={Lock} title="Locked" message="Unlock the secure store to read the metric catalog." /></div>
132
+ {:else if !connected}
133
+ <div class="p-6"><EmptyState icon={LineChart} title="Not connected" message="Connect to a reddb instance to browse its metrics." /></div>
134
+ {:else if catalogError}
135
+ <div class="p-6">
136
+ <EmptyState
137
+ icon={LineChart}
138
+ title="No analytics surface"
139
+ message="This reddb didn't answer the metric catalog. Analytics needs a server with the metrics engine (red.metrics)."
140
+ hint={catalogError}
141
+ />
142
+ </div>
143
+ {:else if metrics.length === 0}
144
+ <div class="p-6">
145
+ <EmptyState
146
+ icon={LineChart}
147
+ title="No metrics registered"
148
+ message="Register one with CREATE METRIC, then feed it via Prometheus remote-write or a metrics collection."
149
+ hint="CREATE METRIC http.latency TYPE histogram ROLE sli;"
150
+ />
151
+ </div>
152
+ {:else}
153
+ <div class="grid min-h-0 flex-1 grid-cols-[260px_1fr] overflow-hidden">
154
+ <!-- Catalog -->
155
+ <div class="overflow-auto border-r border-line-1 p-2">
156
+ <div class="grid gap-0.5">
157
+ {#each metrics as m (m.path)}
158
+ <button
159
+ type="button"
160
+ onclick={() => selectMetric(m)}
161
+ class={[
162
+ 'flex flex-col gap-1 rounded px-2.5 py-2 text-left transition-colors',
163
+ selectedPath === m.path ? 'bg-bg-2' : 'hover:bg-bg-2',
164
+ ].join(' ')}
165
+ >
166
+ <span class="truncate font-mono text-[12px] text-fg-0">{m.path}</span>
167
+ <span class="flex items-center gap-1.5">
168
+ <Badge tone={KIND_TONE[m.kind] ?? 'neutral'}>{m.kind}</Badge>
169
+ <span class="font-mono text-[10px] text-fg-3">{m.role}</span>
170
+ </span>
171
+ </button>
172
+ {/each}
173
+ </div>
174
+ </div>
175
+
176
+ <!-- Chart -->
177
+ <div class="flex min-h-0 flex-col overflow-hidden p-4">
178
+ {#if selected}
179
+ <div class="mb-3 flex items-baseline justify-between">
180
+ <div>
181
+ <div class="font-mono text-[14px] text-fg-0">{selected.path}</div>
182
+ <div class="mt-0.5 text-[11px] text-fg-3">
183
+ {selected.kind} · {chartKind}{#if chartKind !== 'gauge'} · window {windowLabel}{/if}
184
+ </div>
185
+ </div>
186
+ {#if selected.unit}<span class="font-mono text-[11px] text-fg-3">{selected.unit}</span>{/if}
187
+ </div>
188
+ <div class="relative min-h-0 flex-1 rounded border border-line-1 bg-bg-1 p-3">
189
+ {#if chartLoading}
190
+ <div class="flex h-full items-center justify-center font-mono text-[12px] text-fg-3">querying…</div>
191
+ {:else if seriesError}
192
+ <div class="flex h-full items-center justify-center px-6 text-center font-mono text-[12px] text-fg-3">
193
+ {seriesError.includes('404') || seriesError.includes('route not found')
194
+ ? 'this reddb has no Prometheus query endpoint (/api/v1/query_range)'
195
+ : seriesError}
196
+ </div>
197
+ {:else}
198
+ <MetricChart chart={chartKind} {series} unit={selected.unit} />
199
+ {/if}
200
+ </div>
201
+ {:else}
202
+ <div class="flex h-full items-center justify-center font-mono text-[12px] text-fg-3">
203
+ select a metric
204
+ </div>
205
+ {/if}
206
+ </div>
207
+ </div>
208
+ {/if}
209
+ </div>
@@ -0,0 +1,255 @@
1
+ <script lang="ts">
2
+ // Eixo 4 — the ASK chat. Starts as a centred full-screen conversation;
3
+ // the moment an answer arrives grounded on sources, it splits into two
4
+ // columns: the conversation (squeezed left) and the visualization of what
5
+ // the answer was grounded on (right). Citation chips in the answer link a
6
+ // claim to the exact source that supports it — "the logic it took".
7
+ import { Badge } from '@reddb-io/ui-kit'
8
+ import EmptyState from '$lib/EmptyState.svelte'
9
+ import { activity } from '$lib/activity.svelte'
10
+ import { connection } from '$lib/connections.svelte'
11
+ import { secureStore } from '$lib/secureStore.svelte'
12
+ import { parseAnswerSegments, parseUrn, type AskResponse } from '#reddb'
13
+ import { Sparkles, Lock, CornerDownLeft, AlertTriangle } from 'lucide-svelte'
14
+
15
+ interface ChatMessage {
16
+ id: number
17
+ role: 'user' | 'assistant'
18
+ text: string
19
+ response?: AskResponse
20
+ error?: string
21
+ pending?: boolean
22
+ }
23
+
24
+ let messages = $state<ChatMessage[]>([])
25
+ let input = $state('')
26
+ let sending = $state(false)
27
+ let activeArtifact = $state<number | null>(null)
28
+ let highlightMarker = $state<number | null>(null)
29
+ let nextId = 0
30
+
31
+ const connected = $derived(connection.connected)
32
+ const locked = $derived(secureStore.locked)
33
+ const canSend = $derived(connected && !locked && !sending && input.trim().length > 0)
34
+
35
+ // Split as soon as any answer carries grounding sources.
36
+ const splitOpen = $derived(
37
+ messages.some((m) => m.role === 'assistant' && (m.response?.sources_flat?.length ?? 0) > 0),
38
+ )
39
+ const artifactMessage = $derived(
40
+ activeArtifact !== null
41
+ ? messages[activeArtifact]
42
+ : [...messages].reverse().find((m) => (m.response?.sources_flat?.length ?? 0) > 0),
43
+ )
44
+
45
+ const SUGGESTIONS = [
46
+ 'What changed in the last hour?',
47
+ 'Which collections hold the most rows?',
48
+ 'Summarize the most recent errors.',
49
+ ]
50
+
51
+ async function send() {
52
+ const question = input.trim()
53
+ const client = connection.client
54
+ if (!question || !client || sending) return
55
+ input = ''
56
+ const userMsg: ChatMessage = { id: nextId++, role: 'user', text: question }
57
+ const assistantMsg: ChatMessage = { id: nextId++, role: 'assistant', text: '', pending: true }
58
+ messages = [...messages, userMsg, assistantMsg]
59
+ sending = true
60
+ try {
61
+ const response = await activity.track('ask', () => client.ask(question))
62
+ const idx = messages.findIndex((m) => m.id === assistantMsg.id)
63
+ if (idx !== -1) {
64
+ messages[idx] = { ...assistantMsg, pending: false, text: response.answer, response }
65
+ if ((response.sources_flat?.length ?? 0) > 0) activeArtifact = idx
66
+ }
67
+ } catch (e) {
68
+ const idx = messages.findIndex((m) => m.id === assistantMsg.id)
69
+ if (idx !== -1) {
70
+ messages[idx] = { ...assistantMsg, pending: false, error: (e as Error).message }
71
+ }
72
+ } finally {
73
+ sending = false
74
+ }
75
+ }
76
+
77
+ function onKeydown(e: KeyboardEvent) {
78
+ if (e.key === 'Enter' && !e.shiftKey) {
79
+ e.preventDefault()
80
+ if (canSend) void send()
81
+ }
82
+ }
83
+
84
+ function focusSource(messageIndex: number, marker: number) {
85
+ activeArtifact = messageIndex
86
+ highlightMarker = marker
87
+ }
88
+
89
+ /** Pretty-print JSON content, else return the raw string. */
90
+ function formatContent(content: string): string {
91
+ try {
92
+ return JSON.stringify(JSON.parse(content), null, 2)
93
+ } catch {
94
+ return content
95
+ }
96
+ }
97
+
98
+ function fmtCost(usd: number | undefined): string {
99
+ if (usd === undefined) return ''
100
+ return usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`
101
+ }
102
+
103
+ const SOURCE_TONE: Record<string, 'info' | 'ok' | 'accent' | 'warn' | 'neutral'> = {
104
+ table: 'info',
105
+ document: 'ok',
106
+ vector: 'accent',
107
+ graph_node: 'warn',
108
+ graph_edge: 'warn',
109
+ kv: 'neutral',
110
+ }
111
+ </script>
112
+
113
+ {#snippet conversation(squeezed: boolean)}
114
+ <div class="flex h-full flex-col">
115
+ <div class="flex-1 overflow-auto">
116
+ <div class={['mx-auto flex flex-col gap-4 p-4', squeezed ? 'max-w-none' : 'max-w-2xl'].join(' ')}>
117
+ {#if messages.length === 0}
118
+ <div class="flex flex-col items-center gap-4 py-16 text-center">
119
+ <div class="flex size-12 items-center justify-center rounded-full border border-line-2 bg-bg-1">
120
+ <Sparkles class="size-5 text-accent" />
121
+ </div>
122
+ <div>
123
+ <div class="font-mono text-[15px] text-fg-0">Ask your database</div>
124
+ <div class="mt-1 text-[12px] text-fg-3">Grounded answers with citations to the rows, docs, and vectors behind them.</div>
125
+ </div>
126
+ <div class="flex flex-wrap justify-center gap-1.5">
127
+ {#each SUGGESTIONS as s}
128
+ <button
129
+ type="button"
130
+ onclick={() => { input = s; if (canSend) void send() }}
131
+ disabled={!connected || locked}
132
+ class="rounded-full border border-line-2 bg-bg-1 px-3 py-1.5 text-[11px] text-fg-2 hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
133
+ >{s}</button>
134
+ {/each}
135
+ </div>
136
+ </div>
137
+ {/if}
138
+
139
+ {#each messages as m, i (m.id)}
140
+ {#if m.role === 'user'}
141
+ <div class="flex justify-end">
142
+ <div class="max-w-[85%] rounded-lg rounded-br-sm border border-line-2 bg-bg-2 px-3 py-2 font-mono text-[12px] text-fg-0">{m.text}</div>
143
+ </div>
144
+ {:else}
145
+ <div class="flex flex-col gap-1.5">
146
+ {#if m.pending}
147
+ <div class="flex items-center gap-2 text-[12px] text-fg-3"><Sparkles class="size-3.5 animate-pulse text-accent" /> thinking…</div>
148
+ {:else if m.error}
149
+ <div class="flex items-start gap-2 rounded-lg border border-accent/30 bg-accent/5 px-3 py-2 text-[12px] text-fg-1">
150
+ <AlertTriangle class="mt-0.5 size-3.5 shrink-0 text-accent" />
151
+ <span>{m.error.includes('404') || m.error.includes('route not found') ? 'This reddb has no AI endpoint (/ai/ask). Configure a provider to enable Ask.' : m.error}</span>
152
+ </div>
153
+ {:else if m.response}
154
+ <div class="text-[13px] leading-relaxed text-fg-1">
155
+ {#each parseAnswerSegments(m.response.answer) as seg}
156
+ {#if seg.type === 'text'}{seg.value}{:else}<button
157
+ type="button"
158
+ onclick={() => focusSource(i, seg.marker)}
159
+ title="Jump to source {seg.marker}"
160
+ class="mx-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded bg-accent/15 px-1 align-super text-[9px] font-semibold text-accent hover:bg-accent/25"
161
+ >{seg.marker}</button>{/if}
162
+ {/each}
163
+ </div>
164
+ <div class="flex flex-wrap items-center gap-1.5 text-[10px] text-fg-3">
165
+ {#if m.response.validation && !m.response.validation.ok}
166
+ <Badge tone="warn">unverified citations</Badge>
167
+ {/if}
168
+ {#if m.response.sources_flat?.length}
169
+ <button type="button" class="rounded border border-line-1 px-1.5 py-0.5 hover:border-line-3 hover:text-fg-1" onclick={() => { activeArtifact = i; highlightMarker = null }}>
170
+ {m.response.sources_flat.length} source{m.response.sources_flat.length === 1 ? '' : 's'}
171
+ </button>
172
+ {/if}
173
+ {#if m.response.provider}<span class="font-mono">{m.response.provider}{m.response.model ? `·${m.response.model}` : ''}</span>{/if}
174
+ {#if m.response.cache_hit}<span>cached</span>{/if}
175
+ {#if m.response.cost_usd !== undefined}<span class="font-mono">{fmtCost(m.response.cost_usd)}</span>{/if}
176
+ </div>
177
+ {/if}
178
+ </div>
179
+ {/if}
180
+ {/each}
181
+ </div>
182
+ </div>
183
+
184
+ <!-- Composer -->
185
+ <div class="border-t border-line-1 bg-bg-0/80 p-3 backdrop-blur">
186
+ <div class={['mx-auto flex items-end gap-2', squeezed ? 'max-w-none' : 'max-w-2xl'].join(' ')}>
187
+ <textarea
188
+ bind:value={input}
189
+ onkeydown={onKeydown}
190
+ rows="1"
191
+ placeholder={connected ? 'Ask anything about this database…' : 'Connect to ask'}
192
+ disabled={!connected || locked}
193
+ class="max-h-32 min-h-9 flex-1 resize-none rounded-lg border border-line-2 bg-bg-1 px-3 py-2 font-mono text-[12px] text-fg-0 placeholder:text-fg-3 focus:border-line-3 focus:outline-none disabled:opacity-50"
194
+ ></textarea>
195
+ <button
196
+ type="button"
197
+ onclick={send}
198
+ disabled={!canSend}
199
+ class="inline-flex h-9 items-center gap-1.5 rounded-lg bg-accent px-3 text-[12px] font-medium text-white hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-40"
200
+ >
201
+ <CornerDownLeft class="size-3.5" />
202
+ Ask
203
+ </button>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ {/snippet}
208
+
209
+ {#snippet visualization()}
210
+ <div class="flex h-full flex-col overflow-hidden bg-bg-1">
211
+ {#if artifactMessage?.response}
212
+ {@const r = artifactMessage.response}
213
+ <div class="flex items-center justify-between border-b border-line-1 px-4 py-2.5">
214
+ <span class="type-label">grounding · {r.sources_flat.length} source{r.sources_flat.length === 1 ? '' : 's'}</span>
215
+ <span class="font-mono text-[10px] text-fg-3">{r.provider ?? ''}{r.model ? ` · ${r.model}` : ''}</span>
216
+ </div>
217
+ <div class="flex-1 overflow-auto p-3">
218
+ <div class="grid gap-2">
219
+ {#each r.sources_flat as src, si (src.urn + si)}
220
+ {@const ref = parseUrn(src.urn)}
221
+ <div class={[
222
+ 'rounded border bg-bg-0 p-2.5 transition-colors',
223
+ highlightMarker === si + 1 ? 'border-accent' : 'border-line-1',
224
+ ].join(' ')}>
225
+ <div class="mb-1.5 flex items-center gap-2">
226
+ <span class="inline-flex size-4 items-center justify-center rounded bg-accent/15 text-[9px] font-semibold text-accent">{si + 1}</span>
227
+ <Badge tone={SOURCE_TONE[src.kind] ?? 'neutral'}>{src.kind}</Badge>
228
+ {#if ref}<span class="font-mono text-[10px] text-fg-2">{ref.collection}<span class="text-fg-3">/{ref.id}</span></span>{/if}
229
+ {#if src.score !== undefined}<span class="ml-auto font-mono text-[10px] text-fg-3">score {src.score.toFixed(2)}</span>{/if}
230
+ </div>
231
+ <pre class="max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[10px] leading-relaxed text-fg-1">{formatContent(src.content)}</pre>
232
+ </div>
233
+ {/each}
234
+ </div>
235
+ </div>
236
+ {:else}
237
+ <div class="flex h-full items-center justify-center font-mono text-[12px] text-fg-3">no grounding to show</div>
238
+ {/if}
239
+ </div>
240
+ {/snippet}
241
+
242
+ <div class="h-full overflow-hidden bg-bg-0 text-fg-1">
243
+ {#if locked}
244
+ <div class="p-6"><EmptyState icon={Lock} title="Locked" message="Unlock the secure store to ask." /></div>
245
+ {:else if !connected}
246
+ <div class="p-6"><EmptyState icon={Sparkles} title="Not connected" message="Connect to a reddb instance to ask grounded questions." /></div>
247
+ {:else if splitOpen}
248
+ <div class="grid h-full grid-cols-[minmax(340px,42%)_1fr] overflow-hidden">
249
+ <div class="min-h-0 border-r border-line-1">{@render conversation(true)}</div>
250
+ <div class="min-h-0">{@render visualization()}</div>
251
+ </div>
252
+ {:else}
253
+ {@render conversation(false)}
254
+ {/if}
255
+ </div>