@omnixal/openclaw-nats-plugin 0.2.4 → 0.2.5
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/PLUGIN.md +9 -6
- package/README.md +12 -1
- package/dashboard/src/lib/CronPanel.svelte +206 -27
- package/dashboard/src/lib/LogsPanel.svelte +211 -0
- package/dashboard/src/lib/RoutesPanel.svelte +157 -13
- package/dashboard/src/lib/api.ts +77 -0
- package/dashboard/src/lib/components/ui/modal/index.ts +1 -0
- package/dashboard/src/lib/components/ui/modal/modal.svelte +49 -0
- package/dashboard/src/lib/utils.ts +8 -0
- package/package.json +1 -1
- package/sidecar/bun.lock +2 -2
- package/sidecar/package.json +1 -1
- package/sidecar/src/app.module.ts +2 -0
- package/sidecar/src/consumer/consumer.controller.ts +20 -12
- package/sidecar/src/consumer/consumer.module.ts +2 -1
- package/sidecar/src/db/migrations/0005_strong_supernaut.sql +13 -0
- package/sidecar/src/db/migrations/meta/0005_snapshot.json +389 -0
- package/sidecar/src/db/migrations/meta/_journal.json +7 -0
- package/sidecar/src/db/schema.ts +17 -0
- package/sidecar/src/logs/log.controller.ts +50 -0
- package/sidecar/src/logs/log.module.ts +11 -0
- package/sidecar/src/logs/log.repository.ts +78 -0
- package/sidecar/src/logs/log.service.ts +116 -0
- package/sidecar/src/router/router.controller.ts +28 -6
- package/sidecar/src/router/router.repository.ts +8 -0
- package/sidecar/src/router/router.service.ts +4 -0
- package/sidecar/src/scheduler/scheduler.controller.ts +32 -3
- package/sidecar/src/scheduler/scheduler.module.ts +2 -1
- package/sidecar/src/scheduler/scheduler.repository.ts +8 -0
- package/sidecar/src/scheduler/scheduler.service.ts +91 -25
- package/sidecar/src/validation/schemas.ts +27 -0
package/PLUGIN.md
CHANGED
|
@@ -68,14 +68,17 @@ Plugin hooks make lightweight HTTP calls to a NATS sidecar service for event pub
|
|
|
68
68
|
|
|
69
69
|
## Dashboard
|
|
70
70
|
|
|
71
|
-
The plugin includes a web dashboard at `/nats-dashboard` on the Gateway.
|
|
71
|
+
The plugin includes a web dashboard at `/nats-dashboard` on the Gateway. Auto-refreshes every 5 seconds. API calls are proxied through the Gateway (no direct sidecar access needed from the browser).
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
- **Health
|
|
75
|
-
- **
|
|
76
|
-
- **
|
|
73
|
+
Features:
|
|
74
|
+
- **Health** — NATS server, Gateway, sidecar connectivity, uptime, pending queue size, stream configuration
|
|
75
|
+
- **Routes** — full CRUD for event routing rules; pattern matching with `*` and `>` wildcards, priority, target session, delivery counters and lag
|
|
76
|
+
- **Cron Jobs** — full CRUD, pause/resume, run-now; shows next run time, last run status, timezone support
|
|
77
|
+
- **Execution Logs** — per-route and per-cron logs (deliveries, fires, errors) with pagination and filters (status, action type, subject substring)
|
|
78
|
+
- **Metrics** — per-subject publish/consume counters with last activity timestamps
|
|
79
|
+
- **Pending Events** — queued inbound events with priority and age
|
|
77
80
|
|
|
78
|
-
|
|
81
|
+
Click any route or cron job row to open a detail modal with editing and a logs tab.
|
|
79
82
|
|
|
80
83
|
Build the dashboard (required after install):
|
|
81
84
|
|
package/README.md
CHANGED
|
@@ -36,7 +36,14 @@ Setup auto-detects your runtime (Bun or Docker) and configures NATS server + sid
|
|
|
36
36
|
|
|
37
37
|
## Dashboard
|
|
38
38
|
|
|
39
|
-
Built-in web UI at `/nats-dashboard` on the Gateway.
|
|
39
|
+
Built-in web UI at `/nats-dashboard` on the Gateway. Auto-refreshes every 5 seconds.
|
|
40
|
+
|
|
41
|
+
- **Health** — NATS server, Gateway, sidecar connectivity, uptime, pending queue size
|
|
42
|
+
- **Routes** — create, edit, delete event routing rules (pattern matching with `*` and `>` wildcards, priority, target session)
|
|
43
|
+
- **Cron Jobs** — create, edit, delete, pause/resume, run-now; shows next run time and last run status
|
|
44
|
+
- **Execution Logs** — per-route and per-cron delivery/fire/error logs with pagination and filters (status, action, subject)
|
|
45
|
+
- **Metrics** — per-subject publish/consume counters
|
|
46
|
+
- **Pending Events** — queued inbound events with priority and age
|
|
40
47
|
|
|
41
48
|
## Architecture
|
|
42
49
|
|
|
@@ -52,6 +59,10 @@ OpenClaw Gateway
|
|
|
52
59
|
NATS Sidecar (OneBun service, port 3104)
|
|
53
60
|
├── Publisher → receives events via HTTP, publishes to JetStream
|
|
54
61
|
├── Consumer → subscribes to JetStream, delivers to Gateway
|
|
62
|
+
├── Router → pattern-based event routing (exact, *, >)
|
|
63
|
+
├── Scheduler → cron job management with persistent SQLite storage
|
|
64
|
+
├── Logs → execution log recording (deliveries, fires, errors)
|
|
65
|
+
├── Metrics → per-subject publish/consume counters
|
|
55
66
|
├── Dedup → idempotency key deduplication
|
|
56
67
|
├── Filter → subject allowlist/blocklist
|
|
57
68
|
├── Pending → SQLite queue for inbound events
|
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
import * as Table from '$lib/components/ui/table';
|
|
4
4
|
import { Badge } from '$lib/components/ui/badge';
|
|
5
5
|
import { Button } from '$lib/components/ui/button';
|
|
6
|
+
import { Modal } from '$lib/components/ui/modal';
|
|
7
|
+
import LogsPanel from '$lib/LogsPanel.svelte';
|
|
6
8
|
import {
|
|
7
9
|
type CronJob,
|
|
8
10
|
createCronJob,
|
|
9
11
|
deleteCronJob,
|
|
10
12
|
toggleCronJob,
|
|
11
13
|
runCronJobNow,
|
|
14
|
+
updateCronJob,
|
|
12
15
|
} from '$lib/api';
|
|
13
|
-
import { relativeAge } from '$lib/utils';
|
|
16
|
+
import { relativeAge, isValidAgentSubject } from '$lib/utils';
|
|
14
17
|
|
|
15
18
|
interface Props {
|
|
16
19
|
jobs: CronJob[];
|
|
@@ -19,6 +22,7 @@
|
|
|
19
22
|
|
|
20
23
|
let { jobs, onRefresh }: Props = $props();
|
|
21
24
|
|
|
25
|
+
// Create form
|
|
22
26
|
let showForm = $state(false);
|
|
23
27
|
let formName = $state('');
|
|
24
28
|
let formCron = $state('0 9 * * *');
|
|
@@ -29,6 +33,17 @@
|
|
|
29
33
|
let actionError: string | null = $state(null);
|
|
30
34
|
let loading = $state(false);
|
|
31
35
|
|
|
36
|
+
// Modal state
|
|
37
|
+
let selectedJob: CronJob | null = $state(null);
|
|
38
|
+
let activeTab: 'details' | 'logs' = $state('details');
|
|
39
|
+
let editCron = $state('');
|
|
40
|
+
let editSubject = $state('');
|
|
41
|
+
let editPayload = $state('');
|
|
42
|
+
let editTimezone = $state('');
|
|
43
|
+
let editEnabled = $state(true);
|
|
44
|
+
let editError: string | null = $state(null);
|
|
45
|
+
let showDeleteConfirm = $state(false);
|
|
46
|
+
|
|
32
47
|
function formatNextRun(iso: string): string {
|
|
33
48
|
try {
|
|
34
49
|
return new Date(iso).toLocaleString();
|
|
@@ -55,8 +70,8 @@
|
|
|
55
70
|
return;
|
|
56
71
|
}
|
|
57
72
|
|
|
58
|
-
if (!formSubject
|
|
59
|
-
formError = 'Subject must start with agent.events.';
|
|
73
|
+
if (!isValidAgentSubject(formSubject)) {
|
|
74
|
+
formError = 'Subject must start with "agent.events." followed by at least one token and must not end with "."';
|
|
60
75
|
return;
|
|
61
76
|
}
|
|
62
77
|
|
|
@@ -86,37 +101,209 @@
|
|
|
86
101
|
}
|
|
87
102
|
}
|
|
88
103
|
|
|
89
|
-
|
|
104
|
+
function openJobModal(job: CronJob) {
|
|
105
|
+
selectedJob = job;
|
|
106
|
+
activeTab = 'details';
|
|
107
|
+
editCron = job.expr;
|
|
108
|
+
editSubject = job.subject;
|
|
109
|
+
editPayload = job.payload ? JSON.stringify(job.payload, null, 2) : '{}';
|
|
110
|
+
editTimezone = job.timezone;
|
|
111
|
+
editEnabled = job.enabled;
|
|
112
|
+
editError = null;
|
|
113
|
+
showDeleteConfirm = false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function closeModal() {
|
|
117
|
+
selectedJob = null;
|
|
118
|
+
editError = null;
|
|
119
|
+
showDeleteConfirm = false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function handleSave() {
|
|
123
|
+
if (!selectedJob) return;
|
|
124
|
+
|
|
125
|
+
if (!isValidAgentSubject(editSubject)) {
|
|
126
|
+
editError = 'Subject must start with "agent.events." followed by at least one token';
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let parsedPayload: unknown;
|
|
131
|
+
try {
|
|
132
|
+
parsedPayload = JSON.parse(editPayload);
|
|
133
|
+
} catch {
|
|
134
|
+
editError = 'Invalid JSON payload';
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
editError = null;
|
|
140
|
+
loading = true;
|
|
141
|
+
await updateCronJob(selectedJob.name, {
|
|
142
|
+
cron: editCron,
|
|
143
|
+
subject: editSubject,
|
|
144
|
+
payload: parsedPayload,
|
|
145
|
+
timezone: editTimezone,
|
|
146
|
+
enabled: editEnabled,
|
|
147
|
+
});
|
|
148
|
+
closeModal();
|
|
149
|
+
onRefresh();
|
|
150
|
+
} catch (e: any) {
|
|
151
|
+
editError = e.message;
|
|
152
|
+
} finally {
|
|
153
|
+
loading = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function handleToggle(name: string, e: MouseEvent) {
|
|
158
|
+
e.stopPropagation();
|
|
90
159
|
try {
|
|
91
160
|
actionError = null;
|
|
92
161
|
await toggleCronJob(name);
|
|
93
162
|
onRefresh();
|
|
94
|
-
} catch (
|
|
95
|
-
actionError =
|
|
163
|
+
} catch (err: any) {
|
|
164
|
+
actionError = err.message;
|
|
96
165
|
}
|
|
97
166
|
}
|
|
98
167
|
|
|
99
|
-
async function handleRun(name: string) {
|
|
168
|
+
async function handleRun(name: string, e: MouseEvent) {
|
|
169
|
+
e.stopPropagation();
|
|
100
170
|
try {
|
|
101
171
|
actionError = null;
|
|
102
172
|
await runCronJobNow(name);
|
|
103
173
|
onRefresh();
|
|
104
|
-
} catch (
|
|
105
|
-
actionError =
|
|
174
|
+
} catch (err: any) {
|
|
175
|
+
actionError = err.message;
|
|
106
176
|
}
|
|
107
177
|
}
|
|
108
178
|
|
|
109
|
-
async function handleDelete(
|
|
179
|
+
async function handleDelete() {
|
|
180
|
+
if (!selectedJob) return;
|
|
110
181
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
182
|
+
editError = null;
|
|
183
|
+
loading = true;
|
|
184
|
+
await deleteCronJob(selectedJob.name);
|
|
185
|
+
closeModal();
|
|
113
186
|
onRefresh();
|
|
114
187
|
} catch (e: any) {
|
|
115
|
-
|
|
188
|
+
editError = e.message;
|
|
189
|
+
} finally {
|
|
190
|
+
loading = false;
|
|
116
191
|
}
|
|
117
192
|
}
|
|
118
193
|
</script>
|
|
119
194
|
|
|
195
|
+
{#if selectedJob}
|
|
196
|
+
<Modal
|
|
197
|
+
open={true}
|
|
198
|
+
title="Cron Job: {selectedJob.name}"
|
|
199
|
+
onClose={closeModal}
|
|
200
|
+
>
|
|
201
|
+
{#snippet children()}
|
|
202
|
+
<!-- Tabs -->
|
|
203
|
+
<div class="flex gap-1 border-b mb-4">
|
|
204
|
+
<button
|
|
205
|
+
class="px-3 py-1.5 text-sm font-medium border-b-2 transition-colors {activeTab === 'details' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
|
206
|
+
onclick={() => (activeTab = 'details')}
|
|
207
|
+
>Details</button>
|
|
208
|
+
<button
|
|
209
|
+
class="px-3 py-1.5 text-sm font-medium border-b-2 transition-colors {activeTab === 'logs' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
|
210
|
+
onclick={() => (activeTab = 'logs')}
|
|
211
|
+
>Logs</button>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{#if activeTab === 'details'}
|
|
215
|
+
{#if editError}
|
|
216
|
+
<div class="rounded-md bg-destructive/10 p-2 text-xs text-destructive mb-3">{editError}</div>
|
|
217
|
+
{/if}
|
|
218
|
+
|
|
219
|
+
{#if showDeleteConfirm}
|
|
220
|
+
<div class="rounded-md border border-destructive/50 bg-destructive/5 p-4 space-y-3">
|
|
221
|
+
<p class="text-sm">Are you sure you want to delete cron job <span class="font-mono font-semibold">{selectedJob.name}</span>?</p>
|
|
222
|
+
<p class="text-xs text-muted-foreground">This action cannot be undone.</p>
|
|
223
|
+
<div class="flex gap-2">
|
|
224
|
+
<Button variant="destructive" size="sm" onclick={handleDelete} disabled={loading}>
|
|
225
|
+
{loading ? 'Deleting...' : 'Confirm Delete'}
|
|
226
|
+
</Button>
|
|
227
|
+
<Button variant="outline" size="sm" onclick={() => (showDeleteConfirm = false)}>Cancel</Button>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
{:else}
|
|
231
|
+
<div class="space-y-3">
|
|
232
|
+
<div class="space-y-1">
|
|
233
|
+
<label class="text-xs text-muted-foreground" for="edit-cron">Cron Expression</label>
|
|
234
|
+
<input
|
|
235
|
+
id="edit-cron"
|
|
236
|
+
type="text"
|
|
237
|
+
bind:value={editCron}
|
|
238
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm font-mono"
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
<div class="space-y-1">
|
|
242
|
+
<label class="text-xs text-muted-foreground" for="edit-subject">Subject</label>
|
|
243
|
+
<input
|
|
244
|
+
id="edit-subject"
|
|
245
|
+
type="text"
|
|
246
|
+
bind:value={editSubject}
|
|
247
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm font-mono"
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
<div class="space-y-1">
|
|
251
|
+
<label class="text-xs text-muted-foreground" for="edit-payload">Payload (JSON)</label>
|
|
252
|
+
<textarea
|
|
253
|
+
id="edit-payload"
|
|
254
|
+
bind:value={editPayload}
|
|
255
|
+
rows="3"
|
|
256
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm font-mono"
|
|
257
|
+
></textarea>
|
|
258
|
+
</div>
|
|
259
|
+
<div class="grid grid-cols-2 gap-3">
|
|
260
|
+
<div class="space-y-1">
|
|
261
|
+
<label class="text-xs text-muted-foreground" for="edit-tz">Timezone</label>
|
|
262
|
+
<input
|
|
263
|
+
id="edit-tz"
|
|
264
|
+
type="text"
|
|
265
|
+
bind:value={editTimezone}
|
|
266
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
<div class="space-y-1">
|
|
270
|
+
<label for="edit-job-enabled" class="text-xs text-muted-foreground">Enabled</label>
|
|
271
|
+
<div class="flex items-center gap-2 pt-1">
|
|
272
|
+
<input id="edit-job-enabled" type="checkbox" bind:checked={editEnabled} />
|
|
273
|
+
<label for="edit-job-enabled" class="text-sm">{editEnabled ? 'Active' : 'Disabled'}</label>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<div class="text-xs text-muted-foreground pt-2 space-y-1">
|
|
279
|
+
<div>Last run: <span class="font-medium text-foreground">{selectedJob.lastRunAt ? relativeAge(selectedJob.lastRunAt) : '\u2014'}</span></div>
|
|
280
|
+
<div>Next run: <span class="font-medium text-foreground">{selectedJob.nextRun ? formatNextRun(selectedJob.nextRun) : '\u2014'}</span></div>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
{/if}
|
|
284
|
+
{:else}
|
|
285
|
+
<LogsPanel entityType="cron" entityId={selectedJob.id} />
|
|
286
|
+
{/if}
|
|
287
|
+
{/snippet}
|
|
288
|
+
|
|
289
|
+
{#snippet actions()}
|
|
290
|
+
{#if activeTab === 'details' && !showDeleteConfirm}
|
|
291
|
+
<div class="flex w-full justify-between">
|
|
292
|
+
<Button variant="ghost" size="sm" class="text-destructive" onclick={() => (showDeleteConfirm = true)}>
|
|
293
|
+
Delete
|
|
294
|
+
</Button>
|
|
295
|
+
<div class="flex gap-2">
|
|
296
|
+
<Button variant="outline" size="sm" onclick={closeModal}>Cancel</Button>
|
|
297
|
+
<Button size="sm" onclick={handleSave} disabled={loading}>
|
|
298
|
+
{loading ? 'Saving...' : 'Save'}
|
|
299
|
+
</Button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
{/if}
|
|
303
|
+
{/snippet}
|
|
304
|
+
</Modal>
|
|
305
|
+
{/if}
|
|
306
|
+
|
|
120
307
|
<Card.Root>
|
|
121
308
|
<Card.Header class="pb-2">
|
|
122
309
|
<div class="flex items-center justify-between">
|
|
@@ -207,12 +394,12 @@
|
|
|
207
394
|
<Table.Head>Enabled</Table.Head>
|
|
208
395
|
<Table.Head>Last Run</Table.Head>
|
|
209
396
|
<Table.Head>Next Run</Table.Head>
|
|
210
|
-
<Table.Head class="w-
|
|
397
|
+
<Table.Head class="w-32"></Table.Head>
|
|
211
398
|
</Table.Row>
|
|
212
399
|
</Table.Header>
|
|
213
400
|
<Table.Body>
|
|
214
401
|
{#each jobs as job}
|
|
215
|
-
<Table.Row>
|
|
402
|
+
<Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => openJobModal(job)}>
|
|
216
403
|
<Table.Cell class="font-mono text-xs">{job.name}</Table.Cell>
|
|
217
404
|
<Table.Cell class="font-mono">{job.expr}</Table.Cell>
|
|
218
405
|
<Table.Cell class="font-mono text-xs">{job.subject}</Table.Cell>
|
|
@@ -223,27 +410,19 @@
|
|
|
223
410
|
</Badge>
|
|
224
411
|
</Table.Cell>
|
|
225
412
|
<Table.Cell class="text-xs text-muted-foreground">
|
|
226
|
-
{job.lastRunAt ? relativeAge(job.lastRunAt) : '
|
|
413
|
+
{job.lastRunAt ? relativeAge(job.lastRunAt) : '\u2014'}
|
|
227
414
|
</Table.Cell>
|
|
228
415
|
<Table.Cell class="text-xs text-muted-foreground">
|
|
229
|
-
{job.nextRun ? formatNextRun(job.nextRun) : '
|
|
416
|
+
{job.nextRun ? formatNextRun(job.nextRun) : '\u2014'}
|
|
230
417
|
</Table.Cell>
|
|
231
418
|
<Table.Cell>
|
|
232
419
|
<div class="flex gap-1">
|
|
233
|
-
<Button variant="ghost" size="sm" onclick={() => handleToggle(job.name)}>
|
|
420
|
+
<Button variant="ghost" size="sm" onclick={(e: MouseEvent) => handleToggle(job.name, e)}>
|
|
234
421
|
{job.enabled ? 'Pause' : 'Resume'}
|
|
235
422
|
</Button>
|
|
236
|
-
<Button variant="outline" size="sm" onclick={() => handleRun(job.name)}>
|
|
423
|
+
<Button variant="outline" size="sm" onclick={(e: MouseEvent) => handleRun(job.name, e)}>
|
|
237
424
|
Run
|
|
238
425
|
</Button>
|
|
239
|
-
<Button
|
|
240
|
-
variant="ghost"
|
|
241
|
-
size="sm"
|
|
242
|
-
class="text-destructive"
|
|
243
|
-
onclick={() => handleDelete(job.name)}
|
|
244
|
-
>
|
|
245
|
-
Delete
|
|
246
|
-
</Button>
|
|
247
426
|
</div>
|
|
248
427
|
</Table.Cell>
|
|
249
428
|
</Table.Row>
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as Table from '$lib/components/ui/table';
|
|
3
|
+
import { Badge } from '$lib/components/ui/badge';
|
|
4
|
+
import { Button } from '$lib/components/ui/button';
|
|
5
|
+
import { type ExecutionLog, type LogFilters, getLogs } from '$lib/api';
|
|
6
|
+
import { relativeAge } from '$lib/utils';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
entityType: string;
|
|
10
|
+
entityId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let { entityType, entityId }: Props = $props();
|
|
14
|
+
|
|
15
|
+
let items: ExecutionLog[] = $state([]);
|
|
16
|
+
let total: number = $state(0);
|
|
17
|
+
let page: number = $state(0);
|
|
18
|
+
let loading = $state(false);
|
|
19
|
+
let error: string | null = $state(null);
|
|
20
|
+
|
|
21
|
+
// Filters
|
|
22
|
+
let filterStatus: '' | 'true' | 'false' = $state('');
|
|
23
|
+
let filterAction: string = $state('');
|
|
24
|
+
let filterSubject: string = $state('');
|
|
25
|
+
|
|
26
|
+
const PAGE_SIZE = 20;
|
|
27
|
+
|
|
28
|
+
let prevEntity = $state('');
|
|
29
|
+
$effect(() => {
|
|
30
|
+
const key = `${entityType}:${entityId}`;
|
|
31
|
+
if (key !== prevEntity) {
|
|
32
|
+
prevEntity = key;
|
|
33
|
+
page = 0;
|
|
34
|
+
filterStatus = '';
|
|
35
|
+
filterAction = '';
|
|
36
|
+
filterSubject = '';
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function buildFilters(): LogFilters | undefined {
|
|
41
|
+
const f: LogFilters = {};
|
|
42
|
+
if (filterStatus === 'true') f.success = true;
|
|
43
|
+
else if (filterStatus === 'false') f.success = false;
|
|
44
|
+
if (filterAction) f.action = filterAction;
|
|
45
|
+
if (filterSubject.trim()) f.subject = filterSubject.trim();
|
|
46
|
+
return Object.keys(f).length > 0 ? f : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadPage() {
|
|
50
|
+
loading = true;
|
|
51
|
+
error = null;
|
|
52
|
+
try {
|
|
53
|
+
const result = await getLogs(entityType, entityId, PAGE_SIZE, page * PAGE_SIZE, buildFilters());
|
|
54
|
+
items = result.items;
|
|
55
|
+
total = result.total;
|
|
56
|
+
} catch (e: any) {
|
|
57
|
+
error = e.message;
|
|
58
|
+
} finally {
|
|
59
|
+
loading = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function applyFilters() {
|
|
64
|
+
page = 0;
|
|
65
|
+
loadPage();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resetFilters() {
|
|
69
|
+
filterStatus = '';
|
|
70
|
+
filterAction = '';
|
|
71
|
+
filterSubject = '';
|
|
72
|
+
page = 0;
|
|
73
|
+
loadPage();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function prevPage() {
|
|
77
|
+
if (page > 0) {
|
|
78
|
+
page--;
|
|
79
|
+
loadPage();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function nextPage() {
|
|
84
|
+
if ((page + 1) * PAGE_SIZE < total) {
|
|
85
|
+
page++;
|
|
86
|
+
loadPage();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseDetail(detail: string | null): string {
|
|
91
|
+
if (!detail) return '';
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(detail);
|
|
94
|
+
return parsed.message || parsed.target || JSON.stringify(parsed);
|
|
95
|
+
} catch {
|
|
96
|
+
return detail;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
$effect(() => {
|
|
101
|
+
entityType; entityId;
|
|
102
|
+
loadPage();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
let totalPages = $derived(Math.max(1, Math.ceil(total / PAGE_SIZE)));
|
|
106
|
+
let hasFilters = $derived(filterStatus !== '' || filterAction !== '' || filterSubject.trim() !== '');
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<div class="space-y-3">
|
|
110
|
+
<!-- Filters -->
|
|
111
|
+
<div class="flex flex-wrap items-end gap-2">
|
|
112
|
+
<div class="space-y-1">
|
|
113
|
+
<label for="log-filter-status" class="text-xs text-muted-foreground">Status</label>
|
|
114
|
+
<select
|
|
115
|
+
id="log-filter-status"
|
|
116
|
+
bind:value={filterStatus}
|
|
117
|
+
class="rounded-md border border-input bg-background px-2 py-1 text-xs"
|
|
118
|
+
>
|
|
119
|
+
<option value="">All</option>
|
|
120
|
+
<option value="true">Success</option>
|
|
121
|
+
<option value="false">Error</option>
|
|
122
|
+
</select>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="space-y-1">
|
|
125
|
+
<label for="log-filter-action" class="text-xs text-muted-foreground">Action</label>
|
|
126
|
+
<select
|
|
127
|
+
id="log-filter-action"
|
|
128
|
+
bind:value={filterAction}
|
|
129
|
+
class="rounded-md border border-input bg-background px-2 py-1 text-xs"
|
|
130
|
+
>
|
|
131
|
+
<option value="">All</option>
|
|
132
|
+
<option value="delivery">delivery</option>
|
|
133
|
+
<option value="fire">fire</option>
|
|
134
|
+
<option value="error">error</option>
|
|
135
|
+
<option value="skip">skip</option>
|
|
136
|
+
</select>
|
|
137
|
+
</div>
|
|
138
|
+
<div class="space-y-1">
|
|
139
|
+
<label for="log-filter-subject" class="text-xs text-muted-foreground">Subject</label>
|
|
140
|
+
<input
|
|
141
|
+
id="log-filter-subject"
|
|
142
|
+
type="text"
|
|
143
|
+
bind:value={filterSubject}
|
|
144
|
+
placeholder="substring..."
|
|
145
|
+
class="rounded-md border border-input bg-background px-2 py-1 text-xs w-36"
|
|
146
|
+
onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && applyFilters()}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
<Button variant="outline" size="sm" onclick={applyFilters} disabled={loading}>Filter</Button>
|
|
150
|
+
{#if hasFilters}
|
|
151
|
+
<Button variant="ghost" size="sm" onclick={resetFilters}>Clear</Button>
|
|
152
|
+
{/if}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{#if error}
|
|
156
|
+
<div class="rounded-md bg-destructive/10 p-2 text-xs text-destructive">{error}</div>
|
|
157
|
+
{/if}
|
|
158
|
+
|
|
159
|
+
{#if items.length === 0 && !loading}
|
|
160
|
+
<p class="text-xs text-muted-foreground py-2">No logs{hasFilters ? ' matching filters' : ''}</p>
|
|
161
|
+
{:else}
|
|
162
|
+
<Table.Root>
|
|
163
|
+
<Table.Header>
|
|
164
|
+
<Table.Row>
|
|
165
|
+
<Table.Head>Time</Table.Head>
|
|
166
|
+
<Table.Head>Action</Table.Head>
|
|
167
|
+
<Table.Head>Subject</Table.Head>
|
|
168
|
+
<Table.Head>Status</Table.Head>
|
|
169
|
+
<Table.Head>Detail</Table.Head>
|
|
170
|
+
</Table.Row>
|
|
171
|
+
</Table.Header>
|
|
172
|
+
<Table.Body>
|
|
173
|
+
{#each items as log}
|
|
174
|
+
<Table.Row>
|
|
175
|
+
<Table.Cell class="text-xs text-muted-foreground whitespace-nowrap">
|
|
176
|
+
{relativeAge(log.createdAt)}
|
|
177
|
+
</Table.Cell>
|
|
178
|
+
<Table.Cell>
|
|
179
|
+
<Badge variant="outline">{log.action}</Badge>
|
|
180
|
+
</Table.Cell>
|
|
181
|
+
<Table.Cell class="font-mono text-xs">{log.subject}</Table.Cell>
|
|
182
|
+
<Table.Cell>
|
|
183
|
+
<Badge variant={log.success ? 'default' : 'destructive'}>
|
|
184
|
+
{log.success ? 'ok' : 'error'}
|
|
185
|
+
</Badge>
|
|
186
|
+
</Table.Cell>
|
|
187
|
+
<Table.Cell class="text-xs text-muted-foreground max-w-48 truncate" title={parseDetail(log.detail)}>
|
|
188
|
+
{parseDetail(log.detail)}
|
|
189
|
+
</Table.Cell>
|
|
190
|
+
</Table.Row>
|
|
191
|
+
{/each}
|
|
192
|
+
</Table.Body>
|
|
193
|
+
</Table.Root>
|
|
194
|
+
|
|
195
|
+
<!-- Pagination -->
|
|
196
|
+
<div class="flex items-center justify-between pt-1">
|
|
197
|
+
<span class="text-xs text-muted-foreground">
|
|
198
|
+
{total} log{total === 1 ? '' : 's'}{hasFilters ? ' (filtered)' : ''}
|
|
199
|
+
</span>
|
|
200
|
+
<div class="flex items-center gap-2">
|
|
201
|
+
<Button variant="outline" size="sm" onclick={prevPage} disabled={page === 0 || loading}>
|
|
202
|
+
Prev
|
|
203
|
+
</Button>
|
|
204
|
+
<span class="text-xs text-muted-foreground">{page + 1} / {totalPages}</span>
|
|
205
|
+
<Button variant="outline" size="sm" onclick={nextPage} disabled={(page + 1) * PAGE_SIZE >= total || loading}>
|
|
206
|
+
Next
|
|
207
|
+
</Button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
{/if}
|
|
211
|
+
</div>
|