@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,163 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useRouter } from './router.svelte'
|
|
3
|
+
import { connection } from './connections.svelte'
|
|
4
|
+
import { pendingChanges } from './pending-changes.svelte'
|
|
5
|
+
import { ArrowRight, FilePenLine, Lock } from 'lucide-svelte'
|
|
6
|
+
import { readBuildInfo } from './build-info'
|
|
7
|
+
|
|
8
|
+
const build = readBuildInfo()
|
|
9
|
+
|
|
10
|
+
const router = useRouter()
|
|
11
|
+
const pendingCount = $derived(pendingChanges.count)
|
|
12
|
+
|
|
13
|
+
// Read-only badge (#23) — driven solely by the server's reported status.
|
|
14
|
+
const readOnly = $derived(connection.readOnly)
|
|
15
|
+
const readOnlyReason = $derived(connection.readOnlyReason)
|
|
16
|
+
|
|
17
|
+
function openPending() {
|
|
18
|
+
window.dispatchEvent(new CustomEvent('red:open-pending-changes'))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const connected = $derived(connection.connected)
|
|
22
|
+
const role = $derived<'primary' | 'replica' | 'standalone' | 'embedded'>(
|
|
23
|
+
(connection.probe.replication?.role as 'primary' | 'replica' | 'standalone' | undefined)
|
|
24
|
+
?? (connection.active?.role as 'primary' | 'replica' | 'embedded' | undefined)
|
|
25
|
+
?? 'standalone'
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const roleDotColor = $derived(
|
|
29
|
+
role === 'primary' ? 'var(--color-role-primary)' :
|
|
30
|
+
role === 'replica' ? 'var(--color-role-replica)' :
|
|
31
|
+
role === 'embedded' ? 'var(--color-role-embedded)' :
|
|
32
|
+
'var(--color-fg-3)'
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const wal = $derived(
|
|
36
|
+
connection.probe.replication?.wal_lsn
|
|
37
|
+
?? connection.probe.replication?.last_applied_lsn
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const lag = $derived.by(() => {
|
|
41
|
+
const r = connection.probe.replication
|
|
42
|
+
if (!r || role !== 'replica') return undefined
|
|
43
|
+
if (r.last_seen_primary_lsn === undefined || r.last_applied_lsn === undefined) return undefined
|
|
44
|
+
return Math.max(0, r.last_seen_primary_lsn - r.last_applied_lsn)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
function openConnect() {
|
|
48
|
+
window.dispatchEvent(new CustomEvent('red:open-connect'))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function openCluster(e: MouseEvent) {
|
|
52
|
+
const target = e.target as HTMLElement | null
|
|
53
|
+
if (target?.closest('button, a, [data-no-nav]')) return
|
|
54
|
+
router.go({ view: 'cluster' })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function onKey(e: KeyboardEvent) {
|
|
58
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
router.go({ view: 'cluster' })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<div
|
|
66
|
+
class="h-8 flex items-stretch border-b border-line-1 bg-bg-1/60 text-[11px] font-mono select-none"
|
|
67
|
+
role="status"
|
|
68
|
+
aria-label="Connection status"
|
|
69
|
+
>
|
|
70
|
+
<div
|
|
71
|
+
role="button"
|
|
72
|
+
tabindex="0"
|
|
73
|
+
onclick={openCluster}
|
|
74
|
+
onkeydown={onKey}
|
|
75
|
+
title="Open cluster overview"
|
|
76
|
+
class="flex-1 flex items-center gap-3 px-3 cursor-pointer hover:bg-bg-2/40 transition-colors min-w-0"
|
|
77
|
+
>
|
|
78
|
+
{#if connected}
|
|
79
|
+
<span class="inline-flex items-center gap-1.5 shrink-0">
|
|
80
|
+
<span
|
|
81
|
+
class="w-1.5 h-1.5 rounded-full"
|
|
82
|
+
style:background-color={roleDotColor}
|
|
83
|
+
></span>
|
|
84
|
+
<span class="text-fg-1 uppercase tracking-wider text-[10px]">{role}</span>
|
|
85
|
+
</span>
|
|
86
|
+
|
|
87
|
+
<span class="text-fg-3">·</span>
|
|
88
|
+
<span class="text-fg-1 truncate">{connection.active.label}</span>
|
|
89
|
+
|
|
90
|
+
{#if connection.probe.rtt_ms !== undefined}
|
|
91
|
+
<span class="text-fg-3">·</span>
|
|
92
|
+
<span class="text-fg-2">
|
|
93
|
+
<span class="text-fg-3">rtt</span>
|
|
94
|
+
<span class="text-fg-1 tabular-nums">{connection.probe.rtt_ms}</span><span class="text-fg-3">ms</span>
|
|
95
|
+
</span>
|
|
96
|
+
{/if}
|
|
97
|
+
|
|
98
|
+
{#if role === 'replica' && lag !== undefined}
|
|
99
|
+
<span class="text-fg-3">·</span>
|
|
100
|
+
<span class="text-fg-2">
|
|
101
|
+
<span class="text-fg-3">lag</span>
|
|
102
|
+
<span class:text-warn={(lag ?? 0) > 0} class="text-fg-1 tabular-nums">{lag}</span>
|
|
103
|
+
</span>
|
|
104
|
+
{/if}
|
|
105
|
+
|
|
106
|
+
{#if wal !== undefined}
|
|
107
|
+
<span class="text-fg-3">·</span>
|
|
108
|
+
<span class="text-fg-2">
|
|
109
|
+
<span class="text-fg-3">lsn</span>
|
|
110
|
+
<span class="text-fg-1 tabular-nums">{wal}</span>
|
|
111
|
+
</span>
|
|
112
|
+
{/if}
|
|
113
|
+
|
|
114
|
+
{#if readOnly}
|
|
115
|
+
<span class="text-fg-3">·</span>
|
|
116
|
+
<span
|
|
117
|
+
class="inline-flex items-center gap-1 shrink-0 text-warn"
|
|
118
|
+
title={readOnlyReason ?? 'The server is open read-only — mutations are disabled.'}
|
|
119
|
+
>
|
|
120
|
+
<Lock class="size-3" />
|
|
121
|
+
<span class="uppercase tracking-wider text-[10px]">read-only — file in use</span>
|
|
122
|
+
</span>
|
|
123
|
+
{/if}
|
|
124
|
+
{:else}
|
|
125
|
+
<span class="inline-flex items-center gap-1.5 shrink-0">
|
|
126
|
+
<span class="w-1.5 h-1.5 rounded-full bg-fg-3"></span>
|
|
127
|
+
<span class="text-fg-3">Not connected</span>
|
|
128
|
+
</span>
|
|
129
|
+
{/if}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{#if pendingCount > 0}
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
onclick={openPending}
|
|
136
|
+
data-no-nav
|
|
137
|
+
aria-label={`${pendingCount} pending changes`}
|
|
138
|
+
class="inline-flex items-center gap-1.5 px-3 text-accent hover:bg-bg-2/60 transition-colors cursor-pointer bg-transparent border-0 border-l border-line-1"
|
|
139
|
+
>
|
|
140
|
+
<FilePenLine class="size-3" />
|
|
141
|
+
<span class="tabular-nums">Pending changes ({pendingCount})</span>
|
|
142
|
+
</button>
|
|
143
|
+
{/if}
|
|
144
|
+
|
|
145
|
+
{#if !connected}
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onclick={openConnect}
|
|
149
|
+
data-no-nav
|
|
150
|
+
class="inline-flex items-center gap-1 px-3 text-fg-2 hover:text-fg-0 hover:bg-bg-2/60 transition-colors cursor-pointer bg-transparent border-0 border-l border-line-1"
|
|
151
|
+
>
|
|
152
|
+
<span>Connect</span>
|
|
153
|
+
<ArrowRight class="size-3" />
|
|
154
|
+
</button>
|
|
155
|
+
{/if}
|
|
156
|
+
|
|
157
|
+
<span
|
|
158
|
+
class="inline-flex items-center px-3 shrink-0 font-mono text-fg-3 border-l border-line-1 select-none"
|
|
159
|
+
title={`red-ui ${build.version} · ${build.gitSha} · built ${build.buildTime}`}
|
|
160
|
+
>
|
|
161
|
+
v{build.version}
|
|
162
|
+
</span>
|
|
163
|
+
</div>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useRouter } from './router.svelte'
|
|
3
|
+
|
|
4
|
+
interface Item { href: string; label: string; hint?: string }
|
|
5
|
+
interface Props { items: Item[]; title?: string }
|
|
6
|
+
let { items, title }: Props = $props()
|
|
7
|
+
|
|
8
|
+
const router = useRouter()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<nav class="subnav">
|
|
12
|
+
{#if title}<span class="title">{title}</span>{/if}
|
|
13
|
+
{#each items as it}
|
|
14
|
+
<a href={it.href} class:active={router.path === it.href || router.path.startsWith(it.href + '/')}>
|
|
15
|
+
{it.label}
|
|
16
|
+
{#if it.hint}<span class="hint">{it.hint}</span>{/if}
|
|
17
|
+
</a>
|
|
18
|
+
{/each}
|
|
19
|
+
</nav>
|
|
20
|
+
|
|
21
|
+
<style>
|
|
22
|
+
.subnav {
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: 2px;
|
|
26
|
+
padding: 8px 14px;
|
|
27
|
+
background: var(--bg-1);
|
|
28
|
+
border-bottom: 1px solid var(--line-1);
|
|
29
|
+
overflow-x: auto;
|
|
30
|
+
}
|
|
31
|
+
.title {
|
|
32
|
+
font-family: var(--font-mono);
|
|
33
|
+
font-size: 10px;
|
|
34
|
+
text-transform: uppercase;
|
|
35
|
+
letter-spacing: 0.08em;
|
|
36
|
+
color: var(--fg-3);
|
|
37
|
+
margin-right: 12px;
|
|
38
|
+
}
|
|
39
|
+
a {
|
|
40
|
+
display: inline-flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
gap: 6px;
|
|
43
|
+
padding: 4px 10px;
|
|
44
|
+
font-size: 12px;
|
|
45
|
+
color: var(--fg-2);
|
|
46
|
+
text-decoration: none;
|
|
47
|
+
border-radius: var(--r-md);
|
|
48
|
+
white-space: nowrap;
|
|
49
|
+
transition: background 120ms var(--ease-out), color 120ms var(--ease-out);
|
|
50
|
+
}
|
|
51
|
+
a:hover { background: var(--bg-2); color: var(--fg-1); }
|
|
52
|
+
a.active { background: var(--bg-2); color: var(--fg-0); }
|
|
53
|
+
a.active::after {
|
|
54
|
+
content: '';
|
|
55
|
+
position: relative;
|
|
56
|
+
width: 4px; height: 4px;
|
|
57
|
+
border-radius: 9999px;
|
|
58
|
+
background: var(--accent);
|
|
59
|
+
margin-left: 2px;
|
|
60
|
+
}
|
|
61
|
+
.hint { color: var(--fg-3); font-family: var(--font-mono); font-size: 10px; }
|
|
62
|
+
</style>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Kbd } from '@reddb-io/ui-kit'
|
|
3
|
+
import ConnectDropdown from './ConnectDropdown.svelte'
|
|
4
|
+
import ActivityIndicator from './ActivityIndicator.svelte'
|
|
5
|
+
import { Search, Sun, Moon, Network, Database, FileCode2, Shield } from 'lucide-svelte'
|
|
6
|
+
import { theme } from './theme.svelte'
|
|
7
|
+
import { useRouter } from './router.svelte'
|
|
8
|
+
|
|
9
|
+
let { showConnect = true }: { showConnect?: boolean } = $props()
|
|
10
|
+
|
|
11
|
+
const router = useRouter()
|
|
12
|
+
|
|
13
|
+
function openPalette() {
|
|
14
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, ctrlKey: true, bubbles: true }))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const onQuery = $derived(router.view === 'query')
|
|
18
|
+
const onCollections = $derived(router.view === 'collections')
|
|
19
|
+
const onCluster = $derived(router.view === 'cluster')
|
|
20
|
+
const onSecurity = $derived(router.view === 'security')
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<header class="h-11 flex items-center justify-between px-3.5 bg-bg-1/80 backdrop-blur-md border-b border-line-1 relative z-10">
|
|
24
|
+
<div class="flex items-center gap-2.5">
|
|
25
|
+
<a
|
|
26
|
+
href={router.href({ view: 'collections' })}
|
|
27
|
+
onclick={(e) => router.go({ view: 'collections' }, e)}
|
|
28
|
+
class="font-mono font-semibold text-[13px] tracking-tight text-fg-0 no-underline hover:text-accent transition-colors"
|
|
29
|
+
>
|
|
30
|
+
red<span class="text-accent mx-px">·</span>ui
|
|
31
|
+
</a>
|
|
32
|
+
{#if showConnect}
|
|
33
|
+
<div class="w-px h-[18px] bg-line-2"></div>
|
|
34
|
+
<ConnectDropdown />
|
|
35
|
+
<div class="w-px h-[18px] bg-line-2 ml-1"></div>
|
|
36
|
+
{:else}
|
|
37
|
+
<div class="w-px h-[18px] bg-line-2"></div>
|
|
38
|
+
{/if}
|
|
39
|
+
<nav class="flex items-center gap-0.5">
|
|
40
|
+
<a
|
|
41
|
+
href={router.href({ view: 'query' })}
|
|
42
|
+
onclick={(e) => router.go({ view: 'query' }, e)}
|
|
43
|
+
title="Query"
|
|
44
|
+
aria-current={onQuery ? 'page' : undefined}
|
|
45
|
+
class={[
|
|
46
|
+
'inline-flex items-center gap-1.5 h-7 px-2 rounded-md text-xs no-underline transition-colors',
|
|
47
|
+
onQuery ? 'bg-bg-2 text-fg-0' : 'text-fg-2 hover:text-fg-0 hover:bg-bg-2',
|
|
48
|
+
].join(' ')}
|
|
49
|
+
>
|
|
50
|
+
<FileCode2 class="size-3.5" />
|
|
51
|
+
<span>Query</span>
|
|
52
|
+
</a>
|
|
53
|
+
<a
|
|
54
|
+
href={router.href({ view: 'collections' })}
|
|
55
|
+
onclick={(e) => router.go({ view: 'collections' }, e)}
|
|
56
|
+
title="Collections"
|
|
57
|
+
aria-current={onCollections ? 'page' : undefined}
|
|
58
|
+
class={[
|
|
59
|
+
'inline-flex items-center gap-1.5 h-7 px-2 rounded-md text-xs no-underline transition-colors',
|
|
60
|
+
onCollections ? 'bg-bg-2 text-fg-0' : 'text-fg-2 hover:text-fg-0 hover:bg-bg-2',
|
|
61
|
+
].join(' ')}
|
|
62
|
+
>
|
|
63
|
+
<Database class="size-3.5" />
|
|
64
|
+
<span>Collections</span>
|
|
65
|
+
</a>
|
|
66
|
+
<a
|
|
67
|
+
href={router.href({ view: 'cluster' })}
|
|
68
|
+
onclick={(e) => router.go({ view: 'cluster' }, e)}
|
|
69
|
+
title="Cluster topology"
|
|
70
|
+
aria-current={onCluster ? 'page' : undefined}
|
|
71
|
+
class={[
|
|
72
|
+
'inline-flex items-center gap-1.5 h-7 px-2 rounded-md text-xs no-underline transition-colors',
|
|
73
|
+
onCluster ? 'bg-bg-2 text-fg-0' : 'text-fg-2 hover:text-fg-0 hover:bg-bg-2',
|
|
74
|
+
].join(' ')}
|
|
75
|
+
>
|
|
76
|
+
<Network class="size-3.5" />
|
|
77
|
+
<span>Cluster</span>
|
|
78
|
+
</a>
|
|
79
|
+
<a
|
|
80
|
+
href={router.href({ view: 'security' })}
|
|
81
|
+
onclick={(e) => router.go({ view: 'security' }, e)}
|
|
82
|
+
title="Security"
|
|
83
|
+
aria-current={onSecurity ? 'page' : undefined}
|
|
84
|
+
class={[
|
|
85
|
+
'inline-flex items-center gap-1.5 h-7 px-2 rounded-md text-xs no-underline transition-colors',
|
|
86
|
+
onSecurity ? 'bg-bg-2 text-fg-0' : 'text-fg-2 hover:text-fg-0 hover:bg-bg-2',
|
|
87
|
+
].join(' ')}
|
|
88
|
+
>
|
|
89
|
+
<Shield class="size-3.5" />
|
|
90
|
+
<span>Security</span>
|
|
91
|
+
</a>
|
|
92
|
+
</nav>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="flex items-center gap-2.5">
|
|
96
|
+
<ActivityIndicator />
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onclick={() => theme.toggle()}
|
|
100
|
+
title={theme.current === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
|
101
|
+
aria-label={theme.current === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
|
102
|
+
class="inline-flex items-center justify-center size-7 bg-bg-2 border border-line-2 rounded-md text-fg-2 cursor-pointer transition-colors hover:border-line-3 hover:text-fg-0"
|
|
103
|
+
>
|
|
104
|
+
{#if theme.current === 'dark'}
|
|
105
|
+
<Sun class="size-3.5" />
|
|
106
|
+
{:else}
|
|
107
|
+
<Moon class="size-3.5" />
|
|
108
|
+
{/if}
|
|
109
|
+
</button>
|
|
110
|
+
<button
|
|
111
|
+
onclick={openPalette}
|
|
112
|
+
class="inline-flex items-center gap-2 h-7 pl-2.5 pr-2 bg-bg-2 border border-line-2 rounded-md text-fg-2 cursor-pointer text-xs transition-colors hover:border-line-3 hover:text-fg-1"
|
|
113
|
+
>
|
|
114
|
+
<Search class="size-3.5 text-fg-3" />
|
|
115
|
+
<span>Search or run</span>
|
|
116
|
+
<span class="inline-flex gap-0.5 ml-1"><Kbd>⌘</Kbd><Kbd>K</Kbd></span>
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
</header>
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The Mountable Root (ADR-0001). Owns the app chrome and switches between
|
|
3
|
+
// its top-level views purely from an injected `Router` — it never reads or
|
|
4
|
+
// writes the URL itself. Mounted standalone it falls back to an in-memory
|
|
5
|
+
// state router, so a host can embed it without surrendering `window.history`.
|
|
6
|
+
// The SvelteKit Surface wraps this same component with a Kit-backed router.
|
|
7
|
+
import { onMount, untrack } from 'svelte'
|
|
8
|
+
import Splash from './Splash.svelte'
|
|
9
|
+
import Topbar from './Topbar.svelte'
|
|
10
|
+
import StatusBar from './StatusBar.svelte'
|
|
11
|
+
import CommandPalette from './CommandPalette.svelte'
|
|
12
|
+
import ShortcutOverlay from './ShortcutOverlay.svelte'
|
|
13
|
+
import MasterPasswordDialog from './MasterPasswordDialog.svelte'
|
|
14
|
+
import PendingChangesPanel from './PendingChangesPanel.svelte'
|
|
15
|
+
import CollectionsView from './CollectionsView.svelte'
|
|
16
|
+
import ClusterView from './ClusterView.svelte'
|
|
17
|
+
import SecurityView from './SecurityView.svelte'
|
|
18
|
+
import { connection, setConnectionProvider } from './connections.svelte'
|
|
19
|
+
import { runQueryPreferStream, type ConnectionProvider } from '#reddb'
|
|
20
|
+
import { theme, type Theme } from './theme.svelte'
|
|
21
|
+
import { secureStore } from './secureStore.svelte'
|
|
22
|
+
import { pendingChanges, buildUpdateSql, type CommitOutcome } from './pending-changes.svelte'
|
|
23
|
+
import { queryTabs } from './query-tabs.svelte'
|
|
24
|
+
import { tabs } from './tabs.svelte'
|
|
25
|
+
import { detectCapability } from './capability'
|
|
26
|
+
import { defaultSubpage } from './collection-pages'
|
|
27
|
+
import { createStateRouter, pathToLocation, setRouter, type Router, type View } from './router.svelte'
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
router: injectedRouter,
|
|
31
|
+
connectionProvider,
|
|
32
|
+
showConnect = true,
|
|
33
|
+
initialRoute,
|
|
34
|
+
themeTarget,
|
|
35
|
+
persistTheme = true,
|
|
36
|
+
initialTheme,
|
|
37
|
+
}: {
|
|
38
|
+
router?: Router
|
|
39
|
+
connectionProvider?: ConnectionProvider
|
|
40
|
+
showConnect?: boolean
|
|
41
|
+
initialRoute?: string
|
|
42
|
+
themeTarget?: HTMLElement | null
|
|
43
|
+
persistTheme?: boolean
|
|
44
|
+
initialTheme?: Theme
|
|
45
|
+
} = $props()
|
|
46
|
+
|
|
47
|
+
// Provide the router to every descendant view. Standalone (no host router)
|
|
48
|
+
// we mint a state router that keeps navigation entirely in memory. The
|
|
49
|
+
// router is fixed for the lifetime of the mount, so reading the prop once
|
|
50
|
+
// here is intentional.
|
|
51
|
+
// svelte-ignore state_referenced_locally
|
|
52
|
+
const router = setRouter(injectedRouter ?? createStateRouter())
|
|
53
|
+
|
|
54
|
+
// Host-injected connection (ADR-0001 embed Surface): when the embedder
|
|
55
|
+
// hands in a provider (e.g. an InjectedClientProvider wrapping its own
|
|
56
|
+
// authenticated client), wire it as the Core's connection seam and connect
|
|
57
|
+
// immediately, so the Core renders connected without the Connect flow. The
|
|
58
|
+
// provider is fixed for the lifetime of the mount.
|
|
59
|
+
// svelte-ignore state_referenced_locally
|
|
60
|
+
if (connectionProvider) {
|
|
61
|
+
setConnectionProvider(connectionProvider)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function navigateToBootRoute(route: string) {
|
|
65
|
+
const legacyView = route as View
|
|
66
|
+
if (legacyView === 'query' || legacyView === 'collections' || legacyView === 'cluster' || legacyView === 'security') {
|
|
67
|
+
router.go({ view: legacyView }, undefined, true)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const loc = pathToLocation(route.startsWith('/') ? route : `/${route}`)
|
|
72
|
+
if (loc.collection) {
|
|
73
|
+
router.go({ view: 'collection', collection: loc.collection, subpage: loc.subpage ?? 'table' }, undefined, true)
|
|
74
|
+
} else {
|
|
75
|
+
router.go({ view: loc.view }, undefined, true)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onMount(() => {
|
|
80
|
+
if (connectionProvider) {
|
|
81
|
+
// Host-injected provider (#33): adopt its authenticated connection.
|
|
82
|
+
void connection.adoptInjected().then(() => {
|
|
83
|
+
if (initialRoute) navigateToBootRoute(initialRoute)
|
|
84
|
+
})
|
|
85
|
+
} else {
|
|
86
|
+
// Connection Bootstrap: one boot resolver handles Tauri IPC, host globals,
|
|
87
|
+
// the Open Contract URL/hash, and finally persisted standalone state.
|
|
88
|
+
void connection.bootstrap().then((route) => {
|
|
89
|
+
if (route) navigateToBootRoute(route)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
let booted = $state(false)
|
|
95
|
+
let pendingOpen = $state(false)
|
|
96
|
+
|
|
97
|
+
onMount(() => {
|
|
98
|
+
const handler = () => (pendingOpen = true)
|
|
99
|
+
window.addEventListener('red:open-pending-changes', handler as EventListener)
|
|
100
|
+
return () => window.removeEventListener('red:open-pending-changes', handler as EventListener)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Wire the commit executor to the active client. Re-binds whenever the
|
|
104
|
+
// underlying client changes (connect / disconnect / switch).
|
|
105
|
+
$effect(() => {
|
|
106
|
+
const client = connection.client
|
|
107
|
+
if (!client) {
|
|
108
|
+
pendingChanges.setExecutor(null)
|
|
109
|
+
queryTabs.setExecutor(null)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
queryTabs.setExecutor((sql) => runQueryPreferStream(client, sql))
|
|
113
|
+
pendingChanges.setExecutor(async (changes) =>
|
|
114
|
+
Promise.all(
|
|
115
|
+
changes.map(async (c): Promise<CommitOutcome> => {
|
|
116
|
+
try {
|
|
117
|
+
const r = await client.query(buildUpdateSql(c))
|
|
118
|
+
if (!r.ok) return { id: c.id, ok: false, error: r.error ?? 'query failed' }
|
|
119
|
+
return { id: c.id, ok: true }
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { id: c.id, ok: false, error: (e as Error).message }
|
|
122
|
+
}
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const REFRESH_MS = 5000
|
|
129
|
+
|
|
130
|
+
// The inline script in app.html has already set data-theme on <html>;
|
|
131
|
+
// init() syncs the store's reactive state with localStorage so toggles
|
|
132
|
+
// start from the persisted value rather than the in-memory default.
|
|
133
|
+
// svelte-ignore state_referenced_locally
|
|
134
|
+
theme.init({ target: themeTarget ?? null, persist: persistTheme, initial: initialTheme })
|
|
135
|
+
|
|
136
|
+
// Hard lock gate: while the secure store is locked, no automatic network
|
|
137
|
+
// traffic should fire — probing the configured URL would leak its existence
|
|
138
|
+
// and shape. Additionally (#21, acceptance #4) no traffic fires until a real
|
|
139
|
+
// target is resolved (URL/boot params, a stored pin, or an explicit connect),
|
|
140
|
+
// so a credential-less Surface that boots straight to data never probes a
|
|
141
|
+
// default it was never pointed at.
|
|
142
|
+
$effect(() => {
|
|
143
|
+
if (secureStore.locked || !connection.targetResolved) return
|
|
144
|
+
connection.refresh()
|
|
145
|
+
const id = setInterval(() => connection.refresh(), REFRESH_MS)
|
|
146
|
+
return () => clearInterval(id)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Once the secure store binds (Tauri at boot / web after unlock), hydrate
|
|
150
|
+
// history URLs from the encrypted store so the dropdown can reconnect.
|
|
151
|
+
$effect(() => {
|
|
152
|
+
if (secureStore.store) connection.hydrateUrls()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
function openNewQuery() {
|
|
156
|
+
const label = queryTabs.nextLabel()
|
|
157
|
+
const tab = tabs.open({ kind: 'query', label, key: label }, true)
|
|
158
|
+
queryTabs.ensure(tab.id)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Open (or focus) the tab for the collection the router points at, detecting
|
|
162
|
+
// its capability lazily. When the router hasn't pinned a subpage yet (a bare
|
|
163
|
+
// `/c/<name>` deep link), settle on the capability's default subpage — the
|
|
164
|
+
// same redirect the old per-route page did, now router-agnostic.
|
|
165
|
+
async function syncCollectionTab(name: string, subpage: string | null) {
|
|
166
|
+
const existing = tabs.tabs.find((t) => t.kind === 'collection' && t.key === name)
|
|
167
|
+
if (existing) {
|
|
168
|
+
tabs.open({
|
|
169
|
+
kind: 'collection',
|
|
170
|
+
label: name,
|
|
171
|
+
key: name,
|
|
172
|
+
capability: existing.capability,
|
|
173
|
+
overrideCapability: existing.overrideCapability,
|
|
174
|
+
showSystemColumns: existing.showSystemColumns,
|
|
175
|
+
subpage: subpage ?? existing.subpage,
|
|
176
|
+
})
|
|
177
|
+
} else {
|
|
178
|
+
tabs.open({ kind: 'collection', label: name, key: name, capability: 'table', subpage: subpage ?? undefined })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const client = connection.client
|
|
182
|
+
if (!client) {
|
|
183
|
+
if (!subpage) router.go({ view: 'collection', collection: name, subpage: 'table' }, undefined, true)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const cap = await detectCapability(client, name)
|
|
188
|
+
const t = tabs.tabs.find((x) => x.kind === 'collection' && x.key === name)
|
|
189
|
+
const resolved = subpage ?? defaultSubpage(cap)
|
|
190
|
+
if (t) tabs.tabs = tabs.tabs.map((x) => (x === t ? { ...x, capability: cap, subpage: resolved } : x))
|
|
191
|
+
if (!subpage) router.go({ view: 'collection', collection: name, subpage: resolved }, undefined, true)
|
|
192
|
+
} catch {
|
|
193
|
+
// Detection failure is non-fatal — table stays the baseline.
|
|
194
|
+
if (!subpage) router.go({ view: 'collection', collection: name, subpage: 'table' }, undefined, true)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Bridge the router location to tab state. Works identically whether the
|
|
199
|
+
// location comes from in-memory state or the SvelteKit URL.
|
|
200
|
+
let lastView: View | null = null
|
|
201
|
+
let lastCollectionKey: string | null = null
|
|
202
|
+
$effect(() => {
|
|
203
|
+
const view = router.view
|
|
204
|
+
const collection = router.collection
|
|
205
|
+
const subpage = router.subpage
|
|
206
|
+
|
|
207
|
+
if (view === 'collections' && collection) {
|
|
208
|
+
const key = `${collection}::${subpage ?? ''}`
|
|
209
|
+
if (key !== lastCollectionKey) {
|
|
210
|
+
lastCollectionKey = key
|
|
211
|
+
untrack(() => void syncCollectionTab(collection, subpage))
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
lastCollectionKey = null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Entering the query view mints a fresh query tab, matching the old
|
|
218
|
+
// `/query` route. Guarded on the transition so reactive ticks don't
|
|
219
|
+
// spawn duplicate tabs.
|
|
220
|
+
if (view === 'query' && lastView !== 'query') {
|
|
221
|
+
untrack(() => openNewQuery())
|
|
222
|
+
}
|
|
223
|
+
lastView = view
|
|
224
|
+
})
|
|
225
|
+
</script>
|
|
226
|
+
|
|
227
|
+
{#if !booted}
|
|
228
|
+
<Splash onDone={() => (booted = true)} />
|
|
229
|
+
{:else}
|
|
230
|
+
<div class="app grid grid-rows-[44px_32px_1fr] h-screen">
|
|
231
|
+
<Topbar showConnect={showConnect} />
|
|
232
|
+
<StatusBar />
|
|
233
|
+
<main class="relative overflow-hidden z-[1]">
|
|
234
|
+
{#if router.view === 'cluster'}
|
|
235
|
+
<ClusterView />
|
|
236
|
+
{:else if router.view === 'security'}
|
|
237
|
+
<SecurityView />
|
|
238
|
+
{:else}
|
|
239
|
+
<CollectionsView />
|
|
240
|
+
{/if}
|
|
241
|
+
</main>
|
|
242
|
+
<CommandPalette />
|
|
243
|
+
<ShortcutOverlay />
|
|
244
|
+
<MasterPasswordDialog />
|
|
245
|
+
<PendingChangesPanel open={pendingOpen} onClose={() => (pendingOpen = false)} />
|
|
246
|
+
</div>
|
|
247
|
+
{/if}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Global activity tracker. Every reddb HTTP call should flow through
|
|
2
|
+
// `activity.track(label, op)` so the UI can show a single source of
|
|
3
|
+
// truth for "what's happening right now". Two roles:
|
|
4
|
+
//
|
|
5
|
+
// - Counter of in-flight requests (drives the topbar pulse).
|
|
6
|
+
// - Rolling log of the last 20 labels with duration + outcome
|
|
7
|
+
// (drives the activity panel).
|
|
8
|
+
//
|
|
9
|
+
// Labels should describe the *intent* of the call ("schema · collections",
|
|
10
|
+
// "tales · SELECT", "grimm_graph · edges + nodes"), not the raw URL —
|
|
11
|
+
// that's what makes the panel readable when scanning during an incident.
|
|
12
|
+
//
|
|
13
|
+
// All $state reads/writes inside `track` are wrapped in `untrack()`. Why:
|
|
14
|
+
// callers commonly invoke `track()` from inside a `$effect` (e.g. the
|
|
15
|
+
// layout's connection.refresh on unlock). If we read+write `log`/`inflight`
|
|
16
|
+
// without untrack, the effect would track those as dependencies and our
|
|
17
|
+
// own writes would re-fire it — infinite loop (`effect_update_depth_exceeded`).
|
|
18
|
+
|
|
19
|
+
import { untrack } from 'svelte'
|
|
20
|
+
|
|
21
|
+
export interface ActivityEntry {
|
|
22
|
+
id: number
|
|
23
|
+
label: string
|
|
24
|
+
startedAt: number
|
|
25
|
+
durationMs: number | null
|
|
26
|
+
status: 'pending' | 'ok' | 'error'
|
|
27
|
+
error?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAX_LOG = 20
|
|
31
|
+
|
|
32
|
+
class ActivityStore {
|
|
33
|
+
inflight = $state(0)
|
|
34
|
+
log = $state<ActivityEntry[]>([])
|
|
35
|
+
private nextId = 1
|
|
36
|
+
|
|
37
|
+
async track<T>(label: string, op: () => Promise<T>): Promise<T> {
|
|
38
|
+
const id = this.nextId++
|
|
39
|
+
const entry: ActivityEntry = {
|
|
40
|
+
id,
|
|
41
|
+
label,
|
|
42
|
+
startedAt: Date.now(),
|
|
43
|
+
durationMs: null,
|
|
44
|
+
status: 'pending',
|
|
45
|
+
}
|
|
46
|
+
untrack(() => {
|
|
47
|
+
this.log = [entry, ...this.log].slice(0, MAX_LOG)
|
|
48
|
+
this.inflight += 1
|
|
49
|
+
})
|
|
50
|
+
try {
|
|
51
|
+
const result = await op()
|
|
52
|
+
untrack(() =>
|
|
53
|
+
this.update(id, { durationMs: Date.now() - entry.startedAt, status: 'ok' }),
|
|
54
|
+
)
|
|
55
|
+
return result
|
|
56
|
+
} catch (err) {
|
|
57
|
+
untrack(() =>
|
|
58
|
+
this.update(id, {
|
|
59
|
+
durationMs: Date.now() - entry.startedAt,
|
|
60
|
+
status: 'error',
|
|
61
|
+
error: (err as Error).message,
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
throw err
|
|
65
|
+
} finally {
|
|
66
|
+
untrack(() => {
|
|
67
|
+
this.inflight = Math.max(0, this.inflight - 1)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private update(id: number, patch: Partial<ActivityEntry>) {
|
|
73
|
+
this.log = this.log.map((e) => (e.id === id ? { ...e, ...patch } : e))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Panic button for when the indicator gets stuck out of sync with
|
|
77
|
+
* reality (e.g. a fetch that never resolves due to a dropped socket).
|
|
78
|
+
* Clears the counter and marks remaining pending entries as errored. */
|
|
79
|
+
reset() {
|
|
80
|
+
untrack(() => {
|
|
81
|
+
this.log = this.log.map((e) =>
|
|
82
|
+
e.status === 'pending'
|
|
83
|
+
? { ...e, status: 'error', error: 'reset by user', durationMs: Date.now() - e.startedAt }
|
|
84
|
+
: e,
|
|
85
|
+
)
|
|
86
|
+
this.inflight = 0
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const activity = new ActivityStore()
|