@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 +7 -0
- package/package.json +16 -0
- package/src/app.html +13 -0
- package/src/lib/api.js +79 -0
- package/src/lib/components/BackupList.svelte +68 -0
- package/src/lib/components/ProgressBar.svelte +20 -0
- package/src/lib/components/StatusBadge.svelte +24 -0
- package/src/lib/components/TimelineSelector.svelte +54 -0
- package/src/routes/+layout.js +1 -0
- package/src/routes/+layout.server.js +9 -0
- package/src/routes/+layout.svelte +44 -0
- package/src/routes/+page.svelte +95 -0
- package/src/routes/readmodels/+page.svelte +121 -0
- package/src/routes/readmodels/[name]/+page.js +4 -0
- package/src/routes/readmodels/[name]/+page.svelte +116 -0
- package/src/routes/readmodels/[name]/backups/+page.js +4 -0
- package/src/routes/readmodels/[name]/backups/+page.svelte +111 -0
- package/src/routes/readmodels/[name]/replay/+page.js +4 -0
- package/src/routes/readmodels/[name]/replay/+page.svelte +409 -0
- package/svelte.config.js +7 -0
- package/vite.config.js +6 -0
package/Dockerfile
ADDED
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} · 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,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">← 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,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">← 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,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">← 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}
|
package/svelte.config.js
ADDED