@reddb-io/ui 0.2.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/{BHBXmd1Y.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-C2pqg5JH.js → sigma.esm-qqKBpvin.js} +2 -2
- package/dist/embed/{sigma.esm-C2pqg5JH.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 +1137 -226
- 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 +294 -180
- 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/BMc_qoms.js +0 -973
- package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
- package/build/_app/immutable/chunks/CwxFwvow.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/entry/app.CGfmRH-N.js +0 -2
- package/build/_app/immutable/entry/start._LhFJimm.js +0 -1
- package/build/_app/immutable/nodes/0.BYhY6O5-.js +0 -1
- package/build/_app/immutable/nodes/1.4XYGRMul.js +0 -1
- package/build/_app/immutable/nodes/12.DXZfNPNK.js +0 -60
- package/build/_app/immutable/nodes/8.C7OOsMOJ.js +0 -134
- package/dist/embed/RedUiEmbedRoot-Dia9lxfb.js +0 -28570
- package/dist/embed/RedUiEmbedRoot-Dia9lxfb.js.map +0 -1
- package/dist/embed/index-Bq0TT7Ao.js +0 -577
- package/dist/embed/index-Bq0TT7Ao.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
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { activity } from './activity.svelte'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
{#if activity.active}
|
|
6
|
+
<div class="global-progress" role="progressbar" aria-label="reddb activity" aria-valuetext="Loading">
|
|
7
|
+
<div class="global-progress__bar"></div>
|
|
8
|
+
</div>
|
|
9
|
+
{/if}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Renders a metric the way its *nature* dictates (eixo 3): the caller passes
|
|
3
|
+
// the ChartKind derived from the descriptor's `kind`, and this component
|
|
4
|
+
// picks the visualisation — a rate line, a filled gauge level, or a
|
|
5
|
+
// P50/P95/P99 percentile curve. No chart-type picker is ever shown to the
|
|
6
|
+
// user; the metric's kind decides.
|
|
7
|
+
import { LineChart, AreaChart } from 'layerchart'
|
|
8
|
+
import type { ChartKind, NormalizedSeries } from '#reddb'
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
chart: ChartKind
|
|
12
|
+
series: NormalizedSeries[]
|
|
13
|
+
unit?: string | null
|
|
14
|
+
}
|
|
15
|
+
let { chart, series, unit = null }: Props = $props()
|
|
16
|
+
|
|
17
|
+
// Worse percentiles trend redder; single series leads with the accent.
|
|
18
|
+
const PALETTE = ['#ff2056', '#4ade80', '#fbbf24', '#38bdf8', '#a78bfa']
|
|
19
|
+
const PERCENTILE_COLOR: Record<string, string> = {
|
|
20
|
+
p50: '#4ade80',
|
|
21
|
+
p95: '#fbbf24',
|
|
22
|
+
p99: '#ff2056',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// LayerChart auto-detects a time scale from Date x-values.
|
|
26
|
+
const chartSeries = $derived(
|
|
27
|
+
series.map((s, i) => ({
|
|
28
|
+
key: s.label,
|
|
29
|
+
color: PERCENTILE_COLOR[s.label] ?? PALETTE[i % PALETTE.length],
|
|
30
|
+
data: s.points.map((p) => ({ date: new Date(p.t), value: p.v })),
|
|
31
|
+
})),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const hasData = $derived(series.some((s) => s.points.length > 0))
|
|
35
|
+
|
|
36
|
+
// Gauge / ratio: lead with the most recent level, big.
|
|
37
|
+
const latest = $derived.by(() => {
|
|
38
|
+
const pts = series[0]?.points
|
|
39
|
+
return pts && pts.length ? pts[pts.length - 1].v : null
|
|
40
|
+
})
|
|
41
|
+
const isRatio = $derived(chart === 'gauge')
|
|
42
|
+
|
|
43
|
+
function fmtValue(v: number): string {
|
|
44
|
+
if (isRatio && v >= 0 && v <= 1) return `${(v * 100).toFixed(1)}%`
|
|
45
|
+
if (Math.abs(v) >= 1000) return v.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
|
46
|
+
return v.toFixed(Math.abs(v) < 1 ? 3 : 2)
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
{#if !hasData}
|
|
51
|
+
<div class="flex h-full items-center justify-center text-[12px] text-fg-3 font-mono">
|
|
52
|
+
no samples in window
|
|
53
|
+
</div>
|
|
54
|
+
{:else if chart === 'gauge'}
|
|
55
|
+
<!-- Point-in-time level. A ratio shows a 0–100% bar; other gauges show the
|
|
56
|
+
raw latest value. -->
|
|
57
|
+
<div class="flex h-full flex-col items-center justify-center gap-3">
|
|
58
|
+
<div class="font-mono text-[40px] font-semibold leading-none text-accent">
|
|
59
|
+
{latest === null ? '—' : fmtValue(latest)}{#if unit && !isRatio}<span class="ml-1 text-[16px] text-fg-3">{unit}</span>{/if}
|
|
60
|
+
</div>
|
|
61
|
+
{#if isRatio && latest !== null}
|
|
62
|
+
<div class="h-1.5 w-56 overflow-hidden rounded-full bg-bg-3">
|
|
63
|
+
<div class="h-full rounded-full bg-accent" style={`width: ${Math.max(0, Math.min(100, latest * 100))}%`}></div>
|
|
64
|
+
</div>
|
|
65
|
+
{/if}
|
|
66
|
+
</div>
|
|
67
|
+
{:else if chart === 'area'}
|
|
68
|
+
<AreaChart
|
|
69
|
+
data={chartSeries[0]?.data ?? []}
|
|
70
|
+
x="date"
|
|
71
|
+
y="value"
|
|
72
|
+
series={chartSeries}
|
|
73
|
+
props={{ area: { 'fill-opacity': 0.12 } }}
|
|
74
|
+
legend={chartSeries.length > 1}
|
|
75
|
+
/>
|
|
76
|
+
{:else}
|
|
77
|
+
<!-- line + percentiles -->
|
|
78
|
+
<LineChart
|
|
79
|
+
data={chartSeries[0]?.data ?? []}
|
|
80
|
+
x="date"
|
|
81
|
+
y="value"
|
|
82
|
+
series={chartSeries}
|
|
83
|
+
legend={chartSeries.length > 1}
|
|
84
|
+
/>
|
|
85
|
+
{/if}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Play, Loader2 } from 'lucide-svelte'
|
|
2
|
+
import { Play, Loader2, X } from 'lucide-svelte'
|
|
3
3
|
import { connection } from '$lib/connections.svelte'
|
|
4
|
-
import { queryTabs, deriveQueryLabel } from '$lib/query-tabs.svelte'
|
|
4
|
+
import { queryTabs, deriveQueryLabel, formatElapsed } from '$lib/query-tabs.svelte'
|
|
5
5
|
import { tabs } from '$lib/tabs.svelte'
|
|
6
6
|
import { registry } from '$lib/renderers'
|
|
7
7
|
import type { Capability } from '$lib/renderers'
|
|
@@ -23,6 +23,19 @@
|
|
|
23
23
|
const tab = $derived(tabs.tabs.find((t) => t.id === tabId))
|
|
24
24
|
|
|
25
25
|
let textareaEl: HTMLTextAreaElement | undefined = $state()
|
|
26
|
+
let now = $state(Date.now())
|
|
27
|
+
const elapsed = $derived(
|
|
28
|
+
qs?.runningStartedAt ? formatElapsed(Math.max(0, now - qs.runningStartedAt)) : '0ms'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
$effect(() => {
|
|
32
|
+
if (!qs?.running) return
|
|
33
|
+
now = Date.now()
|
|
34
|
+
const id = setInterval(() => {
|
|
35
|
+
now = Date.now()
|
|
36
|
+
}, 100)
|
|
37
|
+
return () => clearInterval(id)
|
|
38
|
+
})
|
|
26
39
|
|
|
27
40
|
function setSql(value: string) {
|
|
28
41
|
queryTabs.setSql(tabId, value)
|
|
@@ -36,6 +49,10 @@
|
|
|
36
49
|
await queryTabs.run(tabId)
|
|
37
50
|
}
|
|
38
51
|
|
|
52
|
+
function cancel() {
|
|
53
|
+
queryTabs.cancel(tabId)
|
|
54
|
+
}
|
|
55
|
+
|
|
39
56
|
function onKey(e: KeyboardEvent) {
|
|
40
57
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
41
58
|
e.preventDefault()
|
|
@@ -93,15 +110,39 @@
|
|
|
93
110
|
</div>
|
|
94
111
|
|
|
95
112
|
<div class="flex-1 overflow-hidden bg-bg-0">
|
|
96
|
-
{#if qs?.
|
|
113
|
+
{#if qs?.running}
|
|
114
|
+
<div class="h-full grid place-items-center p-6" aria-live="polite" aria-busy="true">
|
|
115
|
+
<div class="w-full max-w-md rounded border border-line-1 bg-bg-1 p-4 font-mono">
|
|
116
|
+
<div class="flex items-start justify-between gap-4">
|
|
117
|
+
<div class="min-w-0">
|
|
118
|
+
<div class="flex items-center gap-2 text-fg-1 text-[13px]">
|
|
119
|
+
<Loader2 class="size-4 animate-spin text-accent" />
|
|
120
|
+
<span>Running query</span>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="mt-2 text-[11px] text-fg-3">
|
|
123
|
+
elapsed <span class="tabular-nums text-fg-2">{elapsed}</span>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
class="inline-flex h-7 items-center gap-1 rounded border border-line-2 px-2 text-[11px] text-fg-2 hover:border-danger/50 hover:text-danger cursor-pointer"
|
|
129
|
+
onclick={cancel}
|
|
130
|
+
aria-label="Cancel running query"
|
|
131
|
+
>
|
|
132
|
+
<X class="size-3.5" />
|
|
133
|
+
<span>Cancel</span>
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="mt-3 max-h-24 overflow-hidden rounded bg-bg-0 px-2 py-1.5 text-[11px] text-fg-3">
|
|
137
|
+
<pre class="m-0 whitespace-pre-wrap break-words">{qs.sql.trim()}</pre>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
{:else if qs?.error}
|
|
97
142
|
<div class="p-4 text-[12px] font-mono text-warn">
|
|
98
143
|
<div class="font-semibold mb-1">Query failed</div>
|
|
99
144
|
<pre class="whitespace-pre-wrap">{qs.error}</pre>
|
|
100
145
|
</div>
|
|
101
|
-
{:else if qs?.running && !qs.result}
|
|
102
|
-
<div class="h-full grid place-items-center text-fg-3 text-[12px] font-mono">
|
|
103
|
-
Running…
|
|
104
|
-
</div>
|
|
105
146
|
{:else if !qs?.result}
|
|
106
147
|
<div class="h-full grid place-items-center text-fg-3 text-[12px] font-mono">
|
|
107
148
|
Run a query to see results here.
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { tabs, type Tab } from '$lib/tabs.svelte'
|
|
8
8
|
import { queryTabs } from '$lib/query-tabs.svelte'
|
|
9
9
|
import { defaultSubpage, subpageCapability } from '$lib/collection-pages'
|
|
10
|
+
import { collectionPreviewQuery, safeCollectionName } from '$lib/collection-preview'
|
|
10
11
|
import {
|
|
11
12
|
collectionCatalogBadges,
|
|
12
13
|
collectionCatalogFromRow,
|
|
@@ -38,6 +39,8 @@
|
|
|
38
39
|
{ value: 'document', label: 'Document' },
|
|
39
40
|
{ value: 'json', label: 'JSON' },
|
|
40
41
|
]
|
|
42
|
+
const resultsSkeletonRows = Array.from({ length: 14 }, (_, i) => i)
|
|
43
|
+
const resultsSkeletonColumns = Array.from({ length: 6 }, (_, i) => i)
|
|
41
44
|
|
|
42
45
|
let results = $state<Record<string, QueryResult | null>>({})
|
|
43
46
|
let errors = $state<Record<string, string>>({})
|
|
@@ -169,10 +172,6 @@
|
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
|
|
172
|
-
function safeCollectionName(collection: string): string {
|
|
173
|
-
return collection.replace(/[^A-Za-z0-9_./-]/g, '')
|
|
174
|
-
}
|
|
175
|
-
|
|
176
175
|
function sqlString(s: string): string {
|
|
177
176
|
return `'${s.replace(/'/g, "''")}'`
|
|
178
177
|
}
|
|
@@ -240,7 +239,8 @@
|
|
|
240
239
|
const shouldFetchQueue = subpage === 'queue' || tab.capability === 'queue'
|
|
241
240
|
const shouldFetchVector = subpage === 'vector' || tab.capability === 'vector'
|
|
242
241
|
const shouldFetchStats = subpage === 'stats' || tab.capability === 'stats'
|
|
243
|
-
const
|
|
242
|
+
const shouldFetchKv = subpage === 'kv' || tab.capability === 'kv'
|
|
243
|
+
const fetchMode = shouldFetchGraph ? 'graph-subgraph' : shouldFetchQueue ? 'queue-peek' : shouldFetchVector ? 'vector-inspector' : shouldFetchStats ? 'stats-info' : shouldFetchKv ? 'kv-list' : tab.capability ?? 'unknown'
|
|
244
244
|
const cacheKey = `${tab.key}:${subpage}:${fetchMode}`
|
|
245
245
|
if (resultKeys[tab.id] && resultKeys[tab.id] !== cacheKey) {
|
|
246
246
|
delete results[tab.id]
|
|
@@ -267,9 +267,14 @@
|
|
|
267
267
|
`${collection} · stats preview`,
|
|
268
268
|
() => fetchStatsCollection(client, collection),
|
|
269
269
|
)
|
|
270
|
+
: shouldFetchKv
|
|
271
|
+
? activity.track(
|
|
272
|
+
`${collection} · LIST KV (LIMIT 200)`,
|
|
273
|
+
() => client.query(collectionPreviewQuery(collection, 'kv')),
|
|
274
|
+
)
|
|
270
275
|
: activity.track(
|
|
271
276
|
`${collection} · SELECT * (LIMIT 200)`,
|
|
272
|
-
() => client.query(
|
|
277
|
+
() => client.query(collectionPreviewQuery(collection, tab.capability)),
|
|
273
278
|
)
|
|
274
279
|
job
|
|
275
280
|
.then((r) => { results[tab.id] = r })
|
|
@@ -588,8 +593,71 @@
|
|
|
588
593
|
<pre class="whitespace-pre-wrap">{err}</pre>
|
|
589
594
|
</div>
|
|
590
595
|
{:else if !result}
|
|
591
|
-
<div class="h-full
|
|
592
|
-
|
|
596
|
+
<div class="h-full flex flex-col" aria-busy="true" aria-label={`Loading ${tab.label} results`}>
|
|
597
|
+
<div class="border-b border-line-1 bg-bg-1/35 px-3 py-2">
|
|
598
|
+
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
599
|
+
<div class="min-w-[180px] flex-1">
|
|
600
|
+
<div class="flex items-center gap-2">
|
|
601
|
+
<div class="h-[21px] w-[78px] rounded border border-line-1 bg-bg-2 motion-safe:animate-pulse"></div>
|
|
602
|
+
<div class="h-4 w-44 max-w-[45vw] rounded bg-bg-2 motion-safe:animate-pulse"></div>
|
|
603
|
+
</div>
|
|
604
|
+
<div class="mt-1 flex flex-wrap gap-1">
|
|
605
|
+
<div class="h-[21px] w-24 rounded border border-line-1 bg-bg-0/70 motion-safe:animate-pulse"></div>
|
|
606
|
+
<div class="h-[21px] w-32 rounded border border-line-1 bg-bg-0/70 motion-safe:animate-pulse"></div>
|
|
607
|
+
<div class="h-[21px] w-20 rounded border border-line-1 bg-bg-0/70 motion-safe:animate-pulse"></div>
|
|
608
|
+
</div>
|
|
609
|
+
</div>
|
|
610
|
+
|
|
611
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
612
|
+
<div class="h-[30px] w-[94px] rounded border border-line-1 bg-bg-0/70 motion-safe:animate-pulse"></div>
|
|
613
|
+
<div class="h-[30px] w-[92px] rounded border border-line-1 bg-bg-0/70 motion-safe:animate-pulse"></div>
|
|
614
|
+
<div class="h-[30px] w-[92px] rounded border border-line-1 bg-bg-0/70 motion-safe:animate-pulse"></div>
|
|
615
|
+
<div class="h-[30px] w-[78px] rounded border border-line-1 bg-bg-0/70 motion-safe:animate-pulse"></div>
|
|
616
|
+
<div class="h-[30px] w-[78px] rounded border border-line-1 bg-bg-0/70 motion-safe:animate-pulse"></div>
|
|
617
|
+
</div>
|
|
618
|
+
</div>
|
|
619
|
+
</div>
|
|
620
|
+
|
|
621
|
+
<div class="border-b border-line-1 px-3 py-1.5 flex items-center gap-2 bg-bg-1/40">
|
|
622
|
+
<div class="h-6 w-[70px] rounded border border-line-1 bg-bg-2 motion-safe:animate-pulse"></div>
|
|
623
|
+
<div class="h-6 w-[54px] rounded border border-line-1 bg-bg-2 motion-safe:animate-pulse"></div>
|
|
624
|
+
<div class="h-4 w-px bg-line-1"></div>
|
|
625
|
+
<div class="h-6 w-[76px] rounded border border-line-1 bg-bg-2 motion-safe:animate-pulse"></div>
|
|
626
|
+
<div class="ml-auto h-3 w-[58px] rounded bg-bg-2 motion-safe:animate-pulse"></div>
|
|
627
|
+
</div>
|
|
628
|
+
|
|
629
|
+
<div class="flex-1 overflow-hidden">
|
|
630
|
+
<div class="min-w-[760px]">
|
|
631
|
+
<div class="grid h-8 grid-cols-[40px_repeat(6,minmax(96px,1fr))] border-b border-line-1 bg-bg-1">
|
|
632
|
+
<div class="px-2 py-2">
|
|
633
|
+
<div class="ml-auto h-3 w-4 rounded bg-bg-2 motion-safe:animate-pulse"></div>
|
|
634
|
+
</div>
|
|
635
|
+
{#each resultsSkeletonColumns as col (col)}
|
|
636
|
+
<div class="px-2 py-2">
|
|
637
|
+
<div class="h-3 rounded bg-bg-2 motion-safe:animate-pulse" style={`width: ${col % 3 === 0 ? 72 : col % 3 === 1 ? 104 : 56}px`}></div>
|
|
638
|
+
</div>
|
|
639
|
+
{/each}
|
|
640
|
+
</div>
|
|
641
|
+
{#each resultsSkeletonRows as row (row)}
|
|
642
|
+
<div class="grid h-[29px] grid-cols-[40px_repeat(6,minmax(96px,1fr))] border-b border-line-1/60">
|
|
643
|
+
<div class="px-2 py-2">
|
|
644
|
+
<div class="ml-auto h-3 w-4 rounded bg-bg-2 motion-safe:animate-pulse"></div>
|
|
645
|
+
</div>
|
|
646
|
+
{#each resultsSkeletonColumns as col (col)}
|
|
647
|
+
<div class="px-2 py-2">
|
|
648
|
+
<div class="h-3 rounded bg-bg-2 motion-safe:animate-pulse" style={`width: ${col === 0 ? 48 : 64 + ((row + col) % 4) * 18}px`}></div>
|
|
649
|
+
</div>
|
|
650
|
+
{/each}
|
|
651
|
+
</div>
|
|
652
|
+
{/each}
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
|
|
656
|
+
<div class="border-t border-line-1 px-3 py-1.5 flex items-center gap-3">
|
|
657
|
+
<div class="h-3 w-28 rounded bg-bg-2 motion-safe:animate-pulse"></div>
|
|
658
|
+
<div class="h-3 w-44 rounded bg-bg-2 motion-safe:animate-pulse"></div>
|
|
659
|
+
<div class="h-3 w-20 rounded bg-bg-2 motion-safe:animate-pulse"></div>
|
|
660
|
+
</div>
|
|
593
661
|
</div>
|
|
594
662
|
{:else}
|
|
595
663
|
{@const renderer = pickRenderer(tab, result)}
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
let error = $state<string | null>(null)
|
|
28
28
|
let search = $state('')
|
|
29
29
|
let capabilityFilter = $state<Capability | 'all'>('all')
|
|
30
|
+
const skeletonRows = Array.from({ length: 10 }, (_, i) => i)
|
|
31
|
+
const skeletonChips = Array.from({ length: 4 }, (_, i) => i)
|
|
30
32
|
|
|
31
33
|
const connected = $derived(connection.connected)
|
|
32
34
|
const activeUrl = $derived(connection.active.url)
|
|
@@ -113,7 +115,29 @@
|
|
|
113
115
|
<div class="mt-1 break-words">{error}</div>
|
|
114
116
|
</div>
|
|
115
117
|
{:else if loading && items.length === 0}
|
|
116
|
-
<div class="
|
|
118
|
+
<div class="schema-skeleton" aria-busy="true" aria-label="Loading schema">
|
|
119
|
+
<div class="p-2 border-b border-line-1 grid gap-2">
|
|
120
|
+
<div class="flex items-center gap-1.5 h-7 rounded border border-line-1 bg-bg-0 px-2">
|
|
121
|
+
<Search class="size-3 text-fg-3" />
|
|
122
|
+
<div class="h-3 w-32 rounded bg-bg-2 motion-safe:animate-pulse"></div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div class="flex flex-wrap gap-1">
|
|
126
|
+
{#each skeletonChips as chip (chip)}
|
|
127
|
+
<div class="h-5 rounded border border-line-1 bg-bg-1 px-1.5 motion-safe:animate-pulse" style={`width: ${chip === 0 ? 42 : 28 + chip * 8}px`}></div>
|
|
128
|
+
{/each}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<ul class="py-1" role="presentation">
|
|
133
|
+
{#each skeletonRows as row (row)}
|
|
134
|
+
<li class="flex h-7 items-center gap-2 px-3">
|
|
135
|
+
<div class="h-4 w-4 rounded bg-bg-2 motion-safe:animate-pulse"></div>
|
|
136
|
+
<div class="h-3 rounded bg-bg-2 motion-safe:animate-pulse" style={`width: ${row % 3 === 0 ? 132 : row % 3 === 1 ? 96 : 164}px`}></div>
|
|
137
|
+
</li>
|
|
138
|
+
{/each}
|
|
139
|
+
</ul>
|
|
140
|
+
</div>
|
|
117
141
|
{:else if items.length === 0}
|
|
118
142
|
<div class="p-3 flex flex-col items-start gap-2 text-[12px] text-fg-3 font-mono leading-relaxed">
|
|
119
143
|
<span class="text-fg-2">No user collections.</span>
|
|
@@ -4,22 +4,33 @@
|
|
|
4
4
|
import EmptyState from '$lib/EmptyState.svelte'
|
|
5
5
|
import { activity } from '$lib/activity.svelte'
|
|
6
6
|
import { connection } from '$lib/connections.svelte'
|
|
7
|
+
import type { PermissionDecision } from '$lib/permission-gate'
|
|
8
|
+
import {
|
|
9
|
+
POLICY_READ_CHECK,
|
|
10
|
+
assembleCapabilityMatrix,
|
|
11
|
+
capabilityMatrixChecksFromPolicies,
|
|
12
|
+
type CapabilityMatrixRow,
|
|
13
|
+
} from '$lib/policies'
|
|
7
14
|
import { secureStore } from '$lib/secureStore.svelte'
|
|
8
15
|
import type { AuthPolicy, AuthTenant, AuthUser, Whoami } from '#reddb'
|
|
9
|
-
import { Building2, Lock, RefreshCw, ScrollText,
|
|
16
|
+
import { Building2, CheckCircle2, Lock, RefreshCw, ScrollText, ShieldAlert, Users, XCircle } from 'lucide-svelte'
|
|
10
17
|
|
|
11
18
|
let whoami = $state<Whoami | null>(null)
|
|
12
19
|
let tenants = $state<AuthTenant[]>([])
|
|
13
20
|
let users = $state<AuthUser[]>([])
|
|
14
21
|
let policies = $state<AuthPolicy[]>([])
|
|
22
|
+
let policyReadDecision = $state<PermissionDecision | null>(null)
|
|
23
|
+
let capabilityRows = $state<CapabilityMatrixRow[]>([])
|
|
15
24
|
let loading = $state(false)
|
|
16
25
|
let whoamiError = $state<string | null>(null)
|
|
17
26
|
let tenantsError = $state<string | null>(null)
|
|
18
27
|
let usersError = $state<string | null>(null)
|
|
19
28
|
let policiesError = $state<string | null>(null)
|
|
29
|
+
let capabilityError = $state<string | null>(null)
|
|
20
30
|
|
|
21
31
|
const activeUrl = $derived(connection.active.url)
|
|
22
32
|
const connected = $derived(connection.connected)
|
|
33
|
+
const showPolicyPane = $derived(policyReadDecision?.allowed === true)
|
|
23
34
|
|
|
24
35
|
function fmtTime(v: number | undefined): string {
|
|
25
36
|
if (!v) return '-'
|
|
@@ -34,6 +45,8 @@
|
|
|
34
45
|
tenants = []
|
|
35
46
|
users = []
|
|
36
47
|
policies = []
|
|
48
|
+
policyReadDecision = null
|
|
49
|
+
capabilityRows = []
|
|
37
50
|
return
|
|
38
51
|
}
|
|
39
52
|
loading = true
|
|
@@ -41,20 +54,61 @@
|
|
|
41
54
|
tenantsError = null
|
|
42
55
|
usersError = null
|
|
43
56
|
policiesError = null
|
|
57
|
+
capabilityError = null
|
|
58
|
+
policyReadDecision = null
|
|
59
|
+
capabilityRows = []
|
|
44
60
|
try {
|
|
45
61
|
whoami = await activity.track('security · whoami', () => client.whoami())
|
|
46
62
|
} catch (e) {
|
|
47
63
|
whoamiError = (e as Error).message
|
|
48
64
|
whoami = null
|
|
49
65
|
}
|
|
66
|
+
try {
|
|
67
|
+
tenants = await activity.track('security · tenants', () => client.tenants())
|
|
68
|
+
} catch (e) {
|
|
69
|
+
tenantsError = (e as Error).message
|
|
70
|
+
tenants = []
|
|
71
|
+
}
|
|
50
72
|
try {
|
|
51
73
|
users = await activity.track('security · users', () => client.users())
|
|
52
74
|
} catch (e) {
|
|
53
75
|
usersError = (e as Error).message
|
|
54
76
|
users = []
|
|
55
|
-
} finally {
|
|
56
|
-
loading = false
|
|
57
77
|
}
|
|
78
|
+
try {
|
|
79
|
+
const [decision] = await activity.track('security · auth.can policy read', () => client.authCan([POLICY_READ_CHECK]))
|
|
80
|
+
policyReadDecision = decision ?? {
|
|
81
|
+
...POLICY_READ_CHECK,
|
|
82
|
+
allowed: false,
|
|
83
|
+
reason: 'auth.can omitted policy-read decision',
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
policyReadDecision = {
|
|
87
|
+
...POLICY_READ_CHECK,
|
|
88
|
+
allowed: false,
|
|
89
|
+
reason: (e as Error).message || 'auth.can failed',
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (policyReadDecision.allowed) {
|
|
93
|
+
try {
|
|
94
|
+
policies = await activity.track('security · policies', () => client.policies())
|
|
95
|
+
} catch (e) {
|
|
96
|
+
policiesError = (e as Error).message
|
|
97
|
+
policies = []
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const checks = capabilityMatrixChecksFromPolicies(policies)
|
|
101
|
+
const decisions = await activity.track('security · auth.can capability matrix', () => client.authCan(checks))
|
|
102
|
+
capabilityRows = assembleCapabilityMatrix(policies, decisions)
|
|
103
|
+
} catch (e) {
|
|
104
|
+
capabilityError = (e as Error).message
|
|
105
|
+
capabilityRows = []
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
policies = []
|
|
109
|
+
capabilityRows = []
|
|
110
|
+
}
|
|
111
|
+
loading = false
|
|
58
112
|
}
|
|
59
113
|
|
|
60
114
|
$effect(() => {
|
|
@@ -142,38 +196,40 @@
|
|
|
142
196
|
{/if}
|
|
143
197
|
</Card>
|
|
144
198
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
<div class="
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
199
|
+
{#if showPolicyPane}
|
|
200
|
+
<Card title="policies">
|
|
201
|
+
{#if policiesError}
|
|
202
|
+
<div class="mb-2 inline-flex items-center gap-1.5 rounded border border-warn/40 bg-bg-0 px-2 py-1 text-[11px] font-mono text-warn">
|
|
203
|
+
<ShieldAlert class="size-3.5" />
|
|
204
|
+
policies endpoint denied or unavailable
|
|
205
|
+
</div>
|
|
206
|
+
<pre class="whitespace-pre-wrap break-words text-[12px] font-mono text-fg-3">{policiesError}</pre>
|
|
207
|
+
{:else if policies.length === 0}
|
|
208
|
+
<div class="flex items-center gap-2 text-[12px] font-mono text-fg-3">
|
|
209
|
+
<ScrollText class="size-4" />
|
|
210
|
+
<span>No policies returned for this principal.</span>
|
|
211
|
+
</div>
|
|
212
|
+
{:else}
|
|
213
|
+
<div class="grid gap-1 text-[12px] font-mono">
|
|
214
|
+
{#each policies.slice(0, 6) as policy, i (`${policy.id ?? policy.name ?? i}`)}
|
|
215
|
+
<div class="rounded border border-line-1 bg-bg-0 px-2 py-1.5">
|
|
216
|
+
<div class="flex items-center gap-2">
|
|
217
|
+
<span class="text-fg-0">{policy.name ?? policy.id ?? 'policy'}</span>
|
|
218
|
+
{#if policy.effect}<Badge tone={policy.effect === 'deny' ? 'danger' : 'ok'}>{policy.effect}</Badge>{/if}
|
|
219
|
+
</div>
|
|
220
|
+
<div class="mt-1 text-fg-3">
|
|
221
|
+
{policy.action ?? '*'} · {policy.resource ?? '*'}
|
|
222
|
+
{#if policy.tenant} · tenant {policy.tenant}{/if}
|
|
223
|
+
</div>
|
|
168
224
|
</div>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
225
|
+
{/each}
|
|
226
|
+
{#if policies.length > 6}
|
|
227
|
+
<div class="text-[11px] text-fg-3">{policies.length - 6} more policies in the table.</div>
|
|
228
|
+
{/if}
|
|
229
|
+
</div>
|
|
230
|
+
{/if}
|
|
231
|
+
</Card>
|
|
232
|
+
{/if}
|
|
177
233
|
</div>
|
|
178
234
|
|
|
179
235
|
<div class="grid gap-3 content-start">
|
|
@@ -217,38 +273,93 @@
|
|
|
217
273
|
{/if}
|
|
218
274
|
</Card>
|
|
219
275
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
<
|
|
228
|
-
<
|
|
229
|
-
<
|
|
230
|
-
<
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
</thead>
|
|
237
|
-
<tbody>
|
|
238
|
-
{#each policies as policy, i (`${policy.id ?? policy.name ?? i}`)}
|
|
239
|
-
<tr class="border-b border-line-1/60 hover:bg-bg-1/40">
|
|
240
|
-
<td class="px-2 py-1">{#if policy.effect}<Badge tone={policy.effect === 'deny' ? 'danger' : 'ok'}>{policy.effect}</Badge>{:else}<span class="text-fg-3">-</span>{/if}</td>
|
|
241
|
-
<td class="px-2 py-1 text-fg-1">{policy.principal ?? '-'}</td>
|
|
242
|
-
<td class="px-2 py-1 text-accent">{policy.action ?? '*'}</td>
|
|
243
|
-
<td class="px-2 py-1 text-fg-1">{policy.resource ?? '*'}</td>
|
|
244
|
-
<td class="px-2 py-1 text-fg-2">{policy.tenant ?? '-'}</td>
|
|
276
|
+
{#if showPolicyPane}
|
|
277
|
+
<Card title="policy table">
|
|
278
|
+
{#if policiesError}
|
|
279
|
+
<div class="text-[12px] font-mono text-fg-3">Policy table unavailable for this principal.</div>
|
|
280
|
+
{:else if policies.length === 0}
|
|
281
|
+
<div class="text-[12px] font-mono text-fg-3">No policies to tabulate.</div>
|
|
282
|
+
{:else}
|
|
283
|
+
<div class="overflow-auto">
|
|
284
|
+
<table class="w-full border-collapse text-[12px] font-mono">
|
|
285
|
+
<thead class="sticky top-0 bg-bg-1">
|
|
286
|
+
<tr class="border-b border-line-1 text-fg-3">
|
|
287
|
+
<th class="px-2 py-1.5 text-left font-normal">effect</th>
|
|
288
|
+
<th class="px-2 py-1.5 text-left font-normal">principal</th>
|
|
289
|
+
<th class="px-2 py-1.5 text-left font-normal">action</th>
|
|
290
|
+
<th class="px-2 py-1.5 text-left font-normal">resource</th>
|
|
291
|
+
<th class="px-2 py-1.5 text-left font-normal">tenant</th>
|
|
245
292
|
</tr>
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
293
|
+
</thead>
|
|
294
|
+
<tbody>
|
|
295
|
+
{#each policies as policy, i (`${policy.id ?? policy.name ?? i}`)}
|
|
296
|
+
<tr class="border-b border-line-1/60 hover:bg-bg-1/40">
|
|
297
|
+
<td class="px-2 py-1">{#if policy.effect}<Badge tone={policy.effect === 'deny' ? 'danger' : 'ok'}>{policy.effect}</Badge>{:else}<span class="text-fg-3">-</span>{/if}</td>
|
|
298
|
+
<td class="px-2 py-1 text-fg-1">{policy.principal ?? '-'}</td>
|
|
299
|
+
<td class="px-2 py-1 text-accent">{policy.action ?? '*'}</td>
|
|
300
|
+
<td class="px-2 py-1 text-fg-1">{policy.resource ?? '*'}</td>
|
|
301
|
+
<td class="px-2 py-1 text-fg-2">{policy.tenant ?? '-'}</td>
|
|
302
|
+
</tr>
|
|
303
|
+
{/each}
|
|
304
|
+
</tbody>
|
|
305
|
+
</table>
|
|
306
|
+
</div>
|
|
307
|
+
{/if}
|
|
308
|
+
</Card>
|
|
309
|
+
|
|
310
|
+
<Card title="my capabilities">
|
|
311
|
+
{#if capabilityError}
|
|
312
|
+
<div class="mb-2 inline-flex items-center gap-1.5 rounded border border-warn/40 bg-bg-0 px-2 py-1 text-[11px] font-mono text-warn">
|
|
313
|
+
<ShieldAlert class="size-3.5" />
|
|
314
|
+
capability matrix unavailable
|
|
315
|
+
</div>
|
|
316
|
+
<pre class="whitespace-pre-wrap break-words text-[12px] font-mono text-fg-3">{capabilityError}</pre>
|
|
317
|
+
{:else if capabilityRows.length === 0}
|
|
318
|
+
<div class="text-[12px] font-mono text-fg-3">No capability checks returned.</div>
|
|
319
|
+
{:else}
|
|
320
|
+
<div class="overflow-auto">
|
|
321
|
+
<table class="w-full border-collapse text-[12px] font-mono">
|
|
322
|
+
<thead class="sticky top-0 bg-bg-1">
|
|
323
|
+
<tr class="border-b border-line-1 text-fg-3">
|
|
324
|
+
<th class="px-2 py-1.5 text-left font-normal">can</th>
|
|
325
|
+
<th class="px-2 py-1.5 text-left font-normal">action</th>
|
|
326
|
+
<th class="px-2 py-1.5 text-left font-normal">resource</th>
|
|
327
|
+
<th class="px-2 py-1.5 text-left font-normal">policy effect</th>
|
|
328
|
+
<th class="px-2 py-1.5 text-left font-normal">reason</th>
|
|
329
|
+
</tr>
|
|
330
|
+
</thead>
|
|
331
|
+
<tbody>
|
|
332
|
+
{#each capabilityRows as row (row.id)}
|
|
333
|
+
<tr class="border-b border-line-1/60 hover:bg-bg-1/40">
|
|
334
|
+
<td class="px-2 py-1">
|
|
335
|
+
{#if row.allowed}
|
|
336
|
+
<span class="inline-flex items-center gap-1 text-ok"><CheckCircle2 class="size-3.5" /> yes</span>
|
|
337
|
+
{:else}
|
|
338
|
+
<span class="inline-flex items-center gap-1 text-danger"><XCircle class="size-3.5" /> no</span>
|
|
339
|
+
{/if}
|
|
340
|
+
</td>
|
|
341
|
+
<td class="px-2 py-1 text-accent">{row.action}</td>
|
|
342
|
+
<td class="px-2 py-1 text-fg-1">{row.resourceLabel}</td>
|
|
343
|
+
<td class="px-2 py-1">
|
|
344
|
+
{#if row.effects.length === 0}
|
|
345
|
+
<span class="text-fg-3">-</span>
|
|
346
|
+
{:else}
|
|
347
|
+
<span class="inline-flex flex-wrap gap-1">
|
|
348
|
+
{#each row.effects as effect}
|
|
349
|
+
<Badge tone={effect === 'deny' ? 'danger' : 'ok'}>{effect}</Badge>
|
|
350
|
+
{/each}
|
|
351
|
+
</span>
|
|
352
|
+
{/if}
|
|
353
|
+
</td>
|
|
354
|
+
<td class="max-w-[320px] px-2 py-1 text-fg-3">{row.reason ?? '-'}</td>
|
|
355
|
+
</tr>
|
|
356
|
+
{/each}
|
|
357
|
+
</tbody>
|
|
358
|
+
</table>
|
|
359
|
+
</div>
|
|
360
|
+
{/if}
|
|
361
|
+
</Card>
|
|
362
|
+
{/if}
|
|
252
363
|
</div>
|
|
253
364
|
</div>
|
|
254
365
|
{/if}
|