@omnixal/openclaw-nats-plugin 0.2.16 → 0.2.17
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/lib/LogsPanel.svelte +50 -1
- package/dashboard/src/lib/RoutesPanel.svelte +120 -11
- package/dashboard/src/lib/api.ts +20 -0
- package/package.json +1 -1
- package/plugins/nats-context-engine/index.ts +17 -3
- package/sidecar/src/consumer/consumer.controller.ts +41 -4
- package/sidecar/src/consumer/consumer.module.ts +2 -1
- package/sidecar/src/db/migrations/0008_fluffy_pestilence.sql +1 -0
- package/sidecar/src/db/migrations/0009_sour_romulus.sql +6 -0
- package/sidecar/src/db/migrations/meta/0008_snapshot.json +492 -0
- package/sidecar/src/db/migrations/meta/0009_snapshot.json +514 -0
- package/sidecar/src/db/migrations/meta/_journal.json +14 -0
- package/sidecar/src/db/schema.ts +8 -2
- package/sidecar/src/route-filter/filter-expression.ts +10 -0
- package/sidecar/src/route-filter/route-filter.module.ts +8 -0
- package/sidecar/src/route-filter/route-filter.service.ts +61 -0
- package/sidecar/src/router/router.controller.ts +14 -1
- package/sidecar/src/router/router.repository.ts +15 -4
- package/sidecar/src/router/router.service.ts +14 -3
- package/sidecar/src/validation/schemas.ts +16 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import * as Table from '$lib/components/ui/table';
|
|
3
3
|
import { Badge } from '$lib/components/ui/badge';
|
|
4
4
|
import { Button } from '$lib/components/ui/button';
|
|
5
|
+
import { Modal } from '$lib/components/ui/modal';
|
|
5
6
|
import { type ExecutionLog, type LogFilters, getLogs } from '$lib/api';
|
|
6
7
|
import { relativeAge } from '$lib/utils';
|
|
7
8
|
|
|
@@ -87,6 +88,8 @@
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
let selectedLog: ExecutionLog | null = $state(null);
|
|
92
|
+
|
|
90
93
|
function parseDetail(detail: string | null): string {
|
|
91
94
|
if (!detail) return '';
|
|
92
95
|
try {
|
|
@@ -97,6 +100,15 @@
|
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
function formatDetailFull(detail: string | null): string {
|
|
104
|
+
if (!detail) return '(no detail)';
|
|
105
|
+
try {
|
|
106
|
+
return JSON.stringify(JSON.parse(detail), null, 2);
|
|
107
|
+
} catch {
|
|
108
|
+
return detail;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
100
112
|
$effect(() => {
|
|
101
113
|
entityType; entityId;
|
|
102
114
|
loadPage();
|
|
@@ -106,6 +118,43 @@
|
|
|
106
118
|
let hasFilters = $derived(filterStatus !== '' || filterAction !== '' || filterSubject.trim() !== '');
|
|
107
119
|
</script>
|
|
108
120
|
|
|
121
|
+
{#if selectedLog}
|
|
122
|
+
<Modal
|
|
123
|
+
open={true}
|
|
124
|
+
title="Log Detail"
|
|
125
|
+
onClose={() => (selectedLog = null)}
|
|
126
|
+
>
|
|
127
|
+
{#snippet children()}
|
|
128
|
+
<div class="space-y-3">
|
|
129
|
+
<div class="grid grid-cols-2 gap-2 text-xs">
|
|
130
|
+
<div>
|
|
131
|
+
<span class="text-muted-foreground">Time:</span>
|
|
132
|
+
<span class="font-medium ml-1">{new Date(selectedLog.createdAt).toLocaleString()}</span>
|
|
133
|
+
</div>
|
|
134
|
+
<div>
|
|
135
|
+
<span class="text-muted-foreground">Action:</span>
|
|
136
|
+
<Badge variant="outline" class="ml-1">{selectedLog.action}</Badge>
|
|
137
|
+
</div>
|
|
138
|
+
<div class="col-span-2">
|
|
139
|
+
<span class="text-muted-foreground">Subject:</span>
|
|
140
|
+
<span class="font-mono font-medium ml-1">{selectedLog.subject}</span>
|
|
141
|
+
</div>
|
|
142
|
+
<div>
|
|
143
|
+
<span class="text-muted-foreground">Status:</span>
|
|
144
|
+
<Badge variant={selectedLog.success ? 'default' : 'destructive'} class="ml-1">
|
|
145
|
+
{selectedLog.success ? 'ok' : 'error'}
|
|
146
|
+
</Badge>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
<div class="space-y-1">
|
|
150
|
+
<span class="text-xs text-muted-foreground">Payload / Detail:</span>
|
|
151
|
+
<pre class="rounded-md bg-muted p-3 text-xs font-mono overflow-auto max-h-80 whitespace-pre-wrap break-all">{formatDetailFull(selectedLog.detail)}</pre>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
{/snippet}
|
|
155
|
+
</Modal>
|
|
156
|
+
{/if}
|
|
157
|
+
|
|
109
158
|
<div class="space-y-3">
|
|
110
159
|
<!-- Filters -->
|
|
111
160
|
<div class="flex flex-wrap items-end gap-2">
|
|
@@ -171,7 +220,7 @@
|
|
|
171
220
|
</Table.Header>
|
|
172
221
|
<Table.Body>
|
|
173
222
|
{#each items as log}
|
|
174
|
-
<Table.Row>
|
|
223
|
+
<Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => (selectedLog = log)}>
|
|
175
224
|
<Table.Cell class="text-xs text-muted-foreground whitespace-nowrap">
|
|
176
225
|
{relativeAge(log.createdAt)}
|
|
177
226
|
</Table.Cell>
|
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
// Create form state
|
|
19
19
|
let showForm: boolean = $state(false);
|
|
20
|
+
let formName: string = $state('');
|
|
20
21
|
let formPattern: string = $state('agent.events.');
|
|
21
22
|
let formTarget: string = $state('main');
|
|
22
23
|
let formPriority: number = $state(5);
|
|
24
|
+
let formPayload: string = $state('');
|
|
25
|
+
let formFilter: string = $state('');
|
|
23
26
|
let formError: string | null = $state(null);
|
|
24
27
|
let actionError: string | null = $state(null);
|
|
25
28
|
let loading: boolean = $state(false);
|
|
@@ -30,6 +33,8 @@
|
|
|
30
33
|
let editTarget: string = $state('');
|
|
31
34
|
let editPriority: number = $state(5);
|
|
32
35
|
let editEnabled: boolean = $state(true);
|
|
36
|
+
let editPayload: string = $state('');
|
|
37
|
+
let editFilter: string = $state('');
|
|
33
38
|
let editError: string | null = $state(null);
|
|
34
39
|
let showDeleteConfirm: boolean = $state(false);
|
|
35
40
|
|
|
@@ -40,25 +45,54 @@
|
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
function resetForm() {
|
|
48
|
+
formName = '';
|
|
43
49
|
formPattern = 'agent.events.';
|
|
44
50
|
formTarget = 'main';
|
|
45
51
|
formPriority = 5;
|
|
52
|
+
formPayload = '';
|
|
53
|
+
formFilter = '';
|
|
46
54
|
formError = null;
|
|
47
55
|
showForm = false;
|
|
48
56
|
}
|
|
49
57
|
|
|
58
|
+
function parseJsonPayload(text: string): unknown | undefined {
|
|
59
|
+
const trimmed = text.trim();
|
|
60
|
+
if (!trimmed) return undefined;
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(trimmed);
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatPayload(payload: unknown): string {
|
|
69
|
+
if (payload === null || payload === undefined) return '';
|
|
70
|
+
return JSON.stringify(payload, null, 2);
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
async function handleCreate() {
|
|
51
74
|
formError = null;
|
|
52
75
|
if (!isValidAgentSubject(formPattern)) {
|
|
53
76
|
formError = 'Pattern must start with "agent.events." followed by at least one token and must not end with "."';
|
|
54
77
|
return;
|
|
55
78
|
}
|
|
79
|
+
if (formPayload.trim() && parseJsonPayload(formPayload) === undefined) {
|
|
80
|
+
formError = 'Payload must be valid JSON';
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (formFilter.trim() && parseJsonPayload(formFilter) === undefined) {
|
|
84
|
+
formError = 'Filter must be valid JSON';
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
56
87
|
try {
|
|
57
88
|
loading = true;
|
|
58
89
|
await createRoute({
|
|
59
90
|
pattern: formPattern,
|
|
91
|
+
name: formName.trim() || undefined,
|
|
60
92
|
target: formTarget || undefined,
|
|
61
93
|
priority: formPriority,
|
|
94
|
+
payload: parseJsonPayload(formPayload),
|
|
95
|
+
filter: formFilter.trim() ? (parseJsonPayload(formFilter) as any) : undefined,
|
|
62
96
|
});
|
|
63
97
|
resetForm();
|
|
64
98
|
onRefresh();
|
|
@@ -75,6 +109,8 @@
|
|
|
75
109
|
editTarget = route.target;
|
|
76
110
|
editPriority = route.priority;
|
|
77
111
|
editEnabled = route.enabled;
|
|
112
|
+
editPayload = formatPayload(route.customPayload);
|
|
113
|
+
editFilter = route.filter ? JSON.stringify(route.filter, null, 2) : '';
|
|
78
114
|
editError = null;
|
|
79
115
|
showDeleteConfirm = false;
|
|
80
116
|
}
|
|
@@ -87,6 +123,14 @@
|
|
|
87
123
|
|
|
88
124
|
async function handleSave() {
|
|
89
125
|
if (!selectedRoute) return;
|
|
126
|
+
if (editPayload.trim() && parseJsonPayload(editPayload) === undefined) {
|
|
127
|
+
editError = 'Payload must be valid JSON';
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (editFilter.trim() && parseJsonPayload(editFilter) === undefined) {
|
|
131
|
+
editError = 'Filter must be valid JSON';
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
90
134
|
try {
|
|
91
135
|
editError = null;
|
|
92
136
|
loading = true;
|
|
@@ -94,6 +138,8 @@
|
|
|
94
138
|
target: editTarget,
|
|
95
139
|
priority: editPriority,
|
|
96
140
|
enabled: editEnabled,
|
|
141
|
+
payload: editPayload.trim() ? parseJsonPayload(editPayload) : null,
|
|
142
|
+
filter: editFilter.trim() ? (parseJsonPayload(editFilter) as any) : null,
|
|
97
143
|
});
|
|
98
144
|
closeModal();
|
|
99
145
|
onRefresh();
|
|
@@ -123,7 +169,7 @@
|
|
|
123
169
|
{#if selectedRoute}
|
|
124
170
|
<Modal
|
|
125
171
|
open={true}
|
|
126
|
-
title="Route: {selectedRoute.
|
|
172
|
+
title="Route: {selectedRoute.name}"
|
|
127
173
|
onClose={closeModal}
|
|
128
174
|
>
|
|
129
175
|
{#snippet children()}
|
|
@@ -146,7 +192,7 @@
|
|
|
146
192
|
|
|
147
193
|
{#if showDeleteConfirm}
|
|
148
194
|
<div class="rounded-md border border-destructive/50 bg-destructive/5 p-4 space-y-3">
|
|
149
|
-
<p class="text-sm">Are you sure you want to delete route <span class="font-mono font-semibold">{selectedRoute.
|
|
195
|
+
<p class="text-sm">Are you sure you want to delete route <span class="font-mono font-semibold">{selectedRoute.name}</span>?</p>
|
|
150
196
|
<p class="text-xs text-muted-foreground">This action cannot be undone.</p>
|
|
151
197
|
<div class="flex gap-2">
|
|
152
198
|
<Button variant="destructive" size="sm" onclick={handleDelete} disabled={loading}>
|
|
@@ -187,8 +233,32 @@
|
|
|
187
233
|
</div>
|
|
188
234
|
</div>
|
|
189
235
|
|
|
236
|
+
<div class="space-y-1">
|
|
237
|
+
<label class="text-xs text-muted-foreground" for="edit-payload">Custom Payload (JSON)</label>
|
|
238
|
+
<textarea
|
|
239
|
+
id="edit-payload"
|
|
240
|
+
bind:value={editPayload}
|
|
241
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-mono resize-y min-h-16"
|
|
242
|
+
placeholder={'{"context": "value"}'}
|
|
243
|
+
rows="3"
|
|
244
|
+
></textarea>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div class="space-y-1">
|
|
248
|
+
<label class="text-xs text-muted-foreground" for="edit-filter">Filter (JSON)</label>
|
|
249
|
+
<textarea
|
|
250
|
+
id="edit-filter"
|
|
251
|
+
bind:value={editFilter}
|
|
252
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-mono resize-y min-h-16"
|
|
253
|
+
placeholder={'{"logic": "and", "conditions": [{"field": "status", "op": "eq", "value": "active"}]}'}
|
|
254
|
+
rows="3"
|
|
255
|
+
></textarea>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
190
258
|
<div class="text-xs text-muted-foreground pt-2 space-y-1">
|
|
259
|
+
<div>Pattern: <span class="font-mono font-medium text-foreground">{selectedRoute.pattern}</span></div>
|
|
191
260
|
<div>Deliveries: <span class="font-medium text-foreground">{selectedRoute.deliveryCount}</span></div>
|
|
261
|
+
<div>Filtered out: <span class="font-medium text-foreground">{selectedRoute.filterDropCount}</span></div>
|
|
192
262
|
<div>Last delivered: <span class="font-medium text-foreground">{selectedRoute.lastDeliveredAt ? relativeAge(new Date(selectedRoute.lastDeliveredAt).getTime()) : '\u2014'}</span></div>
|
|
193
263
|
{#if selectedRoute.lastEventSubject}
|
|
194
264
|
<div>Last subject: <span class="font-mono font-medium text-foreground">{selectedRoute.lastEventSubject}</span></div>
|
|
@@ -236,15 +306,27 @@
|
|
|
236
306
|
{#if formError}
|
|
237
307
|
<div class="rounded-md bg-destructive/10 p-2 text-xs text-destructive">{formError}</div>
|
|
238
308
|
{/if}
|
|
239
|
-
<div class="
|
|
240
|
-
<
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
309
|
+
<div class="grid grid-cols-2 gap-3">
|
|
310
|
+
<div class="space-y-1">
|
|
311
|
+
<label class="text-xs text-muted-foreground" for="route-pattern">Pattern</label>
|
|
312
|
+
<input
|
|
313
|
+
id="route-pattern"
|
|
314
|
+
type="text"
|
|
315
|
+
bind:value={formPattern}
|
|
316
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
|
317
|
+
placeholder="agent.events.>"
|
|
318
|
+
/>
|
|
319
|
+
</div>
|
|
320
|
+
<div class="space-y-1">
|
|
321
|
+
<label class="text-xs text-muted-foreground" for="route-name">Name <span class="text-muted-foreground">(optional)</span></label>
|
|
322
|
+
<input
|
|
323
|
+
id="route-name"
|
|
324
|
+
type="text"
|
|
325
|
+
bind:value={formName}
|
|
326
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
|
327
|
+
placeholder="auto from pattern"
|
|
328
|
+
/>
|
|
329
|
+
</div>
|
|
248
330
|
</div>
|
|
249
331
|
<div class="grid grid-cols-2 gap-3">
|
|
250
332
|
<div class="space-y-1">
|
|
@@ -269,6 +351,26 @@
|
|
|
269
351
|
/>
|
|
270
352
|
</div>
|
|
271
353
|
</div>
|
|
354
|
+
<div class="space-y-1">
|
|
355
|
+
<label class="text-xs text-muted-foreground" for="route-payload">Custom Payload (JSON)</label>
|
|
356
|
+
<textarea
|
|
357
|
+
id="route-payload"
|
|
358
|
+
bind:value={formPayload}
|
|
359
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-mono resize-y min-h-16"
|
|
360
|
+
placeholder={'{"context": "value"}'}
|
|
361
|
+
rows="2"
|
|
362
|
+
></textarea>
|
|
363
|
+
</div>
|
|
364
|
+
<div class="space-y-1">
|
|
365
|
+
<label class="text-xs text-muted-foreground" for="route-filter">Filter (JSON)</label>
|
|
366
|
+
<textarea
|
|
367
|
+
id="route-filter"
|
|
368
|
+
bind:value={formFilter}
|
|
369
|
+
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-mono resize-y min-h-16"
|
|
370
|
+
placeholder={'{"logic": "and", "conditions": [{"field": "amount", "op": "gt", "value": 10000}]}'}
|
|
371
|
+
rows="2"
|
|
372
|
+
></textarea>
|
|
373
|
+
</div>
|
|
272
374
|
<div class="flex gap-2">
|
|
273
375
|
<Button size="sm" onclick={handleCreate} disabled={loading}>
|
|
274
376
|
{loading ? 'Creating...' : 'Create'}
|
|
@@ -284,6 +386,7 @@
|
|
|
284
386
|
<Table.Root>
|
|
285
387
|
<Table.Header>
|
|
286
388
|
<Table.Row>
|
|
389
|
+
<Table.Head>Name</Table.Head>
|
|
287
390
|
<Table.Head>Pattern</Table.Head>
|
|
288
391
|
<Table.Head>Target</Table.Head>
|
|
289
392
|
<Table.Head>Priority</Table.Head>
|
|
@@ -296,6 +399,12 @@
|
|
|
296
399
|
<Table.Body>
|
|
297
400
|
{#each routes as route}
|
|
298
401
|
<Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => openRouteModal(route)}>
|
|
402
|
+
<Table.Cell class="font-mono text-xs">
|
|
403
|
+
{route.name}
|
|
404
|
+
{#if route.filter}
|
|
405
|
+
<Badge variant="outline" class="ml-1 text-[10px] px-1 py-0">filter</Badge>
|
|
406
|
+
{/if}
|
|
407
|
+
</Table.Cell>
|
|
299
408
|
<Table.Cell class="font-mono text-xs">{route.pattern}</Table.Cell>
|
|
300
409
|
<Table.Cell>{route.target}</Table.Cell>
|
|
301
410
|
<Table.Cell>
|
package/dashboard/src/lib/api.ts
CHANGED
|
@@ -47,12 +47,27 @@ export async function markDelivered(ids: string[]): Promise<void> {
|
|
|
47
47
|
|
|
48
48
|
// ── Routes ──────────────────────────────────────────────────────────
|
|
49
49
|
|
|
50
|
+
export interface FilterCondition {
|
|
51
|
+
field: string;
|
|
52
|
+
op: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'contains' | 'exists';
|
|
53
|
+
value: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface FilterExpression {
|
|
57
|
+
logic: 'and' | 'or';
|
|
58
|
+
conditions: FilterCondition[];
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
export interface EventRoute {
|
|
51
62
|
id: string;
|
|
63
|
+
name: string;
|
|
52
64
|
pattern: string;
|
|
53
65
|
target: string;
|
|
54
66
|
priority: number;
|
|
55
67
|
enabled: boolean;
|
|
68
|
+
filter: FilterExpression | null;
|
|
69
|
+
filterDropCount: number;
|
|
70
|
+
customPayload: unknown;
|
|
56
71
|
deliveryCount: number;
|
|
57
72
|
lastDeliveredAt: string | null;
|
|
58
73
|
lastEventSubject: string | null;
|
|
@@ -66,8 +81,11 @@ export async function getRoutes(): Promise<EventRoute[]> {
|
|
|
66
81
|
|
|
67
82
|
export async function createRoute(body: {
|
|
68
83
|
pattern: string;
|
|
84
|
+
name?: string;
|
|
69
85
|
target?: string;
|
|
70
86
|
priority?: number;
|
|
87
|
+
payload?: unknown;
|
|
88
|
+
filter?: FilterExpression;
|
|
71
89
|
}): Promise<EventRoute> {
|
|
72
90
|
return fetchJSON('/routes', {
|
|
73
91
|
method: 'POST',
|
|
@@ -80,6 +98,8 @@ export interface UpdateRouteBody {
|
|
|
80
98
|
target?: string;
|
|
81
99
|
priority?: number;
|
|
82
100
|
enabled?: boolean;
|
|
101
|
+
payload?: unknown;
|
|
102
|
+
filter?: FilterExpression | null;
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
export async function updateRoute(id: string, body: UpdateRouteBody): Promise<EventRoute> {
|
package/package.json
CHANGED
|
@@ -164,19 +164,31 @@ export default function (api: any) {
|
|
|
164
164
|
|
|
165
165
|
api.registerTool({
|
|
166
166
|
name: 'nats_subscribe',
|
|
167
|
-
description: 'Subscribe to events matching a pattern. Matched events will be delivered to the target session as messages.',
|
|
167
|
+
description: 'Subscribe to events matching a pattern. Matched events will be delivered to the target session as messages. Multiple subscriptions on the same pattern are possible by specifying different names with payload filters.',
|
|
168
168
|
parameters: {
|
|
169
169
|
type: 'object',
|
|
170
170
|
properties: {
|
|
171
171
|
pattern: { type: 'string', description: 'Subject pattern (exact, or wildcard with * for one level, > for all descendants)' },
|
|
172
|
+
name: { type: 'string', description: 'Unique route name (default: same as pattern). Use different names to create multiple subscriptions on the same pattern.' },
|
|
172
173
|
target: { type: 'string', description: 'Session key to deliver to (default: main)' },
|
|
174
|
+
payload: { type: 'object', description: 'Additional context payload that will be merged with event data on delivery' },
|
|
175
|
+
filter: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
description: 'Payload filter expression. Only events matching the filter will be delivered. Format: { logic: "and"|"or", conditions: [{ field: "dot.path", op: "eq"|"neq"|"gt"|"gte"|"lt"|"lte"|"in"|"nin"|"contains"|"exists", value: ... }] }',
|
|
178
|
+
},
|
|
173
179
|
},
|
|
174
180
|
required: ['pattern'],
|
|
175
181
|
},
|
|
176
182
|
async execute(_id: string, params: any) {
|
|
177
183
|
const result = await sidecarFetch('/api/routes', {
|
|
178
184
|
method: 'POST',
|
|
179
|
-
body: JSON.stringify({
|
|
185
|
+
body: JSON.stringify({
|
|
186
|
+
pattern: params.pattern,
|
|
187
|
+
name: params.name,
|
|
188
|
+
target: params.target ?? 'main',
|
|
189
|
+
payload: params.payload,
|
|
190
|
+
filter: params.filter,
|
|
191
|
+
}),
|
|
180
192
|
});
|
|
181
193
|
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
182
194
|
},
|
|
@@ -342,7 +354,7 @@ export default function (api: any) {
|
|
|
342
354
|
|
|
343
355
|
api.registerTool({
|
|
344
356
|
name: 'nats_route_update',
|
|
345
|
-
description: 'Update an existing route subscription. Can change target session, priority,
|
|
357
|
+
description: 'Update an existing route subscription. Can change target session, priority, enabled status, custom payload, or filter.',
|
|
346
358
|
parameters: {
|
|
347
359
|
type: 'object',
|
|
348
360
|
properties: {
|
|
@@ -350,6 +362,8 @@ export default function (api: any) {
|
|
|
350
362
|
target: { type: 'string', description: 'New target session' },
|
|
351
363
|
priority: { type: 'number', description: 'New priority (1-10)' },
|
|
352
364
|
enabled: { type: 'boolean', description: 'Enable or disable the route' },
|
|
365
|
+
payload: { type: 'object', description: 'New custom payload to merge with event data on delivery' },
|
|
366
|
+
filter: { type: ['object', 'null'], description: 'New payload filter expression (null to clear). Format: { logic: "and"|"or", conditions: [...] }' },
|
|
353
367
|
},
|
|
354
368
|
required: ['id'],
|
|
355
369
|
},
|
|
@@ -5,6 +5,7 @@ import { PendingService } from '../pending/pending.service';
|
|
|
5
5
|
import { RouterService } from '../router/router.service';
|
|
6
6
|
import { MetricsService } from '../metrics/metrics.service';
|
|
7
7
|
import { LogService } from '../logs/log.service';
|
|
8
|
+
import { RouteFilterService } from '../route-filter/route-filter.service';
|
|
8
9
|
import type { NatsEventEnvelope } from '../publisher/envelope';
|
|
9
10
|
|
|
10
11
|
@Controller('/consumer')
|
|
@@ -16,6 +17,7 @@ export class ConsumerController extends BaseController {
|
|
|
16
17
|
private routerService: RouterService,
|
|
17
18
|
private metrics: MetricsService,
|
|
18
19
|
private logService: LogService,
|
|
20
|
+
private routeFilter: RouteFilterService,
|
|
19
21
|
) {
|
|
20
22
|
super();
|
|
21
23
|
}
|
|
@@ -55,16 +57,25 @@ export class ConsumerController extends BaseController {
|
|
|
55
57
|
// Deliver to each matching target
|
|
56
58
|
if (this.gatewayClient.isAlive()) {
|
|
57
59
|
for (const route of routes) {
|
|
60
|
+
if (route.filter) {
|
|
61
|
+
const passed = this.routeFilter.evaluate(envelope.payload, route.filter);
|
|
62
|
+
if (!passed) {
|
|
63
|
+
this.logger.debug(`Event ${envelope.id} filtered out by route ${route.name} (${route.pattern})`);
|
|
64
|
+
await this.routerService.incrementFilterDropCount(route.id);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
58
68
|
try {
|
|
69
|
+
const composedPayload = this.composePayload(envelope, route);
|
|
59
70
|
const injectStart = performance.now();
|
|
60
71
|
await this.gatewayClient.inject({
|
|
61
|
-
message: this.formatMessage(envelope),
|
|
72
|
+
message: this.formatMessage(envelope.subject, composedPayload),
|
|
62
73
|
eventId: envelope.id,
|
|
63
74
|
});
|
|
64
75
|
const lagMs = Math.round(performance.now() - injectStart);
|
|
65
76
|
await this.routerService.recordDelivery(route.id, envelope.subject, lagMs);
|
|
66
77
|
this.metrics.recordConsume(envelope.subject);
|
|
67
|
-
await this.logService.logDelivery(route.id, envelope.subject, JSON.stringify(
|
|
78
|
+
await this.logService.logDelivery(route.id, envelope.subject, JSON.stringify(composedPayload));
|
|
68
79
|
} catch (routeErr) {
|
|
69
80
|
await this.logService.logError('route', route.id, envelope.subject, routeErr);
|
|
70
81
|
// Gateway rejected the request (e.g. missing scope) — store in pending, don't nack
|
|
@@ -100,7 +111,33 @@ export class ConsumerController extends BaseController {
|
|
|
100
111
|
throw new Error('Unable to extract envelope from message');
|
|
101
112
|
}
|
|
102
113
|
|
|
103
|
-
private
|
|
104
|
-
|
|
114
|
+
private composePayload(envelope: NatsEventEnvelope, route: import('../db/schema').DbEventRoute): Record<string, unknown> {
|
|
115
|
+
const eventPayload = (envelope.payload && typeof envelope.payload === 'object' && !Array.isArray(envelope.payload))
|
|
116
|
+
? (envelope.payload as Record<string, unknown>)
|
|
117
|
+
: { data: envelope.payload };
|
|
118
|
+
|
|
119
|
+
const customPayload = (route.customPayload && typeof route.customPayload === 'object' && !Array.isArray(route.customPayload))
|
|
120
|
+
? (route.customPayload as Record<string, unknown>)
|
|
121
|
+
: {};
|
|
122
|
+
|
|
123
|
+
let source: 'cron' | 'timer' | 'external' = 'external';
|
|
124
|
+
if ('_cron' in eventPayload) source = 'cron';
|
|
125
|
+
else if ('_timer' in eventPayload) source = 'timer';
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
...eventPayload,
|
|
129
|
+
...customPayload,
|
|
130
|
+
_delivery: {
|
|
131
|
+
pattern: route.pattern,
|
|
132
|
+
source,
|
|
133
|
+
eventSubject: envelope.subject,
|
|
134
|
+
eventId: envelope.id,
|
|
135
|
+
deliveredAt: new Date().toISOString(),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private formatMessage(subject: string, composedPayload: Record<string, unknown>): string {
|
|
141
|
+
return `[NATS:${subject}] ${JSON.stringify(composedPayload)}`;
|
|
105
142
|
}
|
|
106
143
|
}
|
|
@@ -5,9 +5,10 @@ import { PendingModule } from '../pending/pending.module';
|
|
|
5
5
|
import { RouterModule } from '../router/router.module';
|
|
6
6
|
import { MetricsModule } from '../metrics/metrics.module';
|
|
7
7
|
import { LogModule } from '../logs/log.module';
|
|
8
|
+
import { RouteFilterModule } from '../route-filter/route-filter.module';
|
|
8
9
|
|
|
9
10
|
@Module({
|
|
10
|
-
imports: [PreHandlersModule, PendingModule, RouterModule, MetricsModule, LogModule],
|
|
11
|
+
imports: [PreHandlersModule, PendingModule, RouterModule, MetricsModule, LogModule, RouteFilterModule],
|
|
11
12
|
controllers: [ConsumerController],
|
|
12
13
|
})
|
|
13
14
|
export class ConsumerModule {}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `event_routes` ADD `custom_payload` text;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
DROP INDEX `event_routes_pattern_unique`;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE `event_routes` ADD `name` text NOT NULL DEFAULT '';--> statement-breakpoint
|
|
3
|
+
UPDATE `event_routes` SET `name` = `pattern` WHERE `name` = '';--> statement-breakpoint
|
|
4
|
+
ALTER TABLE `event_routes` ADD `filter` text;--> statement-breakpoint
|
|
5
|
+
ALTER TABLE `event_routes` ADD `filter_drop_count` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
|
6
|
+
CREATE UNIQUE INDEX `event_routes_name_idx` ON `event_routes` (`name`);
|