@hearthlog/shell 0.0.0-stage → 0.1.0

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.
Files changed (80) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +51 -3
  3. package/src/app.template.html +184 -0
  4. package/src/components/ai/DecisionReviewSheet.svelte +226 -0
  5. package/src/components/ai/SuggestButton.svelte +23 -0
  6. package/src/components/ai/SuggestionChip.svelte +13 -0
  7. package/src/components/ai/SuggestionMark.svelte +15 -0
  8. package/src/components/calendar/CalendarFeedsDrawer.svelte +414 -0
  9. package/src/components/calendar/CalendarMonthSummary.svelte +122 -0
  10. package/src/components/calendar/calendar-weeks.svelte.ts +27 -0
  11. package/src/components/calendar/source-icons.ts +19 -0
  12. package/src/components/data-table/DataTableValue.svelte +14 -0
  13. package/src/components/data-table/EntityActionsCell.svelte +36 -0
  14. package/src/components/data-table/EntityRefsCell.svelte +58 -0
  15. package/src/components/data-table/EntityValue.svelte +42 -0
  16. package/src/components/data-table/entity-columns.ts +87 -0
  17. package/src/components/data-table/sqlite-columns.ts +42 -0
  18. package/src/components/entities/CalendarSourceFields.svelte +149 -0
  19. package/src/components/history/ChangeRecord.svelte +30 -0
  20. package/src/components/history/ForkComposer.svelte +174 -0
  21. package/src/components/history/HistoryScrubber.svelte +245 -0
  22. package/src/components/history/LogHistory.svelte +334 -0
  23. package/src/components/history/LogMap.svelte +293 -0
  24. package/src/components/history/LogTree.svelte +124 -0
  25. package/src/components/layout/AccountMenu.svelte +111 -0
  26. package/src/components/layout/AppearanceOptions.svelte +78 -0
  27. package/src/components/layout/BrandWordmark.svelte +12 -0
  28. package/src/components/layout/ErrorToast.svelte +34 -0
  29. package/src/components/layout/LogCreateDrawer.svelte +111 -0
  30. package/src/components/layout/LogPanel.svelte +225 -0
  31. package/src/components/layout/LogStatusChip.svelte +137 -0
  32. package/src/components/layout/MobilePageAction.svelte +44 -0
  33. package/src/components/layout/MovedBanner.svelte +51 -0
  34. package/src/components/layout/NavBar.svelte +603 -0
  35. package/src/components/layout/NavFlyout.svelte +132 -0
  36. package/src/components/layout/NavItem.svelte +36 -0
  37. package/src/components/layout/NavMoreDialog.svelte +179 -0
  38. package/src/components/layout/PageDataStatus.svelte +36 -0
  39. package/src/components/layout/PhoneGlance.svelte +96 -0
  40. package/src/components/layout/SidebarResizer.svelte +122 -0
  41. package/src/components/layout/SyncIndicator.svelte +24 -0
  42. package/src/components/layout/feature-icons.ts +24 -0
  43. package/src/components/layout/nav-indicator.ts +128 -0
  44. package/src/components/layout/sidebar-size.ts +31 -0
  45. package/src/components/sync/SyncIssuesDrawer.svelte +149 -0
  46. package/src/components/sync/SyncStateBadge.svelte +17 -0
  47. package/src/pages/calendar/Page.svelte +1341 -0
  48. package/src/pages/calendar/load.ts +77 -0
  49. package/src/pages/developer/DatabaseBrowser.svelte +325 -0
  50. package/src/pages/developer/Event.svelte +51 -0
  51. package/src/pages/developer/EventLogManager.svelte +268 -0
  52. package/src/pages/developer/IndexedDbStats.svelte +93 -0
  53. package/src/pages/developer/LedgerAdmin.svelte +877 -0
  54. package/src/pages/developer/LocalSqliteStats.svelte +56 -0
  55. package/src/pages/developer/Page.svelte +590 -0
  56. package/src/pages/developer/PendingWorkImport.svelte +49 -0
  57. package/src/pages/developer/PerformanceDashboard.svelte +262 -0
  58. package/src/pages/developer/StackInfo.svelte +117 -0
  59. package/src/pages/developer/SyncLog.svelte +188 -0
  60. package/src/pages/developer/load.ts +28 -0
  61. package/src/pages/entities/Page.svelte +1069 -0
  62. package/src/pages/entities/load.ts +18 -0
  63. package/src/pages/entity/Page.svelte +655 -0
  64. package/src/pages/entity/load.ts +79 -0
  65. package/src/pages/error/ErrorPage.svelte +147 -0
  66. package/src/pages/layout/Layout.svelte +597 -0
  67. package/src/pages/layout/load.ts +28 -0
  68. package/src/pages/redirects/history.ts +20 -0
  69. package/src/pages/redirects/start.ts +11 -0
  70. package/src/pages/redirects/today.ts +9 -0
  71. package/src/service-worker/index.ts +108 -0
  72. package/src/service-worker/offline-bundle.ts +116 -0
  73. package/src/service-worker/virtual.d.ts +3 -0
  74. package/src/styles/index.css +11 -0
  75. package/src/styles/shell.css +296 -0
  76. package/static/_headers +4 -0
  77. package/static/favicon.ico +0 -0
  78. package/static/offline.html +44 -0
  79. package/static/replay/sw.js +2 -0
  80. package/README.md +0 -3
