@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,222 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte'
|
|
3
|
+
import { connection, makeCustomConnection } from './connections.svelte'
|
|
4
|
+
import { Zap, Loader2, AlertCircle, CheckCircle2, Clock, X, Plug, ChevronDown } from 'lucide-svelte'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_URL = 'red://localhost'
|
|
7
|
+
|
|
8
|
+
function pickDefaultUrl(): string {
|
|
9
|
+
if (connection.history[0]?.url) return connection.history[0].url
|
|
10
|
+
if (connection.history[0]?.label) return connection.history[0].label
|
|
11
|
+
if (connection.active.url) return connection.active.url
|
|
12
|
+
return DEFAULT_URL
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let open = $state(false)
|
|
16
|
+
let url = $state(pickDefaultUrl())
|
|
17
|
+
let status = $state<'idle' | 'probing' | 'ok' | 'error'>('idle')
|
|
18
|
+
let error = $state<string | null>(null)
|
|
19
|
+
let rtt = $state<number | null>(null)
|
|
20
|
+
let menuEl: HTMLDivElement | undefined = $state()
|
|
21
|
+
let inputEl: HTMLInputElement | undefined = $state()
|
|
22
|
+
|
|
23
|
+
async function attempt(targetUrl: string) {
|
|
24
|
+
const cleaned = targetUrl.trim().replace(/\/$/, '')
|
|
25
|
+
if (!cleaned) return
|
|
26
|
+
url = cleaned
|
|
27
|
+
// Transport reachability guard (#34): never attempt a transport this
|
|
28
|
+
// Surface can't materialize — fail with a clear message instead of an
|
|
29
|
+
// opaque transport-unsupported error from the client.
|
|
30
|
+
if (!connection.canReach(cleaned)) {
|
|
31
|
+
status = 'error'
|
|
32
|
+
error = `This app can't reach that transport here — supported: ${supportedHint}`
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
status = 'probing'
|
|
36
|
+
error = null
|
|
37
|
+
rtt = null
|
|
38
|
+
const ok = await connection.tryConnect(makeCustomConnection(cleaned))
|
|
39
|
+
if (ok) {
|
|
40
|
+
status = 'ok'
|
|
41
|
+
rtt = connection.probe.rtt_ms ?? null
|
|
42
|
+
setTimeout(() => (open = false), 350)
|
|
43
|
+
} else {
|
|
44
|
+
status = 'error'
|
|
45
|
+
error = connection.probe.error ?? 'unreachable'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function submit(e: Event) {
|
|
50
|
+
e.preventDefault()
|
|
51
|
+
attempt(url)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fmtRel(ms: number) {
|
|
55
|
+
const d = Date.now() - ms
|
|
56
|
+
if (d < 60_000) return 'just now'
|
|
57
|
+
if (d < 3_600_000) return `${Math.round(d / 60_000)}m ago`
|
|
58
|
+
if (d < 86_400_000) return `${Math.round(d / 3_600_000)}h ago`
|
|
59
|
+
return `${Math.round(d / 86_400_000)}d ago`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function forget(e: MouseEvent, id: string) {
|
|
63
|
+
e.stopPropagation()
|
|
64
|
+
connection.forgetById(id)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toggle() {
|
|
68
|
+
open = !open
|
|
69
|
+
if (open) setTimeout(() => inputEl?.focus(), 0)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onMount(() => {
|
|
73
|
+
const onOpen = () => {
|
|
74
|
+
open = true
|
|
75
|
+
setTimeout(() => inputEl?.focus(), 0)
|
|
76
|
+
}
|
|
77
|
+
window.addEventListener('red:open-connect', onOpen)
|
|
78
|
+
return () => window.removeEventListener('red:open-connect', onOpen)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const dotClass = $derived(
|
|
82
|
+
connection.connected ? 'bg-ok' : status === 'error' ? 'bg-danger' : 'bg-fg-3'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const triggerLabel = $derived(
|
|
86
|
+
connection.connected ? connection.active.label : 'Connect'
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const triggerStatus = $derived(
|
|
90
|
+
connection.connected && connection.probe.rtt_ms !== undefined
|
|
91
|
+
? `${connection.probe.rtt_ms}ms`
|
|
92
|
+
: connection.connected
|
|
93
|
+
? 'live'
|
|
94
|
+
: 'no connection'
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
// Transport reachability (#34): a human hint of the schemes this Surface can
|
|
98
|
+
// reach, and a history list filtered to reachable entries so unreachable
|
|
99
|
+
// transports are absent rather than shown-but-broken.
|
|
100
|
+
const supportedHint = $derived.by(() => {
|
|
101
|
+
const t = connection.supportedTransports
|
|
102
|
+
const schemes: string[] = []
|
|
103
|
+
// http(s):// is the only browser transport; red(s):// works everywhere as an
|
|
104
|
+
// http(s)://host:5055 alias (normalizeUrl) so it's not advertised separately.
|
|
105
|
+
if (t.includes('http') || t.includes('https')) schemes.push('http(s)://')
|
|
106
|
+
if (t.includes('unix')) schemes.push('file://')
|
|
107
|
+
return schemes.join(' · ')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const reachableHistory = $derived(
|
|
111
|
+
connection.history.filter((h) => !h.url || connection.canReach(h.url)),
|
|
112
|
+
)
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<div class="relative">
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onclick={toggle}
|
|
119
|
+
aria-haspopup="dialog"
|
|
120
|
+
aria-expanded={open}
|
|
121
|
+
title={connection.connected ? 'Switch connection' : 'Connect to reddb'}
|
|
122
|
+
class="inline-flex items-center gap-2 h-7 pl-2 pr-1.5 bg-bg-2 border border-line-2 rounded-md text-fg-1 cursor-pointer text-xs transition-colors hover:border-line-3 hover:text-fg-0"
|
|
123
|
+
>
|
|
124
|
+
<span class={['w-1.5 h-1.5 rounded-full shrink-0', dotClass].join(' ')}></span>
|
|
125
|
+
{#if !connection.connected}
|
|
126
|
+
<Plug class="size-3 text-fg-3" />
|
|
127
|
+
{/if}
|
|
128
|
+
<span class="font-mono">{triggerLabel}</span>
|
|
129
|
+
<span class="font-mono text-[10px] text-fg-3">{triggerStatus}</span>
|
|
130
|
+
<ChevronDown class="size-3 text-fg-3" />
|
|
131
|
+
</button>
|
|
132
|
+
|
|
133
|
+
{#if open}
|
|
134
|
+
<div class="fixed inset-0 z-40" onclick={() => (open = false)} role="presentation"></div>
|
|
135
|
+
<div
|
|
136
|
+
bind:this={menuEl}
|
|
137
|
+
class="absolute top-[calc(100%+6px)] left-0 z-50 w-[380px] bg-bg-1 border border-line-2 rounded-lg shadow-2xl p-4"
|
|
138
|
+
role="dialog"
|
|
139
|
+
aria-label="Connect to reddb"
|
|
140
|
+
>
|
|
141
|
+
<form onsubmit={submit}>
|
|
142
|
+
<label for="connect-url" class="type-label block mb-1.5">Instance URL</label>
|
|
143
|
+
<input
|
|
144
|
+
id="connect-url"
|
|
145
|
+
bind:this={inputEl}
|
|
146
|
+
bind:value={url}
|
|
147
|
+
spellcheck="false"
|
|
148
|
+
placeholder="red://localhost"
|
|
149
|
+
class="w-full h-9 px-3 mb-1 bg-bg-0 border border-line-2 rounded-md font-mono text-[13px] text-fg-0 outline-none transition-colors focus:border-accent placeholder:text-fg-3"
|
|
150
|
+
/>
|
|
151
|
+
|
|
152
|
+
<div class="mb-2 text-[10px] font-mono text-fg-3">reachable here: {supportedHint}</div>
|
|
153
|
+
|
|
154
|
+
<button
|
|
155
|
+
type="submit"
|
|
156
|
+
disabled={status === 'probing' || !url.trim()}
|
|
157
|
+
class="w-full h-9 bg-accent text-white font-medium rounded-md cursor-pointer transition-colors hover:bg-[#ff3868] disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-[13px]"
|
|
158
|
+
>
|
|
159
|
+
{#if status === 'probing'}
|
|
160
|
+
<Loader2 class="size-3.5 animate-spin" />
|
|
161
|
+
Connecting…
|
|
162
|
+
{:else}
|
|
163
|
+
<Zap class="size-3.5" />
|
|
164
|
+
Connect
|
|
165
|
+
{/if}
|
|
166
|
+
</button>
|
|
167
|
+
|
|
168
|
+
<div class="h-5 mt-2 flex items-center gap-1.5 text-[11px] font-mono">
|
|
169
|
+
{#if status === 'ok'}
|
|
170
|
+
<CheckCircle2 class="size-3 text-ok" />
|
|
171
|
+
<span class="text-ok">Connected{rtt !== null ? ` · ${rtt}ms` : ''}</span>
|
|
172
|
+
{:else if status === 'error'}
|
|
173
|
+
<AlertCircle class="size-3 text-danger" />
|
|
174
|
+
<span class="text-danger truncate">{error}</span>
|
|
175
|
+
{/if}
|
|
176
|
+
</div>
|
|
177
|
+
</form>
|
|
178
|
+
|
|
179
|
+
{#if reachableHistory.length > 0}
|
|
180
|
+
<div class="mt-3 pt-3 border-t border-line-1">
|
|
181
|
+
<div class="flex items-center gap-1.5 mb-2">
|
|
182
|
+
<Clock class="size-3 text-fg-3" />
|
|
183
|
+
<span class="type-label">Recent</span>
|
|
184
|
+
</div>
|
|
185
|
+
<div class="grid gap-0.5 max-h-[180px] overflow-y-auto">
|
|
186
|
+
{#each reachableHistory as h}
|
|
187
|
+
<div class="group flex items-center gap-1 rounded hover:bg-bg-2 transition-colors">
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onclick={() => {
|
|
191
|
+
if (h.url) attempt(h.url)
|
|
192
|
+
else {
|
|
193
|
+
url = h.label
|
|
194
|
+
setTimeout(() => inputEl?.focus(), 0)
|
|
195
|
+
}
|
|
196
|
+
}}
|
|
197
|
+
disabled={status === 'probing'}
|
|
198
|
+
title={h.url ? undefined : 'Enter credentials to reconnect'}
|
|
199
|
+
class="flex-1 flex items-center justify-between gap-3 px-2 py-1.5 text-left cursor-pointer bg-transparent border-0 disabled:cursor-not-allowed disabled:opacity-50 min-w-0"
|
|
200
|
+
>
|
|
201
|
+
<code class="font-mono text-[11px] text-fg-1 truncate">{h.url ?? h.label}</code>
|
|
202
|
+
<span class="font-mono text-[10px] text-fg-3 shrink-0 flex items-center gap-1.5">
|
|
203
|
+
{#if h.rtt_ms !== undefined}<span>{h.rtt_ms}ms</span>{/if}
|
|
204
|
+
<span>{fmtRel(h.last_used)}</span>
|
|
205
|
+
</span>
|
|
206
|
+
</button>
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onclick={(e) => forget(e, h.id)}
|
|
210
|
+
title="Remove from history"
|
|
211
|
+
class="opacity-0 group-hover:opacity-100 p-1 mr-0.5 text-fg-3 hover:text-danger transition-all cursor-pointer bg-transparent border-0"
|
|
212
|
+
>
|
|
213
|
+
<X class="size-3" />
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
{/each}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
{/if}
|
|
220
|
+
</div>
|
|
221
|
+
{/if}
|
|
222
|
+
</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
title: string
|
|
4
|
+
message?: string
|
|
5
|
+
/** lucide-svelte icon component */
|
|
6
|
+
icon?: any
|
|
7
|
+
hint?: string
|
|
8
|
+
actions?: import('svelte').Snippet
|
|
9
|
+
}
|
|
10
|
+
let { title, message, icon: Icon, hint, actions }: Props = $props()
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<div class="flex flex-col items-center justify-center text-center py-16 px-6 min-h-[280px] bg-bg-1 border border-line-1 border-dashed rounded-lg">
|
|
14
|
+
{#if Icon}
|
|
15
|
+
<Icon class="size-9 text-fg-3 mb-4" strokeWidth={1.4} />
|
|
16
|
+
{/if}
|
|
17
|
+
<h2 class="type-h2 m-0 mb-2">{title}</h2>
|
|
18
|
+
{#if message}
|
|
19
|
+
<p class="text-fg-2 text-[13px] m-0 max-w-md leading-relaxed">{message}</p>
|
|
20
|
+
{/if}
|
|
21
|
+
{#if hint}
|
|
22
|
+
<code class="font-mono text-[11px] text-fg-3 mt-3 px-2 py-1 bg-bg-2 border border-line-1 rounded">{hint}</code>
|
|
23
|
+
{/if}
|
|
24
|
+
{#if actions}
|
|
25
|
+
<div class="flex items-center gap-2 mt-5">{@render actions()}</div>
|
|
26
|
+
{/if}
|
|
27
|
+
</div>
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte'
|
|
3
|
+
import { CDCStreamClient, type ChangeEvent, type Subscription } from '#reddb'
|
|
4
|
+
import { connection } from '$lib/connections.svelte'
|
|
5
|
+
import { Pause, Play, Activity, Filter, X } from 'lucide-svelte'
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
collection?: string
|
|
9
|
+
}
|
|
10
|
+
let { collection }: Props = $props()
|
|
11
|
+
|
|
12
|
+
type Op = 'insert' | 'update' | 'delete'
|
|
13
|
+
const OPS: Op[] = ['insert', 'update', 'delete']
|
|
14
|
+
|
|
15
|
+
let events = $state<ChangeEvent[]>([])
|
|
16
|
+
let paused = $state(false)
|
|
17
|
+
let filter = $state<Record<Op, boolean>>({ insert: true, update: true, delete: true })
|
|
18
|
+
let error = $state<string | null>(null)
|
|
19
|
+
let subscription: Subscription | null = null
|
|
20
|
+
let reader: ReadableStreamDefaultReader<ChangeEvent> | null = null
|
|
21
|
+
const MAX = 500
|
|
22
|
+
|
|
23
|
+
function startStream() {
|
|
24
|
+
stopStream()
|
|
25
|
+
error = null
|
|
26
|
+
events = []
|
|
27
|
+
const client = connection.client
|
|
28
|
+
if (!client) {
|
|
29
|
+
error = 'No active connection.'
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
const cdc = new CDCStreamClient({
|
|
33
|
+
client,
|
|
34
|
+
// CDC is poll-only on reddb today: the server exposes `GET /changes`
|
|
35
|
+
// (handlers_backup.rs / routing.rs) and there is NO `/changes/stream` SSE
|
|
36
|
+
// route (verified against ../reddb 2026-06-01). Polling `GET /changes` is
|
|
37
|
+
// therefore the optimal — and only — live-changes transport for every
|
|
38
|
+
// Surface. The CDCStreamClient keeps full SSE plumbing dormant for a
|
|
39
|
+
// hypothetical future server push endpoint; do NOT enable it until reddb
|
|
40
|
+
// actually ships `/changes/stream`, or every subscription 404s. When it
|
|
41
|
+
// lands this becomes:
|
|
42
|
+
// sseFactory: (url) => new EventSource(url),
|
|
43
|
+
// sseProbe: async () => (await fetch(url, { method: 'HEAD' })).ok,
|
|
44
|
+
sseFactory: null,
|
|
45
|
+
})
|
|
46
|
+
subscription = cdc.subscribe({ collection })
|
|
47
|
+
reader = subscription.events.getReader()
|
|
48
|
+
void pump()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function pump() {
|
|
52
|
+
if (!reader) return
|
|
53
|
+
try {
|
|
54
|
+
while (true) {
|
|
55
|
+
const { value, done } = await reader.read()
|
|
56
|
+
if (done) return
|
|
57
|
+
if (paused) continue
|
|
58
|
+
if (!value) continue
|
|
59
|
+
events = [value, ...events].slice(0, MAX)
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
error = (e as Error).message
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stopStream() {
|
|
67
|
+
if (reader) {
|
|
68
|
+
try { reader.cancel() } catch {}
|
|
69
|
+
reader = null
|
|
70
|
+
}
|
|
71
|
+
if (subscription) {
|
|
72
|
+
subscription.close()
|
|
73
|
+
subscription = null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
$effect(() => {
|
|
78
|
+
// Restart whenever connection or collection changes.
|
|
79
|
+
void connection.connected
|
|
80
|
+
void collection
|
|
81
|
+
if (connection.connected) startStream()
|
|
82
|
+
else stopStream()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
onDestroy(stopStream)
|
|
86
|
+
|
|
87
|
+
function relative(ts: number): string {
|
|
88
|
+
const diff = Date.now() - ts
|
|
89
|
+
if (diff < 1000) return 'now'
|
|
90
|
+
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`
|
|
91
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
|
|
92
|
+
return `${Math.floor(diff / 3_600_000)}h ago`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function absolute(ts: number): string {
|
|
96
|
+
return new Date(ts).toISOString().replace('T', ' ').replace('Z', '')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const visible = $derived(events.filter((e) => filter[e.operation as Op] ?? true))
|
|
100
|
+
const scope = $derived(collection ? `collection: ${collection}` : 'global')
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<div class="h-full flex flex-col bg-bg-0">
|
|
104
|
+
<header class="px-3 py-2 border-b border-line-1 flex items-center gap-3 text-fg-3">
|
|
105
|
+
<Activity class="size-3.5" />
|
|
106
|
+
<span class="type-label">Live changes</span>
|
|
107
|
+
<span class="text-[11px] font-mono text-fg-3">· {scope}</span>
|
|
108
|
+
|
|
109
|
+
<div class="ml-auto flex items-center gap-1">
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
class="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-mono rounded border border-line-1 hover:bg-bg-2 text-fg-2"
|
|
113
|
+
onclick={() => (paused = !paused)}
|
|
114
|
+
title={paused ? 'Resume stream' : 'Pause stream'}
|
|
115
|
+
>
|
|
116
|
+
{#if paused}
|
|
117
|
+
<Play class="size-3" /> resume
|
|
118
|
+
{:else}
|
|
119
|
+
<Pause class="size-3" /> pause
|
|
120
|
+
{/if}
|
|
121
|
+
</button>
|
|
122
|
+
|
|
123
|
+
<div class="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-mono rounded border border-line-1 text-fg-3">
|
|
124
|
+
<Filter class="size-3" />
|
|
125
|
+
{#each OPS as op (op)}
|
|
126
|
+
<label class="inline-flex items-center gap-1 cursor-pointer">
|
|
127
|
+
<input
|
|
128
|
+
type="checkbox"
|
|
129
|
+
checked={filter[op]}
|
|
130
|
+
onchange={(ev) => (filter = { ...filter, [op]: (ev.currentTarget as HTMLInputElement).checked })}
|
|
131
|
+
class="accent-accent size-3"
|
|
132
|
+
/>
|
|
133
|
+
<span class={filter[op] ? 'text-fg-1' : 'text-fg-3'}>{op}</span>
|
|
134
|
+
</label>
|
|
135
|
+
{/each}
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
{#if events.length > 0}
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
class="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-mono rounded border border-line-1 hover:bg-bg-2 text-fg-3"
|
|
142
|
+
onclick={() => (events = [])}
|
|
143
|
+
title="Clear buffer"
|
|
144
|
+
>
|
|
145
|
+
<X class="size-3" /> clear
|
|
146
|
+
</button>
|
|
147
|
+
{/if}
|
|
148
|
+
</div>
|
|
149
|
+
</header>
|
|
150
|
+
|
|
151
|
+
<div class="flex-1 overflow-auto">
|
|
152
|
+
{#if error}
|
|
153
|
+
<div class="p-6 text-[12px] font-mono text-fg-3">
|
|
154
|
+
<span class="text-accent">error:</span> {error}
|
|
155
|
+
</div>
|
|
156
|
+
{:else if !connection.connected}
|
|
157
|
+
<div class="p-6 text-[12px] font-mono text-fg-3">
|
|
158
|
+
Not connected. Use the topbar Connect button to open a stream.
|
|
159
|
+
</div>
|
|
160
|
+
{:else if visible.length === 0}
|
|
161
|
+
<div class="p-6 text-[12px] font-mono text-fg-3">
|
|
162
|
+
{paused ? 'Paused — no events captured.' : 'Listening for changes…'}
|
|
163
|
+
</div>
|
|
164
|
+
{:else}
|
|
165
|
+
<ul class="divide-y divide-line-1 font-mono text-[12px]">
|
|
166
|
+
{#each visible as e (e.lsn)}
|
|
167
|
+
<li class="px-3 py-1.5 flex items-center gap-3 hover:bg-bg-1">
|
|
168
|
+
<span class="text-fg-3 w-16 shrink-0" title={absolute(e.timestamp)}>
|
|
169
|
+
{relative(e.timestamp)}
|
|
170
|
+
</span>
|
|
171
|
+
<span class="text-fg-3 w-16 shrink-0">lsn {e.lsn}</span>
|
|
172
|
+
<span
|
|
173
|
+
class={[
|
|
174
|
+
'inline-block w-14 text-center text-[10px] uppercase rounded px-1 py-0.5 shrink-0 border',
|
|
175
|
+
e.operation === 'insert' && 'border-ok/40 text-ok',
|
|
176
|
+
e.operation === 'update' && 'border-warn/40 text-warn',
|
|
177
|
+
e.operation === 'delete' && 'border-accent/40 text-accent',
|
|
178
|
+
]
|
|
179
|
+
.filter(Boolean)
|
|
180
|
+
.join(' ')}
|
|
181
|
+
>
|
|
182
|
+
{e.operation}
|
|
183
|
+
</span>
|
|
184
|
+
<span class="text-fg-2 truncate">{e.collection}</span>
|
|
185
|
+
<span class="text-fg-3">·</span>
|
|
186
|
+
<span class="text-fg-3 truncate">{e.kind}</span>
|
|
187
|
+
{#if e.rid !== undefined}
|
|
188
|
+
<span class="text-fg-3 ml-auto">rid {e.rid}</span>
|
|
189
|
+
{/if}
|
|
190
|
+
</li>
|
|
191
|
+
{/each}
|
|
192
|
+
</ul>
|
|
193
|
+
{/if}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Lock, Loader2, AlertCircle, KeyRound, Trash2 } from 'lucide-svelte'
|
|
3
|
+
import { secureStore } from './secureStore.svelte'
|
|
4
|
+
import { connection } from './connections.svelte'
|
|
5
|
+
|
|
6
|
+
let password = $state('')
|
|
7
|
+
let confirm = $state('')
|
|
8
|
+
let busy = $state(false)
|
|
9
|
+
let confirmingWipe = $state(false)
|
|
10
|
+
let inputEl: HTMLInputElement | undefined = $state()
|
|
11
|
+
|
|
12
|
+
function startWipe() {
|
|
13
|
+
secureStore.error = null
|
|
14
|
+
confirmingWipe = true
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function cancelWipe() {
|
|
18
|
+
confirmingWipe = false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function doWipe() {
|
|
22
|
+
secureStore.wipe()
|
|
23
|
+
password = ''
|
|
24
|
+
confirm = ''
|
|
25
|
+
confirmingWipe = false
|
|
26
|
+
setTimeout(() => inputEl?.focus(), 0)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const needsSetup = $derived(secureStore.needsSetup)
|
|
30
|
+
const error = $derived(secureStore.error)
|
|
31
|
+
const title = $derived(needsSetup ? 'Set a master password' : 'Unlock credentials')
|
|
32
|
+
const hint = $derived(
|
|
33
|
+
needsSetup
|
|
34
|
+
? 'Picks a key that encrypts your connection history at rest. The password lives in this browser session only — there is no recovery.'
|
|
35
|
+
: 'Enter the master password used to encrypt your saved connections.'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
$effect(() => {
|
|
39
|
+
if (secureStore.locked) setTimeout(() => inputEl?.focus(), 0)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
async function submit(e: Event) {
|
|
43
|
+
e.preventDefault()
|
|
44
|
+
if (busy) return
|
|
45
|
+
if (!password) return
|
|
46
|
+
if (needsSetup && password !== confirm) {
|
|
47
|
+
secureStore.error = "Passwords don't match"
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
busy = true
|
|
51
|
+
const ok = await secureStore.unlock(password)
|
|
52
|
+
busy = false
|
|
53
|
+
if (ok) {
|
|
54
|
+
password = ''
|
|
55
|
+
confirm = ''
|
|
56
|
+
// Belt + suspenders: the layout's reactive $effect already kicks
|
|
57
|
+
// connection.refresh() once locked flips, but firing it here too
|
|
58
|
+
// means the connect dot updates within the same microtask the
|
|
59
|
+
// dialog disappears — no "I unlocked but nothing happened" lag.
|
|
60
|
+
connection.refresh()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
{#if secureStore.backend === 'web' && secureStore.locked}
|
|
66
|
+
<div
|
|
67
|
+
class="fixed inset-0 z-[100] flex items-center justify-center bg-bg-0/70 backdrop-blur-sm motion-reduce:backdrop-blur-none"
|
|
68
|
+
role="dialog"
|
|
69
|
+
aria-modal="true"
|
|
70
|
+
aria-labelledby="mp-title"
|
|
71
|
+
>
|
|
72
|
+
<form
|
|
73
|
+
onsubmit={submit}
|
|
74
|
+
class="w-[380px] bg-bg-1 border border-line-2 rounded-lg shadow-2xl p-5"
|
|
75
|
+
>
|
|
76
|
+
<div class="flex items-center gap-2 mb-1.5 text-fg-0">
|
|
77
|
+
{#if needsSetup}
|
|
78
|
+
<KeyRound class="size-4 text-accent" />
|
|
79
|
+
{:else}
|
|
80
|
+
<Lock class="size-4 text-accent" />
|
|
81
|
+
{/if}
|
|
82
|
+
<h2 id="mp-title" class="type-h2 m-0">{title}</h2>
|
|
83
|
+
</div>
|
|
84
|
+
<p class="text-fg-3 text-[12px] leading-relaxed m-0 mb-3">{hint}</p>
|
|
85
|
+
|
|
86
|
+
<label for="mp-pwd" class="type-label block mb-1">Master password</label>
|
|
87
|
+
<input
|
|
88
|
+
id="mp-pwd"
|
|
89
|
+
bind:this={inputEl}
|
|
90
|
+
bind:value={password}
|
|
91
|
+
type="password"
|
|
92
|
+
autocomplete={needsSetup ? 'new-password' : 'current-password'}
|
|
93
|
+
spellcheck="false"
|
|
94
|
+
class="w-full h-9 px-3 mb-2 bg-bg-0 border border-line-2 rounded-md font-mono text-[13px] text-fg-0 outline-none transition-colors focus:border-accent"
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
{#if needsSetup}
|
|
98
|
+
<label for="mp-confirm" class="type-label block mb-1">Confirm</label>
|
|
99
|
+
<input
|
|
100
|
+
id="mp-confirm"
|
|
101
|
+
bind:value={confirm}
|
|
102
|
+
type="password"
|
|
103
|
+
autocomplete="new-password"
|
|
104
|
+
spellcheck="false"
|
|
105
|
+
class="w-full h-9 px-3 mb-2 bg-bg-0 border border-line-2 rounded-md font-mono text-[13px] text-fg-0 outline-none transition-colors focus:border-accent"
|
|
106
|
+
/>
|
|
107
|
+
{/if}
|
|
108
|
+
|
|
109
|
+
<button
|
|
110
|
+
type="submit"
|
|
111
|
+
disabled={busy || !password || (needsSetup && !confirm)}
|
|
112
|
+
class="w-full h-9 mt-1 bg-accent text-white font-medium rounded-md cursor-pointer transition-colors hover:bg-[#ff3868] disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-[13px]"
|
|
113
|
+
>
|
|
114
|
+
{#if busy}
|
|
115
|
+
<Loader2 class="size-3.5 animate-spin" />
|
|
116
|
+
{needsSetup ? 'Setting up…' : 'Unlocking…'}
|
|
117
|
+
{:else}
|
|
118
|
+
{needsSetup ? 'Set password' : 'Unlock'}
|
|
119
|
+
{/if}
|
|
120
|
+
</button>
|
|
121
|
+
|
|
122
|
+
<div class="h-5 mt-2 flex items-center gap-1.5 text-[11px] font-mono">
|
|
123
|
+
{#if error}
|
|
124
|
+
<AlertCircle class="size-3 text-danger" />
|
|
125
|
+
<span class="text-danger truncate">{error}</span>
|
|
126
|
+
{/if}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<!-- Start fresh: when the user can't unlock (forgot the password,
|
|
130
|
+
wedged state, etc.) the only path forward is to wipe the
|
|
131
|
+
encrypted store entirely. There's no recovery — the password
|
|
132
|
+
is the key. Two-step confirmation prevents accidental clicks. -->
|
|
133
|
+
<div class="mt-3 pt-3 border-t border-line-1 text-[11px] font-mono">
|
|
134
|
+
{#if !confirmingWipe}
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onclick={startWipe}
|
|
138
|
+
class="inline-flex items-center gap-1.5 text-fg-3 hover:text-danger transition-colors cursor-pointer bg-transparent border-0 p-0"
|
|
139
|
+
>
|
|
140
|
+
<Trash2 class="size-3" />
|
|
141
|
+
<span>Start fresh — wipe this browser</span>
|
|
142
|
+
</button>
|
|
143
|
+
{:else}
|
|
144
|
+
<div class="flex items-center justify-between gap-2">
|
|
145
|
+
<span class="text-danger">Erase the encrypted store and all saved connections?</span>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="flex items-center gap-2 mt-1.5">
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onclick={doWipe}
|
|
151
|
+
class="inline-flex items-center gap-1.5 px-2 py-1 rounded border border-danger/40 bg-danger/10 text-danger hover:bg-danger/20 cursor-pointer"
|
|
152
|
+
>
|
|
153
|
+
<Trash2 class="size-3" />
|
|
154
|
+
Yes, wipe
|
|
155
|
+
</button>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
onclick={cancelWipe}
|
|
159
|
+
class="px-2 py-1 rounded border border-line-2 text-fg-2 hover:text-fg-0 cursor-pointer"
|
|
160
|
+
>
|
|
161
|
+
Cancel
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
{/if}
|
|
165
|
+
</div>
|
|
166
|
+
</form>
|
|
167
|
+
</div>
|
|
168
|
+
{/if}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
title: string
|
|
4
|
+
/** mono uppercase eyebrow above the title — e.g. "Explore", "Admin" */
|
|
5
|
+
eyebrow?: string
|
|
6
|
+
/** subtle subtitle/description below — single line, fg-2 */
|
|
7
|
+
subtitle?: string
|
|
8
|
+
actions?: import('svelte').Snippet
|
|
9
|
+
children?: import('svelte').Snippet
|
|
10
|
+
}
|
|
11
|
+
let { title, eyebrow, subtitle, actions, children }: Props = $props()
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<header class="flex items-end justify-between gap-4 pb-4 mb-4 border-b border-line-1">
|
|
15
|
+
<div class="min-w-0">
|
|
16
|
+
{#if eyebrow}
|
|
17
|
+
<div class="type-label mb-1.5">{eyebrow}</div>
|
|
18
|
+
{/if}
|
|
19
|
+
<div class="flex items-baseline gap-3 flex-wrap">
|
|
20
|
+
<h1 class="type-h1 m-0">{title}</h1>
|
|
21
|
+
{@render children?.()}
|
|
22
|
+
</div>
|
|
23
|
+
{#if subtitle}
|
|
24
|
+
<p class="type-caption m-0 mt-1.5">{subtitle}</p>
|
|
25
|
+
{/if}
|
|
26
|
+
</div>
|
|
27
|
+
{#if actions}
|
|
28
|
+
<div class="flex items-center gap-2 shrink-0">
|
|
29
|
+
{@render actions()}
|
|
30
|
+
</div>
|
|
31
|
+
{/if}
|
|
32
|
+
</header>
|