@omnixal/openclaw-nats-plugin 0.2.15 → 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/http-handler.ts +12 -67
- package/plugins/nats-context-engine/index.ts +17 -3
- package/sidecar/src/config.ts +4 -0
- 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/pending/pending-flush.service.ts +70 -0
- package/sidecar/src/pending/pending.module.ts +5 -1
- package/sidecar/src/pending/pending.repository.ts +6 -2
- package/sidecar/src/pending/pending.service.ts +2 -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/scheduler/scheduler.controller.ts +2 -2
- 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
|
@@ -2,21 +2,12 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import http from 'node:http';
|
|
6
5
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
6
|
|
|
8
7
|
const ROUTE_PREFIX = '/nats-dashboard';
|
|
9
8
|
const SIDECAR_URL = process.env.NATS_SIDECAR_URL || 'http://127.0.0.1:3104';
|
|
10
9
|
const API_KEY = process.env.NATS_PLUGIN_API_KEY || 'dev-nats-plugin-key';
|
|
11
10
|
|
|
12
|
-
let sidecarParsed: URL;
|
|
13
|
-
try {
|
|
14
|
-
sidecarParsed = new URL(SIDECAR_URL);
|
|
15
|
-
} catch (e) {
|
|
16
|
-
console.error(`[nats-dashboard] Invalid NATS_SIDECAR_URL: ${SIDECAR_URL}`, e);
|
|
17
|
-
sidecarParsed = new URL('http://127.0.0.1:3104');
|
|
18
|
-
}
|
|
19
|
-
|
|
20
11
|
// Stable location (copied during setup) takes priority over in-package dist
|
|
21
12
|
const STABLE_DIST = path.join(homedir(), '.openclaw', 'nats-plugin', 'dashboard');
|
|
22
13
|
const PACKAGE_DIST = path.resolve(__dirname, '../../dashboard/dist');
|
|
@@ -46,7 +37,7 @@ export function createDashboardHandler() {
|
|
|
46
37
|
subPath = url.pathname;
|
|
47
38
|
}
|
|
48
39
|
|
|
49
|
-
// Debug endpoint
|
|
40
|
+
// Debug endpoint
|
|
50
41
|
if (subPath === '/api/_debug') {
|
|
51
42
|
res.statusCode = 200;
|
|
52
43
|
res.setHeader('content-type', 'application/json');
|
|
@@ -55,15 +46,8 @@ export function createDashboardHandler() {
|
|
|
55
46
|
pathname: url.pathname,
|
|
56
47
|
subPath,
|
|
57
48
|
sidecarUrl: SIDECAR_URL,
|
|
58
|
-
sidecarHost: sidecarParsed.hostname,
|
|
59
|
-
sidecarPort: sidecarParsed.port,
|
|
60
|
-
apiKey: API_KEY ? `${API_KEY.slice(0, 4)}...` : '(not set)',
|
|
61
49
|
distDir: DIST_DIR,
|
|
62
50
|
distExists: existsSync(path.join(DIST_DIR, 'index.html')),
|
|
63
|
-
env: {
|
|
64
|
-
NATS_SIDECAR_URL: process.env.NATS_SIDECAR_URL || '(default)',
|
|
65
|
-
NATS_PLUGIN_API_KEY: process.env.NATS_PLUGIN_API_KEY ? 'set' : '(default)',
|
|
66
|
-
},
|
|
67
51
|
}, null, 2));
|
|
68
52
|
return true;
|
|
69
53
|
}
|
|
@@ -91,6 +75,7 @@ async function proxyToSidecar(
|
|
|
91
75
|
res: ServerResponse,
|
|
92
76
|
): Promise<boolean> {
|
|
93
77
|
try {
|
|
78
|
+
const targetUrl = `${SIDECAR_URL}${subPath}${search}`;
|
|
94
79
|
const headers: Record<string, string> = {
|
|
95
80
|
'Authorization': `Bearer ${API_KEY}`,
|
|
96
81
|
};
|
|
@@ -103,69 +88,29 @@ async function proxyToSidecar(
|
|
|
103
88
|
body = await readBody(req);
|
|
104
89
|
}
|
|
105
90
|
|
|
106
|
-
|
|
107
|
-
const upstream = await httpRequest({
|
|
108
|
-
hostname: sidecarParsed.hostname,
|
|
109
|
-
port: Number(sidecarParsed.port),
|
|
110
|
-
path: `${subPath}${search}`,
|
|
91
|
+
const upstream = await fetch(targetUrl, {
|
|
111
92
|
method: req.method || 'GET',
|
|
112
93
|
headers,
|
|
113
|
-
|
|
114
|
-
|
|
94
|
+
body,
|
|
95
|
+
signal: AbortSignal.timeout(10_000),
|
|
96
|
+
});
|
|
115
97
|
|
|
116
|
-
res.statusCode = upstream.
|
|
117
|
-
res.setHeader('content-type', upstream.headers
|
|
118
|
-
|
|
98
|
+
res.statusCode = upstream.status;
|
|
99
|
+
res.setHeader('content-type', upstream.headers.get('content-type') || 'application/json');
|
|
100
|
+
const responseBody = await upstream.text();
|
|
101
|
+
res.end(responseBody);
|
|
119
102
|
} catch (err) {
|
|
120
103
|
const message = err instanceof Error ? err.message : String(err);
|
|
121
|
-
|
|
122
|
-
console.error(`[nats-dashboard] Sidecar proxy error: ${message} (target=${sidecarParsed.hostname}:${sidecarParsed.port}${subPath})`);
|
|
123
|
-
if (stack) console.error(stack);
|
|
104
|
+
console.error(`[nats-dashboard] Sidecar proxy error: ${message} (url=${SIDECAR_URL}${subPath})`);
|
|
124
105
|
res.statusCode = 502;
|
|
125
106
|
res.setHeader('content-type', 'application/json');
|
|
126
|
-
res.end(JSON.stringify({
|
|
127
|
-
error: 'Sidecar unreachable',
|
|
128
|
-
detail: message,
|
|
129
|
-
target: `${sidecarParsed.hostname}:${sidecarParsed.port}${subPath}`,
|
|
130
|
-
sidecarUrl: SIDECAR_URL,
|
|
131
|
-
hint: 'Open /nats-dashboard/api/_debug for full diagnostics',
|
|
132
|
-
}));
|
|
107
|
+
res.end(JSON.stringify({ error: 'Sidecar unreachable', detail: message }));
|
|
133
108
|
}
|
|
134
109
|
return true;
|
|
135
110
|
}
|
|
136
111
|
|
|
137
112
|
const MAX_BODY_BYTES = 1_048_576; // 1MB
|
|
138
113
|
|
|
139
|
-
interface HttpResponse {
|
|
140
|
-
statusCode: number;
|
|
141
|
-
headers: Record<string, string | string[] | undefined>;
|
|
142
|
-
body: string;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function httpRequest(
|
|
146
|
-
opts: http.RequestOptions,
|
|
147
|
-
body?: string,
|
|
148
|
-
): Promise<HttpResponse> {
|
|
149
|
-
return new Promise((resolve, reject) => {
|
|
150
|
-
const req = http.request(opts, (res) => {
|
|
151
|
-
const chunks: Buffer[] = [];
|
|
152
|
-
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
153
|
-
res.on('end', () => {
|
|
154
|
-
resolve({
|
|
155
|
-
statusCode: res.statusCode || 500,
|
|
156
|
-
headers: res.headers,
|
|
157
|
-
body: Buffer.concat(chunks).toString(),
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
res.on('error', reject);
|
|
161
|
-
});
|
|
162
|
-
req.on('error', reject);
|
|
163
|
-
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
|
|
164
|
-
if (body) req.write(body);
|
|
165
|
-
req.end();
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
114
|
function readBody(req: IncomingMessage): Promise<string> {
|
|
170
115
|
return new Promise((resolve, reject) => {
|
|
171
116
|
const chunks: Buffer[] = [];
|
|
@@ -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
|
},
|
package/sidecar/src/config.ts
CHANGED
|
@@ -28,6 +28,10 @@ export const envSchema = {
|
|
|
28
28
|
ttlSeconds: Env.number({ default: 60, env: 'NATS_DEDUP_TTL_SECONDS' }),
|
|
29
29
|
cleanupIntervalMs: Env.number({ default: 300000, env: 'NATS_DEDUP_CLEANUP_INTERVAL_MS' }),
|
|
30
30
|
},
|
|
31
|
+
pending: {
|
|
32
|
+
flushIntervalMs: Env.number({ default: 30000, env: 'NATS_PENDING_FLUSH_INTERVAL_MS' }),
|
|
33
|
+
flushBatchSize: Env.number({ default: 10, env: 'NATS_PENDING_FLUSH_BATCH_SIZE' }),
|
|
34
|
+
},
|
|
31
35
|
auth: {
|
|
32
36
|
pluginApiKey: Env.string({ default: 'dev-nats-plugin-key', env: 'NATS_PLUGIN_API_KEY' }),
|
|
33
37
|
},
|
|
@@ -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 {}
|