@reddb-io/ui 0.3.0 → 0.3.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 (96) hide show
  1. package/build/_app/immutable/assets/0.DgbuWrJy.css +1 -0
  2. package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
  3. package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
  4. package/build/_app/immutable/chunks/85-Fh5Zp.js +404 -0
  5. package/build/_app/immutable/chunks/{C9rXzYLu.js → 8zg2gJwV.js} +1 -1
  6. package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
  7. package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
  8. package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
  9. package/build/_app/immutable/chunks/Bq4DTWK2.js +2 -0
  10. package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
  11. package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
  12. package/build/_app/immutable/chunks/CJFJ501n.js +18 -0
  13. package/build/_app/immutable/chunks/CLBx21Hz.js +2 -0
  14. package/build/_app/immutable/chunks/CnD4hwxC.js +1106 -0
  15. package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
  16. package/build/_app/immutable/chunks/DQCYUNtS.js +1 -0
  17. package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
  18. package/build/_app/immutable/chunks/{C278awGv.js → DV9PDoM3.js} +1 -1
  19. package/build/_app/immutable/entry/app.BDog2y0J.js +2 -0
  20. package/build/_app/immutable/entry/start.D1J45dq5.js +1 -0
  21. package/build/_app/immutable/nodes/0.BTrC4th2.js +1 -0
  22. package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +1 -0
  23. package/build/_app/immutable/nodes/10.D_FCLs0Z.js +96 -0
  24. package/build/_app/immutable/nodes/12.DPE6v6Zf.js +3 -0
  25. package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
  26. package/build/_app/immutable/nodes/15.CcJlZGxa.js +60 -0
  27. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  28. package/build/_app/version.json +1 -1
  29. package/build/index.html +18 -15
  30. package/dist/embed/AnalyticsView-D8z1Nsed.js +23337 -0
  31. package/dist/embed/AnalyticsView-D8z1Nsed.js.map +1 -0
  32. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js +31302 -0
  33. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js.map +1 -0
  34. package/dist/embed/index-0hA141D2.js +85 -0
  35. package/dist/embed/index-0hA141D2.js.map +1 -0
  36. package/dist/embed/index-client-BeiP-va5.js +3303 -0
  37. package/dist/embed/index-client-BeiP-va5.js.map +1 -0
  38. package/dist/embed/index-w1Qo_uW0.js +677 -0
  39. package/dist/embed/index-w1Qo_uW0.js.map +1 -0
  40. package/dist/embed/index.js +1 -1
  41. package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-qqKBpvin.js} +2 -2
  42. package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-qqKBpvin.js.map} +1 -1
  43. package/package.json +5 -3
  44. package/src/app.css +86 -20
  45. package/src/lib/AnalyticsView.svelte +209 -0
  46. package/src/lib/AskView.svelte +255 -0
  47. package/src/lib/ClusterView.svelte +415 -95
  48. package/src/lib/CommandPalette.svelte +2 -0
  49. package/src/lib/GlobalProgressBar.svelte +9 -0
  50. package/src/lib/MetricChart.svelte +85 -0
  51. package/src/lib/QueryEditor.svelte +48 -7
  52. package/src/lib/ResultsPane.svelte +76 -8
  53. package/src/lib/SchemaTree.svelte +25 -1
  54. package/src/lib/SecurityView.svelte +176 -65
  55. package/src/lib/SettingsView.svelte +1111 -254
  56. package/src/lib/Topbar.svelte +29 -1
  57. package/src/lib/Workspace.svelte +18 -3
  58. package/src/lib/activity.svelte.ts +44 -35
  59. package/src/lib/collection-preview.ts +14 -0
  60. package/src/lib/connections.svelte.ts +73 -4
  61. package/src/lib/index.ts +3 -0
  62. package/src/lib/permission-gate.ts +120 -0
  63. package/src/lib/policies.ts +136 -0
  64. package/src/lib/query-tabs.svelte.ts +132 -53
  65. package/src/lib/reddb/analytics.ts +208 -0
  66. package/src/lib/reddb/ask.ts +123 -0
  67. package/src/lib/reddb/client.ts +159 -58
  68. package/src/lib/reddb/cluster-status.ts +306 -0
  69. package/src/lib/reddb/index.ts +3 -0
  70. package/src/lib/reddb/local-url-provider.ts +32 -1
  71. package/src/lib/reddb/redwire-ws-spike.ts +590 -0
  72. package/src/lib/router.svelte.ts +12 -0
  73. package/src/lib/settings-authoring-client.ts +336 -0
  74. package/src/lib/settings-sections.ts +292 -196
  75. package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
  76. package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
  77. package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
  78. package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
  79. package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
  80. package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
  81. package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
  82. package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
  83. package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
  84. package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
  85. package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
  86. package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
  87. package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
  88. package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
  89. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
  90. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
  91. package/dist/embed/index-DGhgtnaK.js +0 -577
  92. package/dist/embed/index-DGhgtnaK.js.map +0 -1
  93. package/dist/embed/index-client-ItCEU64C.js +0 -3087
  94. package/dist/embed/index-client-ItCEU64C.js.map +0 -1
  95. /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
  96. /package/build/_app/immutable/nodes/{10.CAG5_cGP.js → 13.CAG5_cGP.js} +0 -0
