@lazyapps/admin-ui 0.0.0-init.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/Dockerfile ADDED
@@ -0,0 +1,7 @@
1
+ FROM node:lts
2
+ WORKDIR /app
3
+ COPY package.json ./
4
+ RUN npm install
5
+ COPY . ./
6
+ EXPOSE 5173
7
+ CMD ["npx", "vite", "dev", "--host"]
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@lazyapps/admin-ui",
3
+ "version": "0.0.0-init.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite dev",
7
+ "build": "vite build",
8
+ "preview": "vite preview"
9
+ },
10
+ "devDependencies": {
11
+ "@sveltejs/adapter-auto": "^3.0.0",
12
+ "@sveltejs/kit": "^2.0.0",
13
+ "svelte": "^5.0.0",
14
+ "vite": "^6.0.0"
15
+ }
16
+ }
package/src/app.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <script src="https://cdn.tailwindcss.com"></script>
7
+ <title>LazyApps Admin</title>
8
+ %sveltekit.head%
9
+ </head>
10
+ <body data-sveltekit-preload-data="hover">
11
+ <div style="display: contents">%sveltekit.body%</div>
12
+ </body>
13
+ </html>
package/src/lib/api.js ADDED
@@ -0,0 +1,79 @@
1
+ const jsonFetch = (url, options) =>
2
+ fetch(url, {
3
+ ...options,
4
+ headers: {
5
+ 'Content-Type': 'application/json',
6
+ ...options?.headers,
7
+ },
8
+ }).then((r) => {
9
+ if (r.status === 204) return null;
10
+ return r.json().then((body) => (r.ok ? body : Promise.reject(body)));
11
+ });
12
+
13
+ export const createAdminClient = (
14
+ commandProcessorUrl,
15
+ readModelServiceUrls,
16
+ ) => ({
17
+ // Command processor endpoints
18
+
19
+ startReplay: (readModel, fromTimestamp, toTimestamp) =>
20
+ jsonFetch(`${commandProcessorUrl}/api/admin/startReplay`, {
21
+ method: 'POST',
22
+ body: JSON.stringify({ readModel, fromTimestamp, toTimestamp }),
23
+ }),
24
+
25
+ getReplayStatus: (readModel) =>
26
+ jsonFetch(`${commandProcessorUrl}/api/admin/replayStatus/${readModel}`),
27
+
28
+ cancelReplay: (readModel) =>
29
+ jsonFetch(`${commandProcessorUrl}/api/admin/cancelReplay`, {
30
+ method: 'POST',
31
+ body: JSON.stringify({ readModel }),
32
+ }),
33
+
34
+ // Read model service endpoints
35
+
36
+ getStatus: (serviceUrl) => jsonFetch(`${serviceUrl}/admin/status`),
37
+
38
+ getReadModels: (serviceUrl) => jsonFetch(`${serviceUrl}/admin/readmodels`),
39
+
40
+ prepareReplay: (serviceUrl, readModel, options) =>
41
+ jsonFetch(`${serviceUrl}/admin/replay/${readModel}/prepare`, {
42
+ method: 'POST',
43
+ body: JSON.stringify(options),
44
+ }),
45
+
46
+ getReplayReadModelStatus: (serviceUrl, readModel) =>
47
+ jsonFetch(`${serviceUrl}/admin/replay/${readModel}/status`),
48
+
49
+ createBackup: (serviceUrl, readModel) =>
50
+ jsonFetch(`${serviceUrl}/admin/backup/${readModel}`, {
51
+ method: 'POST',
52
+ body: '{}',
53
+ }),
54
+
55
+ listBackups: (serviceUrl, readModel) =>
56
+ jsonFetch(`${serviceUrl}/admin/backups/${readModel}`),
57
+
58
+ deleteBackup: (serviceUrl, backupId) =>
59
+ jsonFetch(`${serviceUrl}/admin/backup/${backupId}`, {
60
+ method: 'DELETE',
61
+ }),
62
+
63
+ // Helpers
64
+
65
+ getServiceUrl: (serviceName) => readModelServiceUrls[serviceName],
66
+
67
+ getServiceNames: () => Object.keys(readModelServiceUrls),
68
+
69
+ findServiceForReadModel: (readModelName) =>
70
+ Promise.all(
71
+ Object.entries(readModelServiceUrls).map(([name, url]) =>
72
+ jsonFetch(`${url}/admin/readmodels`)
73
+ .then((models) =>
74
+ models.find((m) => m.name === readModelName) ? { name, url } : null,
75
+ )
76
+ .catch(() => null),
77
+ ),
78
+ ).then((results) => results.find((r) => r !== null) || null),
79
+ });
@@ -0,0 +1,68 @@
1
+ <script>
2
+ let { backups = [], ondelete, onrestore } = $props();
3
+
4
+ const formatDate = (ts) => {
5
+ if (!ts) return 'N/A';
6
+ return new Date(ts).toLocaleString();
7
+ };
8
+ </script>
9
+
10
+ {#if backups.length === 0}
11
+ <p class="text-gray-500 text-sm">No backups available.</p>
12
+ {:else}
13
+ <div class="overflow-x-auto">
14
+ <table class="min-w-full divide-y divide-gray-200">
15
+ <thead class="bg-gray-50">
16
+ <tr>
17
+ <th
18
+ class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase"
19
+ >Backup ID</th
20
+ >
21
+ <th
22
+ class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase"
23
+ >Created</th
24
+ >
25
+ <th
26
+ class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase"
27
+ >Event Timestamp</th
28
+ >
29
+ <th
30
+ class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase"
31
+ >Actions</th
32
+ >
33
+ </tr>
34
+ </thead>
35
+ <tbody class="bg-white divide-y divide-gray-200">
36
+ {#each backups as backup}
37
+ <tr>
38
+ <td class="px-4 py-2 text-sm font-mono text-gray-900"
39
+ >{backup.backupId}</td
40
+ >
41
+ <td class="px-4 py-2 text-sm text-gray-600"
42
+ >{formatDate(backup.timestamp)}</td
43
+ >
44
+ <td class="px-4 py-2 text-sm text-gray-600"
45
+ >{formatDate(backup.eventTimestamp)}</td
46
+ >
47
+ <td class="px-4 py-2 text-right space-x-2">
48
+ {#if onrestore}
49
+ <button
50
+ onclick={() => onrestore(backup.backupId)}
51
+ class="text-sm text-blue-600 hover:text-blue-800"
52
+ >Restore</button
53
+ >
54
+ {/if}
55
+ {#if ondelete}
56
+ <button
57
+ onclick={() => ondelete(backup.backupId)}
58
+ class="text-sm text-red-600 hover:text-red-800"
59
+ >Delete</button
60
+ >
61
+ {/if}
62
+ </td>
63
+ </tr>
64
+ {/each}
65
+ </tbody>
66
+ </table>
67
+ </div>
68
+ {/if}
@@ -0,0 +1,20 @@
1
+ <script>
2
+ let { current = 0, total = 0, label = '' } = $props();
3
+
4
+ let percentage = $derived(total > 0 ? Math.round((current / total) * 100) : 0);
5
+ </script>
6
+
7
+ <div>
8
+ {#if label}
9
+ <div class="flex justify-between text-sm text-gray-600 mb-1">
10
+ <span>{label}</span>
11
+ <span>{current} / {total} ({percentage}%)</span>
12
+ </div>
13
+ {/if}
14
+ <div class="w-full bg-gray-200 rounded-full h-2.5">
15
+ <div
16
+ class="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
17
+ style="width: {percentage}%"
18
+ ></div>
19
+ </div>
20
+ </div>
@@ -0,0 +1,24 @@
1
+ <script>
2
+ let { status } = $props();
3
+
4
+ const colors = {
5
+ active: 'bg-green-100 text-green-800',
6
+ idle: 'bg-gray-100 text-gray-800',
7
+ replaying: 'bg-yellow-100 text-yellow-800',
8
+ in_progress: 'bg-yellow-100 text-yellow-800',
9
+ prepared: 'bg-blue-100 text-blue-800',
10
+ completed: 'bg-green-100 text-green-800',
11
+ cancelled: 'bg-gray-100 text-gray-600',
12
+ error: 'bg-red-100 text-red-800',
13
+ started: 'bg-blue-100 text-blue-800',
14
+ cancelling: 'bg-orange-100 text-orange-800',
15
+ };
16
+
17
+ let colorClass = $derived(colors[status] || 'bg-gray-100 text-gray-800');
18
+ </script>
19
+
20
+ <span
21
+ class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {colorClass}"
22
+ >
23
+ {status}
24
+ </span>
@@ -0,0 +1,54 @@
1
+ <script>
2
+ let { fromTimestamp = $bindable(0), toTimestamp = $bindable(null) } = $props();
3
+
4
+ let useToTimestamp = $state(false);
5
+
6
+ const tsToDatetime = (ts) => {
7
+ if (!ts) return '';
8
+ return new Date(ts).toISOString().slice(0, 16);
9
+ };
10
+
11
+ const datetimeToTs = (dt) => {
12
+ if (!dt) return null;
13
+ return new Date(dt).getTime();
14
+ };
15
+
16
+ let fromDatetime = $state(tsToDatetime(fromTimestamp));
17
+ let toDatetime = $state(tsToDatetime(toTimestamp));
18
+
19
+ $effect(() => {
20
+ fromTimestamp = datetimeToTs(fromDatetime) || 0;
21
+ });
22
+
23
+ $effect(() => {
24
+ toTimestamp = useToTimestamp ? datetimeToTs(toDatetime) : null;
25
+ });
26
+ </script>
27
+
28
+ <div class="space-y-3">
29
+ <div>
30
+ <label for="from-ts" class="block text-sm font-medium text-gray-700"
31
+ >From</label
32
+ >
33
+ <input
34
+ id="from-ts"
35
+ type="datetime-local"
36
+ bind:value={fromDatetime}
37
+ class="mt-1 block w-full rounded border-gray-300 shadow-sm text-sm px-3 py-2 border"
38
+ />
39
+ </div>
40
+
41
+ <div>
42
+ <label class="flex items-center space-x-2">
43
+ <input type="checkbox" bind:checked={useToTimestamp} class="rounded" />
44
+ <span class="text-sm text-gray-700">Set end timestamp</span>
45
+ </label>
46
+ {#if useToTimestamp}
47
+ <input
48
+ type="datetime-local"
49
+ bind:value={toDatetime}
50
+ class="mt-1 block w-full rounded border-gray-300 shadow-sm text-sm px-3 py-2 border"
51
+ />
52
+ {/if}
53
+ </div>
54
+ </div>
@@ -0,0 +1 @@
1
+ export const ssr = false;
@@ -0,0 +1,9 @@
1
+ import { env } from '$env/dynamic/private';
2
+
3
+ export const load = () => ({
4
+ commandProcessorUrl:
5
+ env.ADMIN_COMMAND_PROCESSOR_URL || 'http://localhost:3001',
6
+ readModelServices: JSON.parse(
7
+ env.ADMIN_READ_MODEL_SERVICES || '{"default":"http://localhost:3002"}',
8
+ ),
9
+ });
@@ -0,0 +1,44 @@
1
+ <script>
2
+ import { setContext } from 'svelte';
3
+ import { createAdminClient } from '$lib/api.js';
4
+
5
+ let { data, children } = $props();
6
+
7
+ const api = createAdminClient(
8
+ data.commandProcessorUrl,
9
+ data.readModelServices,
10
+ );
11
+ setContext('api', api);
12
+ setContext('config', {
13
+ commandProcessorUrl: data.commandProcessorUrl,
14
+ readModelServices: data.readModelServices,
15
+ });
16
+ </script>
17
+
18
+ <div class="min-h-screen bg-gray-50">
19
+ <nav class="bg-white border-b border-gray-200">
20
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
21
+ <div class="flex h-14 items-center justify-between">
22
+ <div class="flex items-center space-x-8">
23
+ <a href="/" class="text-lg font-bold text-gray-900"
24
+ >LazyApps Admin</a
25
+ >
26
+ <a
27
+ href="/"
28
+ class="text-sm text-gray-600 hover:text-gray-900"
29
+ >Dashboard</a
30
+ >
31
+ <a
32
+ href="/readmodels"
33
+ class="text-sm text-gray-600 hover:text-gray-900"
34
+ >Read Models</a
35
+ >
36
+ </div>
37
+ </div>
38
+ </div>
39
+ </nav>
40
+
41
+ <main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
42
+ {@render children()}
43
+ </main>
44
+ </div>
@@ -0,0 +1,95 @@
1
+ <script>
2
+ import { getContext } from 'svelte';
3
+ import StatusBadge from '$lib/components/StatusBadge.svelte';
4
+
5
+ const api = getContext('api');
6
+ const config = getContext('config');
7
+
8
+ let services = $state({});
9
+ let loading = $state(true);
10
+ let error = $state(null);
11
+
12
+ const loadServices = () => {
13
+ loading = true;
14
+ error = null;
15
+
16
+ Promise.all(
17
+ Object.entries(config.readModelServices).map(([name, url]) =>
18
+ api
19
+ .getStatus(url)
20
+ .then((status) => ({ name, url, ...status, error: null }))
21
+ .catch((err) => ({
22
+ name,
23
+ url,
24
+ error: String(err),
25
+ readModels: [],
26
+ })),
27
+ ),
28
+ ).then((results) => {
29
+ const svc = {};
30
+ results.forEach((r) => {
31
+ svc[r.name] = r;
32
+ });
33
+ services = svc;
34
+ loading = false;
35
+ });
36
+ };
37
+
38
+ $effect(() => {
39
+ loadServices();
40
+ });
41
+ </script>
42
+
43
+ <div class="flex items-center justify-between mb-6">
44
+ <h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
45
+ <button
46
+ onclick={loadServices}
47
+ class="px-3 py-1.5 bg-gray-100 text-gray-700 text-sm rounded hover:bg-gray-200"
48
+ >
49
+ Refresh
50
+ </button>
51
+ </div>
52
+
53
+ {#if loading}
54
+ <p class="text-gray-500">Loading services...</p>
55
+ {:else}
56
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
57
+ {#each Object.entries(services) as [name, service]}
58
+ <div class="bg-white rounded-lg shadow p-6">
59
+ <div class="flex items-center justify-between mb-3">
60
+ <h2 class="text-lg font-semibold text-gray-900">{name}</h2>
61
+ {#if service.error}
62
+ <StatusBadge status="error" />
63
+ {:else}
64
+ <StatusBadge status="active" />
65
+ {/if}
66
+ </div>
67
+
68
+ {#if service.error}
69
+ <p class="text-sm text-red-500">{service.error}</p>
70
+ {:else}
71
+ <p class="text-xs text-gray-500 mb-3">
72
+ {service.service} &middot; Uptime: {Math.round(
73
+ service.uptime / 1000,
74
+ )}s
75
+ </p>
76
+ <div class="space-y-2">
77
+ {#each service.readModels || [] as rm}
78
+ <div class="flex items-center justify-between">
79
+ <a
80
+ href="/readmodels/{rm.name}?service={name}"
81
+ class="text-sm text-blue-600 hover:underline"
82
+ >
83
+ {rm.name}
84
+ </a>
85
+ <StatusBadge
86
+ status={rm.replaying ? 'replaying' : 'active'}
87
+ />
88
+ </div>
89
+ {/each}
90
+ </div>
91
+ {/if}
92
+ </div>
93
+ {/each}
94
+ </div>
95
+ {/if}
@@ -0,0 +1,121 @@
1
+ <script>
2
+ import { getContext } from 'svelte';
3
+ import StatusBadge from '$lib/components/StatusBadge.svelte';
4
+
5
+ const api = getContext('api');
6
+ const config = getContext('config');
7
+
8
+ let readModels = $state([]);
9
+ let loading = $state(true);
10
+
11
+ const loadReadModels = () => {
12
+ loading = true;
13
+ Promise.all(
14
+ Object.entries(config.readModelServices).map(([serviceName, url]) =>
15
+ api
16
+ .getReadModels(url)
17
+ .then((models) =>
18
+ models.map((m) => ({ ...m, serviceName, serviceUrl: url })),
19
+ )
20
+ .catch(() => []),
21
+ ),
22
+ ).then((results) => {
23
+ readModels = results.flat();
24
+ loading = false;
25
+ });
26
+ };
27
+
28
+ $effect(() => {
29
+ loadReadModels();
30
+ });
31
+
32
+ const formatTimestamp = (ts) => {
33
+ if (!ts) return 'N/A';
34
+ return new Date(ts).toLocaleString();
35
+ };
36
+ </script>
37
+
38
+ <div class="flex items-center justify-between mb-6">
39
+ <h1 class="text-2xl font-bold text-gray-900">Read Models</h1>
40
+ <button
41
+ onclick={loadReadModels}
42
+ class="px-3 py-1.5 bg-gray-100 text-gray-700 text-sm rounded hover:bg-gray-200"
43
+ >
44
+ Refresh
45
+ </button>
46
+ </div>
47
+
48
+ {#if loading}
49
+ <p class="text-gray-500">Loading read models...</p>
50
+ {:else if readModels.length === 0}
51
+ <p class="text-gray-500">No read models found.</p>
52
+ {:else}
53
+ <div class="bg-white shadow rounded-lg overflow-hidden">
54
+ <table class="min-w-full divide-y divide-gray-200">
55
+ <thead class="bg-gray-50">
56
+ <tr>
57
+ <th
58
+ class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
59
+ >Name</th
60
+ >
61
+ <th
62
+ class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
63
+ >Service</th
64
+ >
65
+ <th
66
+ class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
67
+ >Last Projected</th
68
+ >
69
+ <th
70
+ class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
71
+ >Status</th
72
+ >
73
+ <th
74
+ class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
75
+ >Collections</th
76
+ >
77
+ <th
78
+ class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase"
79
+ >Actions</th
80
+ >
81
+ </tr>
82
+ </thead>
83
+ <tbody class="bg-white divide-y divide-gray-200">
84
+ {#each readModels as rm}
85
+ <tr>
86
+ <td class="px-6 py-4 text-sm font-medium text-gray-900">
87
+ <a
88
+ href="/readmodels/{rm.name}?service={rm.serviceName}"
89
+ class="text-blue-600 hover:underline">{rm.name}</a
90
+ >
91
+ </td>
92
+ <td class="px-6 py-4 text-sm text-gray-600"
93
+ >{rm.serviceName}</td
94
+ >
95
+ <td class="px-6 py-4 text-sm text-gray-600"
96
+ >{formatTimestamp(rm.lastProjectedEventTimestamp)}</td
97
+ >
98
+ <td class="px-6 py-4">
99
+ <StatusBadge status={rm.status} />
100
+ </td>
101
+ <td class="px-6 py-4 text-sm text-gray-600 font-mono"
102
+ >{rm.collections?.join(', ')}</td
103
+ >
104
+ <td class="px-6 py-4 text-right space-x-3">
105
+ <a
106
+ href="/readmodels/{rm.name}/backups?service={rm.serviceName}"
107
+ class="text-sm text-gray-600 hover:text-gray-900"
108
+ >Backups</a
109
+ >
110
+ <a
111
+ href="/readmodels/{rm.name}/replay?service={rm.serviceName}"
112
+ class="text-sm text-blue-600 hover:text-blue-800"
113
+ >Replay</a
114
+ >
115
+ </td>
116
+ </tr>
117
+ {/each}
118
+ </tbody>
119
+ </table>
120
+ </div>
121
+ {/if}
@@ -0,0 +1,4 @@
1
+ export const load = ({ params, url }) => ({
2
+ name: params.name,
3
+ service: url.searchParams.get('service'),
4
+ });
@@ -0,0 +1,116 @@
1
+ <script>
2
+ import { getContext } from 'svelte';
3
+ import StatusBadge from '$lib/components/StatusBadge.svelte';
4
+
5
+ let { data } = $props();
6
+
7
+ const api = getContext('api');
8
+ const config = getContext('config');
9
+
10
+ let readModel = $state(null);
11
+ let replayStatus = $state(null);
12
+ let loading = $state(true);
13
+
14
+ const serviceUrl = $derived(
15
+ data.service
16
+ ? config.readModelServices[data.service]
17
+ : Object.values(config.readModelServices)[0],
18
+ );
19
+
20
+ const loadData = () => {
21
+ loading = true;
22
+ Promise.all([
23
+ api
24
+ .getReadModels(serviceUrl)
25
+ .then((models) => models.find((m) => m.name === data.name) || null),
26
+ api.getReplayReadModelStatus(serviceUrl, data.name).catch(() => null),
27
+ ]).then(([rm, replay]) => {
28
+ readModel = rm;
29
+ replayStatus = replay;
30
+ loading = false;
31
+ });
32
+ };
33
+
34
+ $effect(() => {
35
+ if (serviceUrl) loadData();
36
+ });
37
+
38
+ const formatTimestamp = (ts) => {
39
+ if (!ts) return 'N/A';
40
+ return new Date(ts).toLocaleString();
41
+ };
42
+ </script>
43
+
44
+ <div class="mb-4">
45
+ <a
46
+ href="/readmodels?service={data.service}"
47
+ class="text-sm text-blue-600 hover:underline">&larr; Back to Read Models</a
48
+ >
49
+ </div>
50
+
51
+ {#if loading}
52
+ <p class="text-gray-500">Loading...</p>
53
+ {:else if !readModel}
54
+ <p class="text-red-500">Read model "{data.name}" not found.</p>
55
+ {:else}
56
+ <div class="flex items-center justify-between mb-6">
57
+ <div>
58
+ <h1 class="text-2xl font-bold text-gray-900">{data.name}</h1>
59
+ <p class="text-sm text-gray-500">Service: {data.service}</p>
60
+ </div>
61
+ <StatusBadge status={readModel.status} />
62
+ </div>
63
+
64
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
65
+ <div class="bg-white rounded-lg shadow p-6">
66
+ <h2 class="text-sm font-medium text-gray-500 mb-2">Details</h2>
67
+ <dl class="space-y-2">
68
+ <div>
69
+ <dt class="text-xs text-gray-400">Last Projected Event</dt>
70
+ <dd class="text-sm text-gray-900">
71
+ {formatTimestamp(readModel.lastProjectedEventTimestamp)}
72
+ </dd>
73
+ </div>
74
+ <div>
75
+ <dt class="text-xs text-gray-400">Collections</dt>
76
+ <dd class="text-sm font-mono text-gray-900">
77
+ {readModel.collections?.join(', ')}
78
+ </dd>
79
+ </div>
80
+ </dl>
81
+ </div>
82
+
83
+ {#if replayStatus}
84
+ <div class="bg-white rounded-lg shadow p-6">
85
+ <h2 class="text-sm font-medium text-gray-500 mb-2">
86
+ Replay Status
87
+ </h2>
88
+ <div class="flex items-center space-x-2">
89
+ <StatusBadge status={replayStatus.status} />
90
+ {#if replayStatus.lastProjectedEventTimestamp}
91
+ <span class="text-xs text-gray-500">
92
+ Last: {formatTimestamp(
93
+ replayStatus.lastProjectedEventTimestamp,
94
+ )}
95
+ </span>
96
+ {/if}
97
+ </div>
98
+ </div>
99
+ {/if}
100
+ </div>
101
+
102
+ <div class="flex space-x-4">
103
+ <a
104
+ href="/readmodels/{data.name}/backups?service={data.service}"
105
+ class="px-4 py-2 bg-white border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-50"
106
+ >
107
+ Manage Backups
108
+ </a>
109
+ <a
110
+ href="/readmodels/{data.name}/replay?service={data.service}"
111
+ class="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
112
+ >
113
+ Start Replay
114
+ </a>
115
+ </div>
116
+ {/if}
@@ -0,0 +1,4 @@
1
+ export const load = ({ params, url }) => ({
2
+ name: params.name,
3
+ service: url.searchParams.get('service'),
4
+ });
@@ -0,0 +1,111 @@
1
+ <script>
2
+ import { getContext } from 'svelte';
3
+ import BackupList from '$lib/components/BackupList.svelte';
4
+
5
+ let { data } = $props();
6
+
7
+ const api = getContext('api');
8
+ const config = getContext('config');
9
+
10
+ let backups = $state([]);
11
+ let loading = $state(true);
12
+ let creating = $state(false);
13
+ let error = $state(null);
14
+
15
+ const serviceUrl = $derived(
16
+ data.service
17
+ ? config.readModelServices[data.service]
18
+ : Object.values(config.readModelServices)[0],
19
+ );
20
+
21
+ const loadBackups = () => {
22
+ loading = true;
23
+ error = null;
24
+ api
25
+ .listBackups(serviceUrl, data.name)
26
+ .then((result) => {
27
+ backups = result;
28
+ loading = false;
29
+ })
30
+ .catch((err) => {
31
+ error = err.error || String(err);
32
+ loading = false;
33
+ });
34
+ };
35
+
36
+ $effect(() => {
37
+ if (serviceUrl) loadBackups();
38
+ });
39
+
40
+ const handleCreate = () => {
41
+ creating = true;
42
+ error = null;
43
+ api
44
+ .createBackup(serviceUrl, data.name)
45
+ .then(() => {
46
+ creating = false;
47
+ loadBackups();
48
+ })
49
+ .catch((err) => {
50
+ error = err.error || String(err);
51
+ creating = false;
52
+ });
53
+ };
54
+
55
+ const handleDelete = (backupId) => {
56
+ error = null;
57
+ api
58
+ .deleteBackup(serviceUrl, backupId)
59
+ .then(() => {
60
+ loadBackups();
61
+ })
62
+ .catch((err) => {
63
+ error = err.error || String(err);
64
+ });
65
+ };
66
+ </script>
67
+
68
+ <div class="mb-4">
69
+ <a
70
+ href="/readmodels/{data.name}?service={data.service}"
71
+ class="text-sm text-blue-600 hover:underline">&larr; Back to {data.name}</a
72
+ >
73
+ </div>
74
+
75
+ <div class="flex items-center justify-between mb-6">
76
+ <div>
77
+ <h1 class="text-2xl font-bold text-gray-900">Backups: {data.name}</h1>
78
+ <p class="text-sm text-gray-500">Service: {data.service}</p>
79
+ </div>
80
+ <div class="flex space-x-2">
81
+ <button
82
+ onclick={handleCreate}
83
+ disabled={creating}
84
+ class="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
85
+ >
86
+ {creating ? 'Creating...' : 'Create Backup'}
87
+ </button>
88
+ <button
89
+ onclick={loadBackups}
90
+ class="px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded hover:bg-gray-200"
91
+ >
92
+ Refresh
93
+ </button>
94
+ </div>
95
+ </div>
96
+
97
+ {#if error}
98
+ <div
99
+ class="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700"
100
+ >
101
+ {error}
102
+ </div>
103
+ {/if}
104
+
105
+ {#if loading}
106
+ <p class="text-gray-500">Loading backups...</p>
107
+ {:else}
108
+ <div class="bg-white rounded-lg shadow p-6">
109
+ <BackupList {backups} ondelete={handleDelete} />
110
+ </div>
111
+ {/if}
@@ -0,0 +1,4 @@
1
+ export const load = ({ params, url }) => ({
2
+ name: params.name,
3
+ service: url.searchParams.get('service'),
4
+ });
@@ -0,0 +1,409 @@
1
+ <script>
2
+ import { getContext } from 'svelte';
3
+ import StatusBadge from '$lib/components/StatusBadge.svelte';
4
+ import ProgressBar from '$lib/components/ProgressBar.svelte';
5
+ import TimelineSelector from '$lib/components/TimelineSelector.svelte';
6
+
7
+ let { data } = $props();
8
+
9
+ const api = getContext('api');
10
+ const config = getContext('config');
11
+
12
+ // UI state
13
+ let step = $state('configure'); // configure | preparing | prepared | replaying | done | error
14
+ let error = $state(null);
15
+ let warnings = $state([]);
16
+
17
+ // Configuration
18
+ let replayMode = $state('current'); // current | fromScratch | fromBackup
19
+ let selectedBackupId = $state(null);
20
+ let fromTimestamp = $state(0);
21
+ let toTimestamp = $state(null);
22
+ let backups = $state([]);
23
+
24
+ // Replay state
25
+ let prepareResult = $state(null);
26
+ let replayProgress = $state(null);
27
+ let pollTimer = $state(null);
28
+
29
+ const serviceUrl = $derived(
30
+ data.service
31
+ ? config.readModelServices[data.service]
32
+ : Object.values(config.readModelServices)[0],
33
+ );
34
+
35
+ const loadBackups = () => {
36
+ api
37
+ .listBackups(serviceUrl, data.name)
38
+ .then((result) => {
39
+ backups = result;
40
+ })
41
+ .catch(() => {
42
+ backups = [];
43
+ });
44
+ };
45
+
46
+ const checkExistingReplay = () => {
47
+ api
48
+ .getReplayReadModelStatus(serviceUrl, data.name)
49
+ .then((status) => {
50
+ if (status && status.status === 'in_progress') {
51
+ step = 'replaying';
52
+ startPolling();
53
+ }
54
+ })
55
+ .catch(() => {});
56
+ };
57
+
58
+ $effect(() => {
59
+ if (serviceUrl) {
60
+ loadBackups();
61
+ checkExistingReplay();
62
+ }
63
+ });
64
+
65
+ // Cleanup polling on unmount
66
+ $effect(() => () => {
67
+ if (pollTimer) clearInterval(pollTimer);
68
+ });
69
+
70
+ const handlePrepare = () => {
71
+ step = 'preparing';
72
+ error = null;
73
+ warnings = [];
74
+
75
+ const options = {};
76
+ if (replayMode === 'fromScratch') options.fromScratch = true;
77
+ if (replayMode === 'fromBackup') options.backupId = selectedBackupId;
78
+
79
+ api
80
+ .prepareReplay(serviceUrl, data.name, options)
81
+ .then((result) => {
82
+ prepareResult = result;
83
+ fromTimestamp = result.fromTimestamp || 0;
84
+ warnings = result.warnings || [];
85
+ step = 'prepared';
86
+ })
87
+ .catch((err) => {
88
+ error = err.error || String(err);
89
+ step = 'error';
90
+ });
91
+ };
92
+
93
+ const handleStartReplay = () => {
94
+ error = null;
95
+
96
+ api
97
+ .startReplay(data.name, fromTimestamp, toTimestamp)
98
+ .then(() => {
99
+ step = 'replaying';
100
+ startPolling();
101
+ })
102
+ .catch((err) => {
103
+ error = err.error || String(err);
104
+ step = 'error';
105
+ });
106
+ };
107
+
108
+ const handleCancel = () => {
109
+ api
110
+ .cancelReplay(data.name)
111
+ .then(() => {
112
+ stopPolling();
113
+ step = 'configure';
114
+ replayProgress = null;
115
+ prepareResult = null;
116
+ })
117
+ .catch((err) => {
118
+ error = err.error || String(err);
119
+ });
120
+ };
121
+
122
+ const startPolling = () => {
123
+ stopPolling();
124
+ pollStatus();
125
+ pollTimer = setInterval(pollStatus, 2000);
126
+ };
127
+
128
+ const stopPolling = () => {
129
+ if (pollTimer) {
130
+ clearInterval(pollTimer);
131
+ pollTimer = null;
132
+ }
133
+ };
134
+
135
+ const pollStatus = () => {
136
+ Promise.all([
137
+ api.getReplayStatus(data.name).catch(() => null),
138
+ api.getReplayReadModelStatus(serviceUrl, data.name).catch(() => null),
139
+ ]).then(([cpStatus, rmStatus]) => {
140
+ replayProgress = {
141
+ eventsPublished: cpStatus?.eventsPublished || 0,
142
+ eventsTotal: cpStatus?.eventsTotal || 0,
143
+ cpStatus: cpStatus?.status || 'unknown',
144
+ rmStatus: rmStatus?.status || 'unknown',
145
+ };
146
+
147
+ if (
148
+ cpStatus?.status === 'completed' &&
149
+ rmStatus?.status !== 'in_progress'
150
+ ) {
151
+ stopPolling();
152
+ step = 'done';
153
+ }
154
+ });
155
+ };
156
+
157
+ const handleReset = () => {
158
+ step = 'configure';
159
+ error = null;
160
+ warnings = [];
161
+ prepareResult = null;
162
+ replayProgress = null;
163
+ selectedBackupId = null;
164
+ replayMode = 'current';
165
+ fromTimestamp = 0;
166
+ toTimestamp = null;
167
+ };
168
+
169
+ const formatTimestamp = (ts) => {
170
+ if (!ts) return 'N/A';
171
+ return new Date(ts).toLocaleString();
172
+ };
173
+ </script>
174
+
175
+ <div class="mb-4">
176
+ <a
177
+ href="/readmodels/{data.name}?service={data.service}"
178
+ class="text-sm text-blue-600 hover:underline">&larr; Back to {data.name}</a
179
+ >
180
+ </div>
181
+
182
+ <div class="mb-6">
183
+ <h1 class="text-2xl font-bold text-gray-900">Replay: {data.name}</h1>
184
+ <p class="text-sm text-gray-500">Service: {data.service}</p>
185
+ </div>
186
+
187
+ {#if error}
188
+ <div
189
+ class="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700"
190
+ >
191
+ {error}
192
+ {#if step === 'error'}
193
+ <button
194
+ onclick={handleReset}
195
+ class="ml-2 text-red-800 underline hover:no-underline">Reset</button
196
+ >
197
+ {/if}
198
+ </div>
199
+ {/if}
200
+
201
+ {#if warnings.length > 0}
202
+ <div
203
+ class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-800"
204
+ >
205
+ <p class="font-medium mb-1">Shared collection warnings:</p>
206
+ <ul class="list-disc list-inside">
207
+ {#each warnings as warning}
208
+ <li>{warning}</li>
209
+ {/each}
210
+ </ul>
211
+ <p class="mt-1 text-xs">
212
+ Consider replaying affected read models sequentially.
213
+ </p>
214
+ </div>
215
+ {/if}
216
+
217
+ {#if step === 'configure'}
218
+ <div class="bg-white rounded-lg shadow p-6 space-y-6">
219
+ <h2 class="text-lg font-semibold text-gray-900">Step 1: Configure Replay</h2>
220
+
221
+ <div>
222
+ <p class="text-sm font-medium text-gray-700 mb-3">Starting point</p>
223
+ <div class="space-y-2">
224
+ <label class="flex items-center space-x-2">
225
+ <input
226
+ type="radio"
227
+ bind:group={replayMode}
228
+ value="current"
229
+ class="text-blue-600"
230
+ />
231
+ <span class="text-sm text-gray-700"
232
+ >From current state (replay missed events only)</span
233
+ >
234
+ </label>
235
+ <label class="flex items-center space-x-2">
236
+ <input
237
+ type="radio"
238
+ bind:group={replayMode}
239
+ value="fromScratch"
240
+ class="text-blue-600"
241
+ />
242
+ <span class="text-sm text-gray-700"
243
+ >From scratch (clear data, replay all events)</span
244
+ >
245
+ </label>
246
+ <label class="flex items-center space-x-2">
247
+ <input
248
+ type="radio"
249
+ bind:group={replayMode}
250
+ value="fromBackup"
251
+ class="text-blue-600"
252
+ />
253
+ <span class="text-sm text-gray-700"
254
+ >From backup (restore backup, replay from that point)</span
255
+ >
256
+ </label>
257
+ </div>
258
+ </div>
259
+
260
+ {#if replayMode === 'fromBackup'}
261
+ <div>
262
+ <p class="text-sm font-medium text-gray-700 mb-2">Select backup</p>
263
+ {#if backups.length === 0}
264
+ <p class="text-sm text-gray-500">No backups available.</p>
265
+ {:else}
266
+ <select
267
+ bind:value={selectedBackupId}
268
+ class="block w-full rounded border-gray-300 shadow-sm text-sm px-3 py-2 border"
269
+ >
270
+ <option value={null}>-- Select a backup --</option>
271
+ {#each backups as backup}
272
+ <option value={backup.backupId}>
273
+ {backup.backupId} ({formatTimestamp(backup.timestamp)})
274
+ </option>
275
+ {/each}
276
+ </select>
277
+ {/if}
278
+ </div>
279
+ {/if}
280
+
281
+ <div>
282
+ <p class="text-sm font-medium text-gray-700 mb-2">
283
+ Event time range (optional override)
284
+ </p>
285
+ <TimelineSelector bind:fromTimestamp bind:toTimestamp />
286
+ </div>
287
+
288
+ <button
289
+ onclick={handlePrepare}
290
+ disabled={replayMode === 'fromBackup' && !selectedBackupId}
291
+ class="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
292
+ >
293
+ Prepare Replay
294
+ </button>
295
+ </div>
296
+ {:else if step === 'preparing'}
297
+ <div class="bg-white rounded-lg shadow p-6">
298
+ <p class="text-gray-500">Preparing replay...</p>
299
+ </div>
300
+ {:else if step === 'prepared'}
301
+ <div class="bg-white rounded-lg shadow p-6 space-y-4">
302
+ <h2 class="text-lg font-semibold text-gray-900">
303
+ Step 2: Start Replay
304
+ </h2>
305
+ <div class="bg-gray-50 rounded p-4 space-y-2 text-sm">
306
+ <p>
307
+ <span class="text-gray-500">Read Model:</span>
308
+ <span class="font-medium">{prepareResult.readModel}</span>
309
+ </p>
310
+ <p>
311
+ <span class="text-gray-500">Replay from:</span>
312
+ <span class="font-medium"
313
+ >{formatTimestamp(prepareResult.fromTimestamp) || 'Beginning'}</span
314
+ >
315
+ </p>
316
+ {#if toTimestamp}
317
+ <p>
318
+ <span class="text-gray-500">Replay to:</span>
319
+ <span class="font-medium">{formatTimestamp(toTimestamp)}</span>
320
+ </p>
321
+ {/if}
322
+ {#if prepareResult.preReplayBackupId}
323
+ <p>
324
+ <span class="text-gray-500">Safety backup:</span>
325
+ <span class="font-mono text-xs"
326
+ >{prepareResult.preReplayBackupId}</span
327
+ >
328
+ </p>
329
+ {/if}
330
+ </div>
331
+
332
+ <div class="flex space-x-3">
333
+ <button
334
+ onclick={handleStartReplay}
335
+ class="px-4 py-2 bg-green-600 text-white rounded text-sm hover:bg-green-700"
336
+ >
337
+ Start Replay
338
+ </button>
339
+ <button
340
+ onclick={handleCancel}
341
+ class="px-4 py-2 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300"
342
+ >
343
+ Cancel
344
+ </button>
345
+ </div>
346
+ </div>
347
+ {:else if step === 'replaying'}
348
+ <div class="bg-white rounded-lg shadow p-6 space-y-4">
349
+ <div class="flex items-center justify-between">
350
+ <h2 class="text-lg font-semibold text-gray-900">Replay In Progress</h2>
351
+ <StatusBadge status="replaying" />
352
+ </div>
353
+
354
+ {#if replayProgress}
355
+ <ProgressBar
356
+ current={replayProgress.eventsPublished}
357
+ total={replayProgress.eventsTotal}
358
+ label="Events published"
359
+ />
360
+ <div class="grid grid-cols-2 gap-4 text-sm">
361
+ <div>
362
+ <span class="text-gray-500">Command Processor:</span>
363
+ <StatusBadge status={replayProgress.cpStatus} />
364
+ </div>
365
+ <div>
366
+ <span class="text-gray-500">Read Model:</span>
367
+ <StatusBadge status={replayProgress.rmStatus} />
368
+ </div>
369
+ </div>
370
+ {:else}
371
+ <p class="text-gray-500">Waiting for status...</p>
372
+ {/if}
373
+
374
+ <button
375
+ onclick={handleCancel}
376
+ class="px-4 py-2 bg-red-600 text-white rounded text-sm hover:bg-red-700"
377
+ >
378
+ Cancel Replay
379
+ </button>
380
+ </div>
381
+ {:else if step === 'done'}
382
+ <div class="bg-white rounded-lg shadow p-6 space-y-4">
383
+ <div class="flex items-center space-x-2">
384
+ <h2 class="text-lg font-semibold text-green-700">Replay Complete</h2>
385
+ <StatusBadge status="completed" />
386
+ </div>
387
+
388
+ {#if replayProgress}
389
+ <p class="text-sm text-gray-600">
390
+ {replayProgress.eventsPublished} events replayed.
391
+ </p>
392
+ {/if}
393
+
394
+ <div class="flex space-x-3">
395
+ <a
396
+ href="/readmodels/{data.name}?service={data.service}"
397
+ class="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
398
+ >
399
+ View Read Model
400
+ </a>
401
+ <button
402
+ onclick={handleReset}
403
+ class="px-4 py-2 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300"
404
+ >
405
+ Start New Replay
406
+ </button>
407
+ </div>
408
+ </div>
409
+ {/if}
@@ -0,0 +1,7 @@
1
+ import adapter from '@sveltejs/adapter-auto';
2
+
3
+ export default {
4
+ kit: {
5
+ adapter: adapter(),
6
+ },
7
+ };
package/vite.config.js ADDED
@@ -0,0 +1,6 @@
1
+ import { sveltekit } from '@sveltejs/kit/vite';
2
+ import { defineConfig } from 'vite';
3
+
4
+ export default defineConfig({
5
+ plugins: [sveltekit()],
6
+ });