@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.
- package/LICENSE +235 -0
- package/package.json +51 -3
- package/src/app.template.html +184 -0
- package/src/components/ai/DecisionReviewSheet.svelte +226 -0
- package/src/components/ai/SuggestButton.svelte +23 -0
- package/src/components/ai/SuggestionChip.svelte +13 -0
- package/src/components/ai/SuggestionMark.svelte +15 -0
- package/src/components/calendar/CalendarFeedsDrawer.svelte +414 -0
- package/src/components/calendar/CalendarMonthSummary.svelte +122 -0
- package/src/components/calendar/calendar-weeks.svelte.ts +27 -0
- package/src/components/calendar/source-icons.ts +19 -0
- package/src/components/data-table/DataTableValue.svelte +14 -0
- package/src/components/data-table/EntityActionsCell.svelte +36 -0
- package/src/components/data-table/EntityRefsCell.svelte +58 -0
- package/src/components/data-table/EntityValue.svelte +42 -0
- package/src/components/data-table/entity-columns.ts +87 -0
- package/src/components/data-table/sqlite-columns.ts +42 -0
- package/src/components/entities/CalendarSourceFields.svelte +149 -0
- package/src/components/history/ChangeRecord.svelte +30 -0
- package/src/components/history/ForkComposer.svelte +174 -0
- package/src/components/history/HistoryScrubber.svelte +245 -0
- package/src/components/history/LogHistory.svelte +334 -0
- package/src/components/history/LogMap.svelte +293 -0
- package/src/components/history/LogTree.svelte +124 -0
- package/src/components/layout/AccountMenu.svelte +111 -0
- package/src/components/layout/AppearanceOptions.svelte +78 -0
- package/src/components/layout/BrandWordmark.svelte +12 -0
- package/src/components/layout/ErrorToast.svelte +34 -0
- package/src/components/layout/LogCreateDrawer.svelte +111 -0
- package/src/components/layout/LogPanel.svelte +225 -0
- package/src/components/layout/LogStatusChip.svelte +137 -0
- package/src/components/layout/MobilePageAction.svelte +44 -0
- package/src/components/layout/MovedBanner.svelte +51 -0
- package/src/components/layout/NavBar.svelte +603 -0
- package/src/components/layout/NavFlyout.svelte +132 -0
- package/src/components/layout/NavItem.svelte +36 -0
- package/src/components/layout/NavMoreDialog.svelte +179 -0
- package/src/components/layout/PageDataStatus.svelte +36 -0
- package/src/components/layout/PhoneGlance.svelte +96 -0
- package/src/components/layout/SidebarResizer.svelte +122 -0
- package/src/components/layout/SyncIndicator.svelte +24 -0
- package/src/components/layout/feature-icons.ts +24 -0
- package/src/components/layout/nav-indicator.ts +128 -0
- package/src/components/layout/sidebar-size.ts +31 -0
- package/src/components/sync/SyncIssuesDrawer.svelte +149 -0
- package/src/components/sync/SyncStateBadge.svelte +17 -0
- package/src/pages/calendar/Page.svelte +1341 -0
- package/src/pages/calendar/load.ts +77 -0
- package/src/pages/developer/DatabaseBrowser.svelte +325 -0
- package/src/pages/developer/Event.svelte +51 -0
- package/src/pages/developer/EventLogManager.svelte +268 -0
- package/src/pages/developer/IndexedDbStats.svelte +93 -0
- package/src/pages/developer/LedgerAdmin.svelte +877 -0
- package/src/pages/developer/LocalSqliteStats.svelte +56 -0
- package/src/pages/developer/Page.svelte +590 -0
- package/src/pages/developer/PendingWorkImport.svelte +49 -0
- package/src/pages/developer/PerformanceDashboard.svelte +262 -0
- package/src/pages/developer/StackInfo.svelte +117 -0
- package/src/pages/developer/SyncLog.svelte +188 -0
- package/src/pages/developer/load.ts +28 -0
- package/src/pages/entities/Page.svelte +1069 -0
- package/src/pages/entities/load.ts +18 -0
- package/src/pages/entity/Page.svelte +655 -0
- package/src/pages/entity/load.ts +79 -0
- package/src/pages/error/ErrorPage.svelte +147 -0
- package/src/pages/layout/Layout.svelte +597 -0
- package/src/pages/layout/load.ts +28 -0
- package/src/pages/redirects/history.ts +20 -0
- package/src/pages/redirects/start.ts +11 -0
- package/src/pages/redirects/today.ts +9 -0
- package/src/service-worker/index.ts +108 -0
- package/src/service-worker/offline-bundle.ts +116 -0
- package/src/service-worker/virtual.d.ts +3 -0
- package/src/styles/index.css +11 -0
- package/src/styles/shell.css +296 -0
- package/static/_headers +4 -0
- package/static/favicon.ico +0 -0
- package/static/offline.html +44 -0
- package/static/replay/sw.js +2 -0
- package/README.md +0 -3
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Button from "@hearthlog/ui/components/ui/Button.svelte";
|
|
3
|
+
import { importPendingWork } from "@hearthlog/runtime/store/pending-work-transfer.js";
|
|
4
|
+
import { parsePendingWorkFile, type ImportReport } from "@hearthlog/runtime/store/pending-work.js";
|
|
5
|
+
|
|
6
|
+
let busy = $state(false);
|
|
7
|
+
let error = $state<string | null>(null);
|
|
8
|
+
let report = $state<ImportReport | null>(null);
|
|
9
|
+
let input = $state<HTMLInputElement | null>(null);
|
|
10
|
+
|
|
11
|
+
async function run(): Promise<void> {
|
|
12
|
+
const file = input?.files?.[0];
|
|
13
|
+
if (!file) return;
|
|
14
|
+
busy = true;
|
|
15
|
+
error = null;
|
|
16
|
+
report = null;
|
|
17
|
+
try {
|
|
18
|
+
report = await importPendingWork(parsePendingWorkFile(await file.text()));
|
|
19
|
+
} catch (cause) {
|
|
20
|
+
error = cause instanceof Error ? cause.message : "Could not read the file";
|
|
21
|
+
} finally {
|
|
22
|
+
busy = false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<div class="mt-4 border-t border-[var(--stor-border)] pt-4">
|
|
28
|
+
<h3 class="text-sm font-semibold text-[var(--stor-ink)]">Pending changes from a moved workspace</h3>
|
|
29
|
+
<p class="mt-1 text-sm text-[var(--stor-muted-text)]">Send the changes a device exported before the move. Ones already accepted are recognized.</p>
|
|
30
|
+
<div class="mt-3 flex flex-wrap items-center gap-2">
|
|
31
|
+
<input bind:this={input} type="file" accept="application/json,.json" class="text-sm" disabled={busy} data-testid="pending-work-file" />
|
|
32
|
+
<Button variant="secondary" onclick={run} disabled={busy}>{busy ? "Sending" : "Send changes"}</Button>
|
|
33
|
+
</div>
|
|
34
|
+
{#if error}
|
|
35
|
+
<p class="mt-3 text-sm text-[var(--stor-negative)]" role="alert">{error}</p>
|
|
36
|
+
{/if}
|
|
37
|
+
{#if report}
|
|
38
|
+
<div class="mt-3 text-sm" role="status">
|
|
39
|
+
<p class="text-[var(--stor-ink)]">{report.applied} applied, {report.notApplied} not applied.</p>
|
|
40
|
+
{#if report.notApplied > 0}
|
|
41
|
+
<ul class="mt-1 list-disc pl-5 text-[var(--stor-muted-text)]">
|
|
42
|
+
{#each report.results.filter((result) => result.outcome !== "applied") as result (result.requestId)}
|
|
43
|
+
<li>{result.message}</li>
|
|
44
|
+
{/each}
|
|
45
|
+
</ul>
|
|
46
|
+
{/if}
|
|
47
|
+
</div>
|
|
48
|
+
{/if}
|
|
49
|
+
</div>
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from "svelte";
|
|
3
|
+
import { page } from "$app/state";
|
|
4
|
+
import { workspaceReadiness } from "@hearthlog/ui/state/readiness.js";
|
|
5
|
+
import { currentPageResource } from "@hearthlog/ui/state/page-context.js";
|
|
6
|
+
import {
|
|
7
|
+
clearPerfEntries,
|
|
8
|
+
formatDuration,
|
|
9
|
+
getBaselineMetricLabel,
|
|
10
|
+
getBaselineMetricDescription,
|
|
11
|
+
measurementMetadata,
|
|
12
|
+
getPerfEntries,
|
|
13
|
+
subscribeToPerfEntries,
|
|
14
|
+
summarizeBaselineMetrics,
|
|
15
|
+
type PerfEntry,
|
|
16
|
+
type PerfBaselineMetric,
|
|
17
|
+
} from "@hearthlog/runtime/utils/perf.js";
|
|
18
|
+
import IconDeleteOutline from "~icons/mdi/delete-outline";
|
|
19
|
+
import IconDownload from "~icons/mdi/download";
|
|
20
|
+
import IconRefresh from "~icons/mdi/refresh";
|
|
21
|
+
|
|
22
|
+
let entries = $state<PerfEntry[]>([]);
|
|
23
|
+
const documentId = measurementMetadata().documentId as string;
|
|
24
|
+
const resource = currentPageResource();
|
|
25
|
+
const launchMetrics: PerfBaselineMetric[] = ['firstContentfulPaint', 'shellInteractive', 'dataReady', 'firstSyncComplete'];
|
|
26
|
+
const buildEntries = $derived(entries.filter(entry => entry.metadata?.build === __BUILD_TIMESTAMP__));
|
|
27
|
+
const currentEntries = $derived(buildEntries.filter(entry => entry.metadata?.documentId === documentId));
|
|
28
|
+
const pageEntry = $derived(currentEntries.find(entry => entry.baselineMetric === 'pageContentReady' && entry.metadata?.loadId === resource?.().measurement?.id));
|
|
29
|
+
const pageFailed = $derived(pageEntry?.metadata?.outcome === 'failed' || resource?.().error);
|
|
30
|
+
let summaries = $derived(summarizeBaselineMetrics(buildEntries));
|
|
31
|
+
let activeSummaries = $derived(
|
|
32
|
+
summaries.filter((summary) => summary.sampleCount > 0),
|
|
33
|
+
);
|
|
34
|
+
let idleMetricCount = $derived(summaries.length - activeSummaries.length);
|
|
35
|
+
let recentEntries = $derived(
|
|
36
|
+
buildEntries
|
|
37
|
+
.filter((entry) => entry.baselineMetric)
|
|
38
|
+
.sort((left, right) => right.timestamp - left.timestamp)
|
|
39
|
+
.slice(0, 8),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
function refreshEntries() {
|
|
43
|
+
entries = getPerfEntries();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function exportEntries() {
|
|
47
|
+
const payload = JSON.stringify(
|
|
48
|
+
{
|
|
49
|
+
exportedAt: new Date().toISOString(),
|
|
50
|
+
entries,
|
|
51
|
+
},
|
|
52
|
+
null,
|
|
53
|
+
2,
|
|
54
|
+
);
|
|
55
|
+
const blob = new Blob([payload], { type: "application/json" });
|
|
56
|
+
const url = URL.createObjectURL(blob);
|
|
57
|
+
const link = document.createElement("a");
|
|
58
|
+
link.href = url;
|
|
59
|
+
link.download = "performance-baseline.json";
|
|
60
|
+
document.body.appendChild(link);
|
|
61
|
+
link.click();
|
|
62
|
+
document.body.removeChild(link);
|
|
63
|
+
URL.revokeObjectURL(url);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
onMount(() => {
|
|
67
|
+
refreshEntries();
|
|
68
|
+
return subscribeToPerfEntries((nextEntries) => {
|
|
69
|
+
entries = nextEntries;
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<div class="stor-panel min-w-0 p-[var(--stor-panel-padding)]">
|
|
75
|
+
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
76
|
+
<div>
|
|
77
|
+
<h2 class="text-base font-semibold text-stone-900">
|
|
78
|
+
Performance
|
|
79
|
+
</h2>
|
|
80
|
+
{#if idleMetricCount > 0}
|
|
81
|
+
<p class="mt-1 text-sm text-[var(--stor-muted-text)]">
|
|
82
|
+
{idleMetricCount} idle metric{idleMetricCount === 1 ? "" : "s"}
|
|
83
|
+
</p>
|
|
84
|
+
{/if}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="flex flex-wrap gap-2">
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--stor-surface-muted)] px-3 py-1.5 text-xs font-semibold text-stone-700 hover:bg-stone-200"
|
|
91
|
+
onclick={refreshEntries}
|
|
92
|
+
>
|
|
93
|
+
<IconRefresh class="h-3.5 w-3.5" />
|
|
94
|
+
Refresh
|
|
95
|
+
</button>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--stor-surface-muted)] px-3 py-1.5 text-xs font-semibold text-stone-700 hover:bg-stone-200 disabled:opacity-50"
|
|
99
|
+
onclick={exportEntries}
|
|
100
|
+
disabled={entries.length === 0}
|
|
101
|
+
>
|
|
102
|
+
<IconDownload class="h-3.5 w-3.5" />
|
|
103
|
+
Export
|
|
104
|
+
</button>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
class="inline-flex items-center gap-1.5 rounded-lg bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100 disabled:opacity-50"
|
|
108
|
+
onclick={() => {
|
|
109
|
+
clearPerfEntries();
|
|
110
|
+
refreshEntries();
|
|
111
|
+
}}
|
|
112
|
+
disabled={entries.length === 0}
|
|
113
|
+
>
|
|
114
|
+
<IconDeleteOutline class="h-3.5 w-3.5" />
|
|
115
|
+
Clear
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<section class="mt-4 rounded-lg bg-[var(--stor-surface-muted)] p-3" aria-label="Current page performance">
|
|
121
|
+
<h3 class="text-sm font-semibold text-[var(--stor-ink)]">Current page</h3>
|
|
122
|
+
<p class="mt-1 break-all text-xs text-[var(--stor-muted-text)]">{page.url.pathname}{page.url.search}</p>
|
|
123
|
+
<dl class="mt-3">
|
|
124
|
+
<div class="flex justify-between gap-3 text-xs">
|
|
125
|
+
<dt class="font-medium text-[var(--stor-ink)]">{getBaselineMetricLabel('pageContentReady')}</dt>
|
|
126
|
+
<dd class="shrink-0 tabular-nums text-[var(--stor-muted-text)]">{pageFailed ? 'Failed' : pageEntry ? formatDuration(pageEntry.durationMs) : resource?.().loading ? 'Pending' : 'Not recorded'}</dd>
|
|
127
|
+
</div>
|
|
128
|
+
</dl>
|
|
129
|
+
<p class="mt-1 text-xs text-[var(--stor-muted-text)]">
|
|
130
|
+
{#if pageEntry?.metadata?.kind === 'startup'}
|
|
131
|
+
From opening the app, including local workspace setup.
|
|
132
|
+
{:else if pageEntry?.metadata?.kind === 'navigation'}
|
|
133
|
+
From navigating to this page until its required data has rendered.
|
|
134
|
+
{:else if pageEntry?.metadata?.kind === 'refresh'}
|
|
135
|
+
Background refresh; previously loaded content stays visible.
|
|
136
|
+
{:else if resource?.().loading}
|
|
137
|
+
Waiting for this page's required data to render.
|
|
138
|
+
{:else}
|
|
139
|
+
No retained sample for this page. Navigate or reload to record one.
|
|
140
|
+
{/if}
|
|
141
|
+
</p>
|
|
142
|
+
</section>
|
|
143
|
+
|
|
144
|
+
<section class="mt-4 rounded-lg bg-[var(--stor-surface-muted)] p-3" aria-label="App launch performance">
|
|
145
|
+
<h3 class="text-sm font-semibold text-[var(--stor-ink)]">App launch</h3>
|
|
146
|
+
<p class="mt-1 text-xs text-[var(--stor-muted-text)]">Elapsed since opening the app. These milestones overlap; do not add them together.</p>
|
|
147
|
+
<dl class="mt-3 space-y-3">
|
|
148
|
+
{#each launchMetrics as metric}
|
|
149
|
+
{@const entry = currentEntries.find(entry => entry.baselineMetric === metric)}
|
|
150
|
+
{@const failed = entry?.metadata?.outcome === 'failed' || (metric === 'dataReady' && $workspaceReadiness.error)}
|
|
151
|
+
<div>
|
|
152
|
+
<div class="flex justify-between gap-3 text-xs">
|
|
153
|
+
<dt class="font-medium text-stone-700">{getBaselineMetricLabel(metric)}</dt>
|
|
154
|
+
<dd class="shrink-0 tabular-nums text-stone-600">{failed ? 'Failed' : entry?.metadata?.outcome === 'offline' ? 'Offline' : entry?.metadata?.outcome === 'skipped' ? 'Skipped' : entry ? formatDuration(entry.durationMs) : 'Pending'}</dd>
|
|
155
|
+
</div>
|
|
156
|
+
<p class="mt-0.5 text-xs text-[var(--stor-muted-text)]">{getBaselineMetricDescription(metric)}</p>
|
|
157
|
+
{#if entry}<p class="mt-0.5 break-all text-[11px] text-[var(--stor-muted-text)]">{entry.metadata?.kind} · {entry.metadata?.route}</p>{/if}
|
|
158
|
+
</div>
|
|
159
|
+
{/each}
|
|
160
|
+
</dl>
|
|
161
|
+
</section>
|
|
162
|
+
|
|
163
|
+
<h3 class="mt-4 text-sm font-semibold text-[var(--stor-ink)]">History for this build</h3>
|
|
164
|
+
{#if activeSummaries.length > 0}
|
|
165
|
+
<div class="mt-4 overflow-hidden rounded-lg bg-[var(--stor-surface-muted)]">
|
|
166
|
+
{#each activeSummaries as summary}
|
|
167
|
+
<section class="grid gap-3 border-b border-[var(--stor-border)] p-3 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto_auto_auto] sm:items-center">
|
|
168
|
+
<div class="min-w-0">
|
|
169
|
+
<h3 class="truncate text-sm font-semibold text-stone-900">
|
|
170
|
+
{summary.label}
|
|
171
|
+
</h3>
|
|
172
|
+
<p class="mt-0.5 text-xs text-[var(--stor-muted-text)]">
|
|
173
|
+
{summary.group}
|
|
174
|
+
</p>
|
|
175
|
+
<p class="mt-1 text-xs text-[var(--stor-muted-text)]">{summary.description}</p>
|
|
176
|
+
<p class="mt-1 text-xs text-[var(--stor-muted-text)]">
|
|
177
|
+
{summary.sampleCount} sample{summary.sampleCount === 1
|
|
178
|
+
? ""
|
|
179
|
+
: "s"}
|
|
180
|
+
</p>
|
|
181
|
+
</div>
|
|
182
|
+
<div>
|
|
183
|
+
<div class="text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">
|
|
184
|
+
Latest
|
|
185
|
+
</div>
|
|
186
|
+
<div class="text-sm font-semibold text-stone-900">
|
|
187
|
+
{summary.latestMs !== null
|
|
188
|
+
? formatDuration(summary.latestMs)
|
|
189
|
+
: "—"}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
<div>
|
|
193
|
+
<div class="text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">
|
|
194
|
+
Avg
|
|
195
|
+
</div>
|
|
196
|
+
<div class="text-sm font-semibold text-stone-900">
|
|
197
|
+
{summary.averageMs !== null
|
|
198
|
+
? formatDuration(summary.averageMs)
|
|
199
|
+
: "—"}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
<div>
|
|
203
|
+
<div class="text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">
|
|
204
|
+
Slow
|
|
205
|
+
</div>
|
|
206
|
+
<div class="text-sm font-semibold text-stone-900">
|
|
207
|
+
{summary.slowCount}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</section>
|
|
211
|
+
{/each}
|
|
212
|
+
</div>
|
|
213
|
+
{:else}
|
|
214
|
+
<div
|
|
215
|
+
class="mt-4 rounded-lg border border-dashed border-[var(--stor-border)] bg-[var(--stor-surface-muted)] py-6 text-center"
|
|
216
|
+
>
|
|
217
|
+
<p class="text-sm text-[var(--stor-muted-text)]">No performance samples yet.</p>
|
|
218
|
+
</div>
|
|
219
|
+
{/if}
|
|
220
|
+
|
|
221
|
+
<div class="mt-4">
|
|
222
|
+
<div class="mb-2 flex items-center justify-between gap-3">
|
|
223
|
+
<h3 class="text-sm font-semibold text-stone-900">Recent samples</h3>
|
|
224
|
+
<span class="text-xs text-[var(--stor-muted-text)]">{recentEntries.length}</span>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{#if recentEntries.length > 0}
|
|
228
|
+
<div class="overflow-hidden rounded-lg bg-[var(--stor-surface-muted)]">
|
|
229
|
+
{#each recentEntries as entry}
|
|
230
|
+
<div class="grid gap-1 border-b border-[var(--stor-border)] px-3 py-2 text-xs last:border-b-0 sm:grid-cols-[minmax(0,1fr)_7rem_10rem] sm:items-center">
|
|
231
|
+
<div class="min-w-0">
|
|
232
|
+
<div class="truncate font-medium text-stone-900">
|
|
233
|
+
{entry.baselineMetric
|
|
234
|
+
? getBaselineMetricLabel(
|
|
235
|
+
entry.baselineMetric,
|
|
236
|
+
)
|
|
237
|
+
: entry.operation}
|
|
238
|
+
</div>
|
|
239
|
+
<div class="truncate text-[10.5px] text-[var(--stor-muted-text)]">
|
|
240
|
+
{entry.metadata?.route ?? 'Legacy sample'} · {entry.metadata?.kind ?? entry.category} · {entry.metadata?.outcome ?? 'success'}
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
<div class="font-semibold text-stone-900">
|
|
244
|
+
{formatDuration(entry.durationMs)}
|
|
245
|
+
</div>
|
|
246
|
+
<div class="text-[var(--stor-muted-text)]">
|
|
247
|
+
{new Date(entry.timestamp).toLocaleTimeString()}
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
{/each}
|
|
251
|
+
</div>
|
|
252
|
+
{:else}
|
|
253
|
+
<div
|
|
254
|
+
class="rounded-lg border border-dashed border-[var(--stor-border)] bg-[var(--stor-surface-muted)] py-8 text-center"
|
|
255
|
+
>
|
|
256
|
+
<p class="text-sm text-[var(--stor-muted-text)]">
|
|
257
|
+
No samples yet.
|
|
258
|
+
</p>
|
|
259
|
+
</div>
|
|
260
|
+
{/if}
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { tenant } from '@hearthlog/contracts/tenant/config';
|
|
4
|
+
import { formatSqliteBytes, type LocalSqliteStats } from '@hearthlog/runtime/db/local-statistics.js';
|
|
5
|
+
import IconLayersOutline from '~icons/mdi/layers-outline';
|
|
6
|
+
|
|
7
|
+
let { stats }: { stats: LocalSqliteStats | null } = $props();
|
|
8
|
+
|
|
9
|
+
const stack = __STACK_INFO__;
|
|
10
|
+
const mode = import.meta.env.VITE_E2E === '1' ? 'e2e' : import.meta.env.DEV ? 'development' : 'production';
|
|
11
|
+
const buildDate = new Date(__BUILD_TIMESTAMP__).toLocaleString('en-US');
|
|
12
|
+
const ledgerUrl = import.meta.env.VITE_LEDGER_SERVICE_URL || 'same origin';
|
|
13
|
+
|
|
14
|
+
let opfs = $state('checking');
|
|
15
|
+
let quota = $state('unknown');
|
|
16
|
+
let serviceWorker = $state('checking');
|
|
17
|
+
let online = $state(true);
|
|
18
|
+
const isolated = typeof crossOriginIsolated !== 'undefined' && crossOriginIsolated;
|
|
19
|
+
|
|
20
|
+
onMount(() => {
|
|
21
|
+
online = navigator.onLine;
|
|
22
|
+
const sync = () => (online = navigator.onLine);
|
|
23
|
+
window.addEventListener('online', sync);
|
|
24
|
+
window.addEventListener('offline', sync);
|
|
25
|
+
|
|
26
|
+
navigator.storage
|
|
27
|
+
?.getDirectory?.()
|
|
28
|
+
.then(() => (opfs = 'available'))
|
|
29
|
+
.catch(() => (opfs = 'unavailable'));
|
|
30
|
+
if (!navigator.storage?.getDirectory) opfs = 'unavailable';
|
|
31
|
+
navigator.storage
|
|
32
|
+
?.estimate?.()
|
|
33
|
+
.then(({ usage = 0, quota: limit = 0 }) => {
|
|
34
|
+
quota = `${formatSqliteBytes(usage)} of ${formatSqliteBytes(limit)}`;
|
|
35
|
+
})
|
|
36
|
+
.catch(() => {});
|
|
37
|
+
if ('serviceWorker' in navigator) {
|
|
38
|
+
navigator.serviceWorker
|
|
39
|
+
.getRegistration()
|
|
40
|
+
.then((registration) => {
|
|
41
|
+
serviceWorker = registration?.active ? 'active' : registration ? 'installing' : 'none';
|
|
42
|
+
})
|
|
43
|
+
.catch(() => (serviceWorker = 'unavailable'));
|
|
44
|
+
} else {
|
|
45
|
+
serviceWorker = 'unsupported';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
window.removeEventListener('online', sync);
|
|
50
|
+
window.removeEventListener('offline', sync);
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const frameworks = $derived([
|
|
55
|
+
['Svelte', stack.packages.svelte],
|
|
56
|
+
['SvelteKit', stack.packages['@sveltejs/kit']],
|
|
57
|
+
['Vite', stack.packages.vite],
|
|
58
|
+
['TypeScript', stack.packages.typescript],
|
|
59
|
+
['Tailwind CSS', stack.packages.tailwindcss],
|
|
60
|
+
['Bits UI', stack.packages['bits-ui']],
|
|
61
|
+
['TanStack Table', stack.packages['@tanstack/svelte-table']],
|
|
62
|
+
['TanStack Virtual', stack.packages['@tanstack/svelte-virtual']],
|
|
63
|
+
['Kysely', stack.packages.kysely],
|
|
64
|
+
['SQLocal', stack.packages.sqlocal],
|
|
65
|
+
['date-fns', stack.packages['date-fns']],
|
|
66
|
+
['Node (build)', stack.node],
|
|
67
|
+
]);
|
|
68
|
+
const runtime = $derived([
|
|
69
|
+
['SQLite', stats?.sqliteVersion ?? '…'],
|
|
70
|
+
['Schema version', stats ? String(stats.schemaVersion) : '…'],
|
|
71
|
+
['OPFS', opfs],
|
|
72
|
+
['Storage used', quota],
|
|
73
|
+
['Service worker', serviceWorker],
|
|
74
|
+
['Cross-origin isolated', isolated ? 'yes' : 'no'],
|
|
75
|
+
['Network', online ? 'online' : 'offline'],
|
|
76
|
+
]);
|
|
77
|
+
const build = $derived([
|
|
78
|
+
['Commit', stack.commit ? `${stack.commit}${stack.dirty ? ' (dirty)' : ''}` : 'unknown'],
|
|
79
|
+
['Branch', stack.branch || 'unknown'],
|
|
80
|
+
['Built', buildDate],
|
|
81
|
+
['Mode', mode],
|
|
82
|
+
]);
|
|
83
|
+
const site = $derived([
|
|
84
|
+
['Tenant', tenant.id],
|
|
85
|
+
['Ledger app', tenant.ledgerAppName],
|
|
86
|
+
['Features', String(tenant.features.size)],
|
|
87
|
+
['Ledger URL', ledgerUrl],
|
|
88
|
+
]);
|
|
89
|
+
const groups = $derived([
|
|
90
|
+
{ title: 'Frameworks', rows: frameworks },
|
|
91
|
+
{ title: 'Runtime', rows: runtime },
|
|
92
|
+
{ title: 'Build', rows: build },
|
|
93
|
+
{ title: 'Site', rows: site },
|
|
94
|
+
]);
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<section class="stor-panel min-w-0 p-[var(--stor-panel-padding)]" aria-labelledby="stack-info-heading" data-testid="stack-info">
|
|
98
|
+
<div class="flex items-start gap-3">
|
|
99
|
+
<IconLayersOutline class="mt-0.5 h-5 w-5 shrink-0 text-[var(--stor-accent-strong)]" />
|
|
100
|
+
<h2 id="stack-info-heading" class="text-base font-semibold text-[var(--stor-ink)]">Stack</h2>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="mt-4 grid gap-x-8 gap-y-5 sm:grid-cols-2 xl:grid-cols-4">
|
|
103
|
+
{#each groups as group (group.title)}
|
|
104
|
+
<div class="min-w-0">
|
|
105
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-[var(--stor-muted-text)]">{group.title}</h3>
|
|
106
|
+
<dl class="mt-2 space-y-1.5 text-sm">
|
|
107
|
+
{#each group.rows as [label, value] (label)}
|
|
108
|
+
<div class="flex items-baseline justify-between gap-3">
|
|
109
|
+
<dt class="shrink-0 text-[var(--stor-muted-text)]">{label}</dt>
|
|
110
|
+
<dd class="min-w-0 truncate font-semibold tabular-nums text-[var(--stor-ink)]" title={value}>{value}</dd>
|
|
111
|
+
</div>
|
|
112
|
+
{/each}
|
|
113
|
+
</dl>
|
|
114
|
+
</div>
|
|
115
|
+
{/each}
|
|
116
|
+
</div>
|
|
117
|
+
</section>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from "svelte";
|
|
3
|
+
import {
|
|
4
|
+
syncLog,
|
|
5
|
+
clearSyncLog,
|
|
6
|
+
loadSyncLog,
|
|
7
|
+
loadMoreSyncLog,
|
|
8
|
+
type SyncLogType,
|
|
9
|
+
} from "@hearthlog/runtime/store/sync.svelte.js";
|
|
10
|
+
import IconDeleteOutline from "~icons/mdi/delete-outline";
|
|
11
|
+
|
|
12
|
+
const typeConfig: Record<
|
|
13
|
+
SyncLogType,
|
|
14
|
+
{ label: string; bgColor: string; textColor: string }
|
|
15
|
+
> = {
|
|
16
|
+
upload: {
|
|
17
|
+
label: "UPLOAD",
|
|
18
|
+
bgColor: "bg-green-100",
|
|
19
|
+
textColor: "text-green-700",
|
|
20
|
+
},
|
|
21
|
+
download: {
|
|
22
|
+
label: "DOWNLOAD",
|
|
23
|
+
bgColor: "bg-blue-100",
|
|
24
|
+
textColor: "text-blue-700",
|
|
25
|
+
},
|
|
26
|
+
ws_receive: {
|
|
27
|
+
label: "WEBSOCKET",
|
|
28
|
+
bgColor: "bg-cyan-100",
|
|
29
|
+
textColor: "text-cyan-700",
|
|
30
|
+
},
|
|
31
|
+
ws_ready: {
|
|
32
|
+
label: "READY",
|
|
33
|
+
bgColor: "bg-emerald-100",
|
|
34
|
+
textColor: "text-emerald-700",
|
|
35
|
+
},
|
|
36
|
+
ws_close: {
|
|
37
|
+
label: "DISCONNECT",
|
|
38
|
+
bgColor: "bg-[var(--stor-surface-muted)]",
|
|
39
|
+
textColor: "text-stone-600",
|
|
40
|
+
},
|
|
41
|
+
ws_error: {
|
|
42
|
+
label: "WS ERROR",
|
|
43
|
+
bgColor: "bg-orange-100",
|
|
44
|
+
textColor: "text-orange-700",
|
|
45
|
+
},
|
|
46
|
+
ws_resync: {
|
|
47
|
+
label: "RESYNC",
|
|
48
|
+
bgColor: "bg-violet-100",
|
|
49
|
+
textColor: "text-violet-700",
|
|
50
|
+
},
|
|
51
|
+
drain: {
|
|
52
|
+
label: "DRAIN",
|
|
53
|
+
bgColor: "bg-amber-100",
|
|
54
|
+
textColor: "text-amber-700",
|
|
55
|
+
},
|
|
56
|
+
error: {
|
|
57
|
+
label: "ERROR",
|
|
58
|
+
bgColor: "bg-red-100",
|
|
59
|
+
textColor: "text-red-700",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let expandedIds = $state<Set<string>>(new Set());
|
|
64
|
+
let isLoadingMore = $state(false);
|
|
65
|
+
|
|
66
|
+
onMount(() => {
|
|
67
|
+
loadSyncLog();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function toggleExpanded(id: string) {
|
|
71
|
+
const newSet = new Set(expandedIds);
|
|
72
|
+
if (newSet.has(id)) {
|
|
73
|
+
newSet.delete(id);
|
|
74
|
+
} else {
|
|
75
|
+
newSet.add(id);
|
|
76
|
+
}
|
|
77
|
+
expandedIds = newSet;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatTime(date: Date): string {
|
|
81
|
+
return date.toLocaleTimeString("en-US", {
|
|
82
|
+
hour12: false,
|
|
83
|
+
hour: "2-digit",
|
|
84
|
+
minute: "2-digit",
|
|
85
|
+
second: "2-digit",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function handleLoadMore() {
|
|
90
|
+
isLoadingMore = true;
|
|
91
|
+
try {
|
|
92
|
+
await loadMoreSyncLog();
|
|
93
|
+
} finally {
|
|
94
|
+
isLoadingMore = false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<div class="stor-panel p-[var(--stor-panel-padding)]">
|
|
100
|
+
<div class="mb-3 flex items-center justify-between gap-3">
|
|
101
|
+
<h2 class="text-base font-semibold text-stone-900">
|
|
102
|
+
Recent sync
|
|
103
|
+
</h2>
|
|
104
|
+
<div class="flex gap-2 items-center">
|
|
105
|
+
<span class="text-xs text-[var(--stor-muted-text)]">
|
|
106
|
+
{syncLog.entries.length} entries{syncLog.hasMore ? "+" : ""}
|
|
107
|
+
</span>
|
|
108
|
+
{#if syncLog.entries.length > 0}
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--stor-surface-muted)] px-3 py-1.5 text-xs font-semibold text-stone-700 hover:bg-stone-200"
|
|
112
|
+
onclick={clearSyncLog}
|
|
113
|
+
>
|
|
114
|
+
<IconDeleteOutline class="h-3.5 w-3.5" />
|
|
115
|
+
Clear
|
|
116
|
+
</button>
|
|
117
|
+
{/if}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{#if syncLog.entries.length > 0}
|
|
122
|
+
<div
|
|
123
|
+
class="max-h-80 overflow-y-auto rounded-lg bg-[var(--stor-surface-muted)]"
|
|
124
|
+
>
|
|
125
|
+
{#each syncLog.entries as entry (entry.id)}
|
|
126
|
+
{@const config = typeConfig[entry.type]}
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
class="w-full border-b border-[var(--stor-border)] px-3 py-2 text-left transition-colors last:border-b-0 hover:bg-[var(--stor-surface-muted)] focus:outline-none focus:ring-2 focus:ring-inset focus:ring-stone-300"
|
|
130
|
+
onclick={() => entry.details && toggleExpanded(entry.id)}
|
|
131
|
+
aria-expanded={entry.details
|
|
132
|
+
? expandedIds.has(entry.id)
|
|
133
|
+
: undefined}
|
|
134
|
+
>
|
|
135
|
+
<div class="flex items-center gap-2">
|
|
136
|
+
<span
|
|
137
|
+
class="w-14 shrink-0 text-[10.5px] text-[var(--stor-muted-text)]"
|
|
138
|
+
>
|
|
139
|
+
{formatTime(entry.timestamp)}
|
|
140
|
+
</span>
|
|
141
|
+
<span
|
|
142
|
+
class="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide {config.bgColor} {config.textColor}"
|
|
143
|
+
>
|
|
144
|
+
{config.label}
|
|
145
|
+
</span>
|
|
146
|
+
<span class="text-xs text-stone-700 truncate flex-1">
|
|
147
|
+
{entry.message}
|
|
148
|
+
</span>
|
|
149
|
+
{#if entry.details}
|
|
150
|
+
<span
|
|
151
|
+
class="text-stone-300 text-xs transition-transform"
|
|
152
|
+
class:rotate-90={expandedIds.has(entry.id)}
|
|
153
|
+
>
|
|
154
|
+
▶
|
|
155
|
+
</span>
|
|
156
|
+
{/if}
|
|
157
|
+
</div>
|
|
158
|
+
{#if entry.details && expandedIds.has(entry.id)}
|
|
159
|
+
<div
|
|
160
|
+
class="mt-2 ml-[4rem] rounded bg-[var(--stor-surface-muted)] px-2 py-1 text-[11px] text-[var(--stor-muted-text)]"
|
|
161
|
+
>
|
|
162
|
+
{entry.details}
|
|
163
|
+
</div>
|
|
164
|
+
{/if}
|
|
165
|
+
</button>
|
|
166
|
+
{/each}
|
|
167
|
+
|
|
168
|
+
{#if syncLog.hasMore}
|
|
169
|
+
<div class="py-3 text-center">
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
class="text-xs text-stone-700 hover:text-stone-950 font-medium disabled:opacity-50"
|
|
173
|
+
onclick={handleLoadMore}
|
|
174
|
+
disabled={isLoadingMore}
|
|
175
|
+
>
|
|
176
|
+
{isLoadingMore ? "Loading..." : "Load More"}
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
{/if}
|
|
180
|
+
</div>
|
|
181
|
+
{:else}
|
|
182
|
+
<div
|
|
183
|
+
class="rounded-lg border border-dashed border-[var(--stor-border)] bg-[var(--stor-surface-muted)] py-6 text-center"
|
|
184
|
+
>
|
|
185
|
+
<p class="text-sm text-[var(--stor-muted-text)]">No sync events</p>
|
|
186
|
+
</div>
|
|
187
|
+
{/if}
|
|
188
|
+
</div>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { LoadEvent } from '@sveltejs/kit';
|
|
2
|
+
type PageLoad = (event: LoadEvent) => unknown;
|
|
3
|
+
import { deferPage } from '@hearthlog/runtime/runtime/page-data.svelte.js';
|
|
4
|
+
import { getAllEvents } from '@hearthlog/runtime/event/index.js';
|
|
5
|
+
import { getAllOutboxEvents } from '@hearthlog/runtime/store/outbox.js';
|
|
6
|
+
import { loadRawSqliteTablePage, parseRawSqlitePage, parseRawSqlitePageSize, parseRawSqliteSortDirection, parseRawSqliteSortField } from '@hearthlog/runtime/db/raw-browser.js';
|
|
7
|
+
import { loadLocalSqliteStats } from '@hearthlog/runtime/db/local-statistics.js';
|
|
8
|
+
import { tenant } from '@hearthlog/contracts/tenant/config';
|
|
9
|
+
export const load = ((event) => {
|
|
10
|
+
event.depends('app:developer');
|
|
11
|
+
const requestedTab = event.url.searchParams.get('tab');
|
|
12
|
+
const tab = requestedTab === 'events' || requestedTab === 'database' || requestedTab === 'performance'
|
|
13
|
+
|| (requestedTab === 'ledger' && tenant.admin) ? requestedTab : 'overview';
|
|
14
|
+
const table = event.url.searchParams.get('table');
|
|
15
|
+
const page = parseRawSqlitePage(event.url.searchParams.get('page'));
|
|
16
|
+
const pageSize = parseRawSqlitePageSize(event.url.searchParams.get('pageSize'));
|
|
17
|
+
const sort = parseRawSqliteSortField(event.url.searchParams.get('sort'));
|
|
18
|
+
const direction = parseRawSqliteSortDirection(event.url.searchParams.get('direction'));
|
|
19
|
+
return deferPage(event, { events: [], outboxEvents: [], databasePage: null, sqliteStats: null }, async () => {
|
|
20
|
+
const [events, outboxEvents, databasePage, sqliteStats] = await Promise.all([
|
|
21
|
+
tab === 'events' ? getAllEvents() : Promise.resolve([]),
|
|
22
|
+
tab === 'overview' ? getAllOutboxEvents() : Promise.resolve([]),
|
|
23
|
+
tab === 'database' ? loadRawSqliteTablePage({ tableName: table, page, pageSize, sort, direction }) : Promise.resolve(null),
|
|
24
|
+
tab === 'overview' || tab === 'database' ? loadLocalSqliteStats() : Promise.resolve(null),
|
|
25
|
+
]);
|
|
26
|
+
return { events, outboxEvents, databasePage, sqliteStats };
|
|
27
|
+
});
|
|
28
|
+
}) satisfies PageLoad;
|