@@ -4,11 +4,25 @@
4
4
  import { Badge, Card, NodeBadge } from '@reddb-io/ui-kit'
5
5
  import EmptyState from '$lib/EmptyState.svelte'
6
6
  import ClusterNode from '$lib/ClusterNode.svelte'
7
- import { PRESETS } from '$lib/connections.svelte'
7
+ import { PRESETS, connection } from '$lib/connections.svelte'
8
8
  import { secureStore } from '$lib/secureStore.svelte'
9
9
  import { activity } from '$lib/activity.svelte'
10
10
  import { theme } from '$lib/theme.svelte'
11
- import { RedClient, type ClusterStatus, type Stats, type ReplicationStatus } from '#reddb'
11
+ import { useRouter } from '$lib/router.svelte'
12
+ import { POLICY_READ_CHECK } from '$lib/policies'
13
+ import {
14
+ RedClient,
15
+ type ClusterStatus,
16
+ type Stats,
17
+ type ReplicationStatus,
18
+ type NodeTopology,
19
+ measured,
20
+ reasonOf,
21
+ topologyOf,
22
+ bootDurationMs,
23
+ maxReplicaLagSeconds,
24
+ unavailableFields,
25
+ } from '#reddb'
12
26
  import { Lock, ServerCrash } from 'lucide-svelte'
13
27
 
14
28
  interface KnownNode {
@@ -21,9 +35,30 @@
21
35
  cluster?: ClusterStatus
22
36
  }
23
37
 
38
+ // The high-level topology of a node (embedded · serverless · replicated ·
39
+ // server), derived from deployment shape × replication role. Drives which
40
+ // "hero metric" the node leads with.
41
+ function topology(node: KnownNode): NodeTopology {
42
+ return topologyOf(node.cluster, node.replication, node.preset.role)
43
+ }
44
+
45
+ const TOPOLOGY_LABEL: Record<NodeTopology, string> = {
46
+ embedded: 'embedded · standalone',
47
+ serverless: 'serverless',
48
+ replicated: 'primary / replica',
49
+ server: 'server · standalone',
50
+ unknown: 'unknown',
51
+ }
52
+
24
53
  let known = $state<KnownNode[]>([])
25
54
  let loading = $state(true)
26
55
  let selectedId = $state<string | null>(null)
56
+ const clusterSkeletonNodes = [
57
+ { id: 'primary', x: '50%', y: '38%', role: 'primary', label: 92 },
58
+ { id: 'replica', x: '68%', y: '54%', role: 'replica', label: 112 },
59
+ { id: 'embedded', x: '32%', y: '54%', role: 'embedded', label: 104 },
60
+ ]
61
+ const clusterSkeletonRows = Array.from({ length: 3 }, (_, i) => i)
27
62
  // Per-preset failure tracking. Dead nodes back off to one probe per ~60s
28
63
  // instead of one per 5s so unreachable presets (e.g. embedded with no
29
64
  // local file-backed reddb running) don't flood DevTools with native
@@ -154,34 +189,203 @@
154
189
  )
155
190
 
156
191
  const selected = $derived(known.find((k) => k.id === selectedId))
192
+ // Hoisted out of the template: `{@const}` can't be a child of a plain
193
+ // element, only of a block/component, so derive the inspector's computed
194
+ // bits here and reference them below.
195
+ const selectedHero = $derived(selected ? heroMetric(selected) : null)
196
+ const selectedTopology = $derived<NodeTopology>(selected ? topology(selected) : 'unknown')
197
+ const selectedMissing = $derived(selected ? notExposed(selected) : [])
157
198
  const totalEntities = $derived(liveNodes.reduce((s, k) => s + (k.stats?.store.total_entities ?? 0), 0))
