@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.
Files changed (169) hide show
  1. package/LICENSE +17 -0
  2. package/bin/red-ui.js +259 -0
  3. package/build/CNAME +1 -0
  4. package/build/_app/env.js +1 -0
  5. package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
  6. package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
  7. package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
  8. package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
  9. package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  10. package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  11. package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  12. package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  13. package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  14. package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  15. package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  16. package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
  17. package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
  18. package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
  19. package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
  20. package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
  21. package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
  22. package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
  23. package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
  24. package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
  25. package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
  26. package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
  27. package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
  28. package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
  29. package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
  30. package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
  31. package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
  32. package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
  33. package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
  34. package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
  35. package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
  36. package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
  37. package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
  38. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
  39. package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
  40. package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
  41. package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
  42. package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
  43. package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
  44. package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
  45. package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
  46. package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
  47. package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
  48. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  49. package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
  50. package/build/_app/version.json +1 -0
  51. package/build/favicon.png +0 -0
  52. package/build/index.html +59 -0
  53. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
  54. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
  55. package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
  56. package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
  57. package/dist/embed/core-QuRYvMt8.js +118 -0
  58. package/dist/embed/core-QuRYvMt8.js.map +1 -0
  59. package/dist/embed/index-B6ZQhCsu.js +577 -0
  60. package/dist/embed/index-B6ZQhCsu.js.map +1 -0
  61. package/dist/embed/index-client-ItCEU64C.js +3087 -0
  62. package/dist/embed/index-client-ItCEU64C.js.map +1 -0
  63. package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
  64. package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
  65. package/dist/embed/index.js +11 -0
  66. package/dist/embed/index.js.map +1 -0
  67. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
  68. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
  69. package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
  70. package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
  71. package/dist/embed/ui.css +1 -0
  72. package/package.json +101 -0
  73. package/src/app.css +238 -0
  74. package/src/lib/ActivityIndicator.svelte +100 -0
  75. package/src/lib/ClusterNode.svelte +119 -0
  76. package/src/lib/ClusterView.svelte +541 -0
  77. package/src/lib/CollectionHistory.svelte +266 -0
  78. package/src/lib/CollectionsView.svelte +14 -0
  79. package/src/lib/CommandPalette.svelte +237 -0
  80. package/src/lib/ConnectDropdown.svelte +222 -0
  81. package/src/lib/EmptyState.svelte +27 -0
  82. package/src/lib/LiveChanges.svelte +195 -0
  83. package/src/lib/MasterPasswordDialog.svelte +168 -0
  84. package/src/lib/PageHeader.svelte +32 -0
  85. package/src/lib/PendingChangesPanel.svelte +153 -0
  86. package/src/lib/QueryEditor.svelte +122 -0
  87. package/src/lib/ResultsPane.svelte +747 -0
  88. package/src/lib/SchemaTree.svelte +193 -0
  89. package/src/lib/SecurityView.svelte +255 -0
  90. package/src/lib/ShortcutOverlay.svelte +104 -0
  91. package/src/lib/Splash.svelte +46 -0
  92. package/src/lib/StatusBar.svelte +163 -0
  93. package/src/lib/SubNav.svelte +62 -0
  94. package/src/lib/Topbar.svelte +119 -0
  95. package/src/lib/Workspace.svelte +247 -0
  96. package/src/lib/activity.svelte.ts +91 -0
  97. package/src/lib/build-info.ts +41 -0
  98. package/src/lib/capability.ts +221 -0
  99. package/src/lib/cn.ts +6 -0
  100. package/src/lib/collection-catalog.ts +116 -0
  101. package/src/lib/collection-pages.ts +33 -0
  102. package/src/lib/connection-history.ts +301 -0
  103. package/src/lib/connections.svelte.ts +493 -0
  104. package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
  105. package/src/lib/embed/index.ts +234 -0
  106. package/src/lib/graph-loader/contract.ts +336 -0
  107. package/src/lib/graph-loader/drilldown.ts +334 -0
  108. package/src/lib/graph-loader/index.ts +13 -0
  109. package/src/lib/index.ts +114 -0
  110. package/src/lib/pending-changes.svelte.ts +149 -0
  111. package/src/lib/query-tabs.svelte.ts +130 -0
  112. package/src/lib/reddb/boot-params.ts +254 -0
  113. package/src/lib/reddb/cdc-stream-client.ts +234 -0
  114. package/src/lib/reddb/client.ts +698 -0
  115. package/src/lib/reddb/connection-bootstrap.ts +260 -0
  116. package/src/lib/reddb/connection-provider.contract.ts +72 -0
  117. package/src/lib/reddb/connection-provider.ts +72 -0
  118. package/src/lib/reddb/index.ts +14 -0
  119. package/src/lib/reddb/injected-client-provider.ts +76 -0
  120. package/src/lib/reddb/local-url-provider.ts +245 -0
  121. package/src/lib/reddb/secure-store.ts +41 -0
  122. package/src/lib/reddb/server-capabilities.ts +71 -0
  123. package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
  124. package/src/lib/reddb/transports.ts +89 -0
  125. package/src/lib/reddb/types.ts +32 -0
  126. package/src/lib/reddb/uri.ts +54 -0
  127. package/src/lib/reddb/web-encrypted-store.ts +201 -0
  128. package/src/lib/renderers/DiffRenderer.svelte +184 -0
  129. package/src/lib/renderers/DocumentRenderer.svelte +134 -0
  130. package/src/lib/renderers/GraphRenderer.svelte +1328 -0
  131. package/src/lib/renderers/HypertableRenderer.svelte +253 -0
  132. package/src/lib/renderers/JsonRenderer.svelte +30 -0
  133. package/src/lib/renderers/KvRenderer.svelte +150 -0
  134. package/src/lib/renderers/QueueRenderer.svelte +286 -0
  135. package/src/lib/renderers/StatsRenderer.svelte +123 -0
  136. package/src/lib/renderers/TableRenderer.svelte +440 -0
  137. package/src/lib/renderers/VectorRenderer.svelte +388 -0
  138. package/src/lib/renderers/diff-render.ts +122 -0
  139. package/src/lib/renderers/diff.ts +9 -0
  140. package/src/lib/renderers/document-render.ts +64 -0
  141. package/src/lib/renderers/document.ts +9 -0
  142. package/src/lib/renderers/graph-render.ts +631 -0
  143. package/src/lib/renderers/graph-sigma.ts +395 -0
  144. package/src/lib/renderers/graph.ts +9 -0
  145. package/src/lib/renderers/hypertable-render.ts +320 -0
  146. package/src/lib/renderers/hypertable.ts +9 -0
  147. package/src/lib/renderers/index.ts +42 -0
  148. package/src/lib/renderers/json-render.ts +89 -0
  149. package/src/lib/renderers/json.ts +10 -0
  150. package/src/lib/renderers/kv-render.ts +193 -0
  151. package/src/lib/renderers/kv.ts +9 -0
  152. package/src/lib/renderers/queue-render.ts +197 -0
  153. package/src/lib/renderers/queue.ts +9 -0
  154. package/src/lib/renderers/registry.ts +68 -0
  155. package/src/lib/renderers/stats-render.ts +73 -0
  156. package/src/lib/renderers/stats.ts +9 -0
  157. package/src/lib/renderers/table-render.ts +156 -0
  158. package/src/lib/renderers/table.ts +11 -0
  159. package/src/lib/renderers/types.ts +33 -0
  160. package/src/lib/renderers/vector-render.ts +154 -0
  161. package/src/lib/renderers/vector.ts +9 -0
  162. package/src/lib/router.svelte.ts +163 -0
  163. package/src/lib/secureStore.svelte.ts +148 -0
  164. package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
  165. package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
  166. package/src/lib/shortcuts.ts +22 -0
  167. package/src/lib/surface.ts +80 -0
  168. package/src/lib/tabs.svelte.ts +115 -0
  169. 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, '&amp;')
317
+ .replace(/</g, '&lt;')
318
+ .replace(/>/g, '&gt;')
319
+ .replace(/"/g, '&quot;')
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, '&amp;')
87
+ .replace(/</g, '&lt;')
88
+ .replace(/>/g, '&gt;')
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, '&amp;')
190
+ .replace(/</g, '&lt;')
191
+ .replace(/>/g, '&gt;')
192
+ .replace(/"/g, '&quot;')
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
+ }