@omnixal/openclaw-nats-plugin 0.2.3 → 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.
Files changed (31) hide show
  1. package/PLUGIN.md +9 -6
  2. package/README.md +12 -1
  3. package/dashboard/src/lib/CronPanel.svelte +206 -27
  4. package/dashboard/src/lib/LogsPanel.svelte +211 -0
  5. package/dashboard/src/lib/RoutesPanel.svelte +157 -13
  6. package/dashboard/src/lib/api.ts +77 -0
  7. package/dashboard/src/lib/components/ui/modal/index.ts +1 -0
  8. package/dashboard/src/lib/components/ui/modal/modal.svelte +49 -0
  9. package/dashboard/src/lib/utils.ts +8 -0
  10. package/package.json +1 -1
  11. package/sidecar/bun.lock +2 -2
  12. package/sidecar/package.json +1 -1
  13. package/sidecar/src/app.module.ts +2 -0
  14. package/sidecar/src/consumer/consumer.controller.ts +20 -12
  15. package/sidecar/src/consumer/consumer.module.ts +2 -1
  16. package/sidecar/src/db/migrations/0005_strong_supernaut.sql +13 -0
  17. package/sidecar/src/db/migrations/meta/0005_snapshot.json +389 -0
  18. package/sidecar/src/db/migrations/meta/_journal.json +7 -0
  19. package/sidecar/src/db/schema.ts +17 -0
  20. package/sidecar/src/logs/log.controller.ts +50 -0
  21. package/sidecar/src/logs/log.module.ts +11 -0
  22. package/sidecar/src/logs/log.repository.ts +78 -0
  23. package/sidecar/src/logs/log.service.ts +116 -0
  24. package/sidecar/src/router/router.controller.ts +28 -6
  25. package/sidecar/src/router/router.repository.ts +8 -0
  26. package/sidecar/src/router/router.service.ts +4 -0
  27. package/sidecar/src/scheduler/scheduler.controller.ts +38 -4
  28. package/sidecar/src/scheduler/scheduler.module.ts +2 -1
  29. package/sidecar/src/scheduler/scheduler.repository.ts +8 -0
  30. package/sidecar/src/scheduler/scheduler.service.ts +94 -28
  31. 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
- It shows:
74
- - **Health status** — NATS server, Gateway WebSocket, and sidecar connectivity
75
- - **Pending events** — queued inbound events with priority and age
76
- - **Configuration** — streams, consumer name, dedup TTL
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
- The dashboard auto-refreshes every 5 seconds. API calls are proxied through the Gateway (no direct sidecar access needed from the browser).
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. Shows NATS/Gateway/sidecar health, pending event queue, and configuration. Auto-refreshes every 5 seconds.
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.startsWith('agent.events.')) {
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
- async function handleToggle(name: string) {
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 (e: any) {
95
- actionError = e.message;
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 (e: any) {
105
- actionError = e.message;
174
+ } catch (err: any) {
175
+ actionError = err.message;
106
176
  }
107
177
  }
108
178
 
109
- async function handleDelete(name: string) {
179
+ async function handleDelete() {
180
+ if (!selectedJob) return;
110
181
  try {
111
- actionError = null;
112
- await deleteCronJob(name);
182
+ editError = null;
183
+ loading = true;
184
+ await deleteCronJob(selectedJob.name);
185
+ closeModal();
113
186
  onRefresh();
114
187
  } catch (e: any) {
115
- actionError = e.message;
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-40"></Table.Head>
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>