158
199
 
200
+ // Metalanguage (users · tenants · policies) is authed and per-connection, so
201
+ // it's only meaningful for the node we're actually connected to. For every
202
+ // other probed node we say so honestly rather than faking a surface we have
203
+ // no token for. Visibility is permission-aware: tenants/users are gated
204
+ // server-side; policies appear only when auth.can(policy:read) allows it —
205
+ // mirroring SecurityView's contract.
206
+ const router = useRouter()
207
+ const isConnectedNode = $derived(
208
+ !!selected && connection.connected && !secureStore.locked && connection.active?.url === selected.preset.url,
209
+ )
210
+
211
+ interface MetaSummary {
212
+ username?: string
213
+ role?: string
214
+ users?: number
215
+ tenants?: number
216
+ policies?: number
217
+ policyAllowed?: boolean
218
+ error?: string
219
+ }
220
+ let meta = $state<MetaSummary | null>(null)
221
+
222
+ $effect(() => {
223
+ const sel = selected
224
+ void selectedId
225
+ if (!sel || !isConnectedNode) {
226
+ meta = null
227
+ return
228
+ }
229
+ const client = connection.client
230
+ if (!client) {
231
+ meta = null
232
+ return
233
+ }
234
+ let cancelled = false
235
+ void (async () => {
236
+ const next: MetaSummary = {}
237
+ try {
238
+ const w = await activity.track('cluster · whoami', () => client.whoami())
239
+ next.username = w.username
240
+ next.role = w.role
241
+ } catch (e) {
242
+ next.error = (e as Error).message
243
+ }
244
+ try {
245
+ next.users = (await activity.track('cluster · users', () => client.users())).length
246
+ } catch {
247
+ /* server-gated: leave undefined (absent, not faked) */
248
+ }
249
+ try {
250
+ next.tenants = (await activity.track('cluster · tenants', () => client.tenants())).length
251
+ } catch {
252
+ /* server-gated */
253
+ }
254
+ try {
255
+ const [decision] = await activity.track('cluster · auth.can policy read', () =>
256
+ client.authCan([POLICY_READ_CHECK]),
257
+ )
258
+ next.policyAllowed = decision?.allowed === true
259
+ if (next.policyAllowed) {
260
+ try {
261
+ next.policies = (await activity.track('cluster · policies', () => client.policies())).length
262
+ } catch {
263
+ /* allowed but read failed */
264
+ }
265
+ }
266
+ } catch {
267
+ next.policyAllowed = false
268
+ }
269
+ if (!cancelled) meta = next
270
+ })()
271
+ return () => {
272
+ cancelled = true
273
+ }
274
+ })
275
+
276
+ type Tone = 'neutral' | 'ok' | 'info' | 'warn' | 'danger' | 'accent'
277
+
159
278
  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'
279
+ const shape = measured(node.cluster?.deployment?.shape)
280
+ if (shape) return shape
281
+ if (node.preset.id === 'embedded') return 'embedded'
163
282
  if (node.preset.id.startsWith('docker-')) return 'server · docker preset'
164
- return 'server endpoint'
283
+ return 'server'
165
284
  }
166
285
 
167
286
  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'
287
+ // reddb no longer exposes a file path / container id (#738 honesty
288
+ // contract). Lean on the active transport listeners it does report, then
289
+ // fall back to the preset description.
290
+ const listener = node.cluster?.transports?.active?.[0]
291
+ if (listener) return `${listener.transport} · ${listener.bind_addr}`
292
+ const backend = node.cluster?.storage?.remote_backend
293
+ if (backend) return `remote backend · ${backend}`
294
+ return node.preset.description
175
295
  }