@@ -0,0 +1,56 @@
1
+ <script lang="ts">
2
+ import DataPlaceholder from '@hearthlog/ui/components/data/DataPlaceholder.svelte';
3
+ import { formatSqliteBytes, type LocalSqliteStats } from '@hearthlog/runtime/db/local-statistics.js';
4
+ import IconDatabaseOutline from '~icons/mdi/database-outline';
5
+
6
+ let { stats, loading = false }: { stats: LocalSqliteStats | null; loading?: boolean } = $props();
7
+ const occupiedPercent = $derived(stats && stats.allocatedBytes > 0
8
+ ? Math.round(stats.occupiedBytes / stats.allocatedBytes * 100)
9
+ : 0);
10
+ </script>
11
+
12
+ <section class="stor-panel min-w-0 p-[var(--stor-panel-padding)]" aria-labelledby="local-sqlite-stats-heading" data-testid="local-sqlite-stats">
13
+ <div class="flex items-start gap-3">
14
+ <IconDatabaseOutline class="mt-0.5 h-5 w-5 shrink-0 text-[var(--stor-accent-strong)]" />
15
+ <div>
16
+ <h2 id="local-sqlite-stats-heading" class="text-base font-semibold text-[var(--stor-ink)]">Local SQLite</h2>
17
+ </div>
18
+ </div>
19
+
20
+ {#if stats}
21
+ <div class="mt-5 flex flex-wrap items-end gap-x-8 gap-y-4">
22
+ <div>
23
+ <div class="text-3xl font-semibold tabular-nums tracking-tight text-[var(--stor-ink)]" data-testid="sqlite-allocated-size">
24
+ {formatSqliteBytes(stats.allocatedBytes)}
25
+ </div>
26
+ <p class="mt-1 text-xs text-[var(--stor-muted-text)]">Allocated database pages</p>
27
+ </div>
28
+ <dl class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
29
+ <div><dt class="text-[var(--stor-muted-text)]">Reusable</dt><dd class="font-semibold tabular-nums text-[var(--stor-ink)]">{formatSqliteBytes(stats.reusableBytes)}</dd></div>
30
+ <div><dt class="text-[var(--stor-muted-text)]">Page size</dt><dd class="font-semibold tabular-nums text-[var(--stor-ink)]" data-testid="sqlite-page-size">{formatSqliteBytes(stats.pageSize)}</dd></div>
31
+ </dl>
32
+ </div>
33
+
34
+ <div class="mt-5 h-2 overflow-hidden rounded-full bg-[var(--stor-accent-soft)]" role="img" aria-label={`${occupiedPercent}% occupied pages, ${100 - occupiedPercent}% reusable pages`}>
35
+ <div class="h-full rounded-full bg-[var(--stor-accent)]" style:width={`${occupiedPercent}%`}></div>
36
+ </div>
37
+ <div class="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-xs text-[var(--stor-muted-text)]">
38
+ <span>{formatSqliteBytes(stats.occupiedBytes)} occupied pages</span>
39
+ <span>{formatSqliteBytes(stats.reusableBytes)} reusable pages</span>
40
+ </div>
41
+
42
+ <dl class="mt-5 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-[var(--stor-border)] pt-4 text-sm sm:grid-cols-4">
43
+ <div><dt class="text-[var(--stor-muted-text)]">Tables</dt><dd class="font-semibold tabular-nums text-[var(--stor-ink)]">{stats.tableCount}</dd></div>
44
+ <div><dt class="text-[var(--stor-muted-text)]">Indexes</dt><dd class="font-semibold tabular-nums text-[var(--stor-ink)]">{stats.indexCount}</dd></div>
45
+ <div><dt class="text-[var(--stor-muted-text)]">Pages</dt><dd class="font-semibold tabular-nums text-[var(--stor-ink)]">{stats.pageCount}</dd></div>
46
+ <div><dt class="text-[var(--stor-muted-text)]">Schema version</dt><dd class="font-semibold tabular-nums text-[var(--stor-ink)]">{stats.schemaVersion}</dd></div>
47
+ </dl>
48
+ <div class="mt-4 rounded-xl bg-[var(--stor-surface-muted)] px-3 py-2 text-xs text-[var(--stor-muted-text)]">
49
+ Active log: {stats.activeLog ? `${stats.activeLog.eventCount} events · ${stats.activeLog.pendingOperationCount} queued operations` : 'Counts unavailable'}
50
+ </div>
51
+ {:else if loading}
52
+ <div class="mt-5"><DataPlaceholder /></div>
53
+ {:else}
54
+ <p class="mt-5 text-sm text-[var(--stor-muted-text)]">SQLite statistics are unavailable.</p>
55
+ {/if}
56
+ </section>
@@ -0,0 +1,590 @@
1
+ <script lang="ts">
2
+ import { providePageResource } from "@hearthlog/runtime/runtime/page-data.svelte.js";
3
+ import { workspaceReadiness } from "@hearthlog/ui/state/readiness.js";
4
+ import PageDataStatus from "@hearthlog/shell/components/layout/PageDataStatus.svelte";
5
+ import DataPlaceholder from "@hearthlog/ui/components/data/DataPlaceholder.svelte";
6
+ import PageHeader from '@hearthlog/ui/components/PageHeader.svelte';
7
+ import Button from '@hearthlog/ui/components/ui/Button.svelte';
8
+ let { data } = $props();
9
+ const resource = $derived(data.pageResource);
10
+ providePageResource(() => resource);
11
+ import { untrack } from "svelte";
12
+ import DataValue from "@hearthlog/ui/components/data/DataValue.svelte";
13
+ import { refreshPageData } from "@hearthlog/runtime/runtime/refresh.js";
14
+ import { goto } from "$app/navigation";
15
+ import { page } from "$app/state";
16
+ import { Tabs } from "bits-ui";
17
+ import { tabIndicator } from "@hearthlog/ui/components/ui/tab-indicator.js";
18
+ import type { AppEvent } from "@hearthlog/runtime/event/index.js";
19
+ import DatabaseBrowser from "./DatabaseBrowser.svelte";
20
+ import LocalSqliteStats from './LocalSqliteStats.svelte';
21
+ import IndexedDbStats from './IndexedDbStats.svelte';
22
+ import type { LocalSqliteStats as LocalSqliteStatsData } from '@hearthlog/runtime/db/local-statistics.js';
23
+ import LedgerAdmin from "./LedgerAdmin.svelte";
24
+ import PendingWorkImport from "./PendingWorkImport.svelte";
25
+ import Event from "./Event.svelte";
26
+ import SyncLog from "./SyncLog.svelte";
27
+ import EventLogManager from "./EventLogManager.svelte";
28
+ import PerformanceDashboard from "./PerformanceDashboard.svelte";
29
+ import StackInfo from "./StackInfo.svelte";
30
+ import { events as eventsSignal } from "@hearthlog/runtime/store/events.svelte.js";
31
+ import { localStoreStatus } from "@hearthlog/runtime/store/local-store.svelte.js";
32
+ import { getSyncPhase, syncStatus } from "@hearthlog/runtime/store/sync.svelte.js";
33
+ import { describeAppDataState } from "@hearthlog/runtime/store/sync-state.js";
34
+ import {
35
+ clearOutbox,
36
+ type OutboxEvent,
37
+ } from "@hearthlog/runtime/store/outbox.js";
38
+ import ConfirmDialog from "@hearthlog/ui/components/ConfirmDialog.svelte";
39
+ import EmptyState from "@hearthlog/ui/components/EmptyState.svelte";
40
+ import { notify } from "@hearthlog/ui/state/notifications.svelte.js";
41
+ import IconDeleteOutline from "~icons/mdi/delete-outline";
42
+ import IconDownload from "~icons/mdi/download";
43
+ import IconMagnify from "~icons/mdi/magnify";
44
+ import IconRefresh from "~icons/mdi/refresh";
45
+ import IconUpload from "~icons/mdi/upload";
46
+ import {
47
+ downloadLocalDatabase,
48
+ getLocalRecoverySummary,
49
+ resetLocalDataFromLedger,
50
+ retryLocalMigration,
51
+ type LocalRecoverySummary,
52
+ } from "@hearthlog/runtime/recovery/local-recovery.js";
53
+ import { tenant } from "@hearthlog/contracts/tenant/config";
54
+
55
+ let isResetting = $state(false);
56
+ let resetStatus = $state("");
57
+ let recoverySummary = $state<LocalRecoverySummary>({ pending: null, blocked: null });
58
+ const events = $derived(resource.data.events as AppEvent[]);
59
+ const sqliteStats = $derived(resource.data.sqliteStats as LocalSqliteStatsData | null);
60
+ let eventSearch = $state("");
61
+ let allExpanded = $state(false);
62
+ let eventStates = $state<Record<string, boolean>>({});
63
+
64
+ // Confirmation dialog states
65
+ let showClearOutboxConfirm = $state(false);
66
+ let showResetDbConfirm = $state(false);
67
+
68
+ // Outbox debug state
69
+ const outboxEvents = $derived(resource.data.outboxEvents as OutboxEvent[]);
70
+ const queuedCount = $derived(sqliteStats?.activeLog?.pendingOperationCount ?? 0);
71
+ let isDrainingOutbox = $state(false);
72
+ let outboxMessage = $state("");
73
+
74
+ const buildDate = new Date(__BUILD_TIMESTAMP__).toLocaleString("en-US");
75
+ type DeveloperTab = 'overview' | 'events' | 'database' | 'performance' | 'ledger';
76
+ const activeTab = $derived.by(() => {
77
+ const requested = parseDeveloperTab(page.url.searchParams.get("tab"));
78
+ return requested === "ledger" && !tenant.admin ? 'overview' : requested;
79
+ });
80
+ const pageTitle = $derived(
81
+ activeTab === "database"
82
+ ? "Local storage | stor"
83
+ : activeTab === "ledger"
84
+ ? "Ledger Admin | stor"
85
+ : activeTab === "events"
86
+ ? "Developer Events | stor"
87
+ : activeTab === 'performance'
88
+ ? 'Developer Performance | stor'
89
+ : 'Developer Overview | stor',
90
+ );
91
+ const resetConfirmMessage = $derived.by(() => {
92
+ const pending = recoverySummary.pending === null ? "an unknown number of" : recoverySummary.pending;
93
+ const blocked = recoverySummary.blocked === null ? "an unknown number of" : recoverySummary.blocked;
94
+ return `This deletes local data, including ${pending} pending events and ${blocked} blocked operations. Download SQLite first if you may need them. Remote events will be restored through normal sync after reload.`;
95
+ });
96
+ const syncSummary = $derived.by(() => {
97
+ const presentation = describeAppDataState({
98
+ localStorePhase: localStoreStatus.phase,
99
+ localStoreErrorMessage: localStoreStatus.errorMessage,
100
+ syncPhase: getSyncPhase(),
101
+ syncErrorMessage: syncStatus.errorMessage,
102
+ pendingUploads: syncStatus.pendingUploads,
103
+ needsAttention: syncStatus.needsAttention,
104
+ lastSyncedAt: syncStatus.lastSyncedAt,
105
+ });
106
+ return presentation;
107
+ });
108
+
109
+ const outboxSummary = $derived.by(() => {
110
+ if (queuedCount === 0) return "No queued operations";
111
+ if (queuedCount === 1) return "1 queued operation";
112
+ return `${queuedCount} queued operations`;
113
+ });
114
+
115
+ const filteredEvents = $derived(
116
+ events.filter((e) => {
117
+ if (!eventSearch) return true;
118
+ const search = eventSearch.toLowerCase();
119
+ const typeMatch = e.type?.toLowerCase().includes(search) ?? false;
120
+ const idMatch = e.id?.toLowerCase().includes(search) ?? false;
121
+ const payloadString = e.payload
122
+ ? JSON.stringify(e.payload).toLowerCase()
123
+ : "";
124
+ const payloadMatch = payloadString.includes(search);
125
+ return typeMatch || idMatch || payloadMatch;
126
+ }),
127
+ );
128
+
129
+ function toggleAllEvents() {
130
+ allExpanded = !allExpanded;
131
+ const newStates: Record<string, boolean> = {};
132
+ events.forEach((e) => {
133
+ newStates[e.id] = allExpanded;
134
+ });
135
+ eventStates = newStates;
136
+ }
137
+
138
+ async function refreshOutbox() {
139
+ if (resource.loading) return;
140
+ try { await refreshPageData("app:developer"); }
141
+ catch (error) { console.error("Failed to refresh outbox", error); }
142
+ }
143
+
144
+ async function handleClearOutbox() {
145
+ if (resource.loading) return;
146
+ showClearOutboxConfirm = true;
147
+ }
148
+
149
+ async function executeClearOutbox() {
150
+ if (resource.loading) return;
151
+ showClearOutboxConfirm = false;
152
+ try {
153
+ await clearOutbox();
154
+ const { rebuildReadModel } = await import("@hearthlog/runtime/event/index.js");
155
+ await rebuildReadModel();
156
+ outboxMessage = "Outbox cleared";
157
+ await refreshOutbox();
158
+ syncStatus.pendingUploads = 0;
159
+ setTimeout(() => (outboxMessage = ""), 2000);
160
+ } catch (error) {
161
+ outboxMessage = `Error: ${error instanceof Error ? error.message : String(error)}`;
162
+ }
163
+ }
164
+
165
+ async function handleDrainOutbox() {
166
+ if (!$workspaceReadiness.ready) return;
167
+ isDrainingOutbox = true;
168
+ outboxMessage = "Draining outbox...";
169
+ try {
170
+ // Import the drain function from sync to use the proper upload logic
171
+ const { signalLocalChange } = await import(
172
+ "@hearthlog/runtime/store/sync.svelte.js"
173
+ );
174
+ signalLocalChange();
175
+ outboxMessage = "Drain triggered - check Network tab";
176
+ await new Promise((resolve) => setTimeout(resolve, 2000));
177
+ await refreshOutbox();
178
+ } catch (error) {
179
+ outboxMessage = `Error: ${error instanceof Error ? error.message : String(error)}`;
180
+ } finally {
181
+ isDrainingOutbox = false;
182
+ setTimeout(() => (outboxMessage = ""), 3000);
183
+ }
184
+ }
185
+
186
+ async function downloadSqliteDb() {
187
+ if (resource.loading) return;
188
+ try {
189
+ await downloadLocalDatabase();
190
+ } catch (error) {
191
+ console.error("Failed to download SQLite DB:", error);
192
+ notify.error("Failed to download the SQLite database");
193
+ }
194
+ }
195
+
196
+ async function refreshEvents() {
197
+ if (resource.loading) return;
198
+ try { await refreshPageData("app:developer"); }
199
+ catch (error) { console.error("Failed to refresh events", error); }
200
+ }
201
+
202
+ let lastEventsUpdate = untrack(() => eventsSignal.updatedAt);
203
+ $effect(() => {
204
+ const updatedAt = eventsSignal.updatedAt;
205
+ if (activeTab !== 'events' || resource.loading || updatedAt === lastEventsUpdate) return;
206
+ lastEventsUpdate = updatedAt;
207
+ void refreshEvents();
208
+ });
209
+
210
+ // Check if we just reset the database
211
+ if (
212
+ typeof window !== "undefined" &&
213
+ sessionStorage.getItem("databaseReset") === "true"
214
+ ) {
215
+ resetStatus = "Database successfully reset from remote!";
216
+ sessionStorage.removeItem("databaseReset");
217
+ // Clear the status after 3 seconds
218
+ setTimeout(() => {
219
+ resetStatus = "";
220
+ }, 3000);
221
+ }
222
+
223
+ async function resetDatabase() {
224
+ if (!$workspaceReadiness.ready) return;
225
+ recoverySummary = await getLocalRecoverySummary();
226
+ showResetDbConfirm = true;
227
+ }
228
+
229
+ async function executeResetDatabase() {
230
+ if (resource.loading) return;
231
+ showResetDbConfirm = false;
232
+
233
+ isResetting = true;
234
+ resetStatus = "Stopping synchronization...";
235
+
236
+ try {
237
+ await resetLocalDataFromLedger();
238
+ } catch (error: unknown) {
239
+ resetStatus = `Error: ${error instanceof Error ? error.message : String(error)}`;
240
+ console.error("Reset error:", error);
241
+ isResetting = false;
242
+ }
243
+ }
244
+
245
+ async function retryMigration(): Promise<void> {
246
+ resetStatus = "Retrying local migration...";
247
+ try {
248
+ await retryLocalMigration();
249
+ resetStatus = "Local migration completed.";
250
+ } catch (error) {
251
+ resetStatus = `Error: ${error instanceof Error ? error.message : String(error)}`;
252
+ }
253
+ }
254
+
255
+ function parseDeveloperTab(value: string | null): DeveloperTab {
256
+ return value === "events" ||
257
+ value === "database" ||
258
+ value === 'performance' ||
259
+ value === "ledger"
260
+ ? value
261
+ : 'overview';
262
+ }
263
+
264
+ async function navigateToTab(value: string): Promise<void> {
265
+ const tab = parseDeveloperTab(value);
266
+ const nextUrl = new URL(page.url);
267
+ nextUrl.searchParams.set("tab", tab);
268
+ if (tab !== "database") {
269
+ nextUrl.searchParams.delete("table");
270
+ nextUrl.searchParams.delete("page");
271
+ nextUrl.searchParams.delete("pageSize");
272
+ nextUrl.searchParams.delete("sort");
273
+ nextUrl.searchParams.delete("direction");
274
+ }
275
+
276
+ const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
277
+ const currentPath = `${page.url.pathname}${page.url.search}${page.url.hash}`;
278
+ if (nextPath === currentPath) return;
279
+
280
+ await goto(nextPath, {
281
+ reset: false,
282
+ });
283
+ }
284
+ </script>
285
+
286
+ <svelte:head>
287
+ <title>{pageTitle}</title>
288
+ </svelte:head>
289
+ <PageDataStatus {resource} />
290
+
291
+
292
+ <div class="stor-page w-full p-2.5 sm:p-3">
293
+ {#snippet developerScope()}
294
+ <p class="text-sm text-[var(--stor-muted-text)]">This browser · all local logs</p>
295
+ {/snippet}
296
+ <PageHeader title="Developer" context={developerScope}>
297
+ <span class="hidden text-xs text-[var(--stor-muted-text)] sm:inline">Build {buildDate}</span>
298
+ </PageHeader>
299
+
300
+ <Tabs.Root value={activeTab} onValueChange={(value) => void navigateToTab(value)}>
301
+ <Tabs.List class="stor-page-tabs stor-primary-tabs mobile-scroll flex max-w-full items-center overflow-x-auto" aria-label="Developer view" {@attach tabIndicator}>
302
+ <Tabs.Trigger value="overview" class="stor-page-tab">Overview</Tabs.Trigger>
303
+ <Tabs.Trigger value="events" class="stor-page-tab">Events</Tabs.Trigger>
304
+ <Tabs.Trigger value="database" class="stor-page-tab">Storage</Tabs.Trigger>
305
+ <Tabs.Trigger value="performance" class="stor-page-tab">Performance</Tabs.Trigger>
306
+ {#if tenant.admin}<Tabs.Trigger value="ledger" class="stor-page-tab">Ledger</Tabs.Trigger>{/if}
307
+ </Tabs.List>
308
+ </Tabs.Root>
309
+
310
+ {#if activeTab === 'overview'}
311
+ <div class="stor-panel mt-4 grid grid-cols-3 overflow-hidden lg:mt-6" aria-label="Local status">
312
+ <div class="min-w-0 border-r border-[var(--stor-border)] px-3 py-3 sm:px-[var(--stor-panel-padding)] sm:py-4">
313
+ <p class="text-[10.5px] font-semibold uppercase tracking-wider text-[var(--stor-muted-text)] sm:text-xs">Sync</p>
314
+ <p class="mt-1 truncate text-xs font-semibold text-[var(--stor-ink)] sm:text-sm" title={syncSummary.label}>{syncSummary.label}</p>
315
+ <p class="mt-0.5 hidden text-xs text-[var(--stor-muted-text)] sm:block">{syncSummary.description}</p>
316
+ </div>
317
+ <div class="min-w-0 border-r border-[var(--stor-border)] px-3 py-3 sm:px-[var(--stor-panel-padding)] sm:py-4">
318
+ <p class="text-[10.5px] font-semibold uppercase tracking-wider text-[var(--stor-muted-text)] sm:text-xs">Outbox</p>
319
+ <p class="mt-1 text-xs font-semibold text-[var(--stor-ink)] sm:text-sm"><span class="sm:hidden"><DataValue>{queuedCount}</DataValue> queued</span><span class="hidden sm:inline"><DataValue>{outboxSummary}</DataValue></span></p>
320
+ <p class="mt-0.5 hidden text-xs text-[var(--stor-muted-text)] sm:block">Active log</p>
321
+ </div>
322
+ <div class="min-w-0 px-3 py-3 sm:px-[var(--stor-panel-padding)] sm:py-4">
323
+ <p class="text-[10.5px] font-semibold uppercase tracking-wider text-[var(--stor-muted-text)] sm:text-xs">Local data</p>
324
+ <p class="mt-1 truncate text-xs font-semibold capitalize text-[var(--stor-ink)] sm:text-sm">{localStoreStatus.phase}</p>
325
+ <p class="mt-0.5 hidden text-xs text-[var(--stor-muted-text)] sm:block">{localStoreStatus.phase === 'ready' ? 'SQLite opened' : localStoreStatus.errorMessage ?? 'Waiting for local storage'}</p>
326
+ </div>
327
+ </div>
328
+
329
+ <div class="mt-4 lg:mt-6"><StackInfo stats={sqliteStats} /></div>
330
+
331
+ <div class="mt-4 grid min-w-0 gap-4 lg:mt-6 lg:gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(18rem,1fr)]">
332
+ <LocalSqliteStats stats={sqliteStats} loading={resource.loading} />
333
+ <SyncLog />
334
+ </div>
335
+ <div class="mt-4 lg:mt-6"><IndexedDbStats /></div>
336
+
337
+ <div class="mt-4 grid gap-4 lg:mt-6 lg:gap-6 xl:grid-cols-2">
338
+ <section class="stor-panel p-[var(--stor-panel-padding)]">
339
+ <h2 class="text-base font-semibold text-[var(--stor-ink)]">Inspect</h2>
340
+ <p class="mt-1 text-sm text-[var(--stor-muted-text)]">Explore local data and events.</p>
341
+ <div class="mt-4 flex flex-wrap gap-2">
342
+ <Button variant="secondary" onclick={() => void navigateToTab('database')}>Browse tables</Button>
343
+ <Button variant="secondary" onclick={() => void navigateToTab('events')}>View events</Button>
344
+ </div>
345
+ </section>
346
+ <section class="stor-panel p-[var(--stor-panel-padding)]">
347
+ <h2 class="text-base font-semibold text-[var(--stor-ink)]">Recovery</h2>
348
+ <p class="mt-1 text-sm text-[var(--stor-muted-text)]">Back up or restore local data.</p>
349
+ <div class="mt-4 flex flex-wrap gap-2">
350
+ <Button variant="secondary" onclick={downloadSqliteDb} disabled={resource.loading}>
351
+ <IconDownload class="h-4 w-4" />Download SQLite
352
+ </Button>
353
+ <Button variant="secondary" onclick={retryMigration}>
354
+ <IconRefresh class="h-4 w-4" />Retry migration
355
+ </Button>
356
+ <Button
357
+ variant="secondary"
358
+ class="bg-[var(--stor-negative-soft)] text-[var(--stor-negative)] hover:brightness-95"
359
+ onclick={resetDatabase}
360
+ disabled={resource.loading || isResetting}
361
+ >
362
+ <IconDeleteOutline class="h-4 w-4" />{isResetting ? 'Resetting' : 'Reset local data'}
363
+ </Button>
364
+ </div>
365
+ {#if resetStatus}
366
+ <p class="mt-3 text-sm text-[var(--stor-negative)]" role="status">{resetStatus}</p>
367
+ {/if}
368
+ <PendingWorkImport />
369
+ </section>
370
+ </div>
371
+ <div class="mt-4 lg:mt-6">
372
+ <section class="stor-panel p-[var(--stor-panel-padding)]">
373
+ <div class="flex items-start justify-between gap-3">
374
+ <h2 class="text-base font-semibold text-stone-900">
375
+ Sync queue
376
+ </h2>
377
+ <span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs font-semibold text-stone-600">
378
+ <DataValue>{queuedCount}</DataValue>
379
+ </span>
380
+ </div>
381
+
382
+ <div class="mt-3 grid grid-cols-2 gap-2">
383
+ <button
384
+ type="button"
385
+ class="inline-flex items-center justify-center gap-2 rounded-lg bg-stone-100 px-3 py-2 text-sm font-semibold text-stone-700 hover:bg-stone-200"
386
+ onclick={refreshOutbox}
387
+ disabled={resource.loading}>
388
+ <IconRefresh class="h-4 w-4" />
389
+ Refresh
390
+ </button>
391
+ <button
392
+ type="button"
393
+ class="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl bg-[var(--stor-action)] px-3 text-sm font-semibold text-[var(--stor-action-text)] hover:bg-[var(--stor-action-hover)] disabled:opacity-50 sm:min-h-9"
394
+ onclick={handleDrainOutbox}
395
+ disabled={resource.loading || isDrainingOutbox ||
396
+ queuedCount === 0}
397
+ >
398
+ <IconUpload class="h-4 w-4" />
399
+ {isDrainingOutbox ? "Draining" : "Drain"}
400
+ </button>
401
+ </div>
402
+
403
+ {#if outboxMessage}
404
+ <div class="mt-3 rounded-lg border border-stone-200 bg-stone-50 p-3 text-sm text-stone-700">
405
+ {outboxMessage}
406
+ </div>
407
+ {/if}
408
+
409
+ <div class="mt-3">
410
+ {#if resource.loading}<DataPlaceholder />{:else if queuedCount > 0}
411
+ <div class="max-h-80 space-y-2 overflow-y-auto pr-1">
412
+ {#each outboxEvents as item}
413
+ <article class="rounded-lg border border-stone-200 bg-stone-50 p-3">
414
+ <div class="flex items-start justify-between gap-3">
415
+ <div class="min-w-0">
416
+ <div class="truncate text-xs font-semibold text-stone-800" title={item.eventId}>
417
+ {item.eventId}
418
+ </div>
419
+ <div class="mt-1 text-xs text-stone-500">
420
+ {item.retryCount} retries
421
+ </div>
422
+ </div>
423
+ <span
424
+ class="shrink-0 rounded-full px-2 py-1 text-[10.5px] font-semibold uppercase"
425
+ class:bg-yellow-100={item.status ===
426
+ "pending"}
427
+ class:text-yellow-700={item.status ===
428
+ "pending"}
429
+ class:bg-blue-100={item.status ===
430
+ "syncing"}
431
+ class:text-blue-700={item.status ===
432
+ "syncing"}
433
+ class:bg-red-100={item.status ===
434
+ "failed" || item.status ===
435
+ "needs_attention"}
436
+ class:text-red-700={item.status ===
437
+ "failed" || item.status ===
438
+ "needs_attention"}
439
+ >
440
+ {item.status}
441
+ </span>
442
+ </div>
443
+ {#if item.lastError}
444
+ <div class="mt-2 line-clamp-2 text-xs text-red-700">
445
+ {item.lastError}
446
+ </div>
447
+ {/if}
448
+ </article>
449
+ {/each}
450
+ </div>
451
+ <button
452
+ type="button"
453
+ class="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700 hover:bg-red-100"
454
+ onclick={handleClearOutbox}
455
+ disabled={resource.loading}>
456
+ <IconDeleteOutline class="h-4 w-4" />
457
+ Clear pending events
458
+ </button>
459
+ {:else}
460
+ <div class="rounded-lg border border-dashed border-stone-200 bg-stone-50 px-4 py-6 text-center text-sm text-stone-500">
461
+ No pending events
462
+ </div>
463
+ {/if}
464
+ </div>
465
+ </section>
466
+
467
+ </div>
468
+ {:else if activeTab === "events"}
469
+ <div class="mt-4 lg:mt-6"><fieldset disabled={resource.loading} inert={resource.loading}><EventLogManager /></fieldset></div>
470
+ <div class="mt-4 grid gap-4 lg:mt-6 lg:gap-6 lg:grid-cols-[19rem_minmax(0,1fr)]">
471
+ <aside class="stor-panel p-[var(--stor-panel-padding)] lg:sticky lg:top-6 lg:self-start">
472
+ <h2 class="text-base font-semibold text-stone-900">
473
+ Event stream
474
+ </h2>
475
+
476
+ <div class="relative mt-3">
477
+ <span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-stone-400">
478
+ <IconMagnify class="h-4 w-4" />
479
+ </span>
480
+ <input
481
+ type="text"
482
+ bind:value={eventSearch}
483
+ placeholder="Search events..."
484
+ class="block w-full rounded-lg border border-stone-300 py-2.5 pl-10 pr-3 text-sm outline-none focus:border-stone-500 focus:ring-2 focus:ring-stone-200"
485
+ />
486
+ </div>
487
+
488
+ <div class="mt-4 grid grid-cols-2 gap-2">
489
+ <button
490
+ type="button"
491
+ class="rounded-lg bg-stone-100 px-3 py-2 text-sm font-semibold text-stone-700 transition-colors hover:bg-stone-200"
492
+ onclick={toggleAllEvents}
493
+ >
494
+ {allExpanded ? "Collapse" : "Expand"}
495
+ </button>
496
+ <button
497
+ type="button"
498
+ class="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl bg-[var(--stor-action)] px-3 text-sm font-semibold text-[var(--stor-action-text)] transition-colors hover:bg-[var(--stor-action-hover)] sm:min-h-9"
499
+ onclick={refreshEvents}
500
+ disabled={resource.loading}>
501
+ <IconRefresh class="h-4 w-4" />
502
+ Refresh
503
+ </button>
504
+ </div>
505
+
506
+ <div class="mt-4 rounded-lg bg-stone-50 p-3 text-sm text-stone-600">
507
+ Showing <span class="font-semibold text-stone-950"><DataValue>{filteredEvents.length}</DataValue></span>
508
+ of <span class="font-semibold text-stone-950"><DataValue>{events.length}</DataValue></span>
509
+ {#if eventSearch}
510
+ <button
511
+ class="mt-2 block text-sm font-semibold text-stone-950 underline decoration-stone-300 underline-offset-4"
512
+ onclick={() => (eventSearch = "")}
513
+ >
514
+ Clear filter
515
+ </button>
516
+ {/if}
517
+ </div>
518
+ </aside>
519
+
520
+ <section class="min-w-0">
521
+ <svelte:boundary
522
+ onerror={(error: unknown) =>
523
+ console.error("Event Log Boundary Error:", error)}
524
+ >
525
+ <ul class="space-y-2 pb-4">
526
+ {#each filteredEvents as event (event.id)}
527
+ <Event {event} bind:expanded={eventStates[event.id]} />
528
+ {:else}
529
+ <EmptyState
530
+ title="No events found"
531
+ description="No event type, payload, or identifier matches the current search."
532
+ />
533
+ {/each}
534
+ </ul>
535
+
536
+ {#snippet pending()}
537
+ <div class="flex justify-center py-20">
538
+ <div class="h-8 w-8 animate-spin rounded-full border-b-2 border-stone-900"></div>
539
+ </div>
540
+ {/snippet}
541
+
542
+ {#snippet failed(error: any)}
543
+ <div
544
+ class="rounded-xl border border-red-100 bg-red-50 p-4 text-center text-red-700"
545
+ >
546
+ <p class="font-medium">Error loading events</p>
547
+ <p class="mt-1 text-sm">
548
+ {error?.message ||
549
+ String(error) ||
550
+ "Check the console for more details."}
551
+ </p>
552
+ <button
553
+ class="mt-2 text-xs text-red-600 underline"
554
+ onclick={() => location.reload()}
555
+ >
556
+ Reload page
557
+ </button>
558
+ </div>
559
+ {/snippet}
560
+ </svelte:boundary>
561
+ </section>
562
+ </div>
563
+ {:else if activeTab === "database"}
564
+ <div class="mt-4 grid min-w-0 gap-4 lg:mt-6 lg:gap-6 xl:grid-cols-2">
565
+ <LocalSqliteStats stats={sqliteStats} loading={resource.loading} />
566
+ <IndexedDbStats />
567
+ </div>
568
+ <DatabaseBrowser databasePage={resource.data.databasePage} isLoading={resource.loading || resource.refreshing} />
569
+ {:else if activeTab === 'performance'}
570
+ <div class="mt-4 lg:mt-6"><PerformanceDashboard /></div>
571
+ {:else if activeTab === "ledger" && tenant.admin}
572
+ <LedgerAdmin />
573
+ {/if}
574
+ </div>
575
+
576
+ <ConfirmDialog
577
+ show={showClearOutboxConfirm}
578
+ title="Clear Outbox"
579
+ message="Clear all pending outbox events? This will prevent them from syncing."
580
+ onConfirm={executeClearOutbox}
581
+ onCancel={() => (showClearOutboxConfirm = false)}
582
+ />
583
+
584
+ <ConfirmDialog
585
+ show={showResetDbConfirm}
586
+ title="Reset Database"
587
+ message={resetConfirmMessage}
588
+ onConfirm={executeResetDatabase}
589
+ onCancel={() => (showResetDbConfirm = false)}
590
+ />