@chrysb/alphaclaw 0.8.1-beta.0 → 0.8.1-beta.2

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.
@@ -1,1259 +0,0 @@
1
- import { h } from "https://esm.sh/preact";
2
- import { useCallback, useMemo, useState } from "https://esm.sh/preact/hooks";
3
- import htm from "https://esm.sh/htm";
4
- import { usePolling } from "../hooks/usePolling.js";
5
- import {
6
- kNoDestinationSessionValue,
7
- useDestinationSessionSelection,
8
- } from "../hooks/use-destination-session-selection.js";
9
- import {
10
- createWebhook,
11
- deleteWebhook,
12
- fetchAgents,
13
- fetchWebhookDetail,
14
- fetchWebhookRequest,
15
- fetchWebhookRequests,
16
- fetchWebhooks,
17
- sendAgentMessage,
18
- } from "../lib/api.js";
19
- import {
20
- formatLocaleDateTime,
21
- formatLocaleDateTimeWithTodayTime,
22
- } from "../lib/format.js";
23
- import { showToast } from "./toast.js";
24
- import { PageHeader } from "./page-header.js";
25
- import { ConfirmDialog } from "./confirm-dialog.js";
26
- import { ActionButton } from "./action-button.js";
27
- import { AgentSendModal } from "./agent-send-modal.js";
28
- import { ModalShell } from "./modal-shell.js";
29
- import { Badge } from "./badge.js";
30
- import { CloseIcon } from "./icons.js";
31
- import { SessionSelectField } from "./session-select-field.js";
32
-
33
- const html = htm.bind(h);
34
- const kNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
35
- const kStatusFilters = ["all", "success", "error"];
36
-
37
- const formatDateTime = (value) => {
38
- return formatLocaleDateTime(value, { fallback: "—" });
39
- };
40
-
41
- const formatLastReceived = (value) => {
42
- return formatLocaleDateTimeWithTodayTime(value, { fallback: "—" });
43
- };
44
-
45
- const formatBytes = (size) => {
46
- const bytes = Number(size || 0);
47
- if (!Number.isFinite(bytes) || bytes <= 0) return "0B";
48
- if (bytes < 1024) return `${bytes}B`;
49
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
50
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
51
- };
52
-
53
- const healthClassName = (health) => {
54
- if (health === "red") return "bg-red-500";
55
- if (health === "yellow") return "bg-yellow-500";
56
- return "bg-green-500";
57
- };
58
-
59
- const getRequestStatusTone = (status) => {
60
- if (status === "success") {
61
- return {
62
- dotClass: "bg-green-500/90",
63
- textClass: "text-green-500/90",
64
- };
65
- }
66
- if (status === "error") {
67
- return {
68
- dotClass: "bg-red-500/90",
69
- textClass: "text-red-500/90",
70
- };
71
- }
72
- return {
73
- dotClass: "bg-gray-500/70",
74
- textClass: "text-gray-400",
75
- };
76
- };
77
-
78
- const formatAgentFallbackName = (agentId = "") =>
79
- String(agentId || "")
80
- .trim()
81
- .split(/[-_\s]+/)
82
- .filter(Boolean)
83
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
84
- .join(" ") || "Main Agent";
85
-
86
- const jsonPretty = (value) => {
87
- if (typeof value === "string") {
88
- try {
89
- const parsed = JSON.parse(value);
90
- return JSON.stringify(parsed, null, 2);
91
- } catch {
92
- return value;
93
- }
94
- }
95
- try {
96
- return JSON.stringify(value || {}, null, 2);
97
- } catch {
98
- return String(value || "");
99
- }
100
- };
101
-
102
- const buildWebhookDebugMessage = ({
103
- hookName = "",
104
- webhook = null,
105
- request = null,
106
- }) => {
107
- const hookPath =
108
- String(webhook?.path || "").trim() ||
109
- (hookName ? `/hooks/${hookName}` : "/hooks/unknown");
110
- const gatewayStatus =
111
- request?.gatewayStatus == null ? "n/a" : String(request.gatewayStatus);
112
- return [
113
- "Investigate this failed webhook request and share findings before fixing anything.",
114
- "Reply with your diagnosis first, including the likely root cause, any relevant risks, and what you would change if I approve a fix.",
115
- "",
116
- `Webhook: ${hookPath}`,
117
- `Request ID: ${String(request?.id || "unknown")}`,
118
- `Time: ${String(request?.createdAt || "unknown")}`,
119
- `Method: ${String(request?.method || "unknown")}`,
120
- `Source IP: ${String(request?.sourceIp || "unknown")}`,
121
- `Gateway status: ${gatewayStatus}`,
122
- `Transform path: ${String(webhook?.transformPath || "unknown")}`,
123
- `Payload truncated: ${request?.payloadTruncated ? "yes" : "no"}`,
124
- "",
125
- "Headers:",
126
- jsonPretty(request?.headers),
127
- "",
128
- "Payload:",
129
- jsonPretty(request?.payload),
130
- "",
131
- "Gateway response:",
132
- jsonPretty(request?.gatewayBody),
133
- ].join("\n");
134
- };
135
-
136
- const CreateWebhookModal = ({
137
- visible,
138
- name,
139
- onNameChange,
140
- canCreate,
141
- creating,
142
- onCreate = () => {},
143
- onClose,
144
- }) => {
145
- const {
146
- sessions: selectableSessions,
147
- loading: loadingSessions,
148
- error: destinationLoadError,
149
- destinationSessionKey,
150
- setDestinationSessionKey,
151
- selectedDestination,
152
- } = useDestinationSessionSelection({
153
- enabled: visible,
154
- resetKey: String(visible),
155
- });
156
-
157
- const normalized = String(name || "")
158
- .trim()
159
- .toLowerCase();
160
- const previewName = normalized || "{name}";
161
- const previewUrl = `${window.location.origin}/hooks/${previewName}`;
162
- if (!visible) return null;
163
- return html`
164
- <${ModalShell}
165
- visible=${visible}
166
- onClose=${onClose}
167
- panelClassName="bg-modal border border-border rounded-xl p-5 max-w-lg w-full space-y-4"
168
- >
169
- <${PageHeader}
170
- title="Create Webhook"
171
- actions=${html`
172
- <button
173
- type="button"
174
- onclick=${onClose}
175
- class="h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary"
176
- aria-label="Close modal"
177
- >
178
- <${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
179
- </button>
180
- `}
181
- />
182
- <div class="space-y-2">
183
- <p class="text-xs text-gray-500">Name</p>
184
- <input
185
- type="text"
186
- value=${name}
187
- placeholder="fathom"
188
- onInput=${(e) => onNameChange(e.target.value)}
189
- onKeyDown=${(e) => {
190
- if (e.key === "Enter" && canCreate && !creating) {
191
- onCreate(selectedDestination);
192
- }
193
- if (e.key === "Escape") onClose();
194
- }}
195
- class="w-full bg-black/30 border border-border rounded-lg px-3 py-1.5 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
196
- />
197
- </div>
198
- <${SessionSelectField}
199
- label="Deliver to"
200
- sessions=${selectableSessions}
201
- selectedSessionKey=${destinationSessionKey}
202
- onChangeSessionKey=${setDestinationSessionKey}
203
- disabled=${loadingSessions || creating}
204
- loading=${loadingSessions}
205
- error=${destinationLoadError}
206
- allowNone=${true}
207
- noneValue=${kNoDestinationSessionValue}
208
- noneLabel="Default"
209
- emptyStateText="No paired chat sessions found yet. You can still create the webhook without a default destination."
210
- loadingLabel="Loading destinations..."
211
- />
212
- <div class="border border-border rounded-lg overflow-hidden">
213
- <table class="w-full text-xs">
214
- <tbody>
215
- <tr class="border-b border-border">
216
- <td class="w-24 px-3 py-2 text-gray-500">Path</td>
217
- <td class="px-3 py-2 text-gray-300 font-mono">
218
- <code>/hooks/${previewName}</code>
219
- </td>
220
- </tr>
221
- <tr class="border-b border-border">
222
- <td class="w-24 px-3 py-2 text-gray-500">URL</td>
223
- <td class="px-3 py-2 text-gray-300 font-mono break-all">
224
- <code>${previewUrl}</code>
225
- </td>
226
- </tr>
227
- <tr>
228
- <td class="w-24 px-3 py-2 text-gray-500">Transform</td>
229
- <td class="px-3 py-2 text-gray-300 font-mono">
230
- <code
231
- >hooks/transforms/${previewName}/${previewName}-transform.mjs</code
232
- >
233
- </td>
234
- </tr>
235
- </tbody>
236
- </table>
237
- </div>
238
- <div class="pt-1 flex items-center justify-end gap-2">
239
- <${ActionButton}
240
- onClick=${onClose}
241
- tone="secondary"
242
- size="md"
243
- idleLabel="Cancel"
244
- className="px-4 py-2 rounded-lg text-sm"
245
- />
246
- <${ActionButton}
247
- onClick=${() => onCreate(selectedDestination)}
248
- disabled=${!canCreate || creating}
249
- loading=${creating}
250
- tone="primary"
251
- size="md"
252
- idleLabel="Create"
253
- loadingLabel="Creating..."
254
- className="px-4 py-2 rounded-lg text-sm"
255
- />
256
- </div>
257
- </${ModalShell}>
258
- `;
259
- };
260
-
261
- export const Webhooks = ({
262
- selectedHookName = "",
263
- onSelectHook = () => {},
264
- onBackToList = () => {},
265
- onRestartRequired = () => {},
266
- onOpenFile = () => {},
267
- }) => {
268
- const [isCreating, setIsCreating] = useState(false);
269
- const [newName, setNewName] = useState("");
270
- const [creating, setCreating] = useState(false);
271
- const [deleting, setDeleting] = useState(false);
272
- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
273
- const [deleteTransformDir, setDeleteTransformDir] = useState(true);
274
- const [authMode, setAuthMode] = useState("headers");
275
- const [statusFilter, setStatusFilter] = useState("all");
276
- const [expandedRows, setExpandedRows] = useState(() => new Set());
277
- const [sendingTestWebhook, setSendingTestWebhook] = useState(false);
278
- const [replayingRequestId, setReplayingRequestId] = useState(null);
279
- const [debugLoadingRequestId, setDebugLoadingRequestId] = useState(null);
280
- const [debugRequest, setDebugRequest] = useState(null);
281
-
282
- const listPoll = usePolling(fetchWebhooks, 15000);
283
- const webhooks = listPoll.data?.webhooks || [];
284
- const agentsPoll = usePolling(fetchAgents, 20000);
285
- const agents = Array.isArray(agentsPoll.data?.agents)
286
- ? agentsPoll.data.agents
287
- : [];
288
- const agentNameById = useMemo(
289
- () =>
290
- new Map(
291
- agents.map((agent) => [
292
- String(agent?.id || "").trim(),
293
- String(agent?.name || "").trim() ||
294
- formatAgentFallbackName(agent?.id),
295
- ]),
296
- ),
297
- [agents],
298
- );
299
-
300
- const detailPoll = usePolling(
301
- async () => {
302
- if (!selectedHookName) return null;
303
- const data = await fetchWebhookDetail(selectedHookName);
304
- return data.webhook || null;
305
- },
306
- 10000,
307
- { enabled: !!selectedHookName },
308
- );
309
-
310
- const requestsPoll = usePolling(
311
- async () => {
312
- if (!selectedHookName) return { requests: [] };
313
- const data = await fetchWebhookRequests(selectedHookName, {
314
- limit: 25,
315
- offset: 0,
316
- status: statusFilter,
317
- });
318
- return data;
319
- },
320
- 5000,
321
- { enabled: !!selectedHookName },
322
- );
323
-
324
- const selectedWebhook = detailPoll.data;
325
- const selectedWebhookManaged = Boolean(selectedWebhook?.managed);
326
- const selectedDeliveryAgentId =
327
- String(selectedWebhook?.agentId || "main").trim() || "main";
328
- const selectedDeliveryAgentName =
329
- agentNameById.get(selectedDeliveryAgentId) ||
330
- formatAgentFallbackName(selectedDeliveryAgentId);
331
- const selectedDeliveryChannel =
332
- String(selectedWebhook?.channel || "last").trim() || "last";
333
- const requests = requestsPoll.data?.requests || [];
334
- const webhookUrl =
335
- selectedWebhook?.fullUrl || `.../hooks/${selectedHookName}`;
336
- const webhookUrlWithQueryToken =
337
- selectedWebhook?.queryStringUrl ||
338
- `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}token=<WEBHOOK_TOKEN>`;
339
- const derivedTokenFromQuery = (() => {
340
- try {
341
- const parsed = new URL(webhookUrlWithQueryToken);
342
- return String(parsed.searchParams.get("token") || "").trim();
343
- } catch {
344
- return "";
345
- }
346
- })();
347
- const authHeaderValue =
348
- selectedWebhook?.authHeaderValue ||
349
- (derivedTokenFromQuery
350
- ? `Authorization: Bearer ${derivedTokenFromQuery}`
351
- : "Authorization: Bearer <WEBHOOK_TOKEN>");
352
- const bearerTokenValue = authHeaderValue.startsWith("Authorization: ")
353
- ? authHeaderValue.slice("Authorization: ".length)
354
- : authHeaderValue;
355
- const webhookTestPayload = useMemo(() => {
356
- if (
357
- String(selectedHookName || "")
358
- .trim()
359
- .toLowerCase() === "gmail"
360
- ) {
361
- return {
362
- payload: {
363
- account: "test@gmail.com",
364
- messages: [
365
- {
366
- id: "test-message-1",
367
- from: "alerts@example.com",
368
- to: ["test@gmail.com"],
369
- subject: "Test Gmail webhook event",
370
- snippet:
371
- "This is a simulated Gmail message payload for webhook testing.",
372
- receivedAt: new Date().toISOString(),
373
- },
374
- ],
375
- },
376
- };
377
- }
378
- return {
379
- source: "manual-test",
380
- message: `This is a test of the ${selectedHookName || "webhook"} webhook.`,
381
- };
382
- }, [selectedHookName]);
383
- const webhookTestPayloadJson = JSON.stringify(webhookTestPayload);
384
- const curlCommandHeaders =
385
- `curl -X POST "${webhookUrl}" ` +
386
- `-H "Content-Type: application/json" ` +
387
- `-H "${authHeaderValue}" ` +
388
- `-d '${webhookTestPayloadJson}'`;
389
- const curlCommandQuery =
390
- `curl -X POST "${webhookUrlWithQueryToken}" ` +
391
- `-H "Content-Type: application/json" ` +
392
- `-d '${webhookTestPayloadJson}'`;
393
- const effectiveAuthMode = selectedWebhookManaged ? "headers" : authMode;
394
- const activeCurlCommand =
395
- effectiveAuthMode === "query" ? curlCommandQuery : curlCommandHeaders;
396
-
397
- const canCreate = useMemo(() => {
398
- const name = String(newName || "")
399
- .trim()
400
- .toLowerCase();
401
- return kNamePattern.test(name);
402
- }, [newName]);
403
-
404
- const refreshAll = useCallback(() => {
405
- listPoll.refresh();
406
- detailPoll.refresh();
407
- requestsPoll.refresh();
408
- }, [listPoll.refresh, detailPoll.refresh, requestsPoll.refresh]);
409
-
410
- const handleCreate = useCallback(
411
- async (destination = null) => {
412
- const candidateName = String(newName || "")
413
- .trim()
414
- .toLowerCase();
415
- if (!kNamePattern.test(candidateName)) {
416
- showToast(
417
- "Name must be lowercase letters, numbers, and hyphens",
418
- "error",
419
- );
420
- return;
421
- }
422
- if (creating) return;
423
- setCreating(true);
424
- try {
425
- const data = await createWebhook(candidateName, { destination });
426
- setIsCreating(false);
427
- setNewName("");
428
- onSelectHook(candidateName);
429
- if (data.restartRequired) onRestartRequired(true);
430
- showToast("Webhook created", "success");
431
- if (data.syncWarning) {
432
- showToast(
433
- `Created, but git-sync failed: ${data.syncWarning}`,
434
- "warning",
435
- );
436
- }
437
- refreshAll();
438
- } catch (err) {
439
- showToast(err.message || "Could not create webhook", "error");
440
- } finally {
441
- setCreating(false);
442
- }
443
- },
444
- [newName, creating, refreshAll, onSelectHook, onRestartRequired],
445
- );
446
-
447
- const handleDeleteConfirmed = useCallback(async () => {
448
- if (!selectedHookName || deleting) return;
449
- setDeleting(true);
450
- try {
451
- const data = await deleteWebhook(selectedHookName, {
452
- deleteTransformDir,
453
- });
454
- if (data.restartRequired) onRestartRequired(true);
455
- onBackToList();
456
- setShowDeleteConfirm(false);
457
- setDeleteTransformDir(true);
458
- showToast("Webhook removed", "success");
459
- if (data.deletedTransformDir) {
460
- showToast("Transform directory deleted", "success");
461
- }
462
- if (data.syncWarning) {
463
- showToast(
464
- `Deleted, but git-sync failed: ${data.syncWarning}`,
465
- "warning",
466
- );
467
- }
468
- refreshAll();
469
- } catch (err) {
470
- showToast(err.message || "Could not delete webhook", "error");
471
- } finally {
472
- setDeleting(false);
473
- }
474
- }, [
475
- selectedHookName,
476
- deleting,
477
- deleteTransformDir,
478
- refreshAll,
479
- onBackToList,
480
- onRestartRequired,
481
- ]);
482
-
483
- const handleRequestRowToggle = useCallback((id, isOpen) => {
484
- setExpandedRows((prev) => {
485
- const next = new Set(prev);
486
- if (isOpen) next.add(id);
487
- else next.delete(id);
488
- return next;
489
- });
490
- }, []);
491
-
492
- const handleSendTestWebhook = useCallback(async () => {
493
- if (!selectedHookName || sendingTestWebhook) return;
494
- setSendingTestWebhook(true);
495
- const requestUrl =
496
- effectiveAuthMode === "query" ? webhookUrlWithQueryToken : webhookUrl;
497
- const headers = { "Content-Type": "application/json" };
498
- if (effectiveAuthMode === "headers") {
499
- headers.Authorization = bearerTokenValue;
500
- }
501
- try {
502
- const response = await fetch(requestUrl, {
503
- method: "POST",
504
- headers,
505
- body: webhookTestPayloadJson,
506
- });
507
- const bodyText = await response.text();
508
- let body = null;
509
- try {
510
- body = bodyText ? JSON.parse(bodyText) : null;
511
- } catch {
512
- body = null;
513
- }
514
- const errorMessage =
515
- body?.ok === false
516
- ? body?.error || "Webhook rejected"
517
- : !response.ok
518
- ? body?.error || bodyText || `HTTP ${response.status}`
519
- : "";
520
- if (errorMessage) {
521
- showToast(`Test webhook failed: ${errorMessage}`, "error");
522
- return;
523
- }
524
- showToast("Test webhook sent", "success");
525
- setTimeout(() => requestsPoll.refresh(), 0);
526
- } catch (err) {
527
- showToast(err.message || "Could not send test webhook", "error");
528
- } finally {
529
- setSendingTestWebhook(false);
530
- }
531
- }, [
532
- bearerTokenValue,
533
- effectiveAuthMode,
534
- requestsPoll.refresh,
535
- selectedHookName,
536
- sendingTestWebhook,
537
- webhookTestPayloadJson,
538
- webhookUrl,
539
- webhookUrlWithQueryToken,
540
- ]);
541
-
542
- const handleReplayRequest = useCallback(
543
- async (item) => {
544
- if (!item || replayingRequestId === item.id) return;
545
- if (item.payloadTruncated) {
546
- showToast("Cannot replay a truncated payload", "warning");
547
- return;
548
- }
549
- const requestUrl =
550
- effectiveAuthMode === "query" ? webhookUrlWithQueryToken : webhookUrl;
551
- const headers = { "Content-Type": "application/json" };
552
- if (effectiveAuthMode === "headers") {
553
- headers.Authorization = bearerTokenValue;
554
- }
555
- setReplayingRequestId(item.id);
556
- try {
557
- const response = await fetch(requestUrl, {
558
- method: "POST",
559
- headers,
560
- body: String(item.payload || ""),
561
- });
562
- const bodyText = await response.text();
563
- let body = null;
564
- try {
565
- body = bodyText ? JSON.parse(bodyText) : null;
566
- } catch {
567
- body = null;
568
- }
569
- const errorMessage =
570
- body?.ok === false
571
- ? body?.error || "Webhook rejected"
572
- : !response.ok
573
- ? body?.error || bodyText || `HTTP ${response.status}`
574
- : "";
575
- if (errorMessage) {
576
- showToast(`Replay failed: ${errorMessage}`, "error");
577
- return;
578
- }
579
- showToast("Request replayed", "success");
580
- setTimeout(() => requestsPoll.refresh(), 0);
581
- } catch (err) {
582
- showToast(err.message || "Could not replay request", "error");
583
- } finally {
584
- setReplayingRequestId(null);
585
- }
586
- },
587
- [
588
- bearerTokenValue,
589
- effectiveAuthMode,
590
- replayingRequestId,
591
- requestsPoll.refresh,
592
- webhookUrl,
593
- webhookUrlWithQueryToken,
594
- ],
595
- );
596
-
597
- const handleCopyRequestField = useCallback(async (value, label) => {
598
- try {
599
- await navigator.clipboard.writeText(String(value || ""));
600
- showToast(`${label} copied`, "success");
601
- } catch {
602
- showToast(
603
- `Could not copy ${String(label || "value").toLowerCase()}`,
604
- "error",
605
- );
606
- }
607
- }, []);
608
-
609
- const isListLoading = !listPoll.data && !listPoll.error;
610
- const debugAgentMessage = useMemo(
611
- () =>
612
- buildWebhookDebugMessage({
613
- hookName: selectedHookName,
614
- webhook: selectedWebhook,
615
- request: debugRequest,
616
- }),
617
- [debugRequest, selectedHookName, selectedWebhook],
618
- );
619
-
620
- const handleAskAgentToDebug = useCallback(
621
- async (item) => {
622
- if (!selectedHookName || !item?.id || debugLoadingRequestId === item.id)
623
- return;
624
- try {
625
- setDebugLoadingRequestId(item.id);
626
- const data = await fetchWebhookRequest(selectedHookName, item.id);
627
- setDebugRequest(data?.request || item);
628
- } catch (err) {
629
- showToast(
630
- err.message || "Could not load webhook request details",
631
- "error",
632
- );
633
- } finally {
634
- setDebugLoadingRequestId(null);
635
- }
636
- },
637
- [debugLoadingRequestId, selectedHookName],
638
- );
639
-
640
- return html`
641
- <div class="space-y-4">
642
- <${PageHeader}
643
- title="Webhooks"
644
- leading=${selectedHookName
645
- ? html`
646
- <button
647
- class="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 transition-colors"
648
- onclick=${onBackToList}
649
- >
650
- <svg
651
- width="16"
652
- height="16"
653
- viewBox="0 0 16 16"
654
- fill="currentColor"
655
- >
656
- <path
657
- d="M10.354 3.354a.5.5 0 00-.708-.708l-5 5a.5.5 0 000 .708l5 5a.5.5 0 00.708-.708L5.707 8l4.647-4.646z"
658
- />
659
- </svg>
660
- Back
661
- </button>
662
- `
663
- : null}
664
- actions=${selectedHookName
665
- ? null
666
- : html`
667
- <button
668
- class="text-xs px-3 py-1.5 rounded-lg ac-btn-secondary"
669
- onclick=${() => setIsCreating((open) => !open)}
670
- >
671
- Create new
672
- </button>
673
- `}
674
- />
675
-
676
- ${selectedHookName
677
- ? html`
678
- <div
679
- class="bg-surface border border-border rounded-xl p-4 space-y-4"
680
- >
681
- <div>
682
- <h2 class="font-semibold text-sm">
683
- ${selectedWebhook?.path || `/hooks/${selectedHookName}`}
684
- </h2>
685
- </div>
686
-
687
- <div
688
- class="bg-black/20 border border-border rounded-lg p-3 space-y-4"
689
- >
690
- ${selectedWebhookManaged
691
- ? null
692
- : html`
693
- <div class="space-y-2">
694
- <p class="text-xs text-gray-500">Auth mode</p>
695
- <div class="flex items-center gap-2">
696
- <button
697
- class="text-xs px-2 py-1 rounded border transition-colors ${authMode ===
698
- "headers"
699
- ? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
700
- : "border-border text-gray-400 hover:text-gray-200"}"
701
- onclick=${() => setAuthMode("headers")}
702
- >
703
- Headers
704
- </button>
705
- <button
706
- class="text-xs px-2 py-1 rounded border transition-colors ${authMode ===
707
- "query"
708
- ? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
709
- : "border-border text-gray-400 hover:text-gray-200"}"
710
- onclick=${() => setAuthMode("query")}
711
- >
712
- Query string
713
- </button>
714
- </div>
715
- </div>
716
- `}
717
- <div class="space-y-2">
718
- <p class="text-xs text-gray-500">Webhook URL</p>
719
- <div class="flex items-center gap-2">
720
- <input
721
- type="text"
722
- readonly
723
- value=${effectiveAuthMode === "query"
724
- ? webhookUrlWithQueryToken
725
- : webhookUrl}
726
- class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono"
727
- />
728
- <button
729
- class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0"
730
- onclick=${async () => {
731
- try {
732
- await navigator.clipboard.writeText(
733
- effectiveAuthMode === "query"
734
- ? webhookUrlWithQueryToken
735
- : webhookUrl,
736
- );
737
- showToast("Webhook URL copied", "success");
738
- } catch {
739
- showToast("Could not copy URL", "error");
740
- }
741
- }}
742
- >
743
- Copy
744
- </button>
745
- </div>
746
- </div>
747
- ${selectedWebhookManaged
748
- ? null
749
- : effectiveAuthMode === "headers"
750
- ? html`
751
- <div class="space-y-2">
752
- <p class="text-xs text-gray-500">Auth headers</p>
753
- <div class="flex items-center gap-2">
754
- <input
755
- type="text"
756
- readonly
757
- value=${authHeaderValue}
758
- class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono"
759
- />
760
- <button
761
- class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0"
762
- onclick=${async () => {
763
- try {
764
- await navigator.clipboard.writeText(
765
- bearerTokenValue,
766
- );
767
- showToast("Bearer token copied", "success");
768
- } catch {
769
- showToast(
770
- "Could not copy bearer token",
771
- "error",
772
- );
773
- }
774
- }}
775
- >
776
- Copy
777
- </button>
778
- </div>
779
- </div>
780
- `
781
- : html`
782
- <p class="text-xs text-yellow-300">
783
- Always use auth headers when possible. Query string is
784
- less secure.
785
- </p>
786
- `}
787
- </div>
788
-
789
- <div
790
- class="bg-black/20 border border-border rounded-lg p-3 space-y-2"
791
- >
792
- <p class="text-xs text-gray-500">Deliver to</p>
793
- <p class="text-xs text-gray-200 font-mono ">
794
- ${selectedDeliveryAgentName}${" "}
795
- <span class="text-xs text-gray-500 font-mono">
796
- (${selectedDeliveryChannel})</span
797
- >
798
- </p>
799
- </div>
800
-
801
- <div
802
- class="bg-black/20 border border-border rounded-lg p-3 space-y-2"
803
- >
804
- <p class="text-xs text-gray-500">Test webhook</p>
805
- <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
806
- <input
807
- type="text"
808
- readonly
809
- value=${activeCurlCommand}
810
- class="h-8 w-full sm:flex-1 sm:min-w-0 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono overflow-x-auto scrollbar-hidden"
811
- />
812
- <div
813
- class="grid grid-cols-2 gap-2 w-full sm:w-auto sm:flex sm:items-center"
814
- >
815
- <button
816
- class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary w-full sm:w-auto sm:shrink-0"
817
- onclick=${async () => {
818
- try {
819
- await navigator.clipboard.writeText(
820
- activeCurlCommand,
821
- );
822
- showToast("curl command copied", "success");
823
- } catch {
824
- showToast("Could not copy curl command", "error");
825
- }
826
- }}
827
- >
828
- Copy
829
- </button>
830
- <button
831
- class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary w-full sm:w-auto sm:shrink-0 disabled:opacity-60"
832
- onclick=${handleSendTestWebhook}
833
- disabled=${sendingTestWebhook}
834
- >
835
- ${sendingTestWebhook ? "Sending..." : "Send"}
836
- </button>
837
- </div>
838
- </div>
839
- </div>
840
-
841
- <div class="bg-black/20 border border-border rounded-lg p-3">
842
- <div class="flex items-center gap-2 text-xs text-gray-300">
843
- <span class="text-gray-500">Transform:</span>
844
- ${selectedWebhook?.transformPath
845
- ? html`<button
846
- type="button"
847
- class="ac-tip-link flex-1 min-w-0 truncate block text-left font-mono"
848
- title=${selectedWebhook.transformPath}
849
- onclick=${() =>
850
- onOpenFile(selectedWebhook.transformPath)}
851
- >
852
- ${selectedWebhook.transformPath}
853
- </button>`
854
- : html`<code class="flex-1 min-w-0 truncate block"
855
- >—</code
856
- >`}
857
- <span
858
- class=${`ml-auto inline-flex items-center gap-1 px-1.5 py-0.5 rounded border font-sans ${
859
- selectedWebhook?.transformExists
860
- ? "border-green-500/30 text-green-300 bg-green-500/10"
861
- : "border-yellow-500/30 text-yellow-300 bg-yellow-500/10"
862
- }`}
863
- >
864
- <span class="font-sans text-sm leading-none">
865
- ${selectedWebhook?.transformExists ? "✓" : "!"}
866
- </span>
867
- ${selectedWebhook?.transformExists
868
- ? null
869
- : html`<span>missing</span>`}
870
- </span>
871
- </div>
872
- </div>
873
-
874
- <div class="flex items-center justify-between gap-3">
875
- <p class="text-xs text-gray-600">
876
- Created: ${formatDateTime(selectedWebhook?.createdAt)}
877
- </p>
878
- ${selectedWebhookManaged
879
- ? null
880
- : html`<${ActionButton}
881
- onClick=${() => {
882
- if (deleting) return;
883
- setDeleteTransformDir(true);
884
- setShowDeleteConfirm(true);
885
- }}
886
- disabled=${deleting}
887
- loading=${deleting}
888
- tone="danger"
889
- size="sm"
890
- idleLabel="Delete"
891
- loadingLabel="Deleting..."
892
- className="shrink-0 px-2.5 py-1"
893
- />`}
894
- </div>
895
- </div>
896
-
897
- ${selectedWebhookManaged
898
- ? html`
899
- <div
900
- class="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3"
901
- >
902
- <p class="text-xs text-yellow-200">
903
- This webhook is managed by Gmail Watch setup and cannot be
904
- deleted or edited from this page.
905
- </p>
906
- </div>
907
- `
908
- : null}
909
- <div
910
- class="bg-surface border border-border rounded-xl p-4 space-y-3"
911
- >
912
- <div class="flex items-center justify-between gap-3">
913
- <h3 class="card-label">Request history</h3>
914
- <div class="flex items-center gap-2">
915
- ${kStatusFilters.map(
916
- (filter) => html`
917
- <button
918
- class="text-xs px-2 py-1 rounded border ${statusFilter ===
919
- filter
920
- ? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
921
- : "border-border text-gray-400 hover:text-gray-200"}"
922
- onclick=${() => {
923
- setStatusFilter(filter);
924
- setExpandedRows(new Set());
925
- setTimeout(() => requestsPoll.refresh(), 0);
926
- }}
927
- >
928
- ${filter}
929
- </button>
930
- `,
931
- )}
932
- </div>
933
- </div>
934
-
935
- ${requests.length === 0
936
- ? html`<p class="text-sm text-gray-500">
937
- No requests logged yet.
938
- </p>`
939
- : html`
940
- <div class="ac-history-list">
941
- ${requests.map((item) => {
942
- const statusTone = getRequestStatusTone(item.status);
943
- return html`
944
- <details
945
- class="ac-history-item"
946
- open=${expandedRows.has(item.id)}
947
- ontoggle=${(e) =>
948
- handleRequestRowToggle(
949
- item.id,
950
- !!e.currentTarget?.open,
951
- )}
952
- >
953
- <summary class="ac-history-summary">
954
- <div class="ac-history-summary-row">
955
- <span
956
- class="inline-flex items-center gap-2 min-w-0"
957
- >
958
- <span
959
- class="ac-history-toggle shrink-0"
960
- aria-hidden="true"
961
- >▸</span
962
- >
963
- <span class="truncate text-xs text-gray-300">
964
- ${formatLastReceived(item.createdAt)}
965
- </span>
966
- </span>
967
- <span
968
- class="inline-flex items-center gap-2 shrink-0"
969
- >
970
- <span class="text-xs text-gray-500"
971
- >${formatBytes(item.payloadSize)}</span
972
- >
973
- <span
974
- class=${`text-xs font-medium ${statusTone.textClass}`}
975
- >${item.gatewayStatus || "n/a"}</span
976
- >
977
- <span class="inline-flex items-center">
978
- <span
979
- class=${`h-2.5 w-2.5 rounded-full ${statusTone.dotClass}`}
980
- title=${item.status || "unknown"}
981
- aria-label=${item.status || "unknown"}
982
- ></span>
983
- </span>
984
- </span>
985
- </div>
986
- </summary>
987
- ${expandedRows.has(item.id)
988
- ? html`
989
- <div class="ac-history-body space-y-3">
990
- <div>
991
- <p class="text-[11px] text-gray-500 mb-1">
992
- Headers
993
- </p>
994
- <pre
995
- class="text-xs bg-black/30 border border-border rounded p-2 overflow-auto"
996
- >
997
- ${jsonPretty(item.headers)}</pre
998
- >
999
- <div
1000
- class="mt-2 flex justify-start gap-2"
1001
- >
1002
- <button
1003
- class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary"
1004
- onclick=${() =>
1005
- handleCopyRequestField(
1006
- jsonPretty(item.headers),
1007
- "Headers",
1008
- )}
1009
- >
1010
- Copy
1011
- </button>
1012
- </div>
1013
- </div>
1014
- <div>
1015
- <p class="text-[11px] text-gray-500 mb-1">
1016
- Payload
1017
- ${item.payloadTruncated
1018
- ? "(truncated)"
1019
- : ""}
1020
- </p>
1021
- <pre
1022
- class="text-xs bg-black/30 border border-border rounded p-2 overflow-auto"
1023
- >
1024
- ${jsonPretty(item.payload)}</pre
1025
- >
1026
- <div
1027
- class="mt-2 flex justify-start gap-2"
1028
- >
1029
- <button
1030
- class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary"
1031
- onclick=${() =>
1032
- handleCopyRequestField(
1033
- item.payload,
1034
- "Payload",
1035
- )}
1036
- >
1037
- Copy
1038
- </button>
1039
- <button
1040
- class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary disabled:opacity-60"
1041
- onclick=${() =>
1042
- handleReplayRequest(item)}
1043
- disabled=${item.payloadTruncated ||
1044
- replayingRequestId === item.id}
1045
- title=${item.payloadTruncated
1046
- ? "Cannot replay truncated payload"
1047
- : "Replay this payload"}
1048
- >
1049
- ${replayingRequestId === item.id
1050
- ? "Replaying..."
1051
- : "Replay"}
1052
- </button>
1053
- </div>
1054
- </div>
1055
- <div>
1056
- <p class="text-[11px] text-gray-500 mb-1">
1057
- Gateway response
1058
- (${item.gatewayStatus || "n/a"})
1059
- </p>
1060
- <pre
1061
- class="text-xs bg-black/30 border border-border rounded p-2 overflow-auto"
1062
- >
1063
- ${jsonPretty(item.gatewayBody)}</pre
1064
- >
1065
- <div
1066
- class="mt-2 flex justify-start gap-2"
1067
- >
1068
- <button
1069
- class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary"
1070
- onclick=${() =>
1071
- handleCopyRequestField(
1072
- item.gatewayBody,
1073
- "Gateway response",
1074
- )}
1075
- >
1076
- Copy
1077
- </button>
1078
- ${item.status === "error"
1079
- ? html`<${ActionButton}
1080
- onClick=${() =>
1081
- handleAskAgentToDebug(item)}
1082
- loading=${debugLoadingRequestId ===
1083
- item.id}
1084
- tone="primary"
1085
- size="sm"
1086
- idleLabel="Ask agent to debug"
1087
- loadingLabel="Loading..."
1088
- className="h-7 px-2.5"
1089
- />`
1090
- : null}
1091
- </div>
1092
- </div>
1093
- </div>
1094
- `
1095
- : null}
1096
- </details>
1097
- `;
1098
- })}
1099
- </div>
1100
- `}
1101
- </div>
1102
- `
1103
- : html`
1104
- <div
1105
- class="bg-surface border border-border rounded-xl p-4 space-y-4"
1106
- >
1107
- ${isListLoading
1108
- ? html`<p class="text-xs text-gray-500">Loading webhooks...</p>`
1109
- : null}
1110
- ${!isListLoading && webhooks.length === 0
1111
- ? html`<p class="text-sm text-gray-500">
1112
- No webhooks configured yet. Create one to get started.
1113
- </p>`
1114
- : null}
1115
- ${webhooks.length > 0
1116
- ? html`
1117
- <div class="overflow-auto">
1118
- <table class="w-full text-sm">
1119
- <thead>
1120
- <tr
1121
- class="text-left text-xs text-gray-500 border-b border-border"
1122
- >
1123
- <th class="pb-2 pr-3">Path</th>
1124
- <th class="pb-2 pr-3">Last received</th>
1125
- <th class="pb-2 pr-3">Errors</th>
1126
- <th class="pb-2 pr-3">Health</th>
1127
- <th class="pb-2 pr-3">Type</th>
1128
- </tr>
1129
- </thead>
1130
- <tbody>
1131
- <tr aria-hidden="true">
1132
- <td class="h-2 p-0" colspan="5"></td>
1133
- </tr>
1134
- ${webhooks.map(
1135
- (item) => html`
1136
- <tr
1137
- class="group cursor-pointer"
1138
- onclick=${() => {
1139
- onSelectHook(item.name);
1140
- setStatusFilter("all");
1141
- setExpandedRows(new Set());
1142
- }}
1143
- >
1144
- <td
1145
- class="px-3 py-2.5 group-hover:bg-white/5 first:rounded-l-lg transition-colors"
1146
- >
1147
- <code
1148
- >${item.path || `/hooks/${item.name}`}</code
1149
- >
1150
- </td>
1151
- <td
1152
- class="px-3 py-2.5 text-xs text-gray-400 group-hover:bg-white/5 transition-colors"
1153
- >
1154
- ${formatLastReceived(item.lastReceived)}
1155
- </td>
1156
- <td
1157
- class="px-3 py-2.5 text-xs group-hover:bg-white/5 transition-colors"
1158
- >
1159
- ${item.errorCount || 0}
1160
- </td>
1161
- <td
1162
- class="px-3 py-2.5 group-hover:bg-white/5 last:rounded-r-lg transition-colors"
1163
- >
1164
- <span
1165
- class="inline-block w-2.5 h-2.5 rounded-full ${healthClassName(
1166
- item.health,
1167
- )}"
1168
- title=${item.health}
1169
- />
1170
- </td>
1171
- <td
1172
- class="px-3 py-2.5 text-xs text-gray-400 group-hover:bg-white/5 transition-colors"
1173
- >
1174
- ${item.managed
1175
- ? html`<span
1176
- class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] bg-cyan-500/10 text-cyan-200"
1177
- >Managed</span
1178
- >`
1179
- : html`<${Badge} tone="neutral">Custom</${Badge}>`}
1180
- </td>
1181
- </tr>
1182
- `,
1183
- )}
1184
- </tbody>
1185
- </table>
1186
- </div>
1187
- `
1188
- : null}
1189
- </div>
1190
- `}
1191
-
1192
- <${CreateWebhookModal}
1193
- visible=${isCreating && !selectedHookName}
1194
- name=${newName}
1195
- onNameChange=${setNewName}
1196
- canCreate=${canCreate}
1197
- creating=${creating}
1198
- onCreate=${handleCreate}
1199
- onClose=${() => setIsCreating(false)}
1200
- />
1201
- <${ConfirmDialog}
1202
- visible=${showDeleteConfirm &&
1203
- !!selectedHookName &&
1204
- !selectedWebhookManaged}
1205
- title="Delete webhook?"
1206
- message=${`This removes "/hooks/${selectedHookName}" from openclaw.json.`}
1207
- details=${html`
1208
- <div class="rounded-lg border border-border bg-black/20 p-3">
1209
- <label
1210
- class="flex items-center gap-2 text-xs text-gray-300 select-none"
1211
- >
1212
- <input
1213
- type="checkbox"
1214
- checked=${deleteTransformDir}
1215
- onInput=${(event) =>
1216
- setDeleteTransformDir(!!event.target.checked)}
1217
- />
1218
- Also delete <code>hooks/transforms/${selectedHookName}</code>
1219
- </label>
1220
- </div>
1221
- `}
1222
- confirmLabel="Delete webhook"
1223
- confirmLoadingLabel="Deleting..."
1224
- confirmLoading=${deleting}
1225
- cancelLabel="Cancel"
1226
- onCancel=${() => {
1227
- if (deleting) return;
1228
- setDeleteTransformDir(true);
1229
- setShowDeleteConfirm(false);
1230
- }}
1231
- onConfirm=${handleDeleteConfirmed}
1232
- />
1233
- <${AgentSendModal}
1234
- visible=${!!debugRequest}
1235
- title="Ask agent to debug"
1236
- messageLabel="Debug request"
1237
- messageRows=${12}
1238
- initialMessage=${debugAgentMessage}
1239
- resetKey=${String(debugRequest?.id || "")}
1240
- submitLabel="Send debug request"
1241
- loadingLabel="Sending..."
1242
- onClose=${() => setDebugRequest(null)}
1243
- onSubmit=${async ({ selectedSessionKey, message }) => {
1244
- try {
1245
- await sendAgentMessage({
1246
- message,
1247
- sessionKey: selectedSessionKey,
1248
- });
1249
- showToast("Debug request sent to agent", "success");
1250
- return true;
1251
- } catch (err) {
1252
- showToast(err.message || "Could not send debug request", "error");
1253
- return false;
1254
- }
1255
- }}
1256
- />
1257
- </div>
1258
- `;
1259
- };