176
296
 
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'
297
+ function deploymentTone(node: KnownNode): Tone {
298
+ switch (topology(node)) {
299
+ case 'replicated':
300
+ return 'accent'
301
+ case 'embedded':
302
+ return 'ok'
303
+ case 'serverless':
304
+ return 'info'
305
+ default:
306
+ return 'neutral'
307
+ }
308
+ }
309
+
310
+ function fmtDuration(ms: number | null | undefined): string {
311
+ if (ms === null || ms === undefined || !Number.isFinite(ms)) return 'not exposed'
312
+ if (ms < 1000) return `${Math.round(ms)} ms`
313
+ if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`
314
+ return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`
315
+ }
316
+
317
+ function fmtLagSeconds(seconds: number | null | undefined): string {
318
+ if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) return 'not exposed'
319
+ if (seconds < 1) return `${Math.round(seconds * 1000)} ms`
320
+ if (seconds < 60) return `${seconds.toFixed(1)}s`
321
+ return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`
322
+ }
323
+
324
+ /** Records the replica is behind, from the replica's own perspective. */
325
+ function replicaLagRecords(node: KnownNode): number | null {
326
+ const r = node.replication
327
+ if (r?.last_applied_lsn === undefined || r.last_seen_primary_lsn === undefined) return null
328
+ return r.last_seen_primary_lsn - r.last_applied_lsn
329
+ }
330
+
331
+ interface Hero {
332
+ label: string
333
+ value: string
334
+ tone: Tone
335
+ sub?: string
336
+ }
337
+
338
+ // The single metric each topology leads with — the thing the user said
339
+ // matters most for that shape. Primary→replication lag (wall-clock),
340
+ // serverless→boot time, embedded→on-disk size, server→entities.
341
+ function heroMetric(node: KnownNode): Hero {
342
+ const topo = topology(node)
343
+ if (topo === 'replicated') {
344
+ if (node.role === 'primary') {
345
+ const replicas = node.replication?.replicas ?? []
346
+ const worst = maxReplicaLagSeconds(node.replication)
347
+ if (replicas.length === 0) return { label: 'replication lag', value: 'no replicas', tone: 'neutral' }
348
+ if (worst === null) return { label: 'replication lag', value: 'not exposed', tone: 'neutral' }
349
+ return {
350
+ label: 'replication lag',
351
+ value: worst === 0 ? '✓ in sync' : fmtLagSeconds(worst),
352
+ tone: worst === 0 ? 'ok' : worst < 5 ? 'warn' : 'danger',
353
+ sub: `${replicas.length} replica${replicas.length === 1 ? '' : 's'} · worst case`,
354
+ }
355
+ }
356
+ const lag = replicaLagRecords(node)
357
+ return {
358
+ label: 'replica lag',
359
+ value: lag === null ? 'not exposed' : lag === 0 ? '✓ in sync' : `${lag.toLocaleString()} lsn`,
360
+ tone: lag === null ? 'neutral' : lag === 0 ? 'ok' : 'warn',
361
+ sub: node.replication?.state,
362
+ }
363
+ }
364
+ if (topo === 'serverless') {
365
+ const boot = bootDurationMs(node.cluster)
366
+ return {
367
+ label: 'boot time',
368
+ value: fmtDuration(boot),
369
+ tone: boot === null ? 'neutral' : boot < 1000 ? 'ok' : boot < 5000 ? 'info' : 'warn',
370
+ sub: node.cluster?.phase ? `phase · ${node.cluster.phase}` : undefined,
371
+ }
372
+ }
373
+ if (topo === 'embedded') {
374
+ const size = measured(node.cluster?.storage?.db_size_bytes)
375
+ return {
376
+ label: 'on-disk size',
377
+ value: size === undefined ? 'not exposed' : fmtBytes(size),
378
+ tone: size === undefined ? 'neutral' : 'ok',
379
+ sub: `${(node.stats?.store.total_entities ?? 0).toLocaleString()} entities`,
380
+ }
381
+ }
382
+ const size = measured(node.cluster?.storage?.db_size_bytes)
383
+ return {
384
+ label: 'entities',
385
+ value: (node.stats?.store.total_entities ?? 0).toLocaleString(),
386
+ tone: 'accent',
387
+ sub: size === undefined ? undefined : `${fmtBytes(size)} on disk`,
388
+ }
185
389
  }
186
390
 
187
391
  function fmtBytes(bytes: number | undefined): string {
@@ -205,20 +409,6 @@
205
409
  return `${s}s`
206
410
  }
207
411
 
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
412
  function fmtRate(value: number | undefined): string {
223
413
  return value === undefined || !Number.isFinite(value) ? 'not exposed' : `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}/s`
224
414
  }
@@ -228,39 +418,73 @@
228
418
  }
229
419
 
230
420
  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)
421
+ const size = measured(node.cluster?.storage?.db_size_bytes)
422
+ const backend = node.cluster?.storage?.remote_backend
423
+ if (size !== undefined) {
424
+ return backend ? `${fmtBytes(size)} · ${backend}` : fmtBytes(size)
425
+ }
426
+ if (backend) return `remote · ${backend}`
427
+ const reason = reasonOf(node.cluster?.storage?.db_size_bytes)
428
+ return reason ? `not exposed (${reason})` : 'not exposed'
239
429
  }
240
430
 
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
431
+ // The server's own honesty contract (#738) tells us exactly which fields it
432
+ // cannot measure, and why — no hand-maintained list to drift out of sync.
433
+ function notExposed(node: KnownNode): string[] {
434
+ return unavailableFields(node.cluster).map((f) => f.path.replace(/_/g, ' '))
249
435
  }
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
436
  </script>
260
437
 
261
438
  {#if loading}
262
- <div class="absolute inset-0 grid place-items-center text-fg-3 font-mono text-[12px]">
263
- Probing reachable nodes…
439
+ <div class="absolute inset-0 overflow-hidden bg-bg-0" aria-busy="true" aria-label="Loading cluster topology">
440
+ <div class="absolute inset-0 cluster-skeleton-grid"></div>
441
+
442
+ <svg class="absolute inset-0 h-full w-full" aria-hidden="true">
443
+ <line x1="50%" y1="38%" x2="32%" y2="54%" class="cluster-skeleton-edge motion-safe:animate-pulse" />
444
+ <line x1="50%" y1="38%" x2="68%" y2="54%" class="cluster-skeleton-edge motion-safe:animate-pulse" />
445
+ </svg>
446
+
447
+ {#each clusterSkeletonNodes as node (node.id)}
448
+ <div
449
+ class="absolute w-[220px] rounded border border-line-2 bg-bg-1 p-3 shadow-md motion-safe:animate-pulse"
450
+ style={`left: ${node.x}; top: ${node.y}; transform: translate(-50%, -50%);`}
451
+ >
452
+ <div class="mb-3 flex items-center gap-2">
453
+ <div class={[
454
+ 'h-2 w-2 rounded-full',
455
+ node.role === 'primary' ? 'bg-role-primary' : node.role === 'replica' ? 'bg-role-replica' : 'bg-role-embedded',
456
+ ].join(' ')}></div>
457
+ <div class="h-3 rounded bg-bg-3" style={`width: ${node.label}px`}></div>
458
+ </div>
459
+ <div class="grid grid-cols-2 gap-2">
460
+ <div class="h-9 rounded border border-line-1 bg-bg-2"></div>
461
+ <div class="h-9 rounded border border-line-1 bg-bg-2"></div>
462
+ </div>
463
+ </div>
464
+ {/each}
465
+
466
+ <div class="absolute top-4 left-4 z-10 w-64 rounded border border-line-2 bg-bg-1 p-3 shadow-md">
467
+ <div class="mb-3 h-3 w-16 rounded bg-bg-2 motion-safe:animate-pulse"></div>
468
+ <div class="mb-3 grid grid-cols-2 gap-3 border-b border-line-1 pb-3">
469
+ <div>
470
+ <div class="mb-2 h-3 w-16 rounded bg-bg-2 motion-safe:animate-pulse"></div>
471
+ <div class="h-6 w-12 rounded bg-bg-2 motion-safe:animate-pulse"></div>
472
+ </div>
473
+ <div>
474
+ <div class="mb-2 h-3 w-14 rounded bg-bg-2 motion-safe:animate-pulse"></div>
475
+ <div class="h-6 w-20 rounded bg-bg-2 motion-safe:animate-pulse"></div>
476
+ </div>
477
+ </div>
478
+ <div class="grid gap-1">
479
+ {#each clusterSkeletonRows as row (row)}
480
+ <div class="flex h-[30px] items-center gap-2.5 rounded px-2">
481
+ <div class="h-1.5 w-1.5 rounded-full bg-bg-2 motion-safe:animate-pulse"></div>
482
+ <div class="h-3 flex-1 rounded bg-bg-2 motion-safe:animate-pulse"></div>
483
+ <div class="h-3 rounded bg-bg-2 motion-safe:animate-pulse" style={`width: ${row === 0 ? 36 : 24}px`}></div>
484
+ </div>
485
+ {/each}
486
+ </div>
487
+ </div>
264
488
  </div>
265
489
  {:else if secureStore.locked}
266
490
  <div class="p-6">
@@ -339,6 +563,20 @@
339
563
  <div class="absolute top-4 right-4 z-10 w-[420px] max-h-[calc(100vh-96px)] overflow-auto">
340
564
  <Card title="node · {selected.id}" floating>
341
565
  <div class="grid gap-2 font-mono text-[11px]">
566
+ {#if selectedHero}
567
+ <div class="mb-1 rounded border border-line-1 bg-bg-2 p-3">
568
+ <div class="type-label">{selectedHero.label}</div>
569
+ <div class={[
570
+ 'font-mono text-[22px] font-semibold mt-0.5 leading-none',
571
+ selectedHero.tone === 'ok' ? 'text-role-replica' : selectedHero.tone === 'warn' ? 'text-amber-400' : selectedHero.tone === 'danger' ? 'text-accent' : selectedHero.tone === 'accent' ? 'text-accent' : 'text-fg-0',
572
+ ].join(' ')}>{selectedHero.value}</div>
573
+ {#if selectedHero.sub}<div class="text-[10px] text-fg-3 mt-1">{selectedHero.sub}</div>{/if}
574
+ </div>
575
+ {/if}
576
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
577
+ <span class="text-fg-3">topology</span>
578
+ <span><Badge tone={deploymentTone(selected)}>{TOPOLOGY_LABEL[selectedTopology]}</Badge></span>
579
+ </div>
342
580
  <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
343
581
  <span class="text-fg-3">label</span>
344
582
  <span class="text-fg-0">{selected.preset.label}</span>
@@ -409,38 +647,44 @@
409
647
  <span class="text-fg-3">storage</span>
410
648
  <span class="text-fg-0">{storageLine(selected)}</span>
411
649
  </div>
650
+ {@const totalMem = measured(selected.cluster?.system?.total_memory_bytes) ?? selected.stats.system.total_memory_bytes}
651
+ {@const availMem = measured(selected.cluster?.system?.available_memory_bytes) ?? selected.stats.system.available_memory_bytes}
412
652
  <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
413
653
  <span class="text-fg-3">ram</span>
414
654
  <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>
655
+ {fmtBytes(totalMem - availMem)} used
656
+ <span class="text-fg-3">/ {fmtBytes(totalMem)} ({fmtPct(totalMem - availMem, totalMem)})</span>
420
657
  </span>
421
658
  </div>
422
659
  <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
423
660
  <span class="text-fg-3">cpu</span>
424
661
  <span class="text-fg-0">
425
662
  {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>
663
+ {#if measured(selected.cluster?.system?.cpu_usage) !== undefined}
664
+ <span class="text-fg-3">· {measured(selected.cluster?.system?.cpu_usage)}%</span>
665
+ {:else}
666
+ <span class="text-fg-3">· usage not exposed</span>
667
+ {/if}
427
668
  </span>
428
669
  </div>
670
+ {@const tp = measured(selected.cluster?.throughput)}
429
671
  <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>
672
+ <span class="text-fg-3">throughput</span>
673
+ <span class="text-fg-0">
674
+ {#if tp}
675
+ {fmtRate(tp.requests_per_second)} <span class="text-fg-3">· r {fmtRate(tp.reads_per_second)} / w {fmtRate(tp.writes_per_second)}</span>
676
+ {:else}
677
+ <span class="text-fg-3">not sampled</span>
678
+ {/if}
679
+ </span>
436
680
  </div>
437
681
  <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
438
682
  <span class="text-fg-3">avg response</span>
439
- <span class="text-fg-0">{fmtMs(selected.cluster?.throughput?.avg_response_ms)}</span>
683
+ <span class="text-fg-0">{fmtMs(measured(selected.cluster?.latency)?.avg_response_ms)}</span>
440
684
  </div>
441
685
  <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
442
686
  <span class="text-fg-3">uptime</span>
443
- <span class="text-fg-0">{uptime(selected.stats.started_at_unix_ms)}</span>
687
+ <span class="text-fg-0">{selected.cluster?.uptime_secs !== undefined ? uptime(Date.now() - selected.cluster.uptime_secs * 1000) : uptime(selected.stats.started_at_unix_ms)}</span>
444
688
  </div>
445
689
  {/if}
446
690
  {#if selected.replication}
@@ -448,47 +692,112 @@
448
692
  {#if selected.replication.role === 'primary'}
449
693
  <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
450
694
  <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>
695
+ <span class="text-fg-0">{selected.cluster?.wal?.current_lsn ?? selected.replication.wal_lsn ?? 'not exposed'}</span>
456
696
  </div>
697
+ {#if selected.replication.commit_watermark !== undefined}
698
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
699
+ <span class="text-fg-3">commit mark</span>
700
+ <span class="text-fg-0">{selected.replication.commit_watermark} <span class="text-fg-3">· {selected.cluster?.replication?.commit_policy ?? ''}</span></span>
701
+ </div>
702
+ {/if}
457
703
  <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>
704
+ <span class="text-fg-3">archive lag</span>
705
+ <span class="text-fg-0">{selected.cluster?.wal?.archive_lag_records ?? 'not exposed'} <span class="text-fg-3">records</span></span>
460
706
  </div>
707
+ {#if (selected.replication.full_resync_count ?? 0) > 0 || (selected.replication.partial_resync_count ?? 0) > 0}
708
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
709
+ <span class="text-fg-3">resyncs</span>
710
+ <span class="text-fg-0">{selected.replication.full_resync_count ?? 0} full <span class="text-fg-3">/ {selected.replication.partial_resync_count ?? 0} partial</span></span>
711
+ </div>
712
+ {/if}
461
713
  <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
462
714
  <span class="text-fg-3">replicas</span>
463
- <span class="text-fg-0">{selected.cluster?.replication?.replica_count ?? selected.replication.replica_count ?? 0}</span>
715
+ <span class="text-fg-0">{selected.replication.replica_count ?? selected.cluster?.replication?.replica_count ?? 0}</span>
464
716
  </div>
717
+ {#if selected.replication.replicas?.length}
718
+ <div class="grid gap-1 pl-1">
719
+ {#each selected.replication.replicas as r (r.id)}
720
+ <div class="flex items-center gap-2 text-[10px]">
721
+ <span class={[
722
+ 'w-1.5 h-1.5 rounded-full shrink-0',
723
+ r.lag_seconds === 0 ? 'bg-role-replica' : r.lag_seconds < 5 ? 'bg-amber-400' : 'bg-accent',
724
+ ].join(' ')}></span>
725
+ <span class="text-fg-1 flex-1 truncate">{r.id}{#if r.rebootstrapping} <span class="text-amber-400">↻</span>{/if}</span>
726
+ <span class="text-fg-3">{r.lag_lsn} lsn</span>
727
+ <span class={r.lag_seconds === 0 ? 'text-role-replica' : 'text-fg-0'}>{r.lag_seconds === 0 ? '✓ sync' : fmtLagSeconds(r.lag_seconds)}</span>
728
+ </div>
729
+ {/each}
730
+ </div>
731
+ {/if}
465
732
  {:else if selected.replication.role === 'replica'}
733
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
734
+ <span class="text-fg-3">primary</span>
735
+ <span class="text-fg-0 break-all">{selected.replication.primary_addr ?? selected.replication.leader ?? 'unknown'}</span>
736
+ </div>
466
737
  <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
467
738
  <span class="text-fg-3">applied</span>
468
739
  <span class="text-fg-0">{selected.replication.last_applied_lsn} <span class="text-fg-3">/ {selected.replication.last_seen_primary_lsn}</span></span>
469
740
  </div>
470
- {@const lag = replicationLag(selected)}
741
+ {@const lag = replicaLagRecords(selected)}
471
742
  {#if lag !== null}
472
743
  <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
473
744
  <span class="text-fg-3">lag</span>
474
745
  <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}
746
+ {#if lag === 0}<Badge tone="ok">in sync</Badge>{:else}<Badge tone="warn">{lag.toLocaleString()} lsn</Badge>{/if}
747
+ {#if selected.replication.state}<span class="text-fg-3 ml-1">{selected.replication.state}</span>{/if}
476
748
  </span>
477
749
  </div>
478
750
  {/if}
751
+ {#if selected.replication.last_error}
752
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
753
+ <span class="text-fg-3">last error</span>
754
+ <span class="text-accent break-all">{selected.replication.last_error}</span>
755
+ </div>
756
+ {/if}
479
757
  {/if}
480
758
  {/if}
481
759
  <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>
760
+ {#if isConnectedNode}
761
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
762
+ <span class="text-fg-3">metalanguage</span>
763
+ <span class="text-fg-0">
764
+ {#if meta?.username}you are <span class="text-accent">{meta.username}</span> <span class="text-fg-3">· {meta.role}</span>{:else if meta?.error}<span class="text-fg-3">{meta.error}</span>{:else}<span class="text-fg-3">…</span>{/if}
765
+ </span>
766
+ </div>
767
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-start">
768
+ <span class="text-fg-3"></span>
769
+ <span class="flex flex-wrap items-center gap-1.5">
770
+ {#if meta?.users !== undefined}<span class="rounded border border-line-1 px-1.5 py-0.5 text-[10px] text-fg-1">{meta.users} users</span>{/if}
771
+ {#if meta?.tenants !== undefined}<span class="rounded border border-line-1 px-1.5 py-0.5 text-[10px] text-fg-1">{meta.tenants} tenants</span>{/if}
772
+ {#if meta?.policyAllowed && meta?.policies !== undefined}
773
+ <span class="rounded border border-line-1 px-1.5 py-0.5 text-[10px] text-fg-1">{meta.policies} policies</span>
774
+ {:else if meta && meta.policyAllowed === false}
775
+ <span class="rounded border border-line-1 px-1.5 py-0.5 text-[10px] text-fg-3" title="reddb did not grant policy:read to this principal">policies · no grant</span>
488
776
  {/if}
489
- {/each}
490
- </span>
491
- </div>
777
+ <button
778
+ type="button"
779
+ onclick={(e) => router.go({ view: 'security' }, e)}
780
+ class="rounded border border-line-2 bg-bg-2 px-1.5 py-0.5 text-[10px] text-fg-1 hover:border-line-3 hover:text-fg-0"
781
+ >Security →</button>
782
+ </span>
783
+ </div>
784
+ {:else}
785
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-baseline">
786
+ <span class="text-fg-3">metalanguage</span>
787
+ <span class="text-fg-3">connect to this node to inspect users · tenants · policies</span>
788
+ </div>
789
+ {/if}
790
+ {#if selectedMissing.length}
791
+ <div class="pt-2 mt-1 border-t border-line-1"></div>
792
+ <div class="grid grid-cols-[120px_1fr] gap-3 items-start">
793
+ <span class="text-fg-3">not exposed</span>
794
+ <span class="flex flex-wrap gap-1">
795
+ {#each selectedMissing as item}
796
+ <span class="rounded border border-line-1 px-1.5 py-0.5 text-[10px] text-fg-3">{item}</span>
797
+ {/each}
798
+ </span>
799
+ </div>
800
+ {/if}
492
801
  </div>
493
802
  </Card>
494
803
  </div>
@@ -538,4 +847,15 @@
538
847
  color: var(--color-fg-0) !important;
539
848
  fill: var(--color-fg-0) !important;
540
849
  }
850
+ .cluster-skeleton-grid {
851
+ background-image: radial-gradient(var(--color-line-2) 1px, transparent 1px);
852
+ background-size: 32px 32px;
853
+ opacity: 0.55;
854
+ }
855
+ .cluster-skeleton-edge {
856
+ stroke: var(--color-line-2);
857
+ stroke-width: 1.5;
858
+ stroke-linecap: round;
859
+ opacity: 0.7;
860
+ }
541
861
  </style>
@@ -27,10 +27,12 @@
27
27
  }
28
28
 
29
29
  const commands: Command[] = [
30
+ { id: 'ask', group: 'navigate', label: 'Open ask', hint: '/ask', run: () => navigate({ view: 'ask' }) },
30
31
  { id: 'query', group: 'navigate', label: 'Open query', hint: '/query', run: () => navigate({ view: 'query' }) },
31
32
  { id: 'collections', group: 'navigate', label: 'Open collections', hint: '/collections', run: () => navigate({ view: 'collections' }) },
32
33
  { id: 'topology', group: 'navigate', label: 'Open cluster', hint: '/cluster', run: () => navigate({ view: 'cluster' }) },
33
34
  { id: 'security', group: 'navigate', label: 'Open security', hint: '/security', run: () => navigate({ view: 'security' }) },
35
+ { id: 'analytics', group: 'navigate', label: 'Open analytics', hint: '/analytics', run: () => navigate({ view: 'analytics' }) },
34
36
  { id: 'settings', group: 'navigate', label: 'Open settings', hint: '/settings', run: () => navigate({ view: 'settings' }) },
35
37
  { id: 'appearance', group: 'navigate', label: 'Open appearance', hint: '/appearance', run: () => navigate({ view: 'appearance' }) },
36
38