@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,541 @@
1
+ <script lang="ts">
2
+ import { SvelteFlow, Background, BackgroundVariant, Controls, type Node, type Edge } from '@xyflow/svelte'
3
+ import '@xyflow/svelte/dist/style.css'
4
+ import { Badge, Card, NodeBadge } from '@reddb-io/ui-kit'
5
+ import EmptyState from '$lib/EmptyState.svelte'
6
+ import ClusterNode from '$lib/ClusterNode.svelte'
7
+ import { PRESETS } from '$lib/connections.svelte'
8
+ import { secureStore } from '$lib/secureStore.svelte'
9
+ import { activity } from '$lib/activity.svelte'
10
+ import { theme } from '$lib/theme.svelte'
11
+ import { RedClient, type ClusterStatus, type Stats, type ReplicationStatus } from '#reddb'
12
+ import { Lock, ServerCrash } from 'lucide-svelte'
13
+
14
+ interface KnownNode {
15
+ id: string
16
+ preset: typeof PRESETS[number]
17
+ reachable: boolean
18
+ role: 'primary' | 'replica' | 'standalone'
19
+ stats?: Stats
20
+ replication?: ReplicationStatus
21
+ cluster?: ClusterStatus
22
+ }
23
+
24
+ let known = $state<KnownNode[]>([])
25
+ let loading = $state(true)
26
+ let selectedId = $state<string | null>(null)
27
+ // Per-preset failure tracking. Dead nodes back off to one probe per ~60s
28
+ // instead of one per 5s so unreachable presets (e.g. embedded with no
29
+ // local file-backed reddb running) don't flood DevTools with native
30
+ // fetch errors. Browser logs network failures before catch() executes,
31
+ // so the only fix is to attempt the request less often.
32
+ const failCount = new Map<string, number>()
33
+ const lastAttempt = new Map<string, number>()
34
+ const DEAD_BACKOFF_MS = 60_000
35
+
36
+ const nodeTypes = { cluster: ClusterNode as any }
37
+
38
+ // Theme-aware palette for things xyflow renders as literal SVG attrs
39
+ // (it cannot read CSS variables for `style`/`labelStyle`/`patternColor`).
40
+ const palette = $derived.by(() => {
41
+ const dark = theme.current === 'dark'
42
+ return {
43
+ ok: dark ? '#4ade80' : '#16a34a',
44
+ warn: dark ? '#fbbf24' : '#b45309',
45
+ pattern: dark ? '#1c2027' : '#d4d4d8',
46
+ labelBg: dark ? '#0d0f13' : '#fafafa',
47
+ labelStroke: dark ? '#2a2e36' : '#d4d4d8',
48
+ }
49
+ })
50
+
51
+ async function probeAll() {
52
+ const now = Date.now()
53
+ const results = await Promise.all(
54
+ PRESETS.map(async (p) => {
55
+ const fails = failCount.get(p.id) ?? 0
56
+ const last = lastAttempt.get(p.id) ?? 0
57
+ // After 2 consecutive failures, only retry once per DEAD_BACKOFF_MS.
58
+ // Carry the previous (unreachable) snapshot through so the UI stays stable.
59
+ if (fails >= 2 && now - last < DEAD_BACKOFF_MS) {
60
+ return known.find((k) => k.id === p.id) ?? {
61
+ id: p.id, preset: p, reachable: false, role: 'standalone' as const,
62
+ }
63
+ }
64
+ lastAttempt.set(p.id, now)
65
+ const client = new RedClient(p.url)
66
+ try {
67
+ const [stats, replication, cluster] = await Promise.all([
68
+ activity.track(`cluster · ${p.label} stats`, () => client.stats()),
69
+ activity
70
+ .track(`cluster · ${p.label} replication`, () => client.replication())
71
+ .catch(() => undefined),
72
+ activity
73
+ .track(`cluster · ${p.label} status`, () => client.clusterStatus())
74
+ .catch(() => undefined),
75
+ ])
76
+ failCount.set(p.id, 0)
77
+ return {
78
+ id: p.id, preset: p, reachable: true,
79
+ role: replication?.role ?? 'standalone',
80
+ stats, replication, cluster,
81
+ } as KnownNode
82
+ } catch {
83
+ failCount.set(p.id, fails + 1)
84
+ return { id: p.id, preset: p, reachable: false, role: 'standalone' as const }
85
+ }
86
+ }),
87
+ )
88
+ known = results
89
+ loading = false
90
+ }
91
+
92
+ $effect(() => {
93
+ // Same hard lock gate as the workspace: don't probe nodes until the
94
+ // user authenticates this session — automatic probes would leak the
95
+ // configured topology to anyone watching the network panel. Flip
96
+ // `loading` off so the UI shows a locked EmptyState instead of an
97
+ // infinite "Probing…" message.
98
+ if (secureStore.locked) {
99
+ loading = false
100
+ return
101
+ }
102
+ loading = true
103
+ probeAll()
104
+ const id = setInterval(probeAll, 5000)
105
+ return () => clearInterval(id)
106
+ })
107
+
108
+ const liveNodes = $derived(known.filter((k) => k.reachable))
109
+
110
+ const flowNodes = $derived<Node[]>(
111
+ liveNodes.map((k) => {
112
+ const slot = k.role === 'primary' ? 0 : k.role === 'replica' ? 1 : -1
113
+ return {
114
+ id: k.id,
115
+ type: 'cluster',
116
+ position: { x: 320 + slot * 320, y: 200 },
117
+ data: {
118
+ label: k.preset.label,
119
+ role: k.role,
120
+ collections: k.stats?.store.collection_count ?? 0,
121
+ entities: k.stats?.store.total_entities ?? 0,
122
+ url: k.preset.url,
123
+ },
124
+ }
125
+ }),
126
+ )
127
+
128
+ const flowEdges = $derived<Edge[]>(
129
+ liveNodes
130
+ .filter((k) => k.role === 'replica')
131
+ .map((replica) => {
132
+ const primary = liveNodes.find((p) => p.role === 'primary')
133
+ if (!primary) return null
134
+ const lag = replica.replication?.last_applied_lsn !== undefined && replica.replication?.last_seen_primary_lsn !== undefined
135
+ ? replica.replication.last_seen_primary_lsn - replica.replication.last_applied_lsn
136
+ : null
137
+ const inSync = lag === 0
138
+ const stroke = inSync ? palette.ok : palette.warn
139
+ return {
140
+ id: `${primary.id}-${replica.id}`,
141
+ source: primary.id,
142
+ target: replica.id,
143
+ type: 'smoothstep',
144
+ animated: true,
145
+ style: `stroke:${stroke};stroke-width:1.5;opacity:0.7`,
146
+ label: lag === null ? '' : inSync ? '✓ in sync' : `lag ${lag}`,
147
+ labelStyle: `fill:${stroke};font-family:JetBrains Mono Variable;font-size:10px;font-weight:500`,
148
+ labelBgStyle: `fill:${palette.labelBg};stroke:${palette.labelStroke};stroke-width:1`,
149
+ labelBgPadding: [6, 4],
150
+ labelBgBorderRadius: 4,
151
+ } as Edge
152
+ })
153
+ .filter((e): e is Edge => e !== null),
154
+ )
155
+
156
+ const selected = $derived(known.find((k) => k.id === selectedId))
157
+ const totalEntities = $derived(liveNodes.reduce((s, k) => s + (k.stats?.store.total_entities ?? 0), 0))
158
+
159
+ function deploymentLabel(node: KnownNode): string {
160
+ const mode = node.cluster?.deployment?.mode
161
+ if (mode) return mode
162
+ if (node.preset.id === 'embedded') return 'embedded file endpoint'
163
+ if (node.preset.id.startsWith('docker-')) return 'server · docker preset'
164
+ return 'server endpoint'
165
+ }
166
+
167
+ function deploymentEvidence(node: KnownNode): string {
168
+ const d = node.cluster?.deployment
169
+ if (d?.file_path) return d.file_path
170
+ if (d?.container_id || d?.image) return [d.container_id, d.image].filter(Boolean).join(' · ')
171
+ if (d?.configured_by || d?.process_kind) return [d.configured_by, d.process_kind].filter(Boolean).join(' · ')
172
+ if (node.preset.id === 'embedded') return node.preset.description
173
+ if (node.preset.id.startsWith('docker-')) return node.preset.description
174
+ return 'reddb has not exposed process/container metadata yet'
175
+ }
176
+
177
+ function deploymentTone(node: KnownNode): 'neutral' | 'ok' | 'info' | 'warn' | 'danger' | 'accent' {
178
+ const mode = node.cluster?.deployment?.mode
179
+ if (mode === 'docker') return 'info'
180
+ if (mode === 'embedded') return 'ok'
181
+ if (mode === 'replicated') return 'accent'
182
+ if (node.preset.id.startsWith('docker-')) return 'info'
183
+ if (node.preset.id === 'embedded') return 'ok'
184
+ return 'neutral'
185
+ }
186
+
187
+ function fmtBytes(bytes: number | undefined): string {
188
+ if (bytes === undefined || !Number.isFinite(bytes)) return 'not exposed'
189
+ if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GiB`
190
+ if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MiB`
191
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`
192
+ return `${bytes} B`
193
+ }
194
+
195
+ function fmtPct(used: number, total: number): string {
196
+ if (total <= 0) return 'n/a'
197
+ return `${Math.round((used / total) * 100)}%`
198
+ }
199
+
200
+ function uptime(ms: number): string {
201
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000))
202
+ if (s >= 86400) return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`
203
+ if (s >= 3600) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
204
+ if (s >= 60) return `${Math.floor(s / 60)}m ${s % 60}s`
205
+ return `${s}s`
206
+ }
207
+
208
+ function replicationLag(node: KnownNode): number | null {
209
+ const r = node.replication
210
+ if (r?.last_applied_lsn === undefined || r.last_seen_primary_lsn === undefined) return null
211
+ return r.last_seen_primary_lsn - r.last_applied_lsn
212
+ }
213
+
214
+ function ramUsed(stats: Stats): number {
215
+ return stats.system.total_memory_bytes - stats.system.available_memory_bytes
216
+ }
217
+
218
+ function firstNumber(...values: Array<number | undefined>): number | undefined {
219
+ return values.find((v) => v !== undefined && Number.isFinite(v))
220
+ }
221
+
222
+ function fmtRate(value: number | undefined): string {
223
+ return value === undefined || !Number.isFinite(value) ? 'not exposed' : `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}/s`
224
+ }
225
+
226
+ function fmtMs(value: number | undefined): string {
227
+ return value === undefined || !Number.isFinite(value) ? 'not exposed' : `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} ms`
228
+ }
229
+
230
+ function storageLine(node: KnownNode): string {
231
+ const used = node.cluster?.storage?.used_bytes
232
+ const capacity = node.cluster?.storage?.capacity_bytes
233
+ const free = node.cluster?.storage?.free_bytes
234
+ if (used === undefined && capacity === undefined && free === undefined) return 'not exposed'
235
+ if (used !== undefined && capacity !== undefined) return `${fmtBytes(used)} / ${fmtBytes(capacity)} (${fmtPct(used, capacity)})`
236
+ if (used !== undefined && free !== undefined) return `${fmtBytes(used)} used · ${fmtBytes(free)} free`
237
+ if (capacity !== undefined && free !== undefined) return `${fmtBytes(free)} free / ${fmtBytes(capacity)}`
238
+ return fmtBytes(used ?? capacity ?? free)
239
+ }
240
+
241
+ function missingTelemetryExposed(node: KnownNode, item: string): boolean | ClusterStatus['deployment'] | ClusterStatus['storage'] | ClusterStatus['throughput'] {
242
+ if (item === 'storage capacity') return node.cluster?.storage
243
+ if (item === 'wal size bytes') return node.cluster?.wal?.size_bytes !== undefined
244
+ if (item === 'throughput') return node.cluster?.throughput
245
+ if (item === 'avg response time') return node.cluster?.throughput?.avg_response_ms !== undefined
246
+ if (item === 'cpu usage') return node.cluster?.system?.cpu_usage_percent !== undefined
247
+ if (item === 'deployment metadata') return node.cluster?.deployment
248
+ return false
249
+ }
250
+
251
+ const missingClusterTelemetry = [
252
+ 'storage capacity',
253
+ 'wal size bytes',
254
+ 'throughput',
255
+ 'avg response time',
256
+ 'cpu usage',
257
+ 'deployment metadata',
258
+ ]
259
+ </script>
260
+
261
+ {#if loading}
262
+ <div class="absolute inset-0 grid place-items-center text-fg-3 font-mono text-[12px]">
263
+ Probing reachable nodes…
264
+ </div>
265
+ {:else if secureStore.locked}
266
+ <div class="p-6">
267
+ <EmptyState
268
+ icon={Lock}
269
+ title="Locked"
270
+ message="Unlock the secure store to probe configured nodes. Automatic probes are paused until you authenticate this session."
271
+ />
272
+ </div>
273
+ {:else if liveNodes.length === 0}
274
+ <div class="p-6">
275
+ <EmptyState
276
+ icon={ServerCrash}
277
+ title="No reachable nodes"
278
+ message="None of the configured reddb endpoints responded. Start one and the topology will populate automatically."
279
+ hint="docker compose -f docker/compose.yml up -d"
280
+ />
281
+ </div>
282
+ {:else}
283
+ <div class="absolute inset-0">
284
+ <SvelteFlow
285
+ nodes={flowNodes}
286
+ edges={flowEdges}
287
+ {nodeTypes}
288
+ fitView
289
+ fitViewOptions={{ padding: 0.25, maxZoom: 1 }}
290
+ proOptions={{ hideAttribution: true }}
291
+ nodesDraggable={false}
292
+ nodesConnectable={false}
293
+ onnodeclick={(e) => (selectedId = e.node.id)}
294
+ >
295
+ <Background variant={BackgroundVariant.Dots} bgColor="transparent" patternColor={palette.pattern} gap={32} size={1} />
296
+ <Controls position="bottom-right" />
297
+ </SvelteFlow>
298
+ </div>
299
+
300
+ <!-- Floating summary (top-left) -->
301
+ <div class="absolute top-4 left-4 z-10 w-64">
302
+ <Card title="cluster">
303
+ <div class="grid grid-cols-2 gap-3 mb-3 pb-3 border-b border-line-1">
304
+ <div>
305
+ <div class="type-label">reachable</div>
306
+ <div class="font-mono text-fg-0 text-[18px] font-semibold mt-0.5">
307
+ {liveNodes.length}<span class="text-fg-3 text-[11px] font-normal ml-1">of {known.length}</span>
308
+ </div>
309
+ </div>
310
+ <div>
311
+ <div class="type-label">entities</div>
312
+ <div class="font-mono text-accent text-[18px] font-semibold mt-0.5">{totalEntities.toLocaleString()}</div>
313
+ </div>
314
+ </div>
315
+
316
+ <div class="grid gap-1">
317
+ {#each liveNodes as k}
318
+ <button
319
+ class={[
320
+ 'flex items-center gap-2.5 px-2 py-1.5 rounded text-left bg-transparent border-0 cursor-pointer transition-colors',
321
+ selectedId === k.id ? 'bg-bg-2' : 'hover:bg-bg-2'
322
+ ].join(' ')}
323
+ onclick={() => (selectedId = k.id)}
324
+ >
325
+ <span class={[
326
+ 'w-1.5 h-1.5 rounded-full shrink-0',
327
+ k.role === 'primary' ? 'bg-role-primary' : k.role === 'replica' ? 'bg-role-replica' : 'bg-role-embedded'
328
+ ].join(' ')}></span>
329
+ <span class="font-mono text-[11px] text-fg-1 flex-1 truncate">{k.preset.label}</span>
330
+ <span class="font-mono text-[10px] text-fg-3">{k.stats?.store.total_entities.toLocaleString() ?? 0}</span>
331
+ </button>
332
+ {/each}
333
+ </div>
334
+ </Card>
335
+ </div>
336
+
337
+ <!-- Floating inspector (top-right) -->
338
+ {#if selected}
339
+ <div class="absolute top-4 right-4 z-10 w-[420px] max-h-[calc(100vh-96px)] overflow-auto">
340
+ <Card title="node · {selected.id}" floating>
341
+ <div class="grid gap-2 font-mono text-[11px]">
342
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
343
+ <span class="text-fg-3">label</span>
344
+ <span class="text-fg-0">{selected.preset.label}</span>
345
+ </div>
346
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
347
+ <span class="text-fg-3">url</span>
348
+ <code class="text-fg-0 break-all">{selected.preset.url}</code>
349
+ </div>
350
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
351
+ <span class="text-fg-3">deployment</span>
352
+ <span>
353
+ <Badge tone={deploymentTone(selected)}>
354
+ {deploymentLabel(selected)}
355
+ </Badge>
356
+ </span>
357
+ </div>
358
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
359
+ <span class="text-fg-3">evidence</span>
360
+ <span class="text-fg-1">{deploymentEvidence(selected)}</span>
361
+ </div>
362
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
363
+ <span class="text-fg-3">role</span>
364
+ <span><NodeBadge role={selected.role === 'primary' ? 'primary' : selected.role === 'replica' ? 'replica' : 'embedded'} label={selected.role} /></span>
365
+ </div>
366
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
367
+ <span class="text-fg-3">replicas</span>
368
+ <span class="text-fg-0">
369
+ {#if selected.replication?.role === 'primary'}
370
+ {selected.replication.replica_count ?? 0}
371
+ {:else if selected.replication?.role === 'replica'}
372
+ connected to {selected.replication.primary_addr ?? 'primary'}
373
+ {:else}
374
+ none exposed
375
+ {/if}
376
+ </span>
377
+ </div>
378
+ {#if selected.stats}
379
+ <div class="pt-2 mt-1 border-t border-line-1"></div>
380
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
381
+ <span class="text-fg-3">host</span>
382
+ <span class="text-fg-0">{selected.stats.system.hostname} · {selected.stats.system.os}/{selected.stats.system.arch}</span>
383
+ </div>
384
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
385
+ <span class="text-fg-3">process</span>
386
+ <span class="text-fg-0">pid {selected.stats.system.pid}</span>
387
+ </div>
388
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
389
+ <span class="text-fg-3">connections</span>
390
+ <span class="text-fg-0">{selected.stats.active_connections} active <span class="text-fg-3">/ {selected.stats.idle_connections} idle</span></span>
391
+ </div>
392
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
393
+ <span class="text-fg-3">collections</span>
394
+ <span class="text-fg-0">{selected.stats.store.collection_count}</span>
395
+ </div>
396
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
397
+ <span class="text-fg-3">entities</span>
398
+ <span class="text-accent">{selected.stats.store.total_entities.toLocaleString()}</span>
399
+ </div>
400
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
401
+ <span class="text-fg-3">cross refs</span>
402
+ <span class="text-fg-0">{selected.stats.store.cross_ref_count.toLocaleString()}</span>
403
+ </div>
404
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
405
+ <span class="text-fg-3">store memory</span>
406
+ <span class="text-fg-0">{fmtBytes(selected.stats.store.total_memory_bytes)}</span>
407
+ </div>
408
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
409
+ <span class="text-fg-3">storage</span>
410
+ <span class="text-fg-0">{storageLine(selected)}</span>
411
+ </div>
412
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
413
+ <span class="text-fg-3">ram</span>
414
+ <span class="text-fg-0">
415
+ {fmtBytes(firstNumber(selected.cluster?.system?.memory_used_bytes, ramUsed(selected.stats)))} used
416
+ <span class="text-fg-3">
417
+ / {fmtBytes(firstNumber(selected.cluster?.system?.memory_total_bytes, selected.stats.system.total_memory_bytes))}
418
+ ({fmtPct(firstNumber(selected.cluster?.system?.memory_used_bytes, ramUsed(selected.stats)) ?? 0, firstNumber(selected.cluster?.system?.memory_total_bytes, selected.stats.system.total_memory_bytes) ?? 0)})
419
+ </span>
420
+ </span>
421
+ </div>
422
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
423
+ <span class="text-fg-3">cpu</span>
424
+ <span class="text-fg-0">
425
+ {selected.stats.system.cpu_cores} cores
426
+ <span class="text-fg-3">· {selected.cluster?.system?.cpu_usage_percent === undefined ? 'usage not exposed' : `${selected.cluster.system.cpu_usage_percent}%`}</span>
427
+ </span>
428
+ </div>
429
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
430
+ <span class="text-fg-3">requests/sec</span>
431
+ <span class="text-fg-0">{fmtRate(selected.cluster?.throughput?.requests_per_second)}</span>
432
+ </div>
433
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
434
+ <span class="text-fg-3">read/write</span>
435
+ <span class="text-fg-0">{fmtRate(selected.cluster?.throughput?.reads_per_second)} <span class="text-fg-3">/ {fmtRate(selected.cluster?.throughput?.writes_per_second)}</span></span>
436
+ </div>
437
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
438
+ <span class="text-fg-3">avg response</span>
439
+ <span class="text-fg-0">{fmtMs(selected.cluster?.throughput?.avg_response_ms)}</span>
440
+ </div>
441
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
442
+ <span class="text-fg-3">uptime</span>
443
+ <span class="text-fg-0">{uptime(selected.stats.started_at_unix_ms)}</span>
444
+ </div>
445
+ {/if}
446
+ {#if selected.replication}
447
+ <div class="pt-2 mt-1 border-t border-line-1"></div>
448
+ {#if selected.replication.role === 'primary'}
449
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
450
+ <span class="text-fg-3">wal lsn</span>
451
+ <span class="text-fg-0">{selected.cluster?.wal?.lsn ?? selected.replication.wal_lsn}</span>
452
+ </div>
453
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
454
+ <span class="text-fg-3">wal size</span>
455
+ <span class="text-fg-0">{fmtBytes(selected.cluster?.wal?.size_bytes)}</span>
456
+ </div>
457
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
458
+ <span class="text-fg-3">oldest lsn</span>
459
+ <span class="text-fg-0">{selected.cluster?.wal?.oldest_lsn ?? selected.replication.oldest_lsn ?? 'not exposed'}</span>
460
+ </div>
461
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
462
+ <span class="text-fg-3">replicas</span>
463
+ <span class="text-fg-0">{selected.cluster?.replication?.replica_count ?? selected.replication.replica_count ?? 0}</span>
464
+ </div>
465
+ {:else if selected.replication.role === 'replica'}
466
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
467
+ <span class="text-fg-3">applied</span>
468
+ <span class="text-fg-0">{selected.replication.last_applied_lsn} <span class="text-fg-3">/ {selected.replication.last_seen_primary_lsn}</span></span>
469
+ </div>
470
+ {@const lag = replicationLag(selected)}
471
+ {#if lag !== null}
472
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
473
+ <span class="text-fg-3">lag</span>
474
+ <span>
475
+ {#if lag === 0}<Badge tone="ok">in sync</Badge>{:else}<Badge tone="warn">{selected.cluster?.replication?.lag_ms ? `${selected.cluster.replication.lag_ms} ms` : `${selected.cluster?.replication?.lag_lsn ?? lag} lsn`}</Badge>{/if}
476
+ </span>
477
+ </div>
478
+ {/if}
479
+ {/if}
480
+ {/if}
481
+ <div class="pt-2 mt-1 border-t border-line-1"></div>
482
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-start">
483
+ <span class="text-fg-3">not exposed</span>
484
+ <span class="flex flex-wrap gap-1">
485
+ {#each missingClusterTelemetry as item}
486
+ {#if !missingTelemetryExposed(selected, item)}
487
+ <span class="rounded border border-line-1 px-1.5 py-0.5 text-[10px] text-fg-3">{item}</span>
488
+ {/if}
489
+ {/each}
490
+ </span>
491
+ </div>
492
+ </div>
493
+ </Card>
494
+ </div>
495
+ {/if}
496
+ {/if}
497
+
498
+ <!-- xyflow chrome — keep handles invisible and route control colors through tokens
499
+ so light/dark themes both look right. -->
500
+ <style>
501
+ :global(.svelte-flow) {
502
+ background-color: var(--color-bg-0) !important;
503
+ --xy-background-color-default: var(--color-bg-0);
504
+ }
505
+ :global(.svelte-flow__handle) {
506
+ opacity: 0 !important;
507
+ pointer-events: none !important;
508
+ width: 1px !important;
509
+ height: 1px !important;
510
+ }
511
+ :global(.svelte-flow__node-cluster) {
512
+ background: transparent !important;
513
+ border: 0 !important;
514
+ padding: 0 !important;
515
+ }
516
+ :global(.svelte-flow__edge-path) {
517
+ stroke-linecap: round;
518
+ }
519
+ :global(.svelte-flow__edge-textbg) {
520
+ fill: var(--color-bg-1) !important;
521
+ stroke: var(--color-line-2) !important;
522
+ }
523
+ :global(.svelte-flow__controls) {
524
+ box-shadow: var(--shadow-md) !important;
525
+ border: 1px solid var(--color-line-2) !important;
526
+ border-radius: 6px !important;
527
+ overflow: hidden;
528
+ background: var(--color-bg-1) !important;
529
+ }
530
+ :global(.svelte-flow__controls button) {
531
+ background: var(--color-bg-1) !important;
532
+ border-bottom: 1px solid var(--color-line-1) !important;
533
+ color: var(--color-fg-2) !important;
534
+ fill: var(--color-fg-2) !important;
535
+ }
536
+ :global(.svelte-flow__controls button:hover) {
537
+ background: var(--color-bg-2) !important;
538
+ color: var(--color-fg-0) !important;
539
+ fill: var(--color-fg-0) !important;
540
+ }
541
+ </style>