@reddb-io/ui 0.1.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/LICENSE +17 -0
- package/bin/red-ui.js +259 -0
- package/build/CNAME +1 -0
- package/build/_app/env.js +1 -0
- package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
- package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
- package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
- package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
- package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
- package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
- package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
- package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
- package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
- package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
- package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
- package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
- package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
- package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
- package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
- package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
- package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
- package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
- package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
- package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
- package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
- package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
- package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
- package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
- package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
- package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -0
- package/build/favicon.png +0 -0
- package/build/index.html +59 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
- package/dist/embed/core-QuRYvMt8.js +118 -0
- package/dist/embed/core-QuRYvMt8.js.map +1 -0
- package/dist/embed/index-B6ZQhCsu.js +577 -0
- package/dist/embed/index-B6ZQhCsu.js.map +1 -0
- package/dist/embed/index-client-ItCEU64C.js +3087 -0
- package/dist/embed/index-client-ItCEU64C.js.map +1 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
- package/dist/embed/index.js +11 -0
- package/dist/embed/index.js.map +1 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
- package/dist/embed/ui.css +1 -0
- package/package.json +101 -0
- package/src/app.css +238 -0
- package/src/lib/ActivityIndicator.svelte +100 -0
- package/src/lib/ClusterNode.svelte +119 -0
- package/src/lib/ClusterView.svelte +541 -0
- package/src/lib/CollectionHistory.svelte +266 -0
- package/src/lib/CollectionsView.svelte +14 -0
- package/src/lib/CommandPalette.svelte +237 -0
- package/src/lib/ConnectDropdown.svelte +222 -0
- package/src/lib/EmptyState.svelte +27 -0
- package/src/lib/LiveChanges.svelte +195 -0
- package/src/lib/MasterPasswordDialog.svelte +168 -0
- package/src/lib/PageHeader.svelte +32 -0
- package/src/lib/PendingChangesPanel.svelte +153 -0
- package/src/lib/QueryEditor.svelte +122 -0
- package/src/lib/ResultsPane.svelte +747 -0
- package/src/lib/SchemaTree.svelte +193 -0
- package/src/lib/SecurityView.svelte +255 -0
- package/src/lib/ShortcutOverlay.svelte +104 -0
- package/src/lib/Splash.svelte +46 -0
- package/src/lib/StatusBar.svelte +163 -0
- package/src/lib/SubNav.svelte +62 -0
- package/src/lib/Topbar.svelte +119 -0
- package/src/lib/Workspace.svelte +247 -0
- package/src/lib/activity.svelte.ts +91 -0
- package/src/lib/build-info.ts +41 -0
- package/src/lib/capability.ts +221 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/collection-catalog.ts +116 -0
- package/src/lib/collection-pages.ts +33 -0
- package/src/lib/connection-history.ts +301 -0
- package/src/lib/connections.svelte.ts +493 -0
- package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
- package/src/lib/embed/index.ts +234 -0
- package/src/lib/graph-loader/contract.ts +336 -0
- package/src/lib/graph-loader/drilldown.ts +334 -0
- package/src/lib/graph-loader/index.ts +13 -0
- package/src/lib/index.ts +114 -0
- package/src/lib/pending-changes.svelte.ts +149 -0
- package/src/lib/query-tabs.svelte.ts +130 -0
- package/src/lib/reddb/boot-params.ts +254 -0
- package/src/lib/reddb/cdc-stream-client.ts +234 -0
- package/src/lib/reddb/client.ts +698 -0
- package/src/lib/reddb/connection-bootstrap.ts +260 -0
- package/src/lib/reddb/connection-provider.contract.ts +72 -0
- package/src/lib/reddb/connection-provider.ts +72 -0
- package/src/lib/reddb/index.ts +14 -0
- package/src/lib/reddb/injected-client-provider.ts +76 -0
- package/src/lib/reddb/local-url-provider.ts +245 -0
- package/src/lib/reddb/secure-store.ts +41 -0
- package/src/lib/reddb/server-capabilities.ts +71 -0
- package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
- package/src/lib/reddb/transports.ts +89 -0
- package/src/lib/reddb/types.ts +32 -0
- package/src/lib/reddb/uri.ts +54 -0
- package/src/lib/reddb/web-encrypted-store.ts +201 -0
- package/src/lib/renderers/DiffRenderer.svelte +184 -0
- package/src/lib/renderers/DocumentRenderer.svelte +134 -0
- package/src/lib/renderers/GraphRenderer.svelte +1328 -0
- package/src/lib/renderers/HypertableRenderer.svelte +253 -0
- package/src/lib/renderers/JsonRenderer.svelte +30 -0
- package/src/lib/renderers/KvRenderer.svelte +150 -0
- package/src/lib/renderers/QueueRenderer.svelte +286 -0
- package/src/lib/renderers/StatsRenderer.svelte +123 -0
- package/src/lib/renderers/TableRenderer.svelte +440 -0
- package/src/lib/renderers/VectorRenderer.svelte +388 -0
- package/src/lib/renderers/diff-render.ts +122 -0
- package/src/lib/renderers/diff.ts +9 -0
- package/src/lib/renderers/document-render.ts +64 -0
- package/src/lib/renderers/document.ts +9 -0
- package/src/lib/renderers/graph-render.ts +631 -0
- package/src/lib/renderers/graph-sigma.ts +395 -0
- package/src/lib/renderers/graph.ts +9 -0
- package/src/lib/renderers/hypertable-render.ts +320 -0
- package/src/lib/renderers/hypertable.ts +9 -0
- package/src/lib/renderers/index.ts +42 -0
- package/src/lib/renderers/json-render.ts +89 -0
- package/src/lib/renderers/json.ts +10 -0
- package/src/lib/renderers/kv-render.ts +193 -0
- package/src/lib/renderers/kv.ts +9 -0
- package/src/lib/renderers/queue-render.ts +197 -0
- package/src/lib/renderers/queue.ts +9 -0
- package/src/lib/renderers/registry.ts +68 -0
- package/src/lib/renderers/stats-render.ts +73 -0
- package/src/lib/renderers/stats.ts +9 -0
- package/src/lib/renderers/table-render.ts +156 -0
- package/src/lib/renderers/table.ts +11 -0
- package/src/lib/renderers/types.ts +33 -0
- package/src/lib/renderers/vector-render.ts +154 -0
- package/src/lib/renderers/vector.ts +9 -0
- package/src/lib/router.svelte.ts +163 -0
- package/src/lib/secureStore.svelte.ts +148 -0
- package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
- package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
- package/src/lib/shortcuts.ts +22 -0
- package/src/lib/surface.ts +80 -0
- package/src/lib/tabs.svelte.ts +115 -0
- package/src/lib/theme.svelte.ts +63 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Pure rendering helpers for the hypertable capability.
|
|
2
|
+
//
|
|
3
|
+
// Hypertable result = at least one time-shaped column and one numeric
|
|
4
|
+
// column. The renderer draws an SVG line chart over the time axis with
|
|
5
|
+
// a small data table below for inspection. The metric switcher in the
|
|
6
|
+
// component lets the user flip between numeric columns when there are
|
|
7
|
+
// several; this module exposes the data shape so the test can assert
|
|
8
|
+
// the chart geometry without a browser.
|
|
9
|
+
|
|
10
|
+
import type { QueryResult } from '#reddb'
|
|
11
|
+
|
|
12
|
+
export const ALL_METRICS = '__all__'
|
|
13
|
+
|
|
14
|
+
const TIME_KEYS = ['time', 'timestamp', 'ts', 'event_time', 'bucket', 'created_at']
|
|
15
|
+
const SYSTEM_NUMERIC_KEYS = new Set([
|
|
16
|
+
'created_at',
|
|
17
|
+
'updated_at',
|
|
18
|
+
'time',
|
|
19
|
+
'timestamp',
|
|
20
|
+
'timestamp_ns',
|
|
21
|
+
'rid',
|
|
22
|
+
'tenant',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
export interface HypertablePoint {
|
|
26
|
+
t: number
|
|
27
|
+
v: number
|
|
28
|
+
raw: Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HypertableSeries {
|
|
32
|
+
timeColumn: string
|
|
33
|
+
metricColumn: string
|
|
34
|
+
metrics: string[]
|
|
35
|
+
points: HypertablePoint[]
|
|
36
|
+
metricNameColumn: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const CHART_WIDTH = 640
|
|
40
|
+
export const CHART_HEIGHT = 180
|
|
41
|
+
export const CHART_PADDING = 16
|
|
42
|
+
|
|
43
|
+
function isNumericValue(v: unknown): v is number {
|
|
44
|
+
if (typeof v === 'number') return Number.isFinite(v)
|
|
45
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
46
|
+
const n = Number(v)
|
|
47
|
+
return Number.isFinite(n)
|
|
48
|
+
}
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toNumber(v: unknown): number {
|
|
53
|
+
if (typeof v === 'number') return v
|
|
54
|
+
return Number(v)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toTimestamp(v: unknown): number | null {
|
|
58
|
+
if (v === null || v === undefined) return null
|
|
59
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null
|
|
60
|
+
if (v instanceof Date) return v.getTime()
|
|
61
|
+
if (typeof v === 'string') {
|
|
62
|
+
const n = Number(v)
|
|
63
|
+
if (Number.isFinite(n)) return n
|
|
64
|
+
const d = Date.parse(v)
|
|
65
|
+
return Number.isNaN(d) ? null : d
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function pickTimeColumn(result: QueryResult): string | null {
|
|
71
|
+
const cols = result.result?.columns ?? []
|
|
72
|
+
for (const key of TIME_KEYS) {
|
|
73
|
+
const found = cols.find((c) => c.toLowerCase() === key)
|
|
74
|
+
if (found) return found
|
|
75
|
+
}
|
|
76
|
+
const records = result.result?.records ?? []
|
|
77
|
+
if (records.length === 0) return null
|
|
78
|
+
// Fallback: any column whose first non-null value parses as a date.
|
|
79
|
+
for (const c of cols) {
|
|
80
|
+
const sample = records.find((r) => r.values[c] !== null && r.values[c] !== undefined)
|
|
81
|
+
if (!sample) continue
|
|
82
|
+
if (toTimestamp(sample.values[c]) !== null && !isNumericValue(sample.values[c])) {
|
|
83
|
+
return c
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function numericColumns(result: QueryResult, exclude: string | null): string[] {
|
|
90
|
+
const cols = result.result?.columns ?? []
|
|
91
|
+
const records = result.result?.records ?? []
|
|
92
|
+
if (records.length === 0) return []
|
|
93
|
+
const out: string[] = []
|
|
94
|
+
for (const c of cols) {
|
|
95
|
+
if (c === exclude) continue
|
|
96
|
+
if (c.startsWith('red_')) continue
|
|
97
|
+
if (SYSTEM_NUMERIC_KEYS.has(c.toLowerCase())) continue
|
|
98
|
+
const sample = records.find((r) => r.values[c] !== null && r.values[c] !== undefined)
|
|
99
|
+
if (!sample) continue
|
|
100
|
+
if (isNumericValue(sample.values[c])) out.push(c)
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function hasHypertableShape(result: QueryResult): boolean {
|
|
106
|
+
const t = pickTimeColumn(result)
|
|
107
|
+
if (!t) return false
|
|
108
|
+
return numericColumns(result, t).length > 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function extractSeries(
|
|
112
|
+
result: QueryResult,
|
|
113
|
+
metric?: string,
|
|
114
|
+
): HypertableSeries | null {
|
|
115
|
+
const timeColumn = pickTimeColumn(result)
|
|
116
|
+
if (!timeColumn) return null
|
|
117
|
+
const metricNameColumn = result.result.columns.find((c) => c.toLowerCase() === 'metric') ?? null
|
|
118
|
+
const valueColumn = result.result.columns.find((c) => c.toLowerCase() === 'value') ?? null
|
|
119
|
+
if (metricNameColumn && valueColumn) {
|
|
120
|
+
const names = new Set<string>()
|
|
121
|
+
const allPoints: HypertablePoint[] = []
|
|
122
|
+
for (const rec of result.result.records) {
|
|
123
|
+
const name = rec.values[metricNameColumn]
|
|
124
|
+
const value = rec.values[valueColumn]
|
|
125
|
+
if ((typeof name === 'string' || typeof name === 'number') && isNumericValue(value)) {
|
|
126
|
+
names.add(String(name))
|
|
127
|
+
const t = toTimestamp(rec.values[timeColumn])
|
|
128
|
+
if (t !== null) allPoints.push({ t, v: toNumber(value), raw: rec.values })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const metrics = [...names].sort()
|
|
132
|
+
if (metrics.length > 0) {
|
|
133
|
+
if (!metric || metric === ALL_METRICS) {
|
|
134
|
+
allPoints.sort((a, b) => a.t - b.t)
|
|
135
|
+
return {
|
|
136
|
+
timeColumn,
|
|
137
|
+
metricColumn: valueColumn,
|
|
138
|
+
metrics: [ALL_METRICS, ...metrics],
|
|
139
|
+
points: allPoints,
|
|
140
|
+
metricNameColumn,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const selectedMetric = metrics.includes(metric) ? metric : metrics[0]
|
|
144
|
+
const points: HypertablePoint[] = []
|
|
145
|
+
for (const rec of result.result.records) {
|
|
146
|
+
if (String(rec.values[metricNameColumn]) !== selectedMetric) continue
|
|
147
|
+
const t = toTimestamp(rec.values[timeColumn])
|
|
148
|
+
if (t === null) continue
|
|
149
|
+
const vRaw = rec.values[valueColumn]
|
|
150
|
+
if (!isNumericValue(vRaw)) continue
|
|
151
|
+
points.push({ t, v: toNumber(vRaw), raw: rec.values })
|
|
152
|
+
}
|
|
153
|
+
points.sort((a, b) => a.t - b.t)
|
|
154
|
+
return { timeColumn, metricColumn: valueColumn, metrics: [ALL_METRICS, ...metrics], points, metricNameColumn }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const metrics = numericColumns(result, timeColumn)
|
|
159
|
+
if (metrics.length === 0) return null
|
|
160
|
+
const metricColumn = metric && metrics.includes(metric) ? metric : metrics[0]
|
|
161
|
+
const points: HypertablePoint[] = []
|
|
162
|
+
for (const rec of result.result.records) {
|
|
163
|
+
const tRaw = rec.values[timeColumn]
|
|
164
|
+
const vRaw = rec.values[metricColumn]
|
|
165
|
+
const t = toTimestamp(tRaw)
|
|
166
|
+
if (t === null) continue
|
|
167
|
+
if (!isNumericValue(vRaw)) continue
|
|
168
|
+
points.push({ t, v: toNumber(vRaw), raw: rec.values })
|
|
169
|
+
}
|
|
170
|
+
points.sort((a, b) => a.t - b.t)
|
|
171
|
+
return { timeColumn, metricColumn, metrics, points, metricNameColumn: null }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface ChartGeometry {
|
|
175
|
+
width: number
|
|
176
|
+
height: number
|
|
177
|
+
padding: number
|
|
178
|
+
path: string
|
|
179
|
+
minT: number
|
|
180
|
+
maxT: number
|
|
181
|
+
minV: number
|
|
182
|
+
maxV: number
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function chartGeometry(
|
|
186
|
+
series: HypertableSeries,
|
|
187
|
+
width = CHART_WIDTH,
|
|
188
|
+
height = CHART_HEIGHT,
|
|
189
|
+
padding = CHART_PADDING,
|
|
190
|
+
): ChartGeometry | null {
|
|
191
|
+
const pts = series.points
|
|
192
|
+
if (pts.length === 0) return null
|
|
193
|
+
const minT = pts[0].t
|
|
194
|
+
const maxT = pts[pts.length - 1].t
|
|
195
|
+
let minV = Infinity
|
|
196
|
+
let maxV = -Infinity
|
|
197
|
+
for (const p of pts) {
|
|
198
|
+
if (p.v < minV) minV = p.v
|
|
199
|
+
if (p.v > maxV) maxV = p.v
|
|
200
|
+
}
|
|
201
|
+
if (!Number.isFinite(minV) || !Number.isFinite(maxV)) return null
|
|
202
|
+
const spanT = maxT - minT || 1
|
|
203
|
+
const spanV = maxV - minV || 1
|
|
204
|
+
const w = width - padding * 2
|
|
205
|
+
const h = height - padding * 2
|
|
206
|
+
const segments: string[] = []
|
|
207
|
+
pts.forEach((p, i) => {
|
|
208
|
+
const x = padding + ((p.t - minT) / spanT) * w
|
|
209
|
+
const y = padding + h - ((p.v - minV) / spanV) * h
|
|
210
|
+
segments.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`)
|
|
211
|
+
})
|
|
212
|
+
return {
|
|
213
|
+
width,
|
|
214
|
+
height,
|
|
215
|
+
padding,
|
|
216
|
+
path: segments.join(' '),
|
|
217
|
+
minT,
|
|
218
|
+
maxT,
|
|
219
|
+
minV,
|
|
220
|
+
maxV,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface BucketPoint {
|
|
225
|
+
/** Bucket start in epoch ms. */
|
|
226
|
+
t: number
|
|
227
|
+
/** Number of points falling into this bucket. */
|
|
228
|
+
count: number
|
|
229
|
+
/** Sum of metric values in this bucket. */
|
|
230
|
+
sum: number
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Bucket a series into fixed-size windows (default 1 minute). Returns
|
|
235
|
+
* one BucketPoint per bucket *between* minT and maxT inclusive — empty
|
|
236
|
+
* buckets get count=0 so the bar chart shows gaps in writes instead of
|
|
237
|
+
* collapsing them. The user goal is "writes per minute": a bucket whose
|
|
238
|
+
* count is 0 is meaningful information.
|
|
239
|
+
*/
|
|
240
|
+
export function bucketSeries(series: HypertableSeries, bucketMs = 60_000): BucketPoint[] {
|
|
241
|
+
if (series.points.length === 0) return []
|
|
242
|
+
const firstT = series.points[0].t
|
|
243
|
+
const lastT = series.points[series.points.length - 1].t
|
|
244
|
+
const start = Math.floor(firstT / bucketMs) * bucketMs
|
|
245
|
+
const end = Math.floor(lastT / bucketMs) * bucketMs
|
|
246
|
+
const buckets = new Map<number, BucketPoint>()
|
|
247
|
+
for (let t = start; t <= end; t += bucketMs) {
|
|
248
|
+
buckets.set(t, { t, count: 0, sum: 0 })
|
|
249
|
+
}
|
|
250
|
+
for (const p of series.points) {
|
|
251
|
+
const key = Math.floor(p.t / bucketMs) * bucketMs
|
|
252
|
+
const b = buckets.get(key)
|
|
253
|
+
if (!b) continue
|
|
254
|
+
b.count += 1
|
|
255
|
+
b.sum += p.v
|
|
256
|
+
}
|
|
257
|
+
return [...buckets.values()].sort((a, b) => a.t - b.t)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface BucketGeometry {
|
|
261
|
+
width: number
|
|
262
|
+
height: number
|
|
263
|
+
padding: number
|
|
264
|
+
bars: { x: number; y: number; w: number; h: number; bucket: BucketPoint }[]
|
|
265
|
+
maxCount: number
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function bucketGeometry(
|
|
269
|
+
buckets: BucketPoint[],
|
|
270
|
+
width = CHART_WIDTH,
|
|
271
|
+
height = CHART_HEIGHT,
|
|
272
|
+
padding = CHART_PADDING,
|
|
273
|
+
): BucketGeometry | null {
|
|
274
|
+
if (buckets.length === 0) return null
|
|
275
|
+
const maxCount = buckets.reduce((m, b) => Math.max(m, b.count), 0)
|
|
276
|
+
if (maxCount === 0) return null
|
|
277
|
+
const w = width - padding * 2
|
|
278
|
+
const h = height - padding * 2
|
|
279
|
+
// Leave a 1-px gutter between bars so dense traces still read as bars.
|
|
280
|
+
const slot = w / buckets.length
|
|
281
|
+
const barWidth = Math.max(1, slot - 1)
|
|
282
|
+
const bars = buckets.map((bucket, i) => {
|
|
283
|
+
const ratio = bucket.count / maxCount
|
|
284
|
+
const barH = ratio * h
|
|
285
|
+
return {
|
|
286
|
+
x: padding + i * slot,
|
|
287
|
+
y: padding + h - barH,
|
|
288
|
+
w: barWidth,
|
|
289
|
+
h: barH,
|
|
290
|
+
bucket,
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
return { width, height, padding, bars, maxCount }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function renderHypertableHtml(result: QueryResult, metric?: string): string {
|
|
297
|
+
const series = extractSeries(result, metric)
|
|
298
|
+
if (!series) return '<section class="hypertable empty">No timeseries shape detected.</section>'
|
|
299
|
+
const geom = chartGeometry(series)
|
|
300
|
+
const lines: string[] = []
|
|
301
|
+
lines.push('<section class="hypertable">')
|
|
302
|
+
lines.push(
|
|
303
|
+
`<header class="summary">${series.points.length} pts · metric <code>${escapeHtml(series.metricColumn)}</code></header>`,
|
|
304
|
+
)
|
|
305
|
+
if (geom) {
|
|
306
|
+
lines.push(
|
|
307
|
+
`<svg viewBox="0 0 ${geom.width} ${geom.height}" class="chart"><path d="${geom.path}" /></svg>`,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
lines.push('</section>')
|
|
311
|
+
return lines.join('')
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function escapeHtml(s: string): string {
|
|
315
|
+
return s
|
|
316
|
+
.replace(/&/g, '&')
|
|
317
|
+
.replace(/</g, '<')
|
|
318
|
+
.replace(/>/g, '>')
|
|
319
|
+
.replace(/"/g, '"')
|
|
320
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CapabilityRenderer } from './types'
|
|
2
|
+
import { hasHypertableShape } from './hypertable-render'
|
|
3
|
+
import HypertableRenderer from './HypertableRenderer.svelte'
|
|
4
|
+
|
|
5
|
+
export const hypertableRenderer: CapabilityRenderer = {
|
|
6
|
+
capability: 'hypertable',
|
|
7
|
+
renders: (result) => hasHypertableShape(result),
|
|
8
|
+
component: HypertableRenderer,
|
|
9
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Renderer registry — registers the bundled renderers on import.
|
|
2
|
+
//
|
|
3
|
+
// Consumers should import { registry } from this module rather than
|
|
4
|
+
// going through ./registry directly, so this side-effect runs and the
|
|
5
|
+
// renderers are always available.
|
|
6
|
+
|
|
7
|
+
import { registry } from './registry'
|
|
8
|
+
import { tableRenderer } from './table'
|
|
9
|
+
import { graphRenderer } from './graph'
|
|
10
|
+
import { kvRenderer } from './kv'
|
|
11
|
+
import { hypertableRenderer } from './hypertable'
|
|
12
|
+
import { jsonRenderer } from './json'
|
|
13
|
+
import { vectorRenderer } from './vector'
|
|
14
|
+
import { queueRenderer } from './queue'
|
|
15
|
+
import { statsRenderer } from './stats'
|
|
16
|
+
import { diffRenderer } from './diff'
|
|
17
|
+
import { documentRenderer } from './document'
|
|
18
|
+
|
|
19
|
+
registry.register(tableRenderer)
|
|
20
|
+
registry.register(graphRenderer)
|
|
21
|
+
registry.register(kvRenderer)
|
|
22
|
+
registry.register(hypertableRenderer)
|
|
23
|
+
registry.register(vectorRenderer)
|
|
24
|
+
registry.register(queueRenderer)
|
|
25
|
+
registry.register(statsRenderer)
|
|
26
|
+
registry.register(diffRenderer)
|
|
27
|
+
registry.register(documentRenderer)
|
|
28
|
+
registry.register(jsonRenderer)
|
|
29
|
+
|
|
30
|
+
export { registry, RendererRegistry } from './registry'
|
|
31
|
+
export type { Capability, CapabilityRenderer, RendererProps } from './types'
|
|
32
|
+
export { tableRenderer } from './table'
|
|
33
|
+
export { graphRenderer } from './graph'
|
|
34
|
+
export { kvRenderer } from './kv'
|
|
35
|
+
export { hypertableRenderer } from './hypertable'
|
|
36
|
+
export { jsonRenderer } from './json'
|
|
37
|
+
export { vectorRenderer } from './vector'
|
|
38
|
+
export { queueRenderer } from './queue'
|
|
39
|
+
export { statsRenderer } from './stats'
|
|
40
|
+
export { diffRenderer } from './diff'
|
|
41
|
+
export { documentRenderer } from './document'
|
|
42
|
+
export * from './table-render'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Pure rendering helpers for the JSON capability.
|
|
2
|
+
//
|
|
3
|
+
// JSON is the universal override — the user can switch any tab to JSON
|
|
4
|
+
// to see the raw shape returned by reddb. It is never the default
|
|
5
|
+
// renderer (renders() returns false) so it does not steal results from
|
|
6
|
+
// the more specific capabilities; it only fires when explicitly chosen.
|
|
7
|
+
|
|
8
|
+
import type { QueryResult } from '#reddb'
|
|
9
|
+
|
|
10
|
+
export function projectJson(result: QueryResult): unknown {
|
|
11
|
+
// Strip the verbose envelope to what a developer would actually want
|
|
12
|
+
// to inspect: the query, capability, count, and the raw records.
|
|
13
|
+
return {
|
|
14
|
+
query: result.query,
|
|
15
|
+
capability: result.capability,
|
|
16
|
+
record_count: result.record_count,
|
|
17
|
+
records: result.result.records,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function prettyJson(value: unknown): string {
|
|
22
|
+
return JSON.stringify(value, null, 2)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function highlightJson(json: string): string {
|
|
26
|
+
// Tokenize a known-valid JSON string into <span class="…">…</span>
|
|
27
|
+
// segments. Reads "strings (and keys), numbers, booleans, nulls".
|
|
28
|
+
// We deliberately do not parse — this runs over the already-formatted
|
|
29
|
+
// output of JSON.stringify, which is a closed grammar.
|
|
30
|
+
let out = ''
|
|
31
|
+
let i = 0
|
|
32
|
+
const n = json.length
|
|
33
|
+
while (i < n) {
|
|
34
|
+
const ch = json[i]
|
|
35
|
+
if (ch === '"') {
|
|
36
|
+
let j = i + 1
|
|
37
|
+
while (j < n) {
|
|
38
|
+
if (json[j] === '\\') { j += 2; continue }
|
|
39
|
+
if (json[j] === '"') break
|
|
40
|
+
j += 1
|
|
41
|
+
}
|
|
42
|
+
const slice = json.slice(i, j + 1)
|
|
43
|
+
// Look ahead past whitespace for a colon → this is a key.
|
|
44
|
+
let k = j + 1
|
|
45
|
+
while (k < n && (json[k] === ' ' || json[k] === '\t')) k += 1
|
|
46
|
+
const isKey = json[k] === ':'
|
|
47
|
+
out += `<span class="${isKey ? 'k' : 's'}">${escapeHtml(slice)}</span>`
|
|
48
|
+
i = j + 1
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
if ((ch >= '0' && ch <= '9') || (ch === '-' && json[i + 1] >= '0' && json[i + 1] <= '9')) {
|
|
52
|
+
let j = i + 1
|
|
53
|
+
while (j < n && /[0-9eE+\-.]/.test(json[j])) j += 1
|
|
54
|
+
out += `<span class="n">${escapeHtml(json.slice(i, j))}</span>`
|
|
55
|
+
i = j
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
if (json.startsWith('true', i) || json.startsWith('false', i)) {
|
|
59
|
+
const len = json[i] === 't' ? 4 : 5
|
|
60
|
+
out += `<span class="b">${json.slice(i, i + len)}</span>`
|
|
61
|
+
i += len
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (json.startsWith('null', i)) {
|
|
65
|
+
out += `<span class="z">null</span>`
|
|
66
|
+
i += 4
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
out += escapeHtml(ch)
|
|
70
|
+
i += 1
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderJsonHtml(result: QueryResult): string {
|
|
76
|
+
const payload = projectJson(result)
|
|
77
|
+
const pretty = prettyJson(payload)
|
|
78
|
+
return `<pre class="json">${highlightJson(pretty)}</pre>`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function escapeHtml(s: string): string {
|
|
82
|
+
// JSON tokens are wrapped in <span> bodies, never in attribute values,
|
|
83
|
+
// so we leave double-quotes literal — they belong to the JSON syntax
|
|
84
|
+
// and stay readable in the rendered output.
|
|
85
|
+
return s
|
|
86
|
+
.replace(/&/g, '&')
|
|
87
|
+
.replace(/</g, '<')
|
|
88
|
+
.replace(/>/g, '>')
|
|
89
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CapabilityRenderer } from './types'
|
|
2
|
+
import JsonRenderer from './JsonRenderer.svelte'
|
|
3
|
+
|
|
4
|
+
export const jsonRenderer: CapabilityRenderer = {
|
|
5
|
+
capability: 'json',
|
|
6
|
+
// JSON never wins by shape detection — it's the universal override.
|
|
7
|
+
// The registry still picks it when the user selects it explicitly.
|
|
8
|
+
renders: () => false,
|
|
9
|
+
component: JsonRenderer,
|
|
10
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Pure rendering helpers for the KV capability. The KV renderer shows a
|
|
2
|
+
// tree of keys split on `/` plus a per-key value preview, so a user
|
|
3
|
+
// browsing `config/auth/jwt` sees the `config → auth → jwt` hierarchy
|
|
4
|
+
// rather than 800 flat rows.
|
|
5
|
+
|
|
6
|
+
import type { QueryResult } from '#reddb'
|
|
7
|
+
|
|
8
|
+
export interface KvEntry {
|
|
9
|
+
key: string
|
|
10
|
+
value: unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface KvNode {
|
|
14
|
+
/** Last segment of the path (display label). */
|
|
15
|
+
name: string
|
|
16
|
+
/** Full key when this node represents an actual entry; null for purely structural nodes. */
|
|
17
|
+
fullKey: string | null
|
|
18
|
+
value: unknown
|
|
19
|
+
/** Direct children indexed by segment name, kept sorted on insert. */
|
|
20
|
+
children: KvNode[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function findKeyCol(cols: ReadonlyArray<string>): string | null {
|
|
24
|
+
// Exact preferred names, then any column ending in `_key` (our seed uses
|
|
25
|
+
// `fact_key`/`fact_value`; other apps might use `cfg_key`, etc).
|
|
26
|
+
return (
|
|
27
|
+
cols.find((c) => c.toLowerCase() === 'key') ??
|
|
28
|
+
cols.find((c) => c.toLowerCase() === 'k') ??
|
|
29
|
+
cols.find((c) => /(^|_)key$/i.test(c)) ??
|
|
30
|
+
null
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findValueCol(cols: ReadonlyArray<string>): string | null {
|
|
35
|
+
return (
|
|
36
|
+
cols.find((c) => c.toLowerCase() === 'value') ??
|
|
37
|
+
cols.find((c) => c.toLowerCase() === 'v') ??
|
|
38
|
+
cols.find((c) => /(^|_)value$/i.test(c)) ??
|
|
39
|
+
null
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function extractKv(result: QueryResult): KvEntry[] {
|
|
44
|
+
const cols = result.result.columns
|
|
45
|
+
const keyCol = findKeyCol(cols)
|
|
46
|
+
const valueCol = findValueCol(cols)
|
|
47
|
+
if (!keyCol || !valueCol) return []
|
|
48
|
+
return result.result.records.map((r) => ({
|
|
49
|
+
key: String(r.values[keyCol] ?? ''),
|
|
50
|
+
value: r.values[valueCol],
|
|
51
|
+
}))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function hasKvShape(result: QueryResult): boolean {
|
|
55
|
+
const cols = result.result?.columns ?? []
|
|
56
|
+
return findKeyCol(cols) !== null && findValueCol(cols) !== null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Flatten a KV tree under `prefix` into a plain object so the toolbar's
|
|
61
|
+
* JSON view can render the prefix as a single navigable record.
|
|
62
|
+
*/
|
|
63
|
+
export function flattenUnderPrefix(entries: KvEntry[], prefix: string): Record<string, unknown> {
|
|
64
|
+
const out: Record<string, unknown> = {}
|
|
65
|
+
for (const e of entries) {
|
|
66
|
+
if (!e.key.startsWith(prefix)) continue
|
|
67
|
+
const rest = e.key.slice(prefix.length).replace(/^\/+/, '')
|
|
68
|
+
if (!rest) {
|
|
69
|
+
// exact match → use a sentinel "$value" so the prefix root has a slot
|
|
70
|
+
out.$value = e.value
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
out[rest] = e.value
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Materialize slash-delimited KV keys as a nested JSON object. Exact prefix
|
|
80
|
+
* values share space with children through `$value`, so `a=1` and `a/b=2`
|
|
81
|
+
* remain representable without dropping either value.
|
|
82
|
+
*/
|
|
83
|
+
export function materializeKvJson(entries: KvEntry[], prefix: string): Record<string, unknown> {
|
|
84
|
+
const root: Record<string, unknown> = {}
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (!entry.key.startsWith(prefix)) continue
|
|
87
|
+
const rest = entry.key.slice(prefix.length).replace(/^\/+/, '')
|
|
88
|
+
const segments = rest.split('/').filter(Boolean)
|
|
89
|
+
if (segments.length === 0) {
|
|
90
|
+
root.$value = entry.value
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
let cursor = root
|
|
94
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
95
|
+
const segment = segments[i]
|
|
96
|
+
if (i === segments.length - 1) {
|
|
97
|
+
const existing = cursor[segment]
|
|
98
|
+
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
|
|
99
|
+
;(existing as Record<string, unknown>).$value = entry.value
|
|
100
|
+
} else {
|
|
101
|
+
cursor[segment] = entry.value
|
|
102
|
+
}
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
const existing = cursor[segment]
|
|
106
|
+
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
|
|
107
|
+
const next: Record<string, unknown> = {}
|
|
108
|
+
if (existing !== undefined) next.$value = existing
|
|
109
|
+
cursor[segment] = next
|
|
110
|
+
cursor = next
|
|
111
|
+
} else {
|
|
112
|
+
cursor = existing as Record<string, unknown>
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return root
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildTree(entries: KvEntry[]): KvNode {
|
|
120
|
+
const root: KvNode = { name: '', fullKey: null, value: undefined, children: [] }
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const segments = entry.key.split('/').filter((s) => s.length > 0)
|
|
123
|
+
if (segments.length === 0) continue
|
|
124
|
+
let cursor = root
|
|
125
|
+
segments.forEach((seg, i) => {
|
|
126
|
+
let child = cursor.children.find((c) => c.name === seg)
|
|
127
|
+
if (!child) {
|
|
128
|
+
child = { name: seg, fullKey: null, value: undefined, children: [] }
|
|
129
|
+
// Insert in sorted order so output is deterministic.
|
|
130
|
+
const idx = cursor.children.findIndex((c) => c.name > seg)
|
|
131
|
+
if (idx < 0) cursor.children.push(child)
|
|
132
|
+
else cursor.children.splice(idx, 0, child)
|
|
133
|
+
}
|
|
134
|
+
if (i === segments.length - 1) {
|
|
135
|
+
child.fullKey = entry.key
|
|
136
|
+
child.value = entry.value
|
|
137
|
+
}
|
|
138
|
+
cursor = child
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
return root
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function formatValue(v: unknown): string {
|
|
145
|
+
if (v === null || v === undefined) return 'null'
|
|
146
|
+
if (typeof v === 'object') return JSON.stringify(v)
|
|
147
|
+
return String(v)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function renderKvHtml(result: QueryResult): string {
|
|
151
|
+
const entries = extractKv(result)
|
|
152
|
+
const tree = buildTree(entries)
|
|
153
|
+
const lines: string[] = []
|
|
154
|
+
lines.push('<section class="kv">')
|
|
155
|
+
lines.push(`<header class="summary">${entries.length} keys</header>`)
|
|
156
|
+
lines.push('<ul class="tree">')
|
|
157
|
+
for (const child of tree.children) {
|
|
158
|
+
walk(child, 0, lines)
|
|
159
|
+
}
|
|
160
|
+
lines.push('</ul>')
|
|
161
|
+
lines.push('</section>')
|
|
162
|
+
return lines.join('')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function walk(node: KvNode, depth: number, out: string[]): void {
|
|
166
|
+
const hasValue = node.fullKey !== null
|
|
167
|
+
const cls = ['node']
|
|
168
|
+
if (hasValue) cls.push('leaf')
|
|
169
|
+
out.push(`<li class="${cls.join(' ')}" data-depth="${depth}">`)
|
|
170
|
+
out.push(`<span class="seg">${escapeHtml(node.name)}</span>`)
|
|
171
|
+
if (hasValue) {
|
|
172
|
+
out.push(`<span class="val">${escapeHtml(truncate(formatValue(node.value)))}</span>`)
|
|
173
|
+
}
|
|
174
|
+
if (node.children.length > 0) {
|
|
175
|
+
out.push('<ul class="children">')
|
|
176
|
+
for (const child of node.children) walk(child, depth + 1, out)
|
|
177
|
+
out.push('</ul>')
|
|
178
|
+
}
|
|
179
|
+
out.push('</li>')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function truncate(s: string, max = 80): string {
|
|
183
|
+
if (s.length <= max) return s
|
|
184
|
+
return s.slice(0, max - 1) + '…'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function escapeHtml(s: string): string {
|
|
188
|
+
return s
|
|
189
|
+
.replace(/&/g, '&')
|
|
190
|
+
.replace(/</g, '<')
|
|
191
|
+
.replace(/>/g, '>')
|
|
192
|
+
.replace(/"/g, '"')
|
|
193
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CapabilityRenderer } from './types'
|
|
2
|
+
import { hasKvShape } from './kv-render'
|
|
3
|
+
import KvRenderer from './KvRenderer.svelte'
|
|
4
|
+
|
|
5
|
+
export const kvRenderer: CapabilityRenderer = {
|
|
6
|
+
capability: 'kv',
|
|
7
|
+
renders: (result) => hasKvShape(result),
|
|
8
|
+
component: KvRenderer,
|
|
9
|
+
}
|