@omnixal/openclaw-nats-plugin 0.2.7 → 0.2.8
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/dashboard/src/App.svelte +17 -4
- package/dashboard/src/lib/TimerPanel.svelte +327 -0
- package/dashboard/src/lib/api.ts +35 -0
- package/dashboard/src/lib/utils.ts +1 -0
- package/package.json +1 -1
- package/plugins/nats-context-engine/index.ts +143 -0
- package/sidecar/src/consumer/consumer.controller.ts +3 -1
- package/sidecar/src/db/migrations/0006_cooing_ultimatum.sql +1 -0
- package/sidecar/src/db/migrations/0007_dizzy_komodo.sql +14 -0
- package/sidecar/src/db/migrations/meta/0006_snapshot.json +396 -0
- package/sidecar/src/db/migrations/meta/0007_snapshot.json +485 -0
- package/sidecar/src/db/migrations/meta/_journal.json +14 -0
- package/sidecar/src/db/schema.ts +18 -0
- package/sidecar/src/gateway/gateway-client.service.ts +93 -5
- package/sidecar/src/router/router.controller.ts +1 -2
- package/sidecar/src/router/router.repository.ts +2 -1
- package/sidecar/src/router/router.service.ts +2 -2
- package/sidecar/src/scheduler/scheduler.controller.ts +40 -0
- package/sidecar/src/scheduler/scheduler.repository.ts +49 -2
- package/sidecar/src/scheduler/scheduler.service.ts +107 -0
- package/sidecar/src/validation/schemas.ts +9 -0
- package/skills/nats-events/SKILL.md +81 -12
package/dashboard/src/App.svelte
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
3
|
import {
|
|
4
|
-
getHealth, getPending, getRoutes, getCronJobs, getMetrics,
|
|
5
|
-
type HealthStatus, type PendingEvent, type EventRoute, type CronJob, type SubjectMetric,
|
|
4
|
+
getHealth, getPending, getRoutes, getCronJobs, getTimers, getMetrics,
|
|
5
|
+
type HealthStatus, type PendingEvent, type EventRoute, type CronJob, type TimerJob, type SubjectMetric,
|
|
6
6
|
} from '$lib/api';
|
|
7
7
|
import HealthCards from '$lib/HealthCards.svelte';
|
|
8
8
|
import PendingTable from '$lib/PendingTable.svelte';
|
|
9
9
|
import ConfigPanel from '$lib/ConfigPanel.svelte';
|
|
10
10
|
import RoutesPanel from '$lib/RoutesPanel.svelte';
|
|
11
11
|
import CronPanel from '$lib/CronPanel.svelte';
|
|
12
|
+
import TimerPanel from '$lib/TimerPanel.svelte';
|
|
12
13
|
import MetricsPanel from '$lib/MetricsPanel.svelte';
|
|
13
14
|
import ThemeToggle from '$lib/ThemeToggle.svelte';
|
|
14
15
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
|
@@ -17,31 +18,35 @@
|
|
|
17
18
|
let pending: PendingEvent[] = $state([]);
|
|
18
19
|
let routes: EventRoute[] = $state([]);
|
|
19
20
|
let cronJobs: CronJob[] = $state([]);
|
|
21
|
+
let timers: TimerJob[] = $state([]);
|
|
20
22
|
let metrics: SubjectMetric[] = $state([]);
|
|
21
23
|
let error: string | null = $state(null);
|
|
22
|
-
let activeTab: 'pending' | 'routes' | 'cron' | 'metrics' = $state('pending');
|
|
24
|
+
let activeTab: 'pending' | 'routes' | 'cron' | 'timers' | 'metrics' = $state('pending');
|
|
23
25
|
|
|
24
26
|
const tabs = [
|
|
25
27
|
{ id: 'pending' as const, label: 'Pending' },
|
|
26
28
|
{ id: 'routes' as const, label: 'Routes' },
|
|
27
29
|
{ id: 'cron' as const, label: 'Cron Jobs' },
|
|
30
|
+
{ id: 'timers' as const, label: 'Timers' },
|
|
28
31
|
{ id: 'metrics' as const, label: 'Metrics' },
|
|
29
32
|
];
|
|
30
33
|
|
|
31
34
|
async function refresh() {
|
|
32
35
|
try {
|
|
33
36
|
error = null;
|
|
34
|
-
const [h, p, r, c, m] = await Promise.all([
|
|
37
|
+
const [h, p, r, c, t, m] = await Promise.all([
|
|
35
38
|
getHealth(),
|
|
36
39
|
getPending('default'),
|
|
37
40
|
getRoutes(),
|
|
38
41
|
getCronJobs(),
|
|
42
|
+
getTimers(),
|
|
39
43
|
getMetrics(),
|
|
40
44
|
]);
|
|
41
45
|
health = h;
|
|
42
46
|
pending = p;
|
|
43
47
|
routes = r;
|
|
44
48
|
cronJobs = c;
|
|
49
|
+
timers = t;
|
|
45
50
|
metrics = m;
|
|
46
51
|
} catch (e: any) {
|
|
47
52
|
error = e.message;
|
|
@@ -91,6 +96,12 @@
|
|
|
91
96
|
{#if tab.id === 'pending' && pending.length > 0}
|
|
92
97
|
<span class="ml-1.5 inline-flex items-center justify-center rounded-full bg-destructive/15 text-destructive text-xs font-semibold min-w-5 h-5 px-1.5">{pending.length}</span>
|
|
93
98
|
{/if}
|
|
99
|
+
{#if tab.id === 'timers'}
|
|
100
|
+
{@const pendingTimers = timers.filter(t => !t.fired).length}
|
|
101
|
+
{#if pendingTimers > 0}
|
|
102
|
+
<span class="ml-1.5 inline-flex items-center justify-center rounded-full bg-primary/15 text-primary text-xs font-semibold min-w-5 h-5 px-1.5">{pendingTimers}</span>
|
|
103
|
+
{/if}
|
|
104
|
+
{/if}
|
|
94
105
|
</button>
|
|
95
106
|
{/each}
|
|
96
107
|
</div>
|
|
@@ -101,6 +112,8 @@
|
|
|
101
112
|
<RoutesPanel {routes} onRefresh={refresh} />
|
|
102
113
|
{:else if activeTab === 'cron'}
|
|
103
114
|
<CronPanel jobs={cronJobs} onRefresh={refresh} />
|
|
115
|
+
{:else if activeTab === 'timers'}
|
|
116
|
+
<TimerPanel {timers} onRefresh={refresh} />
|
|
104
117
|
{:else if activeTab === 'metrics'}
|
|
105
118
|
<MetricsPanel {metrics} />
|
|
106
119
|
{/if}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as Card from '$lib/components/ui/card';
|
|
3
|
+
import * as Table from '$lib/components/ui/table';
|
|
4
|
+
import { Badge } from '$lib/components/ui/badge';
|
|
5
|
+
import { Button } from '$lib/components/ui/button';
|
|
6
|
+
import { Modal } from '$lib/components/ui/modal';
|
|
7
|
+
import LogsPanel from '$lib/LogsPanel.svelte';
|
|
8
|
+
import { type TimerJob, createTimer, cancelTimer } from '$lib/api';
|
|
9
|
+
import { relativeAge, formatDuration, isValidAgentSubject } from '$lib/utils';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
timers: TimerJob[];
|
|
13
|
+
onRefresh: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { timers, onRefresh }: Props = $props();
|
|
17
|
+
|
|
18
|
+
// Create form
|
|
19
|
+
let showForm = $state(false);
|
|
20
|
+
let formName = $state('');
|
|
21
|
+
let formDelay = $state('300000');
|
|
22
|
+
let formSubject = $state('agent.events.timer.');
|
|
23
|
+
let formPayload = $state('{}');
|
|
24
|
+
let formError: string | null = $state(null);
|
|
25
|
+
let actionError: string | null = $state(null);
|
|
26
|
+
let loading = $state(false);
|
|
27
|
+
|
|
28
|
+
// Modal state
|
|
29
|
+
let selectedTimer: TimerJob | null = $state(null);
|
|
30
|
+
let activeTab: 'details' | 'logs' = $state('details');
|
|
31
|
+
let showDeleteConfirm = $state(false);
|
|
32
|
+
|
|
33
|
+
// Delay presets
|
|
34
|
+
const presets = [
|
|
35
|
+
{ label: '1m', ms: 60_000 },
|
|
36
|
+
{ label: '5m', ms: 300_000 },
|
|
37
|
+
{ label: '15m', ms: 900_000 },
|
|
38
|
+
{ label: '30m', ms: 1_800_000 },
|
|
39
|
+
{ label: '1h', ms: 3_600_000 },
|
|
40
|
+
{ label: '6h', ms: 21_600_000 },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function resetForm() {
|
|
44
|
+
showForm = false;
|
|
45
|
+
formName = '';
|
|
46
|
+
formDelay = '300000';
|
|
47
|
+
formSubject = 'agent.events.timer.';
|
|
48
|
+
formPayload = '{}';
|
|
49
|
+
formError = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function handleCreate() {
|
|
53
|
+
formError = null;
|
|
54
|
+
|
|
55
|
+
if (!formName.trim()) {
|
|
56
|
+
formError = 'Name is required';
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const delayMs = parseInt(formDelay);
|
|
61
|
+
if (isNaN(delayMs) || delayMs <= 0) {
|
|
62
|
+
formError = 'Delay must be a positive number (ms)';
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!isValidAgentSubject(formSubject)) {
|
|
67
|
+
formError = 'Subject must start with "agent.events." followed by at least one token and must not end with "."';
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let parsedPayload: unknown;
|
|
72
|
+
try {
|
|
73
|
+
parsedPayload = JSON.parse(formPayload);
|
|
74
|
+
} catch {
|
|
75
|
+
formError = 'Invalid JSON payload';
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
loading = true;
|
|
80
|
+
try {
|
|
81
|
+
await createTimer({
|
|
82
|
+
name: formName.trim(),
|
|
83
|
+
delayMs,
|
|
84
|
+
subject: formSubject.trim(),
|
|
85
|
+
payload: parsedPayload,
|
|
86
|
+
});
|
|
87
|
+
resetForm();
|
|
88
|
+
onRefresh();
|
|
89
|
+
} catch (e: any) {
|
|
90
|
+
formError = e.message;
|
|
91
|
+
} finally {
|
|
92
|
+
loading = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function openTimerModal(timer: TimerJob) {
|
|
97
|
+
selectedTimer = timer;
|
|
98
|
+
activeTab = 'details';
|
|
99
|
+
showDeleteConfirm = false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function closeModal() {
|
|
103
|
+
selectedTimer = null;
|
|
104
|
+
showDeleteConfirm = false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function handleCancel() {
|
|
108
|
+
if (!selectedTimer) return;
|
|
109
|
+
try {
|
|
110
|
+
loading = true;
|
|
111
|
+
await cancelTimer(selectedTimer.name);
|
|
112
|
+
closeModal();
|
|
113
|
+
onRefresh();
|
|
114
|
+
} catch (e: any) {
|
|
115
|
+
actionError = e.message;
|
|
116
|
+
} finally {
|
|
117
|
+
loading = false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function statusVariant(timer: TimerJob): 'default' | 'secondary' | 'destructive' {
|
|
122
|
+
if (timer.fired) return 'secondary';
|
|
123
|
+
if (timer.remainingMs <= 0) return 'destructive';
|
|
124
|
+
return 'default';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function statusLabel(timer: TimerJob): string {
|
|
128
|
+
if (timer.fired) return 'Fired';
|
|
129
|
+
if (timer.remainingMs <= 0) return 'Overdue';
|
|
130
|
+
return 'Pending';
|
|
131
|
+
}
|
|
132
|
+
</script>
|
|
133
|
+
|
|
134
|
+
{#if selectedTimer}
|
|
135
|
+
<Modal
|
|
136
|
+
open={true}
|
|
137
|
+
title="Timer: {selectedTimer.name}"
|
|
138
|
+
onClose={closeModal}
|
|
139
|
+
>
|
|
140
|
+
{#snippet children()}
|
|
141
|
+
<div class="flex gap-1 border-b mb-4">
|
|
142
|
+
<button
|
|
143
|
+
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'}"
|
|
144
|
+
onclick={() => (activeTab = 'details')}
|
|
145
|
+
>Details</button>
|
|
146
|
+
<button
|
|
147
|
+
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'}"
|
|
148
|
+
onclick={() => (activeTab = 'logs')}
|
|
149
|
+
>Logs</button>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{#if activeTab === 'details'}
|
|
153
|
+
{#if showDeleteConfirm}
|
|
154
|
+
<div class="rounded-md border border-destructive/50 bg-destructive/5 p-4 space-y-3">
|
|
155
|
+
<p class="text-sm">Cancel timer <span class="font-mono font-semibold">{selectedTimer.name}</span>?</p>
|
|
156
|
+
<p class="text-xs text-muted-foreground">The timer will be removed and will not fire.</p>
|
|
157
|
+
<div class="flex gap-2">
|
|
158
|
+
<Button variant="destructive" size="sm" onclick={handleCancel} disabled={loading}>
|
|
159
|
+
{loading ? 'Cancelling...' : 'Confirm Cancel'}
|
|
160
|
+
</Button>
|
|
161
|
+
<Button variant="outline" size="sm" onclick={() => (showDeleteConfirm = false)}>Back</Button>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
{:else}
|
|
165
|
+
<div class="space-y-3">
|
|
166
|
+
<div class="grid grid-cols-2 gap-3">
|
|
167
|
+
<div class="space-y-1">
|
|
168
|
+
<span class="text-xs text-muted-foreground">Status</span>
|
|
169
|
+
<div>
|
|
170
|
+
<Badge variant={statusVariant(selectedTimer)}>{statusLabel(selectedTimer)}</Badge>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
<div class="space-y-1">
|
|
174
|
+
<span class="text-xs text-muted-foreground">Delay</span>
|
|
175
|
+
<div class="text-sm font-medium">{formatDuration(selectedTimer.delayMs)}</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
<div class="space-y-1">
|
|
179
|
+
<span class="text-xs text-muted-foreground">Subject</span>
|
|
180
|
+
<div class="text-sm font-mono">{selectedTimer.subject}</div>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="space-y-1">
|
|
183
|
+
<span class="text-xs text-muted-foreground">Payload</span>
|
|
184
|
+
<pre class="text-xs font-mono bg-muted/50 rounded-md p-2 overflow-x-auto">{JSON.stringify(selectedTimer.payload, null, 2)}</pre>
|
|
185
|
+
</div>
|
|
186
|
+
<div class="text-xs text-muted-foreground pt-2 space-y-1">
|
|
187
|
+
<div>Created: <span class="font-medium text-foreground">{relativeAge(selectedTimer.createdAt)}</span></div>
|
|
188
|
+
<div>Fire at: <span class="font-medium text-foreground">{new Date(selectedTimer.fireAt).toLocaleString()}</span></div>
|
|
189
|
+
{#if !selectedTimer.fired}
|
|
190
|
+
<div>Remaining: <span class="font-medium text-foreground">{formatDuration(selectedTimer.remainingMs)}</span></div>
|
|
191
|
+
{/if}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
{/if}
|
|
195
|
+
{:else}
|
|
196
|
+
<LogsPanel entityType="timer" entityId={selectedTimer.id} />
|
|
197
|
+
{/if}
|
|
198
|
+
{/snippet}
|
|
199
|
+
|
|
200
|
+
{#snippet actions()}
|
|
201
|
+
{#if activeTab === 'details' && !showDeleteConfirm && !selectedTimer.fired}
|
|
202
|
+
<div class="flex w-full justify-between">
|
|
203
|
+
<Button variant="ghost" size="sm" class="text-destructive" onclick={() => (showDeleteConfirm = true)}>
|
|
204
|
+
Cancel Timer
|
|
205
|
+
</Button>
|
|
206
|
+
<Button variant="outline" size="sm" onclick={closeModal}>Close</Button>
|
|
207
|
+
</div>
|
|
208
|
+
{:else if activeTab === 'details' && !showDeleteConfirm}
|
|
209
|
+
<div class="flex w-full justify-end">
|
|
210
|
+
<Button variant="outline" size="sm" onclick={closeModal}>Close</Button>
|
|
211
|
+
</div>
|
|
212
|
+
{/if}
|
|
213
|
+
{/snippet}
|
|
214
|
+
</Modal>
|
|
215
|
+
{/if}
|
|
216
|
+
|
|
217
|
+
<Card.Root>
|
|
218
|
+
<Card.Header class="pb-2">
|
|
219
|
+
<div class="flex items-center justify-between">
|
|
220
|
+
<Card.Title class="text-sm font-medium">Timers</Card.Title>
|
|
221
|
+
{#if !showForm}
|
|
222
|
+
<Button variant="outline" size="sm" onclick={() => (showForm = true)}>Set Timer</Button>
|
|
223
|
+
{/if}
|
|
224
|
+
</div>
|
|
225
|
+
</Card.Header>
|
|
226
|
+
<Card.Content>
|
|
227
|
+
{#if actionError}
|
|
228
|
+
<div class="rounded-md bg-destructive/10 p-2 text-xs text-destructive mb-2">{actionError}</div>
|
|
229
|
+
{/if}
|
|
230
|
+
|
|
231
|
+
{#if showForm}
|
|
232
|
+
<div class="mb-4 space-y-3 rounded-md border p-4">
|
|
233
|
+
{#if formError}
|
|
234
|
+
<div class="rounded-md bg-destructive/10 p-2 text-xs text-destructive">{formError}</div>
|
|
235
|
+
{/if}
|
|
236
|
+
<div>
|
|
237
|
+
<label for="timer-name" class="text-xs text-muted-foreground">Name</label>
|
|
238
|
+
<input
|
|
239
|
+
id="timer-name"
|
|
240
|
+
type="text"
|
|
241
|
+
bind:value={formName}
|
|
242
|
+
placeholder="check-deploy"
|
|
243
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
<div>
|
|
247
|
+
<label for="timer-delay" class="text-xs text-muted-foreground">Delay (ms)</label>
|
|
248
|
+
<input
|
|
249
|
+
id="timer-delay"
|
|
250
|
+
type="number"
|
|
251
|
+
min="1000"
|
|
252
|
+
bind:value={formDelay}
|
|
253
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
|
254
|
+
/>
|
|
255
|
+
<div class="flex gap-1.5 mt-1.5">
|
|
256
|
+
{#each presets as preset}
|
|
257
|
+
<button
|
|
258
|
+
class="rounded px-2 py-0.5 text-xs border border-input hover:bg-accent transition-colors {formDelay === String(preset.ms) ? 'bg-accent text-foreground' : 'text-muted-foreground'}"
|
|
259
|
+
onclick={() => (formDelay = String(preset.ms))}
|
|
260
|
+
>{preset.label}</button>
|
|
261
|
+
{/each}
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
<div>
|
|
265
|
+
<label for="timer-subject" class="text-xs text-muted-foreground">Subject</label>
|
|
266
|
+
<input
|
|
267
|
+
id="timer-subject"
|
|
268
|
+
type="text"
|
|
269
|
+
bind:value={formSubject}
|
|
270
|
+
placeholder="agent.events.timer.my-event"
|
|
271
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
|
272
|
+
/>
|
|
273
|
+
</div>
|
|
274
|
+
<div>
|
|
275
|
+
<label for="timer-payload" class="text-xs text-muted-foreground">Payload (JSON)</label>
|
|
276
|
+
<textarea
|
|
277
|
+
id="timer-payload"
|
|
278
|
+
bind:value={formPayload}
|
|
279
|
+
rows="3"
|
|
280
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
|
281
|
+
></textarea>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="flex gap-2">
|
|
284
|
+
<Button size="sm" onclick={handleCreate} disabled={loading}>
|
|
285
|
+
{loading ? 'Creating...' : 'Set Timer'}
|
|
286
|
+
</Button>
|
|
287
|
+
<Button variant="ghost" size="sm" onclick={resetForm}>Cancel</Button>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
{/if}
|
|
291
|
+
|
|
292
|
+
{#if timers.length === 0}
|
|
293
|
+
<p class="text-sm text-muted-foreground py-4">No timers</p>
|
|
294
|
+
{:else}
|
|
295
|
+
<Table.Root>
|
|
296
|
+
<Table.Header>
|
|
297
|
+
<Table.Row>
|
|
298
|
+
<Table.Head>Name</Table.Head>
|
|
299
|
+
<Table.Head>Subject</Table.Head>
|
|
300
|
+
<Table.Head>Delay</Table.Head>
|
|
301
|
+
<Table.Head>Status</Table.Head>
|
|
302
|
+
<Table.Head>Remaining</Table.Head>
|
|
303
|
+
<Table.Head>Fire At</Table.Head>
|
|
304
|
+
</Table.Row>
|
|
305
|
+
</Table.Header>
|
|
306
|
+
<Table.Body>
|
|
307
|
+
{#each timers as timer}
|
|
308
|
+
<Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => openTimerModal(timer)}>
|
|
309
|
+
<Table.Cell class="font-mono text-xs">{timer.name}</Table.Cell>
|
|
310
|
+
<Table.Cell class="font-mono text-xs">{timer.subject}</Table.Cell>
|
|
311
|
+
<Table.Cell class="text-xs">{formatDuration(timer.delayMs)}</Table.Cell>
|
|
312
|
+
<Table.Cell>
|
|
313
|
+
<Badge variant={statusVariant(timer)}>{statusLabel(timer)}</Badge>
|
|
314
|
+
</Table.Cell>
|
|
315
|
+
<Table.Cell class="text-xs text-muted-foreground">
|
|
316
|
+
{timer.fired ? '\u2014' : formatDuration(timer.remainingMs)}
|
|
317
|
+
</Table.Cell>
|
|
318
|
+
<Table.Cell class="text-xs text-muted-foreground">
|
|
319
|
+
{new Date(timer.fireAt).toLocaleString()}
|
|
320
|
+
</Table.Cell>
|
|
321
|
+
</Table.Row>
|
|
322
|
+
{/each}
|
|
323
|
+
</Table.Body>
|
|
324
|
+
</Table.Root>
|
|
325
|
+
{/if}
|
|
326
|
+
</Card.Content>
|
|
327
|
+
</Card.Root>
|
package/dashboard/src/lib/api.ts
CHANGED
|
@@ -156,6 +156,41 @@ export async function runCronJobNow(name: string): Promise<void> {
|
|
|
156
156
|
await fetchJSON(`/cron/${encodeURIComponent(name)}/run`, { method: 'POST' });
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// ── Timers ──────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
export interface TimerJob {
|
|
162
|
+
id: string;
|
|
163
|
+
name: string;
|
|
164
|
+
subject: string;
|
|
165
|
+
payload: unknown;
|
|
166
|
+
delayMs: number;
|
|
167
|
+
fireAt: number;
|
|
168
|
+
fired: boolean;
|
|
169
|
+
createdAt: number;
|
|
170
|
+
remainingMs: number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function getTimers(): Promise<TimerJob[]> {
|
|
174
|
+
return fetchJSON('/cron/timer');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function createTimer(body: {
|
|
178
|
+
name: string;
|
|
179
|
+
delayMs: number;
|
|
180
|
+
subject: string;
|
|
181
|
+
payload?: unknown;
|
|
182
|
+
}): Promise<TimerJob> {
|
|
183
|
+
return fetchJSON('/cron/timer', {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify(body),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function cancelTimer(name: string): Promise<void> {
|
|
191
|
+
await fetchJSON(`/cron/timer/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
192
|
+
}
|
|
193
|
+
|
|
159
194
|
// ── Metrics ─────────────────────────────────────────────────────────
|
|
160
195
|
|
|
161
196
|
export interface SubjectMetric {
|
|
@@ -22,6 +22,7 @@ export function relativeAge(ts: number | null): string {
|
|
|
22
22
|
|
|
23
23
|
export function formatDuration(ms: number | null): string {
|
|
24
24
|
if (ms === null) return '\u2014';
|
|
25
|
+
if (ms < 1000) return `${ms}ms`;
|
|
25
26
|
const seconds = Math.floor(ms / 1000);
|
|
26
27
|
if (seconds < 60) return `${seconds}s`;
|
|
27
28
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
package/package.json
CHANGED
|
@@ -231,6 +231,149 @@ export default function (api: any) {
|
|
|
231
231
|
},
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
api.registerTool({
|
|
235
|
+
name: 'nats_cron_update',
|
|
236
|
+
description: 'Update an existing cron job. Can change schedule, subject, payload, timezone, or enabled status.',
|
|
237
|
+
parameters: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: {
|
|
240
|
+
name: { type: 'string', description: 'Job name to update' },
|
|
241
|
+
cron: { type: 'string', description: 'New cron expression' },
|
|
242
|
+
subject: { type: 'string', description: 'New NATS subject' },
|
|
243
|
+
payload: { type: 'object', description: 'New event payload' },
|
|
244
|
+
timezone: { type: 'string', description: 'New timezone' },
|
|
245
|
+
enabled: { type: 'boolean', description: 'Enable or disable the job' },
|
|
246
|
+
},
|
|
247
|
+
required: ['name'],
|
|
248
|
+
},
|
|
249
|
+
async execute(_id: string, params: any) {
|
|
250
|
+
const { name, ...fields } = params;
|
|
251
|
+
const result = await sidecarFetch(`/api/cron/${encodeURIComponent(name)}`, {
|
|
252
|
+
method: 'PATCH',
|
|
253
|
+
body: JSON.stringify(fields),
|
|
254
|
+
});
|
|
255
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
api.registerTool({
|
|
260
|
+
name: 'nats_cron_toggle',
|
|
261
|
+
description: 'Toggle a cron job on/off.',
|
|
262
|
+
parameters: {
|
|
263
|
+
type: 'object',
|
|
264
|
+
properties: {
|
|
265
|
+
name: { type: 'string', description: 'Job name to toggle' },
|
|
266
|
+
},
|
|
267
|
+
required: ['name'],
|
|
268
|
+
},
|
|
269
|
+
async execute(_id: string, params: any) {
|
|
270
|
+
const result = await sidecarFetch(`/api/cron/${encodeURIComponent(params.name)}/toggle`, {
|
|
271
|
+
method: 'PATCH',
|
|
272
|
+
});
|
|
273
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
api.registerTool({
|
|
278
|
+
name: 'nats_cron_run',
|
|
279
|
+
description: 'Manually fire a cron job right now (does not affect its schedule).',
|
|
280
|
+
parameters: {
|
|
281
|
+
type: 'object',
|
|
282
|
+
properties: {
|
|
283
|
+
name: { type: 'string', description: 'Job name to fire' },
|
|
284
|
+
},
|
|
285
|
+
required: ['name'],
|
|
286
|
+
},
|
|
287
|
+
async execute(_id: string, params: any) {
|
|
288
|
+
const result = await sidecarFetch(`/api/cron/${encodeURIComponent(params.name)}/run`, {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
});
|
|
291
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ── Route Management Tools ────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
api.registerTool({
|
|
298
|
+
name: 'nats_route_update',
|
|
299
|
+
description: 'Update an existing route subscription. Can change target session, priority, or enabled status.',
|
|
300
|
+
parameters: {
|
|
301
|
+
type: 'object',
|
|
302
|
+
properties: {
|
|
303
|
+
id: { type: 'string', description: 'Route ID (from nats_subscriptions)' },
|
|
304
|
+
target: { type: 'string', description: 'New target session' },
|
|
305
|
+
priority: { type: 'number', description: 'New priority (1-10)' },
|
|
306
|
+
enabled: { type: 'boolean', description: 'Enable or disable the route' },
|
|
307
|
+
},
|
|
308
|
+
required: ['id'],
|
|
309
|
+
},
|
|
310
|
+
async execute(_id: string, params: any) {
|
|
311
|
+
const { id, ...fields } = params;
|
|
312
|
+
const result = await sidecarFetch(`/api/routes/${encodeURIComponent(id)}`, {
|
|
313
|
+
method: 'PATCH',
|
|
314
|
+
body: JSON.stringify(fields),
|
|
315
|
+
});
|
|
316
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ── Timer (One-Shot Delayed) Tools ────────────────────────────────
|
|
321
|
+
|
|
322
|
+
api.registerTool({
|
|
323
|
+
name: 'nats_timer_set',
|
|
324
|
+
description: 'Set a one-shot timer that publishes a NATS event after a delay. Use for delayed self-pings, reminders, or deferred tasks. Survives sidecar restarts.',
|
|
325
|
+
parameters: {
|
|
326
|
+
type: 'object',
|
|
327
|
+
properties: {
|
|
328
|
+
name: { type: 'string', description: 'Unique timer name (e.g., check-deploy-status, reminder-followup)' },
|
|
329
|
+
delayMs: { type: 'number', description: 'Delay in milliseconds before firing (e.g., 300000 for 5 minutes)' },
|
|
330
|
+
subject: { type: 'string', description: 'NATS subject to publish (must start with agent.events.)' },
|
|
331
|
+
payload: { type: 'object', description: 'Event payload data' },
|
|
332
|
+
},
|
|
333
|
+
required: ['name', 'delayMs', 'subject'],
|
|
334
|
+
},
|
|
335
|
+
async execute(_id: string, params: any) {
|
|
336
|
+
const result = await sidecarFetch('/api/cron/timer', {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
body: JSON.stringify({
|
|
339
|
+
name: params.name,
|
|
340
|
+
delayMs: params.delayMs,
|
|
341
|
+
subject: params.subject,
|
|
342
|
+
payload: params.payload ?? {},
|
|
343
|
+
}),
|
|
344
|
+
});
|
|
345
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
api.registerTool({
|
|
350
|
+
name: 'nats_timer_cancel',
|
|
351
|
+
description: 'Cancel a pending timer by name.',
|
|
352
|
+
parameters: {
|
|
353
|
+
type: 'object',
|
|
354
|
+
properties: {
|
|
355
|
+
name: { type: 'string', description: 'Timer name to cancel' },
|
|
356
|
+
},
|
|
357
|
+
required: ['name'],
|
|
358
|
+
},
|
|
359
|
+
async execute(_id: string, params: any) {
|
|
360
|
+
const result = await sidecarFetch(`/api/cron/timer/${encodeURIComponent(params.name)}`, {
|
|
361
|
+
method: 'DELETE',
|
|
362
|
+
});
|
|
363
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
api.registerTool({
|
|
368
|
+
name: 'nats_timer_list',
|
|
369
|
+
description: 'List all timers (pending and fired) with remaining time.',
|
|
370
|
+
parameters: { type: 'object', properties: {} },
|
|
371
|
+
async execute() {
|
|
372
|
+
const result = await sidecarFetch('/api/cron/timer');
|
|
373
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
|
|
234
377
|
// ── Dashboard UI ─────────────────────────────────────────────────
|
|
235
378
|
|
|
236
379
|
api.registerHttpRoute({
|
|
@@ -56,6 +56,7 @@ export class ConsumerController extends BaseController {
|
|
|
56
56
|
if (this.gatewayClient.isAlive()) {
|
|
57
57
|
for (const route of routes) {
|
|
58
58
|
try {
|
|
59
|
+
const injectStart = performance.now();
|
|
59
60
|
await this.gatewayClient.inject({
|
|
60
61
|
target: route.target,
|
|
61
62
|
message: this.formatMessage(envelope),
|
|
@@ -66,7 +67,8 @@ export class ConsumerController extends BaseController {
|
|
|
66
67
|
priority: (ctx.enrichments['priority'] as number) ?? envelope.meta?.priority ?? 5,
|
|
67
68
|
},
|
|
68
69
|
});
|
|
69
|
-
|
|
70
|
+
const lagMs = Math.round(performance.now() - injectStart);
|
|
71
|
+
await this.routerService.recordDelivery(route.id, envelope.subject, lagMs);
|
|
70
72
|
this.metrics.recordConsume(envelope.subject);
|
|
71
73
|
await this.logService.logDelivery(route.id, envelope.subject, JSON.stringify({ eventId: envelope.id, target: route.target }));
|
|
72
74
|
} catch (routeErr) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `event_routes` ADD `last_delivery_lag_ms` integer;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
CREATE TABLE `timer_jobs` (
|
|
2
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
3
|
+
`name` text NOT NULL,
|
|
4
|
+
`subject` text NOT NULL,
|
|
5
|
+
`payload` text,
|
|
6
|
+
`delay_ms` integer NOT NULL,
|
|
7
|
+
`fire_at` integer NOT NULL,
|
|
8
|
+
`fired` integer DEFAULT false NOT NULL,
|
|
9
|
+
`created_at` integer NOT NULL
|
|
10
|
+
);
|
|
11
|
+
--> statement-breakpoint
|
|
12
|
+
CREATE UNIQUE INDEX `timer_jobs_name_unique` ON `timer_jobs` (`name`);--> statement-breakpoint
|
|
13
|
+
CREATE INDEX `timer_jobs_name_idx` ON `timer_jobs` (`name`);--> statement-breakpoint
|
|
14
|
+
CREATE INDEX `timer_jobs_fire_at_idx` ON `timer_jobs` (`fire_at`);
|