@reddb-io/ui 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +17 -0
- package/bin/red-ui.js +259 -0
- package/build/CNAME +1 -0
- package/build/_app/env.js +1 -0
- package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
- package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
- package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
- package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
- package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
- package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
- package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
- package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
- package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
- package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
- package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
- package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
- package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
- package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
- package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
- package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
- package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
- package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
- package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
- package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
- package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
- package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
- package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
- package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
- package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
- package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -0
- package/build/favicon.png +0 -0
- package/build/index.html +59 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
- package/dist/embed/core-QuRYvMt8.js +118 -0
- package/dist/embed/core-QuRYvMt8.js.map +1 -0
- package/dist/embed/index-B6ZQhCsu.js +577 -0
- package/dist/embed/index-B6ZQhCsu.js.map +1 -0
- package/dist/embed/index-client-ItCEU64C.js +3087 -0
- package/dist/embed/index-client-ItCEU64C.js.map +1 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
- package/dist/embed/index.js +11 -0
- package/dist/embed/index.js.map +1 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
- package/dist/embed/ui.css +1 -0
- package/package.json +101 -0
- package/src/app.css +238 -0
- package/src/lib/ActivityIndicator.svelte +100 -0
- package/src/lib/ClusterNode.svelte +119 -0
- package/src/lib/ClusterView.svelte +541 -0
- package/src/lib/CollectionHistory.svelte +266 -0
- package/src/lib/CollectionsView.svelte +14 -0
- package/src/lib/CommandPalette.svelte +237 -0
- package/src/lib/ConnectDropdown.svelte +222 -0
- package/src/lib/EmptyState.svelte +27 -0
- package/src/lib/LiveChanges.svelte +195 -0
- package/src/lib/MasterPasswordDialog.svelte +168 -0
- package/src/lib/PageHeader.svelte +32 -0
- package/src/lib/PendingChangesPanel.svelte +153 -0
- package/src/lib/QueryEditor.svelte +122 -0
- package/src/lib/ResultsPane.svelte +747 -0
- package/src/lib/SchemaTree.svelte +193 -0
- package/src/lib/SecurityView.svelte +255 -0
- package/src/lib/ShortcutOverlay.svelte +104 -0
- package/src/lib/Splash.svelte +46 -0
- package/src/lib/StatusBar.svelte +163 -0
- package/src/lib/SubNav.svelte +62 -0
- package/src/lib/Topbar.svelte +119 -0
- package/src/lib/Workspace.svelte +247 -0
- package/src/lib/activity.svelte.ts +91 -0
- package/src/lib/build-info.ts +41 -0
- package/src/lib/capability.ts +221 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/collection-catalog.ts +116 -0
- package/src/lib/collection-pages.ts +33 -0
- package/src/lib/connection-history.ts +301 -0
- package/src/lib/connections.svelte.ts +493 -0
- package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
- package/src/lib/embed/index.ts +234 -0
- package/src/lib/graph-loader/contract.ts +336 -0
- package/src/lib/graph-loader/drilldown.ts +334 -0
- package/src/lib/graph-loader/index.ts +13 -0
- package/src/lib/index.ts +114 -0
- package/src/lib/pending-changes.svelte.ts +149 -0
- package/src/lib/query-tabs.svelte.ts +130 -0
- package/src/lib/reddb/boot-params.ts +254 -0
- package/src/lib/reddb/cdc-stream-client.ts +234 -0
- package/src/lib/reddb/client.ts +698 -0
- package/src/lib/reddb/connection-bootstrap.ts +260 -0
- package/src/lib/reddb/connection-provider.contract.ts +72 -0
- package/src/lib/reddb/connection-provider.ts +72 -0
- package/src/lib/reddb/index.ts +14 -0
- package/src/lib/reddb/injected-client-provider.ts +76 -0
- package/src/lib/reddb/local-url-provider.ts +245 -0
- package/src/lib/reddb/secure-store.ts +41 -0
- package/src/lib/reddb/server-capabilities.ts +71 -0
- package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
- package/src/lib/reddb/transports.ts +89 -0
- package/src/lib/reddb/types.ts +32 -0
- package/src/lib/reddb/uri.ts +54 -0
- package/src/lib/reddb/web-encrypted-store.ts +201 -0
- package/src/lib/renderers/DiffRenderer.svelte +184 -0
- package/src/lib/renderers/DocumentRenderer.svelte +134 -0
- package/src/lib/renderers/GraphRenderer.svelte +1328 -0
- package/src/lib/renderers/HypertableRenderer.svelte +253 -0
- package/src/lib/renderers/JsonRenderer.svelte +30 -0
- package/src/lib/renderers/KvRenderer.svelte +150 -0
- package/src/lib/renderers/QueueRenderer.svelte +286 -0
- package/src/lib/renderers/StatsRenderer.svelte +123 -0
- package/src/lib/renderers/TableRenderer.svelte +440 -0
- package/src/lib/renderers/VectorRenderer.svelte +388 -0
- package/src/lib/renderers/diff-render.ts +122 -0
- package/src/lib/renderers/diff.ts +9 -0
- package/src/lib/renderers/document-render.ts +64 -0
- package/src/lib/renderers/document.ts +9 -0
- package/src/lib/renderers/graph-render.ts +631 -0
- package/src/lib/renderers/graph-sigma.ts +395 -0
- package/src/lib/renderers/graph.ts +9 -0
- package/src/lib/renderers/hypertable-render.ts +320 -0
- package/src/lib/renderers/hypertable.ts +9 -0
- package/src/lib/renderers/index.ts +42 -0
- package/src/lib/renderers/json-render.ts +89 -0
- package/src/lib/renderers/json.ts +10 -0
- package/src/lib/renderers/kv-render.ts +193 -0
- package/src/lib/renderers/kv.ts +9 -0
- package/src/lib/renderers/queue-render.ts +197 -0
- package/src/lib/renderers/queue.ts +9 -0
- package/src/lib/renderers/registry.ts +68 -0
- package/src/lib/renderers/stats-render.ts +73 -0
- package/src/lib/renderers/stats.ts +9 -0
- package/src/lib/renderers/table-render.ts +156 -0
- package/src/lib/renderers/table.ts +11 -0
- package/src/lib/renderers/types.ts +33 -0
- package/src/lib/renderers/vector-render.ts +154 -0
- package/src/lib/renderers/vector.ts +9 -0
- package/src/lib/router.svelte.ts +163 -0
- package/src/lib/secureStore.svelte.ts +148 -0
- package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
- package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
- package/src/lib/shortcuts.ts +22 -0
- package/src/lib/surface.ts +80 -0
- package/src/lib/tabs.svelte.ts +115 -0
- package/src/lib/theme.svelte.ts +63 -0
|
@@ -0,0 +1,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>
|