@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,193 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { connection } from './connections.svelte'
|
|
3
|
+
import { secureStore } from './secureStore.svelte'
|
|
4
|
+
import { activity } from './activity.svelte'
|
|
5
|
+
import {
|
|
6
|
+
CAPABILITY_GLYPHS,
|
|
7
|
+
detectCapability,
|
|
8
|
+
isInternalCollection,
|
|
9
|
+
type Capability,
|
|
10
|
+
} from './capability'
|
|
11
|
+
import { defaultSubpage } from './collection-pages'
|
|
12
|
+
import { useRouter } from './router.svelte'
|
|
13
|
+
import { Plug, Database, Lock, Search, X } from 'lucide-svelte'
|
|
14
|
+
|
|
15
|
+
const router = useRouter()
|
|
16
|
+
|
|
17
|
+
interface Item {
|
|
18
|
+
name: string
|
|
19
|
+
capability: Capability
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The router is the source of truth for which collection is selected.
|
|
23
|
+
const activeCollection = $derived(router.collection)
|
|
24
|
+
|
|
25
|
+
let items = $state<Item[]>([])
|
|
26
|
+
let loading = $state(false)
|
|
27
|
+
let error = $state<string | null>(null)
|
|
28
|
+
let search = $state('')
|
|
29
|
+
let capabilityFilter = $state<Capability | 'all'>('all')
|
|
30
|
+
|
|
31
|
+
const connected = $derived(connection.connected)
|
|
32
|
+
const activeUrl = $derived(connection.active.url)
|
|
33
|
+
const capabilityCounts = $derived.by(() => {
|
|
34
|
+
const counts = new Map<Capability, number>()
|
|
35
|
+
for (const item of items) counts.set(item.capability, (counts.get(item.capability) ?? 0) + 1)
|
|
36
|
+
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
37
|
+
})
|
|
38
|
+
const filteredItems = $derived.by(() => {
|
|
39
|
+
const q = search.trim().toLowerCase()
|
|
40
|
+
return items.filter((item) => {
|
|
41
|
+
if (capabilityFilter !== 'all' && item.capability !== capabilityFilter) return false
|
|
42
|
+
if (!q) return true
|
|
43
|
+
const g = CAPABILITY_GLYPHS[item.capability]
|
|
44
|
+
return item.name.toLowerCase().includes(q) || g.label.toLowerCase().includes(q)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
async function refresh() {
|
|
49
|
+
const client = connection.client
|
|
50
|
+
if (!client || !connected) {
|
|
51
|
+
items = []
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
loading = true
|
|
55
|
+
error = null
|
|
56
|
+
const token = activeUrl
|
|
57
|
+
try {
|
|
58
|
+
const names = await activity.track('schema · list collections', () => client.collections())
|
|
59
|
+
const user = names.filter((n) => !isInternalCollection(n)).sort()
|
|
60
|
+
items = user.map((name) => ({ name, capability: 'table' as Capability }))
|
|
61
|
+
const probes = user.map((name) =>
|
|
62
|
+
activity.track(`schema · probe ${name}`, async () => {
|
|
63
|
+
const cap = await detectCapability(client, name)
|
|
64
|
+
if (token !== activeUrl) return
|
|
65
|
+
items = items.map((it) => (it.name === name ? { ...it, capability: cap } : it))
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
await Promise.all(probes)
|
|
69
|
+
} catch (e) {
|
|
70
|
+
error = (e as Error).message
|
|
71
|
+
items = []
|
|
72
|
+
} finally {
|
|
73
|
+
loading = false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
$effect(() => {
|
|
78
|
+
// Block all probing until the secure store is unlocked, so the
|
|
79
|
+
// network panel doesn't leak the schema before the user authenticates.
|
|
80
|
+
if (secureStore.locked) return
|
|
81
|
+
void connected
|
|
82
|
+
void activeUrl
|
|
83
|
+
refresh()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<div class="px-3 py-2 border-b border-line-1 flex items-center justify-between text-fg-3">
|
|
89
|
+
<div class="flex items-center gap-2">
|
|
90
|
+
<Database class="size-3.5" />
|
|
91
|
+
<span class="type-label">Schema</span>
|
|
92
|
+
</div>
|
|
93
|
+
{#if connected && items.length > 0}
|
|
94
|
+
<span class="font-mono text-[10px] text-fg-3">{items.length}</span>
|
|
95
|
+
{/if}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{#if secureStore.locked}
|
|
99
|
+
<div class="p-3 flex flex-col items-start gap-2 text-[12px] text-fg-3 font-mono leading-relaxed">
|
|
100
|
+
<Lock class="size-4 text-accent" />
|
|
101
|
+
<span class="text-fg-1">Locked.</span>
|
|
102
|
+
<span>Enter your master password to load the schema.</span>
|
|
103
|
+
</div>
|
|
104
|
+
{:else if !connected}
|
|
105
|
+
<div class="p-3 flex flex-col items-start gap-2 text-[12px] text-fg-3 font-mono leading-relaxed">
|
|
106
|
+
<Plug class="size-4 text-fg-3" />
|
|
107
|
+
<span>No schema to show.</span>
|
|
108
|
+
<span>Connect to a reddb instance to inspect collections.</span>
|
|
109
|
+
</div>
|
|
110
|
+
{:else if error}
|
|
111
|
+
<div class="p-3 text-[12px] text-fg-3 font-mono leading-relaxed">
|
|
112
|
+
<div class="text-fg-2">Couldn't load collections.</div>
|
|
113
|
+
<div class="mt-1 break-words">{error}</div>
|
|
114
|
+
</div>
|
|
115
|
+
{:else if loading && items.length === 0}
|
|
116
|
+
<div class="p-3 text-[12px] text-fg-3 font-mono">Loading…</div>
|
|
117
|
+
{:else if items.length === 0}
|
|
118
|
+
<div class="p-3 flex flex-col items-start gap-2 text-[12px] text-fg-3 font-mono leading-relaxed">
|
|
119
|
+
<span class="text-fg-2">No user collections.</span>
|
|
120
|
+
<span>Seed the instance to populate the schema.</span>
|
|
121
|
+
<code class="px-2 py-1 bg-bg-2 border border-line-1 rounded text-[11px]">./scripts/seed.sh {activeUrl}</code>
|
|
122
|
+
</div>
|
|
123
|
+
{:else}
|
|
124
|
+
<div class="p-2 border-b border-line-1 grid gap-2">
|
|
125
|
+
<div class="flex items-center gap-1.5 h-7 rounded border border-line-1 bg-bg-0 px-2 text-[11px] font-mono">
|
|
126
|
+
<Search class="size-3 text-fg-3" />
|
|
127
|
+
<input
|
|
128
|
+
type="text"
|
|
129
|
+
bind:value={search}
|
|
130
|
+
placeholder="search collections…"
|
|
131
|
+
class="min-w-0 flex-1 bg-transparent text-fg-1 outline-none placeholder:text-fg-3"
|
|
132
|
+
/>
|
|
133
|
+
{#if search}
|
|
134
|
+
<button type="button" class="text-fg-3 hover:text-fg-1 cursor-pointer" aria-label="Clear collection search" onclick={() => (search = '')}>
|
|
135
|
+
<X class="size-3" />
|
|
136
|
+
</button>
|
|
137
|
+
{/if}
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div class="flex flex-wrap gap-1">
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
onclick={() => (capabilityFilter = 'all')}
|
|
144
|
+
class={[
|
|
145
|
+
'h-5 rounded border px-1.5 font-mono text-[10px] cursor-pointer transition-colors',
|
|
146
|
+
capabilityFilter === 'all' ? 'border-accent/40 bg-accent/10 text-accent' : 'border-line-1 text-fg-3 hover:text-fg-1 hover:border-line-2',
|
|
147
|
+
].join(' ')}
|
|
148
|
+
>
|
|
149
|
+
all {items.length}
|
|
150
|
+
</button>
|
|
151
|
+
{#each capabilityCounts as [cap, count] (cap)}
|
|
152
|
+
{@const g = CAPABILITY_GLYPHS[cap]}
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
onclick={() => (capabilityFilter = cap)}
|
|
156
|
+
title={g.label}
|
|
157
|
+
class={[
|
|
158
|
+
'h-5 rounded border px-1.5 font-mono text-[10px] cursor-pointer transition-colors',
|
|
159
|
+
capabilityFilter === cap ? 'border-accent/40 bg-accent/10 text-accent' : 'border-line-1 text-fg-3 hover:text-fg-1 hover:border-line-2',
|
|
160
|
+
].join(' ')}
|
|
161
|
+
>
|
|
162
|
+
{g.glyph} {count}
|
|
163
|
+
</button>
|
|
164
|
+
{/each}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
<ul class="py-1" role="list">
|
|
168
|
+
{#if filteredItems.length === 0}
|
|
169
|
+
<li class="px-3 py-2 font-mono text-[12px] text-fg-3">No matching collections.</li>
|
|
170
|
+
{/if}
|
|
171
|
+
{#each filteredItems as item (item.name)}
|
|
172
|
+
{@const g = CAPABILITY_GLYPHS[item.capability]}
|
|
173
|
+
{@const active = item.name === activeCollection}
|
|
174
|
+
{@const target = { view: 'collection' as const, collection: item.name, subpage: defaultSubpage(item.capability) }}
|
|
175
|
+
<li>
|
|
176
|
+
<a
|
|
177
|
+
href={router.href(target)}
|
|
178
|
+
onclick={(e) => router.go(target, e)}
|
|
179
|
+
aria-current={active ? 'page' : undefined}
|
|
180
|
+
title={`${g.label} · ${item.name}`}
|
|
181
|
+
class={[
|
|
182
|
+
'w-full flex items-center gap-2 px-3 py-1 font-mono text-[12px] no-underline transition-colors',
|
|
183
|
+
active ? 'bg-bg-1 text-fg-0' : 'text-fg-2 hover:bg-bg-1 hover:text-fg-0',
|
|
184
|
+
].join(' ')}
|
|
185
|
+
>
|
|
186
|
+
<span class="inline-block w-4 text-center text-fg-3" aria-hidden="true">{g.glyph}</span>
|
|
187
|
+
<span class="truncate">{item.name}</span>
|
|
188
|
+
<span class="sr-only">{g.label}</span>
|
|
189
|
+
</a>
|
|
190
|
+
</li>
|
|
191
|
+
{/each}
|
|
192
|
+
</ul>
|
|
193
|
+
{/if}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Card, Badge } from '@reddb-io/ui-kit'
|
|
3
|
+
import PageHeader from '$lib/PageHeader.svelte'
|
|
4
|
+
import EmptyState from '$lib/EmptyState.svelte'
|
|
5
|
+
import { activity } from '$lib/activity.svelte'
|
|
6
|
+
import { connection } from '$lib/connections.svelte'
|
|
7
|
+
import { secureStore } from '$lib/secureStore.svelte'
|
|
8
|
+
import type { AuthPolicy, AuthTenant, AuthUser, Whoami } from '#reddb'
|
|
9
|
+
import { Building2, Lock, RefreshCw, ScrollText, Shield, ShieldAlert, Users } from 'lucide-svelte'
|
|
10
|
+
|
|
11
|
+
let whoami = $state<Whoami | null>(null)
|
|
12
|
+
let tenants = $state<AuthTenant[]>([])
|
|
13
|
+
let users = $state<AuthUser[]>([])
|
|
14
|
+
let policies = $state<AuthPolicy[]>([])
|
|
15
|
+
let loading = $state(false)
|
|
16
|
+
let whoamiError = $state<string | null>(null)
|
|
17
|
+
let tenantsError = $state<string | null>(null)
|
|
18
|
+
let usersError = $state<string | null>(null)
|
|
19
|
+
let policiesError = $state<string | null>(null)
|
|
20
|
+
|
|
21
|
+
const activeUrl = $derived(connection.active.url)
|
|
22
|
+
const connected = $derived(connection.connected)
|
|
23
|
+
|
|
24
|
+
function fmtTime(v: number | undefined): string {
|
|
25
|
+
if (!v) return '-'
|
|
26
|
+
const ms = v > 10_000_000_000 ? v : v * 1000
|
|
27
|
+
return new Date(ms).toISOString().replace('T', ' ').slice(0, 19)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function refresh() {
|
|
31
|
+
const client = connection.client
|
|
32
|
+
if (!client || secureStore.locked || !connected) {
|
|
33
|
+
whoami = null
|
|
34
|
+
tenants = []
|
|
35
|
+
users = []
|
|
36
|
+
policies = []
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
loading = true
|
|
40
|
+
whoamiError = null
|
|
41
|
+
tenantsError = null
|
|
42
|
+
usersError = null
|
|
43
|
+
policiesError = null
|
|
44
|
+
try {
|
|
45
|
+
whoami = await activity.track('security · whoami', () => client.whoami())
|
|
46
|
+
} catch (e) {
|
|
47
|
+
whoamiError = (e as Error).message
|
|
48
|
+
whoami = null
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
users = await activity.track('security · users', () => client.users())
|
|
52
|
+
} catch (e) {
|
|
53
|
+
usersError = (e as Error).message
|
|
54
|
+
users = []
|
|
55
|
+
} finally {
|
|
56
|
+
loading = false
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
$effect(() => {
|
|
61
|
+
if (secureStore.locked) return
|
|
62
|
+
void activeUrl
|
|
63
|
+
void connected
|
|
64
|
+
refresh()
|
|
65
|
+
})
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<div class="h-full overflow-auto bg-bg-0 p-6 text-fg-1">
|
|
69
|
+
<PageHeader
|
|
70
|
+
eyebrow="Admin"
|
|
71
|
+
title="Security"
|
|
72
|
+
subtitle="Tenants, users, and policy surfaces are permission-aware. Controls only appear when reddb exposes the grant."
|
|
73
|
+
>
|
|
74
|
+
{#snippet actions()}
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onclick={refresh}
|
|
78
|
+
disabled={loading || secureStore.locked || !connected}
|
|
79
|
+
class="inline-flex h-7 items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
|
|
80
|
+
>
|
|
81
|
+
<RefreshCw class={loading ? 'size-3.5 animate-spin' : 'size-3.5'} />
|
|
82
|
+
refresh
|
|
83
|
+
</button>
|
|
84
|
+
{/snippet}
|
|
85
|
+
</PageHeader>
|
|
86
|
+
|
|
87
|
+
{#if secureStore.locked}
|
|
88
|
+
<EmptyState icon={Lock} title="Security locked" message="Enter the master password before red-ui loads auth data." />
|
|
89
|
+
{:else if !connected}
|
|
90
|
+
<EmptyState icon={ShieldAlert} title="No active connection" message="Connect to a reddb instance to inspect security posture." />
|
|
91
|
+
{:else}
|
|
92
|
+
<div class="grid grid-cols-[minmax(280px,0.75fr)_1.25fr] gap-3">
|
|
93
|
+
<div class="grid gap-3 content-start">
|
|
94
|
+
<Card title="session">
|
|
95
|
+
{#if whoamiError}
|
|
96
|
+
<div class="text-[12px] font-mono text-warn break-words">{whoamiError}</div>
|
|
97
|
+
{:else if whoami}
|
|
98
|
+
<div class="grid grid-cols-[110px_1fr] gap-x-3 gap-y-2 text-[12px] font-mono">
|
|
99
|
+
<span class="text-fg-3">authenticated</span>
|
|
100
|
+
<span>{#if whoami.authenticated}<Badge tone="ok">yes</Badge>{:else}<Badge tone="warn">no</Badge>{/if}</span>
|
|
101
|
+
<span class="text-fg-3">username</span>
|
|
102
|
+
<span class="text-fg-0">{whoami.username}</span>
|
|
103
|
+
<span class="text-fg-3">role</span>
|
|
104
|
+
<span class="text-accent">{whoami.role}</span>
|
|
105
|
+
{#if whoami.note}
|
|
106
|
+
<span class="text-fg-3">note</span>
|
|
107
|
+
<span class="text-fg-1">{whoami.note}</span>
|
|
108
|
+
{/if}
|
|
109
|
+
</div>
|
|
110
|
+
{:else}
|
|
111
|
+
<div class="text-[12px] font-mono text-fg-3">Loading session…</div>
|
|
112
|
+
{/if}
|
|
113
|
+
</Card>
|
|
114
|
+
|
|
115
|
+
<Card title="tenants">
|
|
116
|
+
{#if tenantsError}
|
|
117
|
+
<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">
|
|
118
|
+
<ShieldAlert class="size-3.5" />
|
|
119
|
+
tenants endpoint denied or unavailable
|
|
120
|
+
</div>
|
|
121
|
+
<pre class="whitespace-pre-wrap break-words text-[12px] font-mono text-fg-3">{tenantsError}</pre>
|
|
122
|
+
{:else if tenants.length === 0}
|
|
123
|
+
<div class="flex items-center gap-2 text-[12px] font-mono text-fg-3">
|
|
124
|
+
<Building2 class="size-4" />
|
|
125
|
+
<span>No tenants returned for this principal.</span>
|
|
126
|
+
</div>
|
|
127
|
+
{:else}
|
|
128
|
+
<div class="grid gap-1 text-[12px] font-mono">
|
|
129
|
+
{#each tenants as tenant, i (`${tenant.id ?? tenant.slug ?? tenant.name ?? i}`)}
|
|
130
|
+
<div class="rounded border border-line-1 bg-bg-0 px-2 py-1.5">
|
|
131
|
+
<div class="flex items-center gap-2">
|
|
132
|
+
<span class="text-fg-0">{tenant.name ?? tenant.slug ?? tenant.id ?? 'tenant'}</span>
|
|
133
|
+
{#if tenant.role}<Badge tone="accent">{tenant.role}</Badge>{/if}
|
|
134
|
+
</div>
|
|
135
|
+
<div class="mt-1 text-fg-3">
|
|
136
|
+
{tenant.id ?? tenant.slug ?? '-'}
|
|
137
|
+
{#if tenant.grants?.length} · {tenant.grants.join(', ')}{/if}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
{/each}
|
|
141
|
+
</div>
|
|
142
|
+
{/if}
|
|
143
|
+
</Card>
|
|
144
|
+
|
|
145
|
+
<Card title="policies">
|
|
146
|
+
{#if policiesError}
|
|
147
|
+
<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">
|
|
148
|
+
<ShieldAlert class="size-3.5" />
|
|
149
|
+
policies endpoint denied or unavailable
|
|
150
|
+
</div>
|
|
151
|
+
<pre class="whitespace-pre-wrap break-words text-[12px] font-mono text-fg-3">{policiesError}</pre>
|
|
152
|
+
{:else if policies.length === 0}
|
|
153
|
+
<div class="flex items-center gap-2 text-[12px] font-mono text-fg-3">
|
|
154
|
+
<ScrollText class="size-4" />
|
|
155
|
+
<span>No policies returned for this principal.</span>
|
|
156
|
+
</div>
|
|
157
|
+
{:else}
|
|
158
|
+
<div class="grid gap-1 text-[12px] font-mono">
|
|
159
|
+
{#each policies.slice(0, 6) as policy, i (`${policy.id ?? policy.name ?? i}`)}
|
|
160
|
+
<div class="rounded border border-line-1 bg-bg-0 px-2 py-1.5">
|
|
161
|
+
<div class="flex items-center gap-2">
|
|
162
|
+
<span class="text-fg-0">{policy.name ?? policy.id ?? 'policy'}</span>
|
|
163
|
+
{#if policy.effect}<Badge tone={policy.effect === 'deny' ? 'danger' : 'ok'}>{policy.effect}</Badge>{/if}
|
|
164
|
+
</div>
|
|
165
|
+
<div class="mt-1 text-fg-3">
|
|
166
|
+
{policy.action ?? '*'} · {policy.resource ?? '*'}
|
|
167
|
+
{#if policy.tenant} · tenant {policy.tenant}{/if}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
{/each}
|
|
171
|
+
{#if policies.length > 6}
|
|
172
|
+
<div class="text-[11px] text-fg-3">{policies.length - 6} more policies in the table.</div>
|
|
173
|
+
{/if}
|
|
174
|
+
</div>
|
|
175
|
+
{/if}
|
|
176
|
+
</Card>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div class="grid gap-3 content-start">
|
|
180
|
+
<Card title="users">
|
|
181
|
+
{#if usersError}
|
|
182
|
+
<div class="mb-3 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">
|
|
183
|
+
<ShieldAlert class="size-3.5" />
|
|
184
|
+
users endpoint denied or unavailable
|
|
185
|
+
</div>
|
|
186
|
+
<pre class="whitespace-pre-wrap break-words text-[12px] font-mono text-fg-3">{usersError}</pre>
|
|
187
|
+
{:else if users.length === 0}
|
|
188
|
+
<div class="flex items-center gap-2 text-[12px] font-mono text-fg-3">
|
|
189
|
+
<Users class="size-4" />
|
|
190
|
+
<span>No users returned for this principal.</span>
|
|
191
|
+
</div>
|
|
192
|
+
{:else}
|
|
193
|
+
<div class="overflow-auto">
|
|
194
|
+
<table class="w-full border-collapse text-[12px] font-mono">
|
|
195
|
+
<thead class="sticky top-0 bg-bg-1">
|
|
196
|
+
<tr class="border-b border-line-1 text-fg-3">
|
|
197
|
+
<th class="px-2 py-1.5 text-left font-normal">user</th>
|
|
198
|
+
<th class="px-2 py-1.5 text-left font-normal">role</th>
|
|
199
|
+
<th class="px-2 py-1.5 text-left font-normal">mfa</th>
|
|
200
|
+
<th class="px-2 py-1.5 text-left font-normal">created</th>
|
|
201
|
+
<th class="px-2 py-1.5 text-left font-normal">last active</th>
|
|
202
|
+
</tr>
|
|
203
|
+
</thead>
|
|
204
|
+
<tbody>
|
|
205
|
+
{#each users as user (user.username)}
|
|
206
|
+
<tr class="border-b border-line-1/60 hover:bg-bg-1/40">
|
|
207
|
+
<td class="px-2 py-1 text-fg-0">{user.username}</td>
|
|
208
|
+
<td class="px-2 py-1 text-accent">{user.role ?? '-'}</td>
|
|
209
|
+
<td class="px-2 py-1">{#if user.mfa}<Badge tone="ok">on</Badge>{:else}<span class="text-fg-3">-</span>{/if}</td>
|
|
210
|
+
<td class="px-2 py-1 text-fg-2">{fmtTime(user.created_at)}</td>
|
|
211
|
+
<td class="px-2 py-1 text-fg-2">{fmtTime(user.last_active)}</td>
|
|
212
|
+
</tr>
|
|
213
|
+
{/each}
|
|
214
|
+
</tbody>
|
|
215
|
+
</table>
|
|
216
|
+
</div>
|
|
217
|
+
{/if}
|
|
218
|
+
</Card>
|
|
219
|
+
|
|
220
|
+
<Card title="policy table">
|
|
221
|
+
{#if policiesError}
|
|
222
|
+
<div class="text-[12px] font-mono text-fg-3">Policy table unavailable for this principal.</div>
|
|
223
|
+
{:else if policies.length === 0}
|
|
224
|
+
<div class="text-[12px] font-mono text-fg-3">No policies to tabulate.</div>
|
|
225
|
+
{:else}
|
|
226
|
+
<div class="overflow-auto">
|
|
227
|
+
<table class="w-full border-collapse text-[12px] font-mono">
|
|
228
|
+
<thead class="sticky top-0 bg-bg-1">
|
|
229
|
+
<tr class="border-b border-line-1 text-fg-3">
|
|
230
|
+
<th class="px-2 py-1.5 text-left font-normal">effect</th>
|
|
231
|
+
<th class="px-2 py-1.5 text-left font-normal">principal</th>
|
|
232
|
+
<th class="px-2 py-1.5 text-left font-normal">action</th>
|
|
233
|
+
<th class="px-2 py-1.5 text-left font-normal">resource</th>
|
|
234
|
+
<th class="px-2 py-1.5 text-left font-normal">tenant</th>
|
|
235
|
+
</tr>
|
|
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>
|
|
245
|
+
</tr>
|
|
246
|
+
{/each}
|
|
247
|
+
</tbody>
|
|
248
|
+
</table>
|
|
249
|
+
</div>
|
|
250
|
+
{/if}
|
|
251
|
+
</Card>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
{/if}
|
|
255
|
+
</div>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte'
|
|
3
|
+
import { Kbd } from '@reddb-io/ui-kit'
|
|
4
|
+
import { shortcuts, shortcutContexts, type ShortcutContext } from './shortcuts'
|
|
5
|
+
|
|
6
|
+
let open = $state(false)
|
|
7
|
+
|
|
8
|
+
function close() {
|
|
9
|
+
open = false
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isTypingTarget(el: EventTarget | null): boolean {
|
|
13
|
+
if (!(el instanceof HTMLElement)) return false
|
|
14
|
+
if (el.isContentEditable) return true
|
|
15
|
+
const tag = el.tagName
|
|
16
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function onKey(e: KeyboardEvent) {
|
|
20
|
+
if (e.key === 'Escape' && open) {
|
|
21
|
+
e.preventDefault()
|
|
22
|
+
close()
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
26
|
+
if (isTypingTarget(e.target)) return
|
|
27
|
+
e.preventDefault()
|
|
28
|
+
open = !open
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
onMount(() => {
|
|
33
|
+
window.addEventListener('keydown', onKey)
|
|
34
|
+
const onOpen = () => { open = true }
|
|
35
|
+
window.addEventListener('red:open-shortcuts', onOpen)
|
|
36
|
+
return () => {
|
|
37
|
+
window.removeEventListener('keydown', onKey)
|
|
38
|
+
window.removeEventListener('red:open-shortcuts', onOpen)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const grouped = $derived(
|
|
43
|
+
shortcutContexts
|
|
44
|
+
.map((ctx) => ({ ctx, items: shortcuts.filter((s) => s.context === ctx) }))
|
|
45
|
+
.filter((g) => g.items.length > 0)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const ctxBlurb: Record<ShortcutContext, string> = {
|
|
49
|
+
Global: 'Available anywhere in the app',
|
|
50
|
+
Connection: 'Manage which reddb you talk to',
|
|
51
|
+
Workspace: 'Move between tabs and panes',
|
|
52
|
+
Editing: 'While editing data or running queries',
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
{#if open}
|
|
57
|
+
<div
|
|
58
|
+
class="fixed inset-0 z-[200] bg-bg-0/70 backdrop-blur-md flex items-center justify-center p-6"
|
|
59
|
+
onclick={close}
|
|
60
|
+
role="presentation"
|
|
61
|
+
>
|
|
62
|
+
<div
|
|
63
|
+
class="w-full max-w-[640px] max-h-[80vh] overflow-y-auto bg-bg-1 border border-line-2 rounded-lg shadow-2xl"
|
|
64
|
+
role="dialog"
|
|
65
|
+
aria-modal="true"
|
|
66
|
+
aria-label="Keyboard shortcuts"
|
|
67
|
+
tabindex="-1"
|
|
68
|
+
onclick={(e) => e.stopPropagation()}
|
|
69
|
+
onkeydown={null}
|
|
70
|
+
>
|
|
71
|
+
<header class="flex items-center justify-between px-5 py-3 border-b border-line-1">
|
|
72
|
+
<div class="flex items-center gap-2">
|
|
73
|
+
<span class="type-label">Keyboard shortcuts</span>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="flex items-center gap-1.5 text-fg-3 text-[11px]">
|
|
76
|
+
<Kbd>Esc</Kbd> close
|
|
77
|
+
</div>
|
|
78
|
+
</header>
|
|
79
|
+
|
|
80
|
+
<div class="p-5 grid gap-5">
|
|
81
|
+
{#each grouped as group}
|
|
82
|
+
<section>
|
|
83
|
+
<div class="flex items-baseline justify-between mb-2">
|
|
84
|
+
<h3 class="type-label m-0">{group.ctx}</h3>
|
|
85
|
+
<span class="text-fg-3 text-[11px]">{ctxBlurb[group.ctx]}</span>
|
|
86
|
+
</div>
|
|
87
|
+
<ul class="grid gap-1 m-0 p-0 list-none">
|
|
88
|
+
{#each group.items as s}
|
|
89
|
+
<li class="flex items-center justify-between gap-3 px-2 py-1.5 rounded hover:bg-bg-2">
|
|
90
|
+
<span class="text-fg-1 text-[13px]">{s.label}</span>
|
|
91
|
+
<span class="inline-flex gap-1 shrink-0">
|
|
92
|
+
{#each s.keys as k}
|
|
93
|
+
<Kbd>{k}</Kbd>
|
|
94
|
+
{/each}
|
|
95
|
+
</span>
|
|
96
|
+
</li>
|
|
97
|
+
{/each}
|
|
98
|
+
</ul>
|
|
99
|
+
</section>
|
|
100
|
+
{/each}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
{/if}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte'
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
onDone?: () => void
|
|
6
|
+
}
|
|
7
|
+
let { onDone }: Props = $props()
|
|
8
|
+
|
|
9
|
+
let visible = $state(true)
|
|
10
|
+
let stage = $state(0) // 0: pre, 1: in, 2: out
|
|
11
|
+
|
|
12
|
+
onMount(() => {
|
|
13
|
+
const inT = setTimeout(() => (stage = 1), 20)
|
|
14
|
+
const outT = setTimeout(() => (stage = 2), 800)
|
|
15
|
+
const closeT = setTimeout(() => {
|
|
16
|
+
visible = false
|
|
17
|
+
onDone?.()
|
|
18
|
+
}, 1000)
|
|
19
|
+
return () => {
|
|
20
|
+
clearTimeout(inT)
|
|
21
|
+
clearTimeout(outT)
|
|
22
|
+
clearTimeout(closeT)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
{#if visible}
|
|
28
|
+
<div
|
|
29
|
+
class="fixed inset-0 z-[9999] grid place-items-center bg-bg-0 transition-opacity duration-200 ease-out"
|
|
30
|
+
class:opacity-0={stage === 2}
|
|
31
|
+
class:opacity-100={stage === 1}
|
|
32
|
+
class:pointer-events-none={stage === 2}
|
|
33
|
+
style:opacity={stage === 0 ? 0 : undefined}
|
|
34
|
+
>
|
|
35
|
+
<div class="flex items-center gap-2">
|
|
36
|
+
<div class="w-2 h-2 rounded-full bg-accent shadow-[0_0_12px_var(--color-accent-glow)]"></div>
|
|
37
|
+
<span class="font-mono font-semibold text-fg-0 text-[15px] tracking-tight">red<span class="text-accent mx-px">·</span>ui</span>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
{/if}
|
|
41
|
+
|
|
42
|
+
<style>
|
|
43
|
+
@media (prefers-reduced-motion: reduce) {
|
|
44
|
+
div { transition: none !important; }
|
|
45
|
+
}
|
|
46
|
+
</style>
|