@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,197 @@
|
|
|
1
|
+
import type { QueryResult } from '#reddb'
|
|
2
|
+
|
|
3
|
+
export interface QueueMessage {
|
|
4
|
+
index: number
|
|
5
|
+
id: string
|
|
6
|
+
messageColumn: string | null
|
|
7
|
+
message: unknown
|
|
8
|
+
state: string
|
|
9
|
+
consumer: string | null
|
|
10
|
+
dlc: string | null
|
|
11
|
+
values: Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface QueueEvent {
|
|
15
|
+
messageId: string
|
|
16
|
+
collection: string
|
|
17
|
+
op: string
|
|
18
|
+
entityId: string
|
|
19
|
+
lsn: number | null
|
|
20
|
+
ts: number | null
|
|
21
|
+
before: unknown
|
|
22
|
+
after: unknown
|
|
23
|
+
payload: Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface QueueCommand {
|
|
27
|
+
label: string
|
|
28
|
+
query: string
|
|
29
|
+
mutates: boolean
|
|
30
|
+
note: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MESSAGE_COLUMNS = ['message', 'payload', 'body', 'value', 'data']
|
|
34
|
+
const STATE_COLUMNS = ['state', 'status', 'delivery_state']
|
|
35
|
+
const CONSUMER_COLUMNS = ['consumer', 'consumer_id', 'worker', 'lease_owner']
|
|
36
|
+
const DLC_COLUMNS = ['dlc', 'dlq', 'dead_letter_collection', 'dead_letter_queue']
|
|
37
|
+
|
|
38
|
+
function pick(values: Record<string, unknown>, names: string[]): [string, unknown] | null {
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
if (name in values) return [name, values[name]]
|
|
41
|
+
}
|
|
42
|
+
const lowered = Object.entries(values).find(([k]) => names.includes(k.toLowerCase()))
|
|
43
|
+
return lowered ?? null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function extractQueueMessages(result: QueryResult): QueueMessage[] {
|
|
47
|
+
return result.result.records.map((record, index) => {
|
|
48
|
+
const values = record.values ?? {}
|
|
49
|
+
const message = pick(values, MESSAGE_COLUMNS)
|
|
50
|
+
const state = pick(values, STATE_COLUMNS)
|
|
51
|
+
const consumer = pick(values, CONSUMER_COLUMNS)
|
|
52
|
+
const dlc = pick(values, DLC_COLUMNS)
|
|
53
|
+
return {
|
|
54
|
+
index,
|
|
55
|
+
id: String(values.message_id ?? values.id ?? values.rid ?? index + 1),
|
|
56
|
+
messageColumn: message?.[0] ?? null,
|
|
57
|
+
message: message?.[1] ?? values,
|
|
58
|
+
state: String(state?.[1] ?? 'ready'),
|
|
59
|
+
consumer: consumer?.[1] === undefined || consumer?.[1] === null ? null : String(consumer[1]),
|
|
60
|
+
dlc: dlc?.[1] === undefined || dlc?.[1] === null ? null : String(dlc[1]),
|
|
61
|
+
values,
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function hasQueueShape(result: QueryResult): boolean {
|
|
67
|
+
if (result.capability === 'queue') return true
|
|
68
|
+
return result.result.records.some((record) => {
|
|
69
|
+
const keys = Object.keys(record.values ?? {}).map((k) => k.toLowerCase())
|
|
70
|
+
if (keys.includes('message_id') && MESSAGE_COLUMNS.some((c) => keys.includes(c))) return true
|
|
71
|
+
return MESSAGE_COLUMNS.some((c) => keys.includes(c)) && (
|
|
72
|
+
STATE_COLUMNS.some((c) => keys.includes(c)) ||
|
|
73
|
+
CONSUMER_COLUMNS.some((c) => keys.includes(c)) ||
|
|
74
|
+
DLC_COLUMNS.some((c) => keys.includes(c))
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function queueSummary(messages: QueueMessage[]) {
|
|
80
|
+
const active = new Set<string>()
|
|
81
|
+
const states = new Map<string, number>()
|
|
82
|
+
const dlcs = new Set<string>()
|
|
83
|
+
const operations = new Map<string, number>()
|
|
84
|
+
const sources = new Set<string>()
|
|
85
|
+
for (const msg of messages) {
|
|
86
|
+
states.set(msg.state, (states.get(msg.state) ?? 0) + 1)
|
|
87
|
+
if (msg.consumer && /active|leased|processing|running/i.test(msg.state)) active.add(msg.consumer)
|
|
88
|
+
if (msg.dlc) dlcs.add(msg.dlc)
|
|
89
|
+
if (msg.message && typeof msg.message === 'object' && !Array.isArray(msg.message)) {
|
|
90
|
+
const payload = msg.message as Record<string, unknown>
|
|
91
|
+
const op = payload.op
|
|
92
|
+
const source = payload.collection
|
|
93
|
+
if (typeof op === 'string') operations.set(op, (operations.get(op) ?? 0) + 1)
|
|
94
|
+
if (typeof source === 'string') sources.add(source)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
activeConsumers: active.size,
|
|
99
|
+
states: [...states.entries()],
|
|
100
|
+
dlcs: [...dlcs],
|
|
101
|
+
operations: [...operations.entries()].sort((a, b) => a[0].localeCompare(b[0])),
|
|
102
|
+
sources: [...sources].sort(),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function numberOrNull(v: unknown): number | null {
|
|
107
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v
|
|
108
|
+
if (typeof v === 'string') {
|
|
109
|
+
const n = Number(v)
|
|
110
|
+
if (Number.isFinite(n)) return n
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function extractQueueEvents(messages: QueueMessage[]): QueueEvent[] {
|
|
116
|
+
const events: QueueEvent[] = []
|
|
117
|
+
for (const msg of messages) {
|
|
118
|
+
if (!msg.message || typeof msg.message !== 'object' || Array.isArray(msg.message)) continue
|
|
119
|
+
const payload = msg.message as Record<string, unknown>
|
|
120
|
+
const collection = payload.collection
|
|
121
|
+
const op = payload.op
|
|
122
|
+
if (typeof collection !== 'string' || typeof op !== 'string') continue
|
|
123
|
+
const id = payload.id ?? (payload.after && typeof payload.after === 'object' && !Array.isArray(payload.after)
|
|
124
|
+
? (payload.after as Record<string, unknown>).id
|
|
125
|
+
: undefined)
|
|
126
|
+
events.push({
|
|
127
|
+
messageId: msg.id,
|
|
128
|
+
collection,
|
|
129
|
+
op,
|
|
130
|
+
entityId: id === undefined || id === null ? String(payload.rid ?? msg.id) : String(id),
|
|
131
|
+
lsn: numberOrNull(payload.lsn),
|
|
132
|
+
ts: numberOrNull(payload.ts),
|
|
133
|
+
before: payload.before,
|
|
134
|
+
after: payload.after,
|
|
135
|
+
payload,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
return events
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function queueCommandPlan(queue: string): QueueCommand[] {
|
|
142
|
+
const safeQueue = queue.replace(/[^A-Za-z0-9_./-]/g, '')
|
|
143
|
+
const consumer = 'red-ui-preview'
|
|
144
|
+
const group = 'showcase'
|
|
145
|
+
return [
|
|
146
|
+
{
|
|
147
|
+
label: 'Backlog depth',
|
|
148
|
+
query: `QUEUE LEN ${safeQueue}`,
|
|
149
|
+
mutates: false,
|
|
150
|
+
note: 'Read-only count of messages currently stored in the queue.',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
label: 'Peek messages',
|
|
154
|
+
query: `QUEUE PEEK ${safeQueue} 25`,
|
|
155
|
+
mutates: false,
|
|
156
|
+
note: 'Read-only inspection. Does not lease, ack, or remove messages.',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
label: 'Read as fanout consumer',
|
|
160
|
+
query: `QUEUE READ ${safeQueue} CONSUMER ${consumer} COUNT 5`,
|
|
161
|
+
mutates: true,
|
|
162
|
+
note: 'Leases messages for this consumer. ACK/NACK decides what happens next.',
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
label: 'Read through explicit group',
|
|
166
|
+
query: `QUEUE READ ${safeQueue} GROUP ${group} CONSUMER worker-a COUNT 5`,
|
|
167
|
+
mutates: true,
|
|
168
|
+
note: 'Creates/uses consumer group delivery state for an application tier.',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
label: 'Inspect pending group messages',
|
|
172
|
+
query: `QUEUE PENDING ${safeQueue} GROUP ${group}`,
|
|
173
|
+
mutates: false,
|
|
174
|
+
note: 'Shows leased-but-unacked messages for a group.',
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
label: 'Ack a delivered message',
|
|
178
|
+
query: `QUEUE ACK ${safeQueue} GROUP ${group} 'message-id'`,
|
|
179
|
+
mutates: true,
|
|
180
|
+
note: 'Removes a successfully processed message for that group.',
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
label: 'Nack a delivered message',
|
|
184
|
+
query: `QUEUE NACK ${safeQueue} GROUP ${group} 'message-id'`,
|
|
185
|
+
mutates: true,
|
|
186
|
+
note: 'Returns or retries a failed message according to queue policy.',
|
|
187
|
+
},
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function queuePushQuery(queue: string, payload: unknown): string {
|
|
192
|
+
const safeQueue = queue.replace(/[^A-Za-z0-9_./-]/g, '')
|
|
193
|
+
const rendered = typeof payload === 'string'
|
|
194
|
+
? `'${payload.replace(/'/g, "''")}'`
|
|
195
|
+
: JSON.stringify(payload ?? null)
|
|
196
|
+
return `QUEUE PUSH ${safeQueue} ${rendered}`
|
|
197
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CapabilityRenderer } from './types'
|
|
2
|
+
import { hasQueueShape } from './queue-render'
|
|
3
|
+
import QueueRenderer from './QueueRenderer.svelte'
|
|
4
|
+
|
|
5
|
+
export const queueRenderer: CapabilityRenderer = {
|
|
6
|
+
capability: 'queue',
|
|
7
|
+
renders: (result) => hasQueueShape(result),
|
|
8
|
+
component: QueueRenderer,
|
|
9
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// CapabilityRenderer registry — deep module behind a tiny surface.
|
|
2
|
+
//
|
|
3
|
+
// Each renderer declares which capability it handles. pick() takes a
|
|
4
|
+
// capability hint (from the query result, slice metadata, or an override)
|
|
5
|
+
// and picks the right renderer; if no renderer matches the hint, it falls
|
|
6
|
+
// back to the renderer that `renders(result)` accepts, and finally to the
|
|
7
|
+
// 'table' renderer which is the universal default.
|
|
8
|
+
|
|
9
|
+
import type { QueryResult } from '#reddb'
|
|
10
|
+
import type { Capability, CapabilityRenderer } from './types'
|
|
11
|
+
|
|
12
|
+
export class RendererRegistry {
|
|
13
|
+
private renderers: CapabilityRenderer[] = []
|
|
14
|
+
|
|
15
|
+
register(renderer: CapabilityRenderer): void {
|
|
16
|
+
const idx = this.renderers.findIndex((r) => r.capability === renderer.capability)
|
|
17
|
+
if (idx >= 0) this.renderers[idx] = renderer
|
|
18
|
+
else this.renderers.push(renderer)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
unregister(capability: Capability): void {
|
|
22
|
+
this.renderers = this.renderers.filter((r) => r.capability !== capability)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
list(): readonly CapabilityRenderer[] {
|
|
26
|
+
return this.renderers
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
has(capability: Capability): boolean {
|
|
30
|
+
return this.renderers.some((r) => r.capability === capability)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pick a renderer for this result.
|
|
35
|
+
*
|
|
36
|
+
* Resolution order:
|
|
37
|
+
* 1. `override` capability if present and registered.
|
|
38
|
+
* 2. `capability` hint if present, registered, and `renders(result)` agrees.
|
|
39
|
+
* 3. First renderer whose `renders(result)` returns true.
|
|
40
|
+
* 4. The 'table' renderer (universal fallback).
|
|
41
|
+
*
|
|
42
|
+
* Throws if no renderer matches and no 'table' renderer is registered.
|
|
43
|
+
*/
|
|
44
|
+
pick(
|
|
45
|
+
capability: Capability | undefined,
|
|
46
|
+
result: QueryResult,
|
|
47
|
+
override?: Capability,
|
|
48
|
+
): CapabilityRenderer {
|
|
49
|
+
if (override) {
|
|
50
|
+
const r = this.renderers.find((r) => r.capability === override)
|
|
51
|
+
if (r) return r
|
|
52
|
+
}
|
|
53
|
+
if (capability) {
|
|
54
|
+
const r = this.renderers.find((r) => r.capability === capability)
|
|
55
|
+
if (r && r.renders(result)) return r
|
|
56
|
+
}
|
|
57
|
+
const shapeMatch = this.renderers.find((r) => r.renders(result))
|
|
58
|
+
if (shapeMatch) return shapeMatch
|
|
59
|
+
const fallback = this.renderers.find((r) => r.capability === 'table')
|
|
60
|
+
if (!fallback) {
|
|
61
|
+
throw new Error('No renderer matched and no table fallback is registered')
|
|
62
|
+
}
|
|
63
|
+
return fallback
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Shared singleton — renderers self-register into this from ./index.ts. */
|
|
68
|
+
export const registry = new RendererRegistry()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { QueryResult } from '#reddb'
|
|
2
|
+
|
|
3
|
+
export interface StatMetric {
|
|
4
|
+
key: string
|
|
5
|
+
label: string
|
|
6
|
+
value: number
|
|
7
|
+
unit: string
|
|
8
|
+
source: Record<string, unknown>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const NAME_COLUMNS = ['metric', 'stat', 'name', 'key']
|
|
12
|
+
const VALUE_COLUMNS = ['value', 'count', 'total', 'avg', 'rate', 'p50', 'p95', 'p99']
|
|
13
|
+
const UNIT_COLUMNS = ['unit', 'units']
|
|
14
|
+
|
|
15
|
+
function unitFor(key: string): string {
|
|
16
|
+
if (/bytes?|memory|storage|wal/i.test(key)) return 'bytes'
|
|
17
|
+
if (/rps|rate|requests?_per_second|writes?_per_second|reads?_per_second/i.test(key)) return '/s'
|
|
18
|
+
if (/ms|latency|response/i.test(key)) return 'ms'
|
|
19
|
+
if (/percent|ratio|usage/i.test(key)) return '%'
|
|
20
|
+
return ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function explicitUnit(values: Record<string, unknown>): string | null {
|
|
24
|
+
for (const column of UNIT_COLUMNS) {
|
|
25
|
+
const value = values[column]
|
|
26
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
27
|
+
}
|
|
28
|
+
const entry = Object.entries(values).find(([k, v]) =>
|
|
29
|
+
UNIT_COLUMNS.includes(k.toLowerCase()) && typeof v === 'string' && v.trim(),
|
|
30
|
+
)
|
|
31
|
+
return entry ? String(entry[1]).trim() : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function extractStats(result: QueryResult): StatMetric[] {
|
|
35
|
+
const metrics: StatMetric[] = []
|
|
36
|
+
for (const record of result.result.records) {
|
|
37
|
+
const values = record.values ?? {}
|
|
38
|
+
const nameEntry = Object.entries(values).find(([k, v]) =>
|
|
39
|
+
NAME_COLUMNS.includes(k.toLowerCase()) && (typeof v === 'string' || typeof v === 'number'),
|
|
40
|
+
)
|
|
41
|
+
const valueEntry = Object.entries(values).find(([k, v]) =>
|
|
42
|
+
VALUE_COLUMNS.includes(k.toLowerCase()) && typeof v === 'number' && Number.isFinite(v),
|
|
43
|
+
)
|
|
44
|
+
if (nameEntry && valueEntry) {
|
|
45
|
+
const label = String(nameEntry[1])
|
|
46
|
+
metrics.push({ key: label, label, value: valueEntry[1] as number, unit: explicitUnit(values) ?? unitFor(label), source: values })
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
for (const [key, value] of Object.entries(values)) {
|
|
50
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) continue
|
|
51
|
+
metrics.push({ key, label: key, value, unit: unitFor(key), source: values })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return metrics
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hasStatsShape(result: QueryResult): boolean {
|
|
58
|
+
if (result.capability === 'stats') return true
|
|
59
|
+
const metrics = extractStats(result)
|
|
60
|
+
return metrics.length > 0 && metrics.length >= result.result.records.length
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatMetric(value: number, unit: string): string {
|
|
64
|
+
if (unit === 'bytes') {
|
|
65
|
+
const abs = Math.abs(value)
|
|
66
|
+
if (abs >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(1)} GiB`
|
|
67
|
+
if (abs >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MiB`
|
|
68
|
+
if (abs >= 1024) return `${(value / 1024).toFixed(1)} KiB`
|
|
69
|
+
return `${value} B`
|
|
70
|
+
}
|
|
71
|
+
if (unit === '%' || unit === 'ms' || unit === '/s') return `${value.toLocaleString()}${unit}`
|
|
72
|
+
return value.toLocaleString()
|
|
73
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CapabilityRenderer } from './types'
|
|
2
|
+
import { hasStatsShape } from './stats-render'
|
|
3
|
+
import StatsRenderer from './StatsRenderer.svelte'
|
|
4
|
+
|
|
5
|
+
export const statsRenderer: CapabilityRenderer = {
|
|
6
|
+
capability: 'stats',
|
|
7
|
+
renders: (result) => hasStatsShape(result),
|
|
8
|
+
component: StatsRenderer,
|
|
9
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Pure rendering helpers for the table capability. Used by both the
|
|
2
|
+
// Svelte TableRenderer component (for live UI) and the golden snapshot
|
|
3
|
+
// test (which needs a DOM-free representation).
|
|
4
|
+
|
|
5
|
+
import type { QueryResult } from '#reddb'
|
|
6
|
+
|
|
7
|
+
export const TABLE_ROW_LIMIT = 80
|
|
8
|
+
export const ELLIPSIS_AT = 64
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* reddb's reserved public item fields. The server enforces these as
|
|
12
|
+
* unwritable by users (see `crates/reddb-server/src/reserved_fields.rs`)
|
|
13
|
+
* and stamps them onto every read. The UI hides them by default so the
|
|
14
|
+
* user-defined shape is visible; a toggle reintroduces them as a
|
|
15
|
+
* prefix block.
|
|
16
|
+
*/
|
|
17
|
+
export const SYSTEM_FIELDS = [
|
|
18
|
+
'rid',
|
|
19
|
+
'collection',
|
|
20
|
+
'kind',
|
|
21
|
+
'tenant',
|
|
22
|
+
'created_at',
|
|
23
|
+
'updated_at',
|
|
24
|
+
] as const
|
|
25
|
+
const SYSTEM_SET = new Set<string>(SYSTEM_FIELDS)
|
|
26
|
+
|
|
27
|
+
export function isSystemField(column: string): boolean {
|
|
28
|
+
return SYSTEM_SET.has(column.toLowerCase())
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface VisibleColumnsOptions {
|
|
32
|
+
/** When true, reddb system fields lead; when false (default) they're hidden. */
|
|
33
|
+
showSystem?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function visibleColumns(
|
|
37
|
+
result: QueryResult,
|
|
38
|
+
opts: VisibleColumnsOptions = {},
|
|
39
|
+
): string[] {
|
|
40
|
+
const all = result.result.columns
|
|
41
|
+
// `red_*` and `body` are noise even in show-system mode — the row is
|
|
42
|
+
// already unrolled, and red_* are internal-only.
|
|
43
|
+
const usable = all.filter((c) => !c.startsWith('red_') && c !== 'body')
|
|
44
|
+
|
|
45
|
+
const userCols = usable.filter((c) => !isSystemField(c))
|
|
46
|
+
const id = userCols.find((c) => c.toLowerCase() === 'id')
|
|
47
|
+
const userRest = userCols.filter((c) => c !== id)
|
|
48
|
+
const userOrdered = [id, ...userRest].filter((c): c is string => Boolean(c))
|
|
49
|
+
|
|
50
|
+
if (!opts.showSystem) {
|
|
51
|
+
return userOrdered.length > 0 ? userOrdered : all
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// System block, in canonical order (rid first — it's the actual address).
|
|
55
|
+
const systemOrdered = SYSTEM_FIELDS.filter((f) => usable.includes(f))
|
|
56
|
+
return [...systemOrdered, ...userOrdered]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type IdentityKind = 'rid' | 'id'
|
|
60
|
+
|
|
61
|
+
export function identityKind(column: string): IdentityKind | null {
|
|
62
|
+
const c = column.toLowerCase()
|
|
63
|
+
if (c === 'rid') return 'rid'
|
|
64
|
+
if (c === 'id') return 'id'
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function inferPkColumn(columns: string[]): string | null {
|
|
69
|
+
// rid wins — it's the system-assigned row id and the actual key
|
|
70
|
+
// reddb uses to address documents. id is user-defined and may not be unique.
|
|
71
|
+
return (
|
|
72
|
+
columns.find((c) => c.toLowerCase() === 'rid') ??
|
|
73
|
+
columns.find((c) => c.toLowerCase() === 'id') ??
|
|
74
|
+
columns.find((c) => c.toLowerCase().endsWith('_id')) ??
|
|
75
|
+
columns[0] ??
|
|
76
|
+
null
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatCell(v: unknown): string {
|
|
81
|
+
if (v === null || v === undefined) return ''
|
|
82
|
+
if (typeof v === 'object') return JSON.stringify(v)
|
|
83
|
+
return String(v)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isNull(v: unknown): boolean {
|
|
87
|
+
return v === null || v === undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function ellipsize(s: string, max = ELLIPSIS_AT): string {
|
|
91
|
+
if (s.length <= max) return s
|
|
92
|
+
return s.slice(0, max - 1) + '…'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function visibleRows(result: QueryResult, limit = TABLE_ROW_LIMIT): Array<Record<string, unknown>> {
|
|
96
|
+
return result.result.records.slice(0, limit).map((rec) => rec.values)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Produce a stable, DOM-free HTML string for the given query result.
|
|
101
|
+
* Used by the golden snapshot test. Intentionally minimal: no inline
|
|
102
|
+
* styles, no event handlers, no editing affordances — those live in the
|
|
103
|
+
* Svelte component.
|
|
104
|
+
*/
|
|
105
|
+
export function renderTableHtml(
|
|
106
|
+
result: QueryResult,
|
|
107
|
+
opts: VisibleColumnsOptions = {},
|
|
108
|
+
): string {
|
|
109
|
+
const columns = visibleColumns(result, opts)
|
|
110
|
+
const pk = inferPkColumn(columns)
|
|
111
|
+
const rows = visibleRows(result)
|
|
112
|
+
const lines: string[] = []
|
|
113
|
+
lines.push('<table>')
|
|
114
|
+
lines.push('<thead><tr>')
|
|
115
|
+
lines.push('<th class="num">#</th>')
|
|
116
|
+
for (const c of columns) {
|
|
117
|
+
const kind = identityKind(c)
|
|
118
|
+
const mark = kind === 'rid'
|
|
119
|
+
? '<span class="badge sys">sys</span>'
|
|
120
|
+
: kind === 'id'
|
|
121
|
+
? '<span class="badge pk">pk</span>'
|
|
122
|
+
: c === pk
|
|
123
|
+
? '<span class="badge pk">pk</span>'
|
|
124
|
+
: ''
|
|
125
|
+
const cls = kind ? ` class="id ${kind}"` : ''
|
|
126
|
+
lines.push(`<th${cls}>${escapeHtml(c)}${mark}</th>`)
|
|
127
|
+
}
|
|
128
|
+
lines.push('</tr></thead>')
|
|
129
|
+
lines.push('<tbody>')
|
|
130
|
+
rows.forEach((row, i) => {
|
|
131
|
+
lines.push('<tr>')
|
|
132
|
+
lines.push(`<td class="num">${i + 1}</td>`)
|
|
133
|
+
for (const c of columns) {
|
|
134
|
+
const v = row[c]
|
|
135
|
+
const kind = identityKind(c)
|
|
136
|
+
const cls = kind ? ` class="id ${kind}"` : ''
|
|
137
|
+
if (isNull(v)) {
|
|
138
|
+
lines.push(`<td${cls}><span class="null">NULL</span></td>`)
|
|
139
|
+
} else {
|
|
140
|
+
lines.push(`<td${cls}><span class="cell">${escapeHtml(ellipsize(formatCell(v)))}</span></td>`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
lines.push('</tr>')
|
|
144
|
+
})
|
|
145
|
+
lines.push('</tbody>')
|
|
146
|
+
lines.push('</table>')
|
|
147
|
+
return lines.join('')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function escapeHtml(s: string): string {
|
|
151
|
+
return s
|
|
152
|
+
.replace(/&/g, '&')
|
|
153
|
+
.replace(/</g, '<')
|
|
154
|
+
.replace(/>/g, '>')
|
|
155
|
+
.replace(/"/g, '"')
|
|
156
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CapabilityRenderer } from './types'
|
|
2
|
+
import TableRenderer from './TableRenderer.svelte'
|
|
3
|
+
|
|
4
|
+
export const tableRenderer: CapabilityRenderer = {
|
|
5
|
+
capability: 'table',
|
|
6
|
+
// Table is the universal fallback — it can paint any shaped result that
|
|
7
|
+
// has `columns` and `records`. The registry uses this both for hint
|
|
8
|
+
// matches and as the last-resort default.
|
|
9
|
+
renders: (result) => !!result?.result && Array.isArray(result.result.columns),
|
|
10
|
+
component: TableRenderer,
|
|
11
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Component } from 'svelte'
|
|
2
|
+
import type { QueryResult, RedClient } from '#reddb'
|
|
3
|
+
|
|
4
|
+
export type Capability =
|
|
5
|
+
| 'table'
|
|
6
|
+
| 'graph'
|
|
7
|
+
| 'hypertable'
|
|
8
|
+
| 'kv'
|
|
9
|
+
| 'vector'
|
|
10
|
+
| 'queue'
|
|
11
|
+
| 'stats'
|
|
12
|
+
| 'diff'
|
|
13
|
+
| 'document'
|
|
14
|
+
| 'json'
|
|
15
|
+
|
|
16
|
+
export interface RendererProps {
|
|
17
|
+
result: QueryResult
|
|
18
|
+
collection?: string
|
|
19
|
+
client?: RedClient
|
|
20
|
+
subpage?: string
|
|
21
|
+
/** When true, reddb system fields (rid, collection, kind, tenant,
|
|
22
|
+
* created_at, updated_at) are shown ahead of user fields. Default false:
|
|
23
|
+
* the user-defined shape is what they're trying to read. */
|
|
24
|
+
showSystem?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CapabilityRenderer {
|
|
28
|
+
capability: Capability
|
|
29
|
+
/** Result-shape disambiguation: does this renderer claim this result? */
|
|
30
|
+
renders(result: QueryResult): boolean
|
|
31
|
+
/** Svelte component that paints the result. */
|
|
32
|
+
component: Component<RendererProps>
|
|
33
|
+
}
|