@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reddb-io/ui",
|
|
3
|
-
"version": "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.
|
|
65
|
-
"@reddb-io/ui-mcp": "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
|
|
2
|
-
@import
|
|
3
|
-
@import
|
|
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.
|
|
55
|
-
--color-accent-glow: rgba(225, 29, 72, 0.
|
|
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:
|
|
94
|
-
--font-mono:
|
|
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:
|
|
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=
|
|
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
|
-
* {
|
|
168
|
+
* {
|
|
169
|
+
box-sizing: border-box;
|
|
170
|
+
}
|
|
166
171
|
|
|
167
|
-
html,
|
|
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:
|
|
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(
|
|
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:
|
|
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 {
|
|
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 {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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>
|