@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.
- package/build/_app/immutable/assets/0.DgbuWrJy.css +1 -0
- package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
- package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
- package/build/_app/immutable/chunks/85-Fh5Zp.js +404 -0
- package/build/_app/immutable/chunks/{C9rXzYLu.js → 8zg2gJwV.js} +1 -1
- package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
- package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
- package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
- package/build/_app/immutable/chunks/Bq4DTWK2.js +2 -0
- package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
- package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
- package/build/_app/immutable/chunks/CJFJ501n.js +18 -0
- package/build/_app/immutable/chunks/CLBx21Hz.js +2 -0
- package/build/_app/immutable/chunks/CnD4hwxC.js +1106 -0
- package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
- package/build/_app/immutable/chunks/DQCYUNtS.js +1 -0
- package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
- package/build/_app/immutable/chunks/{C278awGv.js → DV9PDoM3.js} +1 -1
- package/build/_app/immutable/entry/app.BDog2y0J.js +2 -0
- package/build/_app/immutable/entry/start.D1J45dq5.js +1 -0
- package/build/_app/immutable/nodes/0.BTrC4th2.js +1 -0
- package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +1 -0
- package/build/_app/immutable/nodes/10.D_FCLs0Z.js +96 -0
- package/build/_app/immutable/nodes/12.DPE6v6Zf.js +3 -0
- package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/15.CcJlZGxa.js +60 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -1
- package/build/index.html +18 -15
- package/dist/embed/AnalyticsView-D8z1Nsed.js +23337 -0
- package/dist/embed/AnalyticsView-D8z1Nsed.js.map +1 -0
- package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js +31302 -0
- package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js.map +1 -0
- package/dist/embed/index-0hA141D2.js +85 -0
- package/dist/embed/index-0hA141D2.js.map +1 -0
- package/dist/embed/index-client-BeiP-va5.js +3303 -0
- package/dist/embed/index-client-BeiP-va5.js.map +1 -0
- package/dist/embed/index-w1Qo_uW0.js +677 -0
- package/dist/embed/index-w1Qo_uW0.js.map +1 -0
- package/dist/embed/index.js +1 -1
- package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-qqKBpvin.js} +2 -2
- package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-qqKBpvin.js.map} +1 -1
- package/package.json +5 -3
- package/src/app.css +86 -20
- package/src/lib/AnalyticsView.svelte +209 -0
- package/src/lib/AskView.svelte +255 -0
- package/src/lib/ClusterView.svelte +415 -95
- package/src/lib/CommandPalette.svelte +2 -0
- package/src/lib/GlobalProgressBar.svelte +9 -0
- package/src/lib/MetricChart.svelte +85 -0
- package/src/lib/QueryEditor.svelte +48 -7
- package/src/lib/ResultsPane.svelte +76 -8
- package/src/lib/SchemaTree.svelte +25 -1
- package/src/lib/SecurityView.svelte +176 -65
- package/src/lib/SettingsView.svelte +1111 -254
- package/src/lib/Topbar.svelte +29 -1
- package/src/lib/Workspace.svelte +18 -3
- package/src/lib/activity.svelte.ts +44 -35
- package/src/lib/collection-preview.ts +14 -0
- package/src/lib/connections.svelte.ts +73 -4
- package/src/lib/index.ts +3 -0
- package/src/lib/permission-gate.ts +120 -0
- package/src/lib/policies.ts +136 -0
- package/src/lib/query-tabs.svelte.ts +132 -53
- package/src/lib/reddb/analytics.ts +208 -0
- package/src/lib/reddb/ask.ts +123 -0
- package/src/lib/reddb/client.ts +159 -58
- package/src/lib/reddb/cluster-status.ts +306 -0
- package/src/lib/reddb/index.ts +3 -0
- package/src/lib/reddb/local-url-provider.ts +32 -1
- package/src/lib/reddb/redwire-ws-spike.ts +590 -0
- package/src/lib/router.svelte.ts +12 -0
- package/src/lib/settings-authoring-client.ts +336 -0
- package/src/lib/settings-sections.ts +292 -196
- package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
- package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
- package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
- package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
- package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
- package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
- package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
- package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
- package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
- package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
- package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
- package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
- package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
- package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
- package/dist/embed/index-DGhgtnaK.js +0 -577
- package/dist/embed/index-DGhgtnaK.js.map +0 -1
- package/dist/embed/index-client-ItCEU64C.js +0 -3087
- package/dist/embed/index-client-ItCEU64C.js.map +0 -1
- /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
- /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 {
|
|
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
|
|
161
|
-
if (
|
|
162
|
-
if (node.preset.id === 'embedded') return 'embedded
|
|
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
|
|
283
|
+
return 'server'
|
|
165
284
|
}
|
|
166
285
|
|
|
167
286
|
function deploymentEvidence(node: KnownNode): string {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
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):
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
return
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
263
|
-
|
|
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(
|
|
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
|
-
|
|
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">
|
|
431
|
-
<span class="text-fg-0">
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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?.
|
|
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?.
|
|
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">
|
|
459
|
-
<span class="text-fg-0">{selected.cluster?.wal?.
|
|
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.
|
|
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 =
|
|
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">{
|
|
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
|
-
|
|
483
|
-
<
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
{#if
|
|
487
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|