@reddb-io/ui 0.3.0 → 0.3.2
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.BWZTMKZW.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/{C9rXzYLu.js → B-yl15ta.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/Bgb6fHGb.js +18 -0
- package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
- package/build/_app/immutable/chunks/C-kqMbRi.js +1 -0
- package/build/_app/immutable/chunks/C3SGj6Qd.js +2 -0
- package/build/_app/immutable/chunks/{C278awGv.js → C7kf23SQ.js} +1 -1
- package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
- package/build/_app/immutable/chunks/CHvQYbpl.js +1 -0
- package/build/_app/immutable/chunks/CO7K4bD2.js +1164 -0
- package/build/_app/immutable/chunks/CP65IkJc.js +1 -0
- package/build/_app/immutable/chunks/CxnvJShw.js +404 -0
- package/build/_app/immutable/chunks/D02PB2u7.js +1 -0
- package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
- package/build/_app/immutable/chunks/DN7v3Tar.js +2 -0
- package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
- package/build/_app/immutable/entry/app.BeX4Tmu5.js +2 -0
- package/build/_app/immutable/entry/start.D7y1MZex.js +1 -0
- package/build/_app/immutable/nodes/0.C0e5KQVb.js +2 -0
- package/build/_app/immutable/nodes/1.ceiVuZbB.js +1 -0
- package/build/_app/immutable/nodes/10.DeFqKRR2.js +96 -0
- package/build/_app/immutable/nodes/12.CzMvqoYS.js +3 -0
- package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/15.CRDBqbiH.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-SKyGxeAx.js +23337 -0
- package/dist/embed/AnalyticsView-SKyGxeAx.js.map +1 -0
- package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js +31960 -0
- package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js.map +1 -0
- package/dist/embed/event-BvRbkLjD.js +46 -0
- package/dist/embed/event-BvRbkLjD.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-BVxMkfHe.js +8 -0
- package/dist/embed/index-BVxMkfHe.js.map +1 -0
- package/dist/embed/index-BY6fc1oQ.js +856 -0
- package/dist/embed/index-BY6fc1oQ.js.map +1 -0
- package/dist/embed/index-client-BHUUkI8H.js +3304 -0
- package/dist/embed/index-client-BHUUkI8H.js.map +1 -0
- package/dist/embed/index.js +1 -1
- package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-BawyGZ3s.js} +2 -2
- package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-BawyGZ3s.js.map} +1 -1
- package/dist/embed/window-ZR8_mM8o.js +1889 -0
- package/dist/embed/window-ZR8_mM8o.js.map +1 -0
- package/package.json +7 -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/ConnectDropdown.svelte +60 -3
- package/src/lib/DevConsole.svelte +155 -0
- package/src/lib/ErrorBoundary.fixture.svelte +11 -0
- package/src/lib/ErrorBoundary.fixtures.svelte.ts +5 -0
- package/src/lib/ErrorBoundary.svelte +78 -0
- package/src/lib/ErrorBoundaryCrashChild.fixture.svelte +9 -0
- package/src/lib/GlobalProgressBar.svelte +9 -0
- package/src/lib/LoadingOverlay.svelte +109 -0
- package/src/lib/MetricChart.svelte +85 -0
- package/src/lib/QueryEditor.svelte +48 -7
- package/src/lib/ResultsPane.svelte +76 -8
- package/src/lib/SchemaTree.svelte +25 -1
- package/src/lib/SecurityView.svelte +176 -65
- package/src/lib/SettingsView.svelte +1111 -254
- package/src/lib/StatusBar.svelte +19 -1
- package/src/lib/Topbar.svelte +29 -1
- package/src/lib/Workspace.svelte +136 -5
- package/src/lib/activity.svelte.ts +44 -35
- package/src/lib/collection-preview.ts +14 -0
- package/src/lib/connections.svelte.ts +167 -4
- package/src/lib/embed/RedUiEmbedRoot.svelte +13 -8
- package/src/lib/embed/index.ts +34 -1
- package/src/lib/index.ts +10 -0
- package/src/lib/loading.svelte.ts +142 -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 +292 -73
- package/src/lib/reddb/cluster-status.ts +306 -0
- package/src/lib/reddb/desktop-error.ts +89 -0
- package/src/lib/reddb/dev-console.ts +186 -0
- package/src/lib/reddb/index.ts +5 -0
- package/src/lib/reddb/local-url-provider.ts +28 -1
- package/src/lib/reddb/redwire-ws-spike.ts +590 -0
- package/src/lib/reddb/tauri-encrypted-store.ts +4 -9
- package/src/lib/router.svelte.ts +12 -0
- package/src/lib/settings-authoring-client.ts +336 -0
- package/src/lib/settings-sections.ts +292 -196
- package/src/lib/tabs.svelte.ts +5 -0
- package/src/lib/workspace-session.ts +81 -0
- package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
- package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
- package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
- package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
- package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
- package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
- package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
- package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
- package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
- package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
- package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
- package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
- package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
- package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
- package/dist/embed/index-DGhgtnaK.js +0 -577
- package/dist/embed/index-DGhgtnaK.js.map +0 -1
- package/dist/embed/index-client-ItCEU64C.js +0 -3087
- package/dist/embed/index-client-ItCEU64C.js.map +0 -1
- /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
- /package/build/_app/immutable/nodes/{10.CAG5_cGP.js → 13.CAG5_cGP.js} +0 -0
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte'
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
connection,
|
|
5
|
+
makeCustomConnection,
|
|
6
|
+
openDatabaseFile,
|
|
7
|
+
unreachableConnectHint,
|
|
8
|
+
} from './connections.svelte'
|
|
9
|
+
import {
|
|
10
|
+
Zap,
|
|
11
|
+
Loader2,
|
|
12
|
+
AlertCircle,
|
|
13
|
+
CheckCircle2,
|
|
14
|
+
Clock,
|
|
15
|
+
X,
|
|
16
|
+
Plug,
|
|
17
|
+
ChevronDown,
|
|
18
|
+
FolderOpen,
|
|
19
|
+
} from 'lucide-svelte'
|
|
5
20
|
|
|
6
21
|
const DEFAULT_URL = 'red://localhost'
|
|
7
22
|
|
|
@@ -29,7 +44,11 @@
|
|
|
29
44
|
// opaque transport-unsupported error from the client.
|
|
30
45
|
if (!connection.canReach(cleaned)) {
|
|
31
46
|
status = 'error'
|
|
32
|
-
|
|
47
|
+
// A file:// target on the web gets an honest, actionable explanation
|
|
48
|
+
// (#123) instead of the generic transport-unsupported line.
|
|
49
|
+
error =
|
|
50
|
+
unreachableConnectHint(cleaned) ??
|
|
51
|
+
`This app can't reach that transport here — supported: ${supportedHint}`
|
|
33
52
|
return
|
|
34
53
|
}
|
|
35
54
|
status = 'probing'
|
|
@@ -51,6 +70,27 @@
|
|
|
51
70
|
attempt(url)
|
|
52
71
|
}
|
|
53
72
|
|
|
73
|
+
// Desktop-only "Open database file…" flow (#123): the native OS picker feeds
|
|
74
|
+
// the chosen .rdb into the existing file:// connect path. Hidden on the web
|
|
75
|
+
// Surface, where there is no picker (file:// is unreachable there).
|
|
76
|
+
async function openFile() {
|
|
77
|
+
status = 'probing'
|
|
78
|
+
error = null
|
|
79
|
+
rtt = null
|
|
80
|
+
const res = await openDatabaseFile()
|
|
81
|
+
if (res.ok) {
|
|
82
|
+
status = 'ok'
|
|
83
|
+
rtt = connection.probe.rtt_ms ?? null
|
|
84
|
+
url = connection.active.url
|
|
85
|
+
setTimeout(() => (open = false), 350)
|
|
86
|
+
} else if (res.cancelled) {
|
|
87
|
+
status = 'idle'
|
|
88
|
+
} else {
|
|
89
|
+
status = 'error'
|
|
90
|
+
error = res.reason ?? 'unreachable'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
54
94
|
function fmtRel(ms: number) {
|
|
55
95
|
const d = Date.now() - ms
|
|
56
96
|
if (d < 60_000) return 'just now'
|
|
@@ -110,6 +150,10 @@
|
|
|
110
150
|
const reachableHistory = $derived(
|
|
111
151
|
connection.history.filter((h) => !h.url || connection.canReach(h.url)),
|
|
112
152
|
)
|
|
153
|
+
|
|
154
|
+
// Native file open is only reachable on a Surface that can speak the unix /
|
|
155
|
+
// embedded-file transport (the Tauri desktop shell) (#123).
|
|
156
|
+
const canOpenFile = $derived(connection.supportedTransports.includes('unix'))
|
|
113
157
|
</script>
|
|
114
158
|
|
|
115
159
|
<div class="relative">
|
|
@@ -176,6 +220,19 @@
|
|
|
176
220
|
</div>
|
|
177
221
|
</form>
|
|
178
222
|
|
|
223
|
+
{#if canOpenFile}
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
onclick={openFile}
|
|
227
|
+
disabled={status === 'probing'}
|
|
228
|
+
title="Open a local .rdb database file"
|
|
229
|
+
class="mt-1 w-full h-9 bg-bg-2 border border-line-2 text-fg-1 font-medium rounded-md cursor-pointer transition-colors hover:border-line-3 hover:text-fg-0 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-[13px]"
|
|
230
|
+
>
|
|
231
|
+
<FolderOpen class="size-3.5 text-fg-3" />
|
|
232
|
+
Open database file…
|
|
233
|
+
</button>
|
|
234
|
+
{/if}
|
|
235
|
+
|
|
179
236
|
{#if reachableHistory.length > 0}
|
|
180
237
|
<div class="mt-3 pt-3 border-t border-line-1">
|
|
181
238
|
<div class="flex items-center gap-1.5 mb-2">
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Instrumented developer console panel (#128). A collapsible, bottom-docked
|
|
3
|
+
// log of every query and HTTP call the client made — verb, target, duration,
|
|
4
|
+
// row count — copyable and secret-free. It bridges the framework-agnostic
|
|
5
|
+
// `devConsole` store (deliberately rune-free so the data layer can log
|
|
6
|
+
// without importing Svelte) into reactivity by subscribing and mirroring the
|
|
7
|
+
// snapshot into `$state`.
|
|
8
|
+
//
|
|
9
|
+
// It never steals the accent: status is carried by ok/danger dots and the
|
|
10
|
+
// panel chrome uses the neutral surface ramp, so the one-accent-per-screen
|
|
11
|
+
// rule (design principle #6) holds even while the console is open.
|
|
12
|
+
import { devConsole as defaultStore, type ConsoleEntry } from '#reddb'
|
|
13
|
+
import { X, Copy, Trash2, Check } from 'lucide-svelte'
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
store = defaultStore,
|
|
17
|
+
open: openProp = false,
|
|
18
|
+
}: {
|
|
19
|
+
store?: typeof defaultStore
|
|
20
|
+
open?: boolean
|
|
21
|
+
} = $props()
|
|
22
|
+
|
|
23
|
+
// Seed the panel's open state from the prop once; thereafter it's toggled
|
|
24
|
+
// locally by the shell event and the close button.
|
|
25
|
+
// svelte-ignore state_referenced_locally
|
|
26
|
+
let open = $state(openProp)
|
|
27
|
+
let entries = $state<readonly ConsoleEntry[]>([])
|
|
28
|
+
let copiedId = $state<number | null>(null)
|
|
29
|
+
|
|
30
|
+
// Bridge the rune-free store into reactivity: subscribe once, mirror every
|
|
31
|
+
// snapshot into local `$state`. The subscribe fires immediately, so the
|
|
32
|
+
// panel paints the current log on mount.
|
|
33
|
+
$effect(() => store.subscribe((snap) => (entries = snap)))
|
|
34
|
+
|
|
35
|
+
// Toggle from anywhere in the shell (the StatusBar button dispatches this).
|
|
36
|
+
$effect(() => {
|
|
37
|
+
const toggle = () => (open = !open)
|
|
38
|
+
window.addEventListener('red:toggle-dev-console', toggle)
|
|
39
|
+
return () => window.removeEventListener('red:toggle-dev-console', toggle)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Newest first — an operator reads the most recent call at the top.
|
|
43
|
+
const ordered = $derived([...entries].reverse())
|
|
44
|
+
|
|
45
|
+
async function copy(text: string, id: number) {
|
|
46
|
+
try {
|
|
47
|
+
await navigator.clipboard?.writeText(text)
|
|
48
|
+
copiedId = id
|
|
49
|
+
setTimeout(() => {
|
|
50
|
+
if (copiedId === id) copiedId = null
|
|
51
|
+
}, 1200)
|
|
52
|
+
} catch {
|
|
53
|
+
// Clipboard denied (insecure context / permissions) — silently no-op.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function entryText(e: ConsoleEntry): string {
|
|
58
|
+
const head = `${e.verb} ${e.target} → ${e.ok ? (e.status ?? 'ok') : 'error'} · ${e.durationMs}ms${
|
|
59
|
+
e.rowCount === undefined ? '' : ` · ${e.rowCount} rows`
|
|
60
|
+
}`
|
|
61
|
+
const parts = [head]
|
|
62
|
+
if (e.payload) parts.push(e.payload)
|
|
63
|
+
if (e.error) parts.push(e.error)
|
|
64
|
+
return parts.join('\n')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fmtDuration(ms: number): string {
|
|
68
|
+
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
{#if open}
|
|
73
|
+
<div
|
|
74
|
+
class="fixed inset-x-0 bottom-0 z-[9990] flex max-h-[45vh] flex-col border-t border-line-2 bg-bg-1 shadow-2xl"
|
|
75
|
+
role="log"
|
|
76
|
+
aria-label="Developer console"
|
|
77
|
+
data-testid="dev-console"
|
|
78
|
+
>
|
|
79
|
+
<div class="flex items-center justify-between gap-3 border-b border-line-1 bg-bg-2/60 px-3 py-1.5">
|
|
80
|
+
<div class="flex items-center gap-2 font-mono text-[11px] text-fg-2">
|
|
81
|
+
<span class="uppercase tracking-wider text-fg-1">Console</span>
|
|
82
|
+
<span class="text-fg-3" data-testid="dev-console-count">{entries.length} calls</span>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="flex items-center gap-1">
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onclick={() => store.clear()}
|
|
88
|
+
title="Clear console"
|
|
89
|
+
aria-label="Clear console"
|
|
90
|
+
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono text-[11px] text-fg-3 hover:bg-bg-3/60 hover:text-fg-1 transition-colors"
|
|
91
|
+
>
|
|
92
|
+
<Trash2 class="size-3" />
|
|
93
|
+
Clear
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onclick={() => (open = false)}
|
|
98
|
+
title="Close console"
|
|
99
|
+
aria-label="Close console"
|
|
100
|
+
class="inline-flex items-center rounded p-0.5 text-fg-3 hover:bg-bg-3/60 hover:text-fg-1 transition-colors"
|
|
101
|
+
>
|
|
102
|
+
<X class="size-3.5" />
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div class="min-h-0 flex-1 overflow-y-auto">
|
|
108
|
+
{#if ordered.length === 0}
|
|
109
|
+
<div class="grid h-24 place-items-center font-mono text-[12px] text-fg-3">
|
|
110
|
+
No calls yet — the client hasn't asked the database anything.
|
|
111
|
+
</div>
|
|
112
|
+
{:else}
|
|
113
|
+
<ul class="divide-y divide-line-1/60">
|
|
114
|
+
{#each ordered as entry (entry.id)}
|
|
115
|
+
<li class="group flex items-start gap-2 px-3 py-1.5 font-mono text-[11px] hover:bg-bg-2/40" data-testid="dev-console-entry">
|
|
116
|
+
<span
|
|
117
|
+
class="mt-[5px] h-1.5 w-1.5 shrink-0 rounded-full"
|
|
118
|
+
class:bg-ok={entry.ok}
|
|
119
|
+
class:bg-danger={!entry.ok}
|
|
120
|
+
aria-hidden="true"
|
|
121
|
+
></span>
|
|
122
|
+
<span class="w-11 shrink-0 uppercase text-fg-2">{entry.verb}</span>
|
|
123
|
+
<span class="min-w-0 flex-1">
|
|
124
|
+
<span class="text-fg-1">{entry.target}</span>
|
|
125
|
+
{#if entry.payload}
|
|
126
|
+
<span class="ml-1 text-fg-3 break-all">{entry.payload.replace(/\s+/g, ' ').slice(0, 120)}</span>
|
|
127
|
+
{/if}
|
|
128
|
+
{#if entry.error}
|
|
129
|
+
<span class="ml-1 text-danger break-all">— {entry.error}</span>
|
|
130
|
+
{/if}
|
|
131
|
+
</span>
|
|
132
|
+
{#if entry.rowCount !== undefined}
|
|
133
|
+
<span class="shrink-0 tabular-nums text-fg-3" title="rows returned">{entry.rowCount}r</span>
|
|
134
|
+
{/if}
|
|
135
|
+
<span class="w-12 shrink-0 text-right tabular-nums text-fg-2" title="duration">{fmtDuration(entry.durationMs)}</span>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onclick={() => copy(entryText(entry), entry.id)}
|
|
139
|
+
title="Copy entry"
|
|
140
|
+
aria-label="Copy entry"
|
|
141
|
+
class="shrink-0 rounded p-0.5 text-fg-3 opacity-0 transition-opacity hover:bg-bg-3/60 hover:text-fg-1 group-hover:opacity-100 focus:opacity-100"
|
|
142
|
+
>
|
|
143
|
+
{#if copiedId === entry.id}
|
|
144
|
+
<Check class="size-3 text-ok" />
|
|
145
|
+
{:else}
|
|
146
|
+
<Copy class="size-3" />
|
|
147
|
+
{/if}
|
|
148
|
+
</button>
|
|
149
|
+
</li>
|
|
150
|
+
{/each}
|
|
151
|
+
</ul>
|
|
152
|
+
{/if}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
{/if}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Test-only harness: wraps the crash child in the real ErrorBoundary exactly
|
|
3
|
+
// as the Workspace root does, so the regression test exercises the shipped
|
|
4
|
+
// boundary rather than a stand-in.
|
|
5
|
+
import ErrorBoundary from './ErrorBoundary.svelte'
|
|
6
|
+
import CrashChild from './ErrorBoundaryCrashChild.fixture.svelte'
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<ErrorBoundary>
|
|
10
|
+
<CrashChild />
|
|
11
|
+
</ErrorBoundary>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Test-only reactive control for the ErrorBoundary regression test. Kept in a
|
|
2
|
+
// `.svelte.ts` module so the `$state` rune is compiled. The crash child reads
|
|
3
|
+
// `crashCtrl.crash` at (re)creation time, so flipping it before a boundary
|
|
4
|
+
// reset models a transient fault that has since cleared.
|
|
5
|
+
export const crashCtrl = $state({ crash: true, message: 'render-crash-test' })
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Black-screen shield (#126). A render crash inside the Workspace used to
|
|
3
|
+
// tear down the whole webview and leave a black window with no way back.
|
|
4
|
+
// This wraps its children in a Svelte error boundary whose `failed` state
|
|
5
|
+
// shows the message, the stack, and two recovery actions:
|
|
6
|
+
// - Retry render: re-runs the boundary's children (`reset`). Recovers when
|
|
7
|
+
// the fault was transient — the offending state has since changed.
|
|
8
|
+
// - Reload window: hard-reloads the document. Always works, even when the
|
|
9
|
+
// fault reproduces on every render.
|
|
10
|
+
// Styling stays on the dark tokens and keeps a single accent (the primary
|
|
11
|
+
// Retry action), per the one-accent discipline.
|
|
12
|
+
import type { Snippet } from 'svelte'
|
|
13
|
+
import { RotateCw, RefreshCw } from 'lucide-svelte'
|
|
14
|
+
|
|
15
|
+
let { children }: { children: Snippet } = $props()
|
|
16
|
+
|
|
17
|
+
function reloadWindow() {
|
|
18
|
+
if (typeof window !== 'undefined') window.location.reload()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function messageOf(error: unknown): string {
|
|
22
|
+
if (error instanceof Error) return error.message || error.name
|
|
23
|
+
return String(error)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stackOf(error: unknown): string | null {
|
|
27
|
+
return error instanceof Error && error.stack ? error.stack : null
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<svelte:boundary>
|
|
32
|
+
{@render children()}
|
|
33
|
+
|
|
34
|
+
{#snippet failed(error, reset)}
|
|
35
|
+
<div
|
|
36
|
+
role="alert"
|
|
37
|
+
class="flex h-full w-full flex-col items-center justify-center gap-5 overflow-auto bg-bg-0 px-6 py-10 text-center"
|
|
38
|
+
>
|
|
39
|
+
<div class="flex flex-col items-center gap-2">
|
|
40
|
+
<RotateCw class="size-8 text-fg-3" strokeWidth={1.4} />
|
|
41
|
+
<h1 class="type-h2 m-0 text-fg-0">Something crashed while rendering</h1>
|
|
42
|
+
<p class="m-0 max-w-lg text-[13px] leading-relaxed text-fg-2">
|
|
43
|
+
The workspace hit an unexpected error. Your connection is untouched —
|
|
44
|
+
retry the render, or reload the window to start clean.
|
|
45
|
+
</p>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<p class="m-0 max-w-lg break-words font-mono text-[12px] text-accent">
|
|
49
|
+
{messageOf(error)}
|
|
50
|
+
</p>
|
|
51
|
+
|
|
52
|
+
{#if stackOf(error)}
|
|
53
|
+
<pre
|
|
54
|
+
class="m-0 max-h-52 w-full max-w-2xl overflow-auto rounded-lg border border-line-1 bg-bg-1 p-3 text-left font-mono text-[11px] leading-relaxed text-fg-3"
|
|
55
|
+
>{stackOf(error)}</pre>
|
|
56
|
+
{/if}
|
|
57
|
+
|
|
58
|
+
<div class="flex items-center gap-2">
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
onclick={reset}
|
|
62
|
+
class="inline-flex h-9 items-center gap-1.5 rounded-lg bg-accent px-3 text-[12px] font-medium text-white hover:bg-accent/90"
|
|
63
|
+
>
|
|
64
|
+
<RotateCw class="size-3.5" strokeWidth={2} />
|
|
65
|
+
Retry render
|
|
66
|
+
</button>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onclick={reloadWindow}
|
|
70
|
+
class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-line-1 bg-bg-1 px-3 text-[12px] font-medium text-fg-1 hover:bg-bg-2"
|
|
71
|
+
>
|
|
72
|
+
<RefreshCw class="size-3.5" strokeWidth={2} />
|
|
73
|
+
Reload window
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
{/snippet}
|
|
78
|
+
</svelte:boundary>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Test-only child that throws during render when `crashCtrl.crash` is set,
|
|
3
|
+
// reproducing a component regression that would otherwise black out the
|
|
4
|
+
// webview. On a boundary reset it is recreated and re-reads the flag.
|
|
5
|
+
import { crashCtrl } from './ErrorBoundary.fixtures.svelte'
|
|
6
|
+
if (crashCtrl.crash) throw new Error(crashCtrl.message)
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<div data-testid="child-alive">child rendered</div>
|
|
@@ -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,109 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Step-logged loading overlay (#127). While a connection open is in flight it
|
|
3
|
+
// shows an elapsed badge and a scrolling log of the real steps the connect
|
|
4
|
+
// sequence appended (spawn, readiness, handshake, initial stats/schema). A
|
|
5
|
+
// stalled step reads as stalled — the last (active) step stays on screen and
|
|
6
|
+
// its elapsed keeps counting. Live-or-empty: it renders only real steps from
|
|
7
|
+
// the `loading` store, never fabricated progress.
|
|
8
|
+
//
|
|
9
|
+
// Flash guard: fast opens never paint the overlay. We only reveal after
|
|
10
|
+
// `revealAfterMs` of continuous loading; an open that finishes under the
|
|
11
|
+
// threshold clears the store before the reveal timer fires, so nothing shows.
|
|
12
|
+
import { loading as defaultStore, formatElapsed, type LoadingStep } from './loading.svelte'
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
store = defaultStore,
|
|
16
|
+
revealAfterMs = 400,
|
|
17
|
+
}: {
|
|
18
|
+
store?: typeof defaultStore
|
|
19
|
+
revealAfterMs?: number
|
|
20
|
+
} = $props()
|
|
21
|
+
|
|
22
|
+
let revealed = $state(false)
|
|
23
|
+
let now = $state(0)
|
|
24
|
+
|
|
25
|
+
// Drive the reveal threshold and the ticking elapsed badge off the store's
|
|
26
|
+
// active flag. Both timers are torn down the instant the open finishes, so a
|
|
27
|
+
// sub-threshold open never flips `revealed` true.
|
|
28
|
+
$effect(() => {
|
|
29
|
+
if (!store.active) {
|
|
30
|
+
revealed = false
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
now = Date.now()
|
|
34
|
+
const reveal = setTimeout(() => (revealed = true), revealAfterMs)
|
|
35
|
+
const tick = setInterval(() => (now = Date.now()), 100)
|
|
36
|
+
return () => {
|
|
37
|
+
clearTimeout(reveal)
|
|
38
|
+
clearInterval(tick)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const elapsed = $derived(store.active ? store.elapsedMs(now) : 0)
|
|
43
|
+
|
|
44
|
+
function stepElapsed(step: LoadingStep): number {
|
|
45
|
+
if (step.durationMs !== null) return step.durationMs
|
|
46
|
+
return Math.max(0, now - step.startedAt)
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
{#if store.active && revealed}
|
|
51
|
+
<div
|
|
52
|
+
class="fixed inset-0 z-[9998] grid place-items-center bg-bg-0/85 backdrop-blur-sm"
|
|
53
|
+
role="status"
|
|
54
|
+
aria-live="polite"
|
|
55
|
+
aria-busy="true"
|
|
56
|
+
data-testid="loading-overlay"
|
|
57
|
+
>
|
|
58
|
+
<div class="w-[min(460px,90vw)] rounded-lg border border-line-2 bg-bg-1 p-5 shadow-2xl">
|
|
59
|
+
<div class="flex items-center justify-between gap-3">
|
|
60
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
61
|
+
<span
|
|
62
|
+
class="h-2 w-2 shrink-0 animate-pulse rounded-full bg-accent shadow-[0_0_12px_var(--color-accent-glow)] motion-reduce:animate-none"
|
|
63
|
+
></span>
|
|
64
|
+
<span class="truncate font-mono text-[13px] font-semibold text-fg-0">
|
|
65
|
+
{store.label ?? 'Opening connection'}
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
<span
|
|
69
|
+
class="shrink-0 rounded bg-bg-2 px-1.5 py-0.5 font-mono text-[12px] tabular-nums text-fg-2"
|
|
70
|
+
data-testid="loading-elapsed"
|
|
71
|
+
>
|
|
72
|
+
{formatElapsed(elapsed)}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<ol class="mt-4 max-h-[220px] space-y-1.5 overflow-y-auto" data-testid="loading-steps">
|
|
77
|
+
{#each store.steps as step (step.id)}
|
|
78
|
+
<li class="flex items-start gap-2 font-mono text-[12px]" data-testid="loading-step">
|
|
79
|
+
<span
|
|
80
|
+
class="mt-[3px] h-1.5 w-1.5 shrink-0 rounded-full"
|
|
81
|
+
class:bg-accent={step.status === 'active'}
|
|
82
|
+
class:animate-pulse={step.status === 'active'}
|
|
83
|
+
class:motion-reduce:animate-none={step.status === 'active'}
|
|
84
|
+
class:bg-ok={step.status === 'done'}
|
|
85
|
+
class:bg-danger={step.status === 'error'}
|
|
86
|
+
aria-hidden="true"
|
|
87
|
+
></span>
|
|
88
|
+
<span class="min-w-0 flex-1">
|
|
89
|
+
<span
|
|
90
|
+
class="text-fg-1"
|
|
91
|
+
class:text-fg-0={step.status === 'active'}
|
|
92
|
+
class:text-danger={step.status === 'error'}
|
|
93
|
+
>{step.label}</span>
|
|
94
|
+
{#if step.detail}
|
|
95
|
+
<span class="ml-1 text-fg-3">{step.detail}</span>
|
|
96
|
+
{/if}
|
|
97
|
+
{#if step.error}
|
|
98
|
+
<span class="ml-1 text-danger">— {step.error}</span>
|
|
99
|
+
{/if}
|
|
100
|
+
</span>
|
|
101
|
+
<span class="shrink-0 tabular-nums text-fg-3" data-testid="loading-step-elapsed">
|
|
102
|
+
{formatElapsed(stepElapsed(step))}
|
|
103
|
+
</span>
|
|
104
|
+
</li>
|
|
105
|
+
{/each}
|
|
106
|
+
</ol>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
{/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.
|