@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.
@@ -0,0 +1,167 @@
1
+ import { useCallback, useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ fetchWebhookRequest,
4
+ fetchWebhookRequests,
5
+ } from "../../../lib/api.js";
6
+ import { usePolling } from "../../../hooks/usePolling.js";
7
+ import { showToast } from "../../toast.js";
8
+
9
+ export const useRequestHistory = ({
10
+ selectedHookName = "",
11
+ effectiveAuthMode = "headers",
12
+ webhookUrl = "",
13
+ webhookUrlWithQueryToken = "",
14
+ bearerTokenValue = "",
15
+ }) => {
16
+ const [statusFilter, setStatusFilter] = useState("all");
17
+ const [expandedRows, setExpandedRows] = useState(() => new Set());
18
+ const [replayingRequestId, setReplayingRequestId] = useState(null);
19
+ const [debugLoadingRequestId, setDebugLoadingRequestId] = useState(null);
20
+ const [debugRequest, setDebugRequest] = useState(null);
21
+
22
+ const requestsPoll = usePolling(
23
+ async () => {
24
+ if (!selectedHookName) return { requests: [] };
25
+ const data = await fetchWebhookRequests(selectedHookName, {
26
+ limit: 25,
27
+ offset: 0,
28
+ status: statusFilter,
29
+ });
30
+ return data;
31
+ },
32
+ 5000,
33
+ { enabled: !!selectedHookName },
34
+ );
35
+
36
+ const requests = requestsPoll.data?.requests || [];
37
+
38
+ const handleRequestRowToggle = useCallback((id, isOpen) => {
39
+ setExpandedRows((prev) => {
40
+ const next = new Set(prev);
41
+ if (isOpen) next.add(id);
42
+ else next.delete(id);
43
+ return next;
44
+ });
45
+ }, []);
46
+
47
+ const handleSetStatusFilter = useCallback(
48
+ (filter) => {
49
+ setStatusFilter(filter);
50
+ setExpandedRows(new Set());
51
+ setTimeout(() => requestsPoll.refresh(), 0);
52
+ },
53
+ [requestsPoll.refresh],
54
+ );
55
+
56
+ const resetState = useCallback(() => {
57
+ setStatusFilter("all");
58
+ setExpandedRows(new Set());
59
+ setDebugRequest(null);
60
+ setDebugLoadingRequestId(null);
61
+ setReplayingRequestId(null);
62
+ }, []);
63
+
64
+ const handleCopyRequestField = useCallback(async (value, label) => {
65
+ try {
66
+ await navigator.clipboard.writeText(String(value || ""));
67
+ showToast(`${label} copied`, "success");
68
+ } catch {
69
+ showToast(
70
+ `Could not copy ${String(label || "value").toLowerCase()}`,
71
+ "error",
72
+ );
73
+ }
74
+ }, []);
75
+
76
+ const requestUrl = useMemo(() => {
77
+ return effectiveAuthMode === "query" ? webhookUrlWithQueryToken : webhookUrl;
78
+ }, [effectiveAuthMode, webhookUrl, webhookUrlWithQueryToken]);
79
+
80
+ const requestHeaders = useMemo(() => {
81
+ const headers = { "Content-Type": "application/json" };
82
+ if (effectiveAuthMode === "headers") {
83
+ headers.Authorization = bearerTokenValue;
84
+ }
85
+ return headers;
86
+ }, [bearerTokenValue, effectiveAuthMode]);
87
+
88
+ const handleReplayRequest = useCallback(
89
+ async (item) => {
90
+ if (!item || replayingRequestId === item.id) return;
91
+ if (item.payloadTruncated) {
92
+ showToast("Cannot replay a truncated payload", "warning");
93
+ return;
94
+ }
95
+ setReplayingRequestId(item.id);
96
+ try {
97
+ const response = await fetch(requestUrl, {
98
+ method: "POST",
99
+ headers: requestHeaders,
100
+ body: String(item.payload || ""),
101
+ });
102
+ const bodyText = await response.text();
103
+ let body = null;
104
+ try {
105
+ body = bodyText ? JSON.parse(bodyText) : null;
106
+ } catch {
107
+ body = null;
108
+ }
109
+ const errorMessage =
110
+ body?.ok === false
111
+ ? body?.error || "Webhook rejected"
112
+ : !response.ok
113
+ ? body?.error || bodyText || `HTTP ${response.status}`
114
+ : "";
115
+ if (errorMessage) {
116
+ showToast(`Replay failed: ${errorMessage}`, "error");
117
+ return;
118
+ }
119
+ showToast("Request replayed", "success");
120
+ setTimeout(() => requestsPoll.refresh(), 0);
121
+ } catch (err) {
122
+ showToast(err.message || "Could not replay request", "error");
123
+ } finally {
124
+ setReplayingRequestId(null);
125
+ }
126
+ },
127
+ [replayingRequestId, requestHeaders, requestUrl, requestsPoll.refresh],
128
+ );
129
+
130
+ const handleAskAgentToDebug = useCallback(
131
+ async (item) => {
132
+ if (!selectedHookName || !item?.id || debugLoadingRequestId === item.id)
133
+ return;
134
+ try {
135
+ setDebugLoadingRequestId(item.id);
136
+ const data = await fetchWebhookRequest(selectedHookName, item.id);
137
+ setDebugRequest(data?.request || item);
138
+ } catch (err) {
139
+ showToast(err.message || "Could not load webhook request details", "error");
140
+ } finally {
141
+ setDebugLoadingRequestId(null);
142
+ }
143
+ },
144
+ [debugLoadingRequestId, selectedHookName],
145
+ );
146
+
147
+ return {
148
+ state: {
149
+ requests,
150
+ statusFilter,
151
+ expandedRows,
152
+ replayingRequestId,
153
+ debugLoadingRequestId,
154
+ debugRequest,
155
+ },
156
+ actions: {
157
+ refreshRequests: requestsPoll.refresh,
158
+ handleRequestRowToggle,
159
+ handleSetStatusFilter,
160
+ handleReplayRequest,
161
+ handleCopyRequestField,
162
+ handleAskAgentToDebug,
163
+ setDebugRequest,
164
+ resetState,
165
+ },
166
+ };
167
+ };
@@ -0,0 +1,386 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { ActionButton } from "../../action-button.js";
4
+ import { Badge } from "../../badge.js";
5
+ import { ConfirmDialog } from "../../confirm-dialog.js";
6
+ import { showToast } from "../../toast.js";
7
+ import { formatDateTime } from "../helpers.js";
8
+ import { RequestHistory } from "../request-history/index.js";
9
+ import { useWebhookDetail } from "./use-webhook-detail.js";
10
+
11
+ const html = htm.bind(h);
12
+
13
+ export const WebhookDetail = ({
14
+ selectedHookName = "",
15
+ onBackToList = () => {},
16
+ onRestartRequired = () => {},
17
+ onOpenFile = () => {},
18
+ }) => {
19
+ const { state, actions } = useWebhookDetail({
20
+ selectedHookName,
21
+ onBackToList,
22
+ onRestartRequired,
23
+ });
24
+
25
+ const {
26
+ authMode,
27
+ selectedWebhook,
28
+ isWebhookLoading,
29
+ webhookLoadError,
30
+ selectedWebhookManaged,
31
+ selectedDeliveryAgentName,
32
+ selectedDeliveryChannel,
33
+ webhookUrl,
34
+ oauthCallbackUrl,
35
+ hasOauthCallback,
36
+ webhookUrlWithQueryToken,
37
+ authHeaderValue,
38
+ bearerTokenValue,
39
+ effectiveAuthMode,
40
+ activeCurlCommand,
41
+ deleting,
42
+ showDeleteConfirm,
43
+ deleteTransformDir,
44
+ sendingTestWebhook,
45
+ rotatingOauthCallback,
46
+ showRotateOauthConfirm,
47
+ } = state;
48
+
49
+ return html`
50
+ <div class="space-y-4">
51
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-4">
52
+ <div>
53
+ <h2 class="font-semibold text-sm">
54
+ ${selectedWebhook?.path || `/hooks/${selectedHookName}`}
55
+ </h2>
56
+ </div>
57
+
58
+ ${isWebhookLoading
59
+ ? html`<div class="bg-black/20 border border-border rounded-lg p-3">
60
+ <p class="text-xs text-gray-500">Loading webhook details...</p>
61
+ </div>`
62
+ : webhookLoadError
63
+ ? html`<div class="bg-black/20 border border-border rounded-lg p-3">
64
+ <p class="text-xs text-red-300">
65
+ ${webhookLoadError?.message || "Could not load webhook details"}
66
+ </p>
67
+ </div>`
68
+ : hasOauthCallback
69
+ ? null
70
+ : html`<div class="bg-black/20 border border-border rounded-lg p-3 space-y-4">
71
+ ${selectedWebhookManaged
72
+ ? null
73
+ : html`
74
+ <div class="space-y-2">
75
+ <p class="text-xs text-gray-500">Auth mode</p>
76
+ <div class="flex items-center gap-2">
77
+ <button
78
+ class="text-xs px-2 py-1 rounded border transition-colors ${authMode ===
79
+ "headers"
80
+ ? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
81
+ : "border-border text-gray-400 hover:text-gray-200"}"
82
+ onclick=${() => actions.setAuthMode("headers")}
83
+ >
84
+ Headers
85
+ </button>
86
+ <button
87
+ class="text-xs px-2 py-1 rounded border transition-colors ${authMode ===
88
+ "query"
89
+ ? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
90
+ : "border-border text-gray-400 hover:text-gray-200"}"
91
+ onclick=${() => actions.setAuthMode("query")}
92
+ >
93
+ Query string
94
+ </button>
95
+ </div>
96
+ </div>
97
+ `}
98
+ <div class="space-y-2">
99
+ <p class="text-xs text-gray-500">Webhook URL</p>
100
+ <div class="flex items-center gap-2">
101
+ <input
102
+ type="text"
103
+ readonly
104
+ value=${effectiveAuthMode === "query"
105
+ ? webhookUrlWithQueryToken
106
+ : webhookUrl}
107
+ class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono"
108
+ />
109
+ <button
110
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0"
111
+ onclick=${async () => {
112
+ try {
113
+ await navigator.clipboard.writeText(
114
+ effectiveAuthMode === "query"
115
+ ? webhookUrlWithQueryToken
116
+ : webhookUrl,
117
+ );
118
+ showToast("Webhook URL copied", "success");
119
+ } catch {
120
+ showToast("Could not copy URL", "error");
121
+ }
122
+ }}
123
+ >
124
+ Copy
125
+ </button>
126
+ </div>
127
+ </div>
128
+ ${selectedWebhookManaged
129
+ ? null
130
+ : effectiveAuthMode === "headers"
131
+ ? html`
132
+ <div class="space-y-2">
133
+ <p class="text-xs text-gray-500">Auth headers</p>
134
+ <div class="flex items-center gap-2">
135
+ <input
136
+ type="text"
137
+ readonly
138
+ value=${authHeaderValue}
139
+ class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono"
140
+ />
141
+ <button
142
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0"
143
+ onclick=${async () => {
144
+ try {
145
+ await navigator.clipboard.writeText(
146
+ bearerTokenValue,
147
+ );
148
+ showToast("Bearer token copied", "success");
149
+ } catch {
150
+ showToast("Could not copy bearer token", "error");
151
+ }
152
+ }}
153
+ >
154
+ Copy
155
+ </button>
156
+ </div>
157
+ </div>
158
+ `
159
+ : html`
160
+ <p class="text-xs text-yellow-300">
161
+ Always use auth headers when possible. Query string is
162
+ less secure.
163
+ </p>
164
+ `}
165
+ </div>`}
166
+
167
+ ${isWebhookLoading || webhookLoadError || selectedWebhookManaged || !hasOauthCallback
168
+ ? null
169
+ : html`
170
+ <div class="bg-black/20 border border-border rounded-lg p-3 space-y-2">
171
+ <div class="flex items-center gap-2">
172
+ <p class="text-xs text-gray-500">OAuth Callback URL</p>
173
+ ${hasOauthCallback
174
+ ? html`<${Badge} tone="neutral">OAuth alias</${Badge}>`
175
+ : null}
176
+ </div>
177
+ <div class="flex items-center gap-2">
178
+ <input
179
+ type="text"
180
+ readonly
181
+ value=${hasOauthCallback ? oauthCallbackUrl : "Not enabled"}
182
+ class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono"
183
+ />
184
+ ${hasOauthCallback
185
+ ? html`
186
+ <button
187
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0"
188
+ onclick=${async () => {
189
+ try {
190
+ await navigator.clipboard.writeText(
191
+ oauthCallbackUrl,
192
+ );
193
+ showToast("OAuth callback URL copied", "success");
194
+ } catch {
195
+ showToast("Could not copy URL", "error");
196
+ }
197
+ }}
198
+ >
199
+ Copy
200
+ </button>
201
+ `
202
+ : null}
203
+ </div>
204
+ <div class="flex items-center justify-start gap-3 flex-wrap">
205
+ <div class="flex items-center gap-2">
206
+ <button
207
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary disabled:opacity-60"
208
+ onclick=${() => {
209
+ if (rotatingOauthCallback) return;
210
+ actions.setShowRotateOauthConfirm(true);
211
+ }}
212
+ disabled=${rotatingOauthCallback}
213
+ >
214
+ ${rotatingOauthCallback ? "Rotating..." : "Rotate"}
215
+ </button>
216
+ </div>
217
+ <p class="text-xs text-yellow-300">
218
+ Keep this URL private. Rotate if exposed.
219
+ </p>
220
+ </div>
221
+ </div>
222
+ `}
223
+
224
+ <div class="bg-black/20 border border-border rounded-lg p-3 space-y-2">
225
+ <p class="text-xs text-gray-500">Deliver to</p>
226
+ <p class="text-xs text-gray-200 font-mono ">
227
+ ${selectedDeliveryAgentName}${" "}
228
+ <span class="text-xs text-gray-500 font-mono"
229
+ >(${selectedDeliveryChannel})</span
230
+ >
231
+ </p>
232
+ </div>
233
+
234
+ <div class="bg-black/20 border border-border rounded-lg p-3 space-y-2">
235
+ <p class="text-xs text-gray-500">Test webhook</p>
236
+ <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
237
+ <input
238
+ type="text"
239
+ readonly
240
+ value=${activeCurlCommand}
241
+ 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"
242
+ />
243
+ <div class="grid grid-cols-2 gap-2 w-full sm:w-auto sm:flex sm:items-center">
244
+ <button
245
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary w-full sm:w-auto sm:shrink-0"
246
+ onclick=${async () => {
247
+ try {
248
+ await navigator.clipboard.writeText(activeCurlCommand);
249
+ showToast("curl command copied", "success");
250
+ } catch {
251
+ showToast("Could not copy curl command", "error");
252
+ }
253
+ }}
254
+ >
255
+ Copy
256
+ </button>
257
+ <button
258
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary w-full sm:w-auto sm:shrink-0 disabled:opacity-60"
259
+ onclick=${actions.handleSendTestWebhook}
260
+ disabled=${sendingTestWebhook}
261
+ >
262
+ ${sendingTestWebhook ? "Sending..." : "Send"}
263
+ </button>
264
+ </div>
265
+ </div>
266
+ </div>
267
+
268
+ <div class="bg-black/20 border border-border rounded-lg p-3">
269
+ <div class="flex items-center gap-2 text-xs text-gray-300">
270
+ <span class="text-gray-500">Transform:</span>
271
+ ${selectedWebhook?.transformPath
272
+ ? html`<button
273
+ type="button"
274
+ class="ac-tip-link flex-1 min-w-0 truncate block text-left font-mono"
275
+ title=${selectedWebhook.transformPath}
276
+ onclick=${() => onOpenFile(selectedWebhook.transformPath)}
277
+ >
278
+ ${selectedWebhook.transformPath}
279
+ </button>`
280
+ : html`<code class="flex-1 min-w-0 truncate block">—</code>`}
281
+ <span
282
+ class=${`ml-auto inline-flex items-center gap-1 px-1.5 py-0.5 rounded border font-sans ${
283
+ selectedWebhook?.transformExists
284
+ ? "border-green-500/30 text-green-300 bg-green-500/10"
285
+ : "border-yellow-500/30 text-yellow-300 bg-yellow-500/10"
286
+ }`}
287
+ >
288
+ <span class="font-sans text-sm leading-none">
289
+ ${selectedWebhook?.transformExists ? "✓" : "!"}
290
+ </span>
291
+ ${selectedWebhook?.transformExists ? null : html`<span>missing</span>`}
292
+ </span>
293
+ </div>
294
+ </div>
295
+
296
+ <div class="flex items-center justify-between gap-3">
297
+ <p class="text-xs text-gray-600">
298
+ Created: ${formatDateTime(selectedWebhook?.createdAt)}
299
+ </p>
300
+ ${selectedWebhookManaged
301
+ ? null
302
+ : html`<${ActionButton}
303
+ onClick=${() => {
304
+ if (deleting) return;
305
+ actions.setDeleteTransformDir(true);
306
+ actions.setShowDeleteConfirm(true);
307
+ }}
308
+ disabled=${deleting}
309
+ loading=${deleting}
310
+ tone="danger"
311
+ size="sm"
312
+ idleLabel="Delete"
313
+ loadingLabel="Deleting..."
314
+ className="shrink-0 px-2.5 py-1"
315
+ />`}
316
+ </div>
317
+ </div>
318
+
319
+ ${selectedWebhookManaged && !isWebhookLoading && !webhookLoadError
320
+ ? html`
321
+ <div class="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3">
322
+ <p class="text-xs text-yellow-200">
323
+ This webhook is managed by Gmail Watch setup and cannot be
324
+ deleted or edited from this page.
325
+ </p>
326
+ </div>
327
+ `
328
+ : null}
329
+ <${RequestHistory}
330
+ selectedHookName=${selectedHookName}
331
+ selectedWebhook=${selectedWebhook}
332
+ effectiveAuthMode=${effectiveAuthMode}
333
+ webhookUrl=${webhookUrl}
334
+ webhookUrlWithQueryToken=${webhookUrlWithQueryToken}
335
+ bearerTokenValue=${bearerTokenValue}
336
+ />
337
+ <${ConfirmDialog}
338
+ visible=${showRotateOauthConfirm &&
339
+ !!selectedHookName &&
340
+ !selectedWebhookManaged &&
341
+ hasOauthCallback}
342
+ title="Rotate OAuth callback?"
343
+ message="Rotating will generate a new callback URL and immediately invalidate the current URL."
344
+ confirmLabel="Rotate callback URL"
345
+ confirmLoadingLabel="Rotating..."
346
+ confirmLoading=${rotatingOauthCallback}
347
+ cancelLabel="Cancel"
348
+ onCancel=${() => {
349
+ if (rotatingOauthCallback) return;
350
+ actions.setShowRotateOauthConfirm(false);
351
+ }}
352
+ onConfirm=${actions.handleRotateOauthCallback}
353
+ />
354
+ <${ConfirmDialog}
355
+ visible=${showDeleteConfirm &&
356
+ !!selectedHookName &&
357
+ !selectedWebhookManaged}
358
+ title="Delete webhook?"
359
+ message=${`This removes "/hooks/${selectedHookName}" from openclaw.json.`}
360
+ details=${html`
361
+ <div class="rounded-lg border border-border bg-black/20 p-3">
362
+ <label class="flex items-center gap-2 text-xs text-gray-300 select-none">
363
+ <input
364
+ type="checkbox"
365
+ checked=${deleteTransformDir}
366
+ onInput=${(event) =>
367
+ actions.setDeleteTransformDir(!!event.target.checked)}
368
+ />
369
+ Also delete <code>hooks/transforms/${selectedHookName}</code>
370
+ </label>
371
+ </div>
372
+ `}
373
+ confirmLabel="Delete webhook"
374
+ confirmLoadingLabel="Deleting..."
375
+ confirmLoading=${deleting}
376
+ cancelLabel="Cancel"
377
+ onCancel=${() => {
378
+ if (deleting) return;
379
+ actions.setDeleteTransformDir(true);
380
+ actions.setShowDeleteConfirm(false);
381
+ }}
382
+ onConfirm=${actions.handleDeleteConfirmed}
383
+ />
384
+ </div>
385
+ `;
386
+ };