@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,286 @@
1
+ <script lang="ts">
2
+ import type { QueryResult } from '#reddb'
3
+ import { connection } from '$lib/connections.svelte'
4
+ import { activity } from '$lib/activity.svelte'
5
+ import { Braces, ClipboardList, Loader2, Plus, Radio, Search, Send, X } from 'lucide-svelte'
6
+ import {
7
+ extractQueueEvents,
8
+ extractQueueMessages,
9
+ queueCommandPlan,
10
+ queuePushQuery,
11
+ queueSummary,
12
+ type QueueMessage,
13
+ } from './queue-render'
14
+
15
+ interface Props {
16
+ result: QueryResult
17
+ collection?: string
18
+ }
19
+
20
+ let { result, collection }: Props = $props()
21
+
22
+ let mode = $state<'events' | 'messages' | 'commands' | 'json'>('events')
23
+ let filter = $state('')
24
+ let selected = $state<QueueMessage | null>(null)
25
+ let composerOpen = $state(false)
26
+ let payload = $state('')
27
+ let inserting = $state(false)
28
+ let insertError = $state<string | null>(null)
29
+ let inserted = $state(0)
30
+ const payloadPlaceholder = '{"type":"email","to":"ops@example.com"}'
31
+
32
+ const messages = $derived(extractQueueMessages(result))
33
+ const summary = $derived(queueSummary(messages))
34
+ const events = $derived(extractQueueEvents(messages))
35
+ const commands = $derived(collection ? queueCommandPlan(collection) : [])
36
+ const filtered = $derived.by(() => {
37
+ const q = filter.trim().toLowerCase()
38
+ if (!q) return messages
39
+ return messages.filter((msg) =>
40
+ msg.id.toLowerCase().includes(q) ||
41
+ msg.state.toLowerCase().includes(q) ||
42
+ JSON.stringify(msg.message).toLowerCase().includes(q),
43
+ )
44
+ })
45
+
46
+ function display(v: unknown): string {
47
+ if (typeof v === 'string') return v
48
+ return JSON.stringify(v)
49
+ }
50
+
51
+ function fmtTs(v: number | null): string {
52
+ if (v === null) return '-'
53
+ try {
54
+ return new Date(v).toISOString().replace('T', ' ').slice(0, 19)
55
+ } catch {
56
+ return String(v)
57
+ }
58
+ }
59
+
60
+ function parsePayload(): unknown {
61
+ const text = payload.trim()
62
+ if (!text) return ''
63
+ try {
64
+ return JSON.parse(text)
65
+ } catch {
66
+ return text
67
+ }
68
+ }
69
+
70
+ async function insertMessage() {
71
+ const client = connection.client
72
+ if (!client || !collection || !payload.trim()) return
73
+ inserting = true
74
+ insertError = null
75
+ try {
76
+ await activity.track(`${collection} · enqueue message`, () =>
77
+ client.query(queuePushQuery(collection, parsePayload())),
78
+ )
79
+ payload = ''
80
+ inserted += 1
81
+ } catch (e) {
82
+ insertError = (e as Error).message
83
+ } finally {
84
+ inserting = false
85
+ }
86
+ }
87
+ </script>
88
+
89
+ <div class="flex h-full flex-col text-fg-1">
90
+ <div class="border-b border-line-1 px-3 py-1.5 flex items-center gap-2 text-[11px] font-mono bg-bg-1/40">
91
+ <div class="inline-flex border border-line-2 rounded overflow-hidden">
92
+ <button
93
+ type="button"
94
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors', mode === 'events' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
95
+ onclick={() => (mode = 'events')}
96
+ aria-pressed={mode === 'events'}
97
+ >
98
+ <Radio class="size-3" />
99
+ events
100
+ </button>
101
+ <button
102
+ type="button"
103
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'messages' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
104
+ onclick={() => (mode = 'messages')}
105
+ aria-pressed={mode === 'messages'}
106
+ >
107
+ <Send class="size-3" />
108
+ messages
109
+ </button>
110
+ <button
111
+ type="button"
112
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'commands' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
113
+ onclick={() => (mode = 'commands')}
114
+ aria-pressed={mode === 'commands'}
115
+ >
116
+ <ClipboardList class="size-3" />
117
+ commands
118
+ </button>
119
+ <button
120
+ type="button"
121
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'json' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
122
+ onclick={() => (mode = 'json')}
123
+ aria-pressed={mode === 'json'}
124
+ >
125
+ <Braces class="size-3" />
126
+ json
127
+ </button>
128
+ </div>
129
+
130
+ <button
131
+ type="button"
132
+ onclick={() => (composerOpen = !composerOpen)}
133
+ disabled={!collection}
134
+ class={['inline-flex items-center gap-1 h-6 px-2 rounded border cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed', composerOpen ? 'border-accent/40 bg-accent/10 text-accent' : 'border-line-1 text-fg-3 hover:text-fg-1 hover:border-line-2'].join(' ')}
135
+ aria-pressed={composerOpen}
136
+ title={collection ? 'Insert a queue message' : 'Open this as a collection to insert messages'}
137
+ >
138
+ <Plus class="size-3" />
139
+ insert
140
+ </button>
141
+
142
+ <div class="inline-flex items-center gap-1.5 border border-line-1 rounded px-2 py-1 bg-bg-0">
143
+ <Search class="size-3 text-fg-3" />
144
+ <input type="text" bind:value={filter} placeholder="message/state…" class="bg-transparent text-fg-1 outline-none w-[180px] placeholder:text-fg-3" />
145
+ {#if filter}
146
+ <button type="button" onclick={() => (filter = '')} class="text-fg-3 hover:text-fg-1 cursor-pointer" aria-label="Clear queue filter">
147
+ <X class="size-3" />
148
+ </button>
149
+ {/if}
150
+ </div>
151
+
152
+ <div class="ml-auto flex items-center gap-3 text-fg-3">
153
+ <span>{filtered.length.toLocaleString()} messages</span>
154
+ {#if events.length}<span>{events.length.toLocaleString()} events</span>{/if}
155
+ <span>{summary.activeConsumers} active consumers</span>
156
+ {#if summary.sources.length}<span>from <span class="text-fg-2">{summary.sources.join(', ')}</span></span>{/if}
157
+ {#if summary.dlcs.length}<span>dlc <span class="text-fg-2">{summary.dlcs.join(', ')}</span></span>{/if}
158
+ {#if collection}<span class="text-fg-2">{collection}</span>{/if}
159
+ </div>
160
+ </div>
161
+
162
+ {#if composerOpen}
163
+ <div class="border-b border-line-1 bg-bg-1/20 px-3 py-2 text-[11px] font-mono">
164
+ <div class="flex gap-2">
165
+ <textarea
166
+ bind:value={payload}
167
+ rows="3"
168
+ placeholder={payloadPlaceholder}
169
+ class="flex-1 resize-none rounded border border-line-1 bg-bg-0 px-2 py-1 text-[12px] text-fg-1 outline-none placeholder:text-fg-3 focus:border-accent"
170
+ ></textarea>
171
+ <button
172
+ type="button"
173
+ onclick={insertMessage}
174
+ disabled={inserting || !payload.trim() || !collection}
175
+ class="inline-flex h-7 items-center gap-1.5 rounded bg-accent px-3 text-white disabled:opacity-40 disabled:cursor-not-allowed"
176
+ >
177
+ {#if inserting}<Loader2 class="size-3 animate-spin" />{:else}<Send class="size-3" />{/if}
178
+ insert
179
+ </button>
180
+ </div>
181
+ {#if insertError}
182
+ <div class="mt-1.5 text-warn">{insertError}</div>
183
+ {:else if inserted > 0}
184
+ <div class="mt-1.5 text-ok">{inserted} inserted. Refresh the collection to reload from reddb.</div>
185
+ {/if}
186
+ </div>
187
+ {/if}
188
+
189
+ {#if mode === 'events'}
190
+ <div class="flex-1 min-h-0 overflow-auto">
191
+ {#if events.length === 0}
192
+ <div class="p-3 text-[12px] font-mono text-fg-3">No WITH EVENTS envelopes detected in this queue result.</div>
193
+ {:else}
194
+ <table class="w-full border-collapse text-[12px] font-mono">
195
+ <thead class="sticky top-0 bg-bg-1 z-10">
196
+ <tr class="border-b border-line-1 text-fg-3">
197
+ <th class="px-2 py-1.5 text-left font-normal">time</th>
198
+ <th class="px-2 py-1.5 text-left font-normal">source</th>
199
+ <th class="px-2 py-1.5 text-left font-normal">op</th>
200
+ <th class="px-2 py-1.5 text-left font-normal">entity</th>
201
+ <th class="px-2 py-1.5 text-right font-normal">lsn</th>
202
+ <th class="px-2 py-1.5 text-left font-normal">after</th>
203
+ </tr>
204
+ </thead>
205
+ <tbody>
206
+ {#each events as event (`${event.messageId}-${event.lsn ?? event.entityId}`)}
207
+ <tr class="border-b border-line-1/60 hover:bg-bg-1/40">
208
+ <td class="px-2 py-1 text-fg-2">{fmtTs(event.ts)}</td>
209
+ <td class="px-2 py-1 text-fg-0">{event.collection}</td>
210
+ <td class="px-2 py-1"><span class="rounded border border-accent/30 bg-accent/10 px-1.5 py-0.5 text-accent">{event.op}</span></td>
211
+ <td class="px-2 py-1 text-fg-1">{event.entityId}</td>
212
+ <td class="px-2 py-1 text-right text-fg-3">{event.lsn ?? '-'}</td>
213
+ <td class="px-2 py-1 text-fg-2 truncate max-w-[520px]">{display(event.after)}</td>
214
+ </tr>
215
+ {/each}
216
+ </tbody>
217
+ </table>
218
+ {/if}
219
+ </div>
220
+ {:else if mode === 'commands'}
221
+ <div class="flex-1 min-h-0 overflow-auto p-3 text-[12px] font-mono">
222
+ <div class="grid gap-2">
223
+ {#each commands as command (command.query)}
224
+ <div class="rounded border border-line-1 bg-bg-1 px-3 py-2">
225
+ <div class="mb-1 flex items-center gap-2">
226
+ <span class="text-fg-0">{command.label}</span>
227
+ <span class={['rounded border px-1.5 py-0.5 text-[10px]', command.mutates ? 'border-warn/30 bg-warn/10 text-warn' : 'border-ok/30 bg-ok/10 text-ok'].join(' ')}>
228
+ {command.mutates ? 'mutates' : 'read-only'}
229
+ </span>
230
+ </div>
231
+ <pre class="m-0 overflow-x-auto rounded bg-bg-0 px-2 py-1 text-fg-1">{command.query}</pre>
232
+ <div class="mt-1 text-fg-3">{command.note}</div>
233
+ </div>
234
+ {/each}
235
+ </div>
236
+ </div>
237
+ {:else}
238
+ <div class="flex-1 min-h-0 grid grid-cols-[minmax(360px,0.9fr)_1.1fr]">
239
+ <div class="overflow-auto border-r border-line-1 text-[12px] font-mono">
240
+ {#if filtered.length === 0}
241
+ <div class="p-3 text-fg-3">No queue messages in this result.</div>
242
+ {:else}
243
+ {#each filtered as msg (msg.id)}
244
+ <button
245
+ type="button"
246
+ onclick={() => (selected = msg)}
247
+ class={['w-full text-left px-3 py-2 border-b border-line-1/60 hover:bg-bg-1/50 cursor-pointer', selected === msg ? 'bg-bg-1 text-fg-0' : 'text-fg-2'].join(' ')}
248
+ >
249
+ <div class="flex items-center gap-2">
250
+ <span class="text-accent">{msg.id}</span>
251
+ <span class="rounded border border-line-1 px-1 py-0.5 text-[10px] text-fg-3">{msg.state}</span>
252
+ {#if msg.consumer}<span class="ml-auto text-fg-3">{msg.consumer}</span>{/if}
253
+ </div>
254
+ <div class="mt-1 truncate text-fg-1">{display(msg.message)}</div>
255
+ </button>
256
+ {/each}
257
+ {/if}
258
+ </div>
259
+
260
+ <div class="overflow-auto p-3 text-[12px] font-mono">
261
+ {#if mode === 'json'}
262
+ <pre class="whitespace-pre-wrap text-fg-1">{JSON.stringify(filtered.map((m) => m.values), null, 2)}</pre>
263
+ {:else if selected}
264
+ <div class="grid grid-cols-[120px_1fr] gap-x-3 gap-y-1.5">
265
+ <span class="text-fg-3">id</span><span class="text-fg-0">{selected.id}</span>
266
+ <span class="text-fg-3">state</span><span class="text-fg-0">{selected.state}</span>
267
+ <span class="text-fg-3">consumer</span><span class="text-fg-0">{selected.consumer ?? 'none'}</span>
268
+ <span class="text-fg-3">dlc</span><span class="text-fg-0">{selected.dlc ?? 'none'}</span>
269
+ <span class="text-fg-3">message</span><pre class="m-0 whitespace-pre-wrap text-fg-1">{display(selected.message)}</pre>
270
+ </div>
271
+ {:else}
272
+ <div class="text-fg-3">Select a message to inspect payload, delivery state, consumer, and DLC link.</div>
273
+ {/if}
274
+ </div>
275
+ </div>
276
+ {/if}
277
+
278
+ <div class="border-t border-line-1 px-3 py-1.5 text-[10px] font-mono text-fg-3 flex items-center gap-3">
279
+ {#each summary.states as [state, count] (state)}
280
+ <span><span class="text-fg-2">{state}</span> {count.toLocaleString()}</span>
281
+ {/each}
282
+ {#each summary.operations as [op, count] (op)}
283
+ <span><span class="text-fg-2">{op}</span> {count.toLocaleString()}</span>
284
+ {/each}
285
+ </div>
286
+ </div>
@@ -0,0 +1,123 @@
1
+ <script lang="ts">
2
+ import type { QueryResult } from '#reddb'
3
+ import { Gauge, List, Table2 } from 'lucide-svelte'
4
+ import { extractStats, formatMetric } from './stats-render'
5
+
6
+ interface Props {
7
+ result: QueryResult
8
+ collection?: string
9
+ }
10
+
11
+ let { result, collection }: Props = $props()
12
+
13
+ let mode = $state<'kpi' | 'gauge' | 'table'>('kpi')
14
+ const metrics = $derived(extractStats(result))
15
+ const max = $derived(Math.max(1, ...metrics.map((m) => Math.abs(m.value))))
16
+
17
+ function percent(value: number): number {
18
+ if (value >= 0 && value <= 100) return value
19
+ return Math.min(100, Math.abs(value) / max * 100)
20
+ }
21
+ </script>
22
+
23
+ <div class="flex h-full flex-col text-fg-1">
24
+ {#if metrics.length === 0}
25
+ <div class="flex-1 grid place-items-center text-fg-3 text-[12px] font-mono p-6">
26
+ No numeric statistics in this result.
27
+ </div>
28
+ {:else}
29
+ <div class="border-b border-line-1 px-3 py-1.5 flex items-center gap-2 text-[11px] font-mono bg-bg-1/40">
30
+ <div class="inline-flex border border-line-2 rounded overflow-hidden">
31
+ <button
32
+ type="button"
33
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors', mode === 'kpi' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
34
+ onclick={() => (mode = 'kpi')}
35
+ aria-pressed={mode === 'kpi'}
36
+ >
37
+ <List class="size-3" />
38
+ kpis
39
+ </button>
40
+ <button
41
+ type="button"
42
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'gauge' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
43
+ onclick={() => (mode = 'gauge')}
44
+ aria-pressed={mode === 'gauge'}
45
+ >
46
+ <Gauge class="size-3" />
47
+ gauges
48
+ </button>
49
+ <button
50
+ type="button"
51
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'table' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
52
+ onclick={() => (mode = 'table')}
53
+ aria-pressed={mode === 'table'}
54
+ >
55
+ <Table2 class="size-3" />
56
+ table
57
+ </button>
58
+ </div>
59
+ <div class="ml-auto text-fg-3">
60
+ {metrics.length.toLocaleString()} metrics
61
+ {#if collection}· <span class="text-fg-2">{collection}</span>{/if}
62
+ </div>
63
+ </div>
64
+
65
+ {#if mode === 'kpi'}
66
+ <div class="flex-1 overflow-auto p-3">
67
+ <div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-2">
68
+ {#each metrics as metric, i (`${metric.key}-${i}`)}
69
+ <div class="rounded border border-line-1 bg-bg-1 px-3 py-2">
70
+ <div class="type-label truncate">{metric.label}</div>
71
+ <div class="mt-1 font-mono text-[20px] font-semibold text-fg-0">{formatMetric(metric.value, metric.unit)}</div>
72
+ </div>
73
+ {/each}
74
+ </div>
75
+ </div>
76
+ {:else if mode === 'gauge'}
77
+ <div class="flex-1 overflow-auto p-3">
78
+ <div class="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2">
79
+ {#each metrics as metric, i (`${metric.key}-${i}`)}
80
+ {@const p = percent(metric.value)}
81
+ <div class="rounded border border-line-1 bg-bg-1 px-3 py-3 text-center">
82
+ <svg viewBox="0 0 120 70" class="mx-auto h-[82px] w-[150px]" aria-label="{metric.label} gauge">
83
+ <path d="M 15 60 A 45 45 0 0 1 105 60" fill="none" stroke="var(--color-line-2)" stroke-width="10" stroke-linecap="round" />
84
+ <path
85
+ d="M 15 60 A 45 45 0 0 1 105 60"
86
+ fill="none"
87
+ stroke="var(--color-accent)"
88
+ stroke-width="10"
89
+ stroke-linecap="round"
90
+ pathLength="100"
91
+ stroke-dasharray={`${p} 100`}
92
+ />
93
+ <text x="60" y="58" text-anchor="middle" class="fill-fg-0 font-mono text-[13px] font-semibold">{formatMetric(metric.value, metric.unit)}</text>
94
+ </svg>
95
+ <div class="mt-1 truncate font-mono text-[11px] text-fg-2">{metric.label}</div>
96
+ </div>
97
+ {/each}
98
+ </div>
99
+ </div>
100
+ {:else}
101
+ <div class="flex-1 overflow-auto">
102
+ <table class="w-full border-collapse text-[12px] font-mono">
103
+ <thead class="sticky top-0 bg-bg-1 z-10">
104
+ <tr class="border-b border-line-1 text-fg-3">
105
+ <th class="px-2 py-1.5 text-left font-normal">metric</th>
106
+ <th class="px-2 py-1.5 text-right font-normal">value</th>
107
+ <th class="px-2 py-1.5 text-left font-normal">unit</th>
108
+ </tr>
109
+ </thead>
110
+ <tbody>
111
+ {#each metrics as metric, i (`${metric.key}-${i}`)}
112
+ <tr class="border-b border-line-1/60 hover:bg-bg-1/40">
113
+ <td class="px-2 py-1 text-fg-0">{metric.label}</td>
114
+ <td class="px-2 py-1 text-right text-accent">{metric.value.toLocaleString()}</td>
115
+ <td class="px-2 py-1 text-fg-3">{metric.unit || '-'}</td>
116
+ </tr>
117
+ {/each}
118
+ </tbody>
119
+ </table>
120
+ </div>
121
+ {/if}
122
+ {/if}
123
+ </div>