@chrysb/alphaclaw 0.2.3 → 0.3.1
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/LICENSE +21 -0
- package/README.md +179 -0
- package/bin/alphaclaw.js +79 -0
- package/lib/public/css/shell.css +57 -2
- package/lib/public/css/theme.css +231 -0
- package/lib/public/js/app.js +330 -89
- package/lib/public/js/components/action-button.js +92 -0
- package/lib/public/js/components/channels.js +16 -7
- package/lib/public/js/components/confirm-dialog.js +25 -19
- package/lib/public/js/components/credentials-modal.js +32 -23
- package/lib/public/js/components/device-pairings.js +15 -2
- package/lib/public/js/components/envars.js +22 -65
- package/lib/public/js/components/features.js +1 -1
- package/lib/public/js/components/gateway.js +139 -32
- package/lib/public/js/components/global-restart-banner.js +31 -0
- package/lib/public/js/components/google.js +9 -9
- package/lib/public/js/components/icons.js +19 -0
- package/lib/public/js/components/info-tooltip.js +18 -0
- package/lib/public/js/components/loading-spinner.js +32 -0
- package/lib/public/js/components/modal-shell.js +42 -0
- package/lib/public/js/components/models.js +34 -29
- package/lib/public/js/components/onboarding/welcome-form-step.js +45 -32
- package/lib/public/js/components/onboarding/welcome-pairing-step.js +2 -2
- package/lib/public/js/components/onboarding/welcome-setup-step.js +7 -24
- package/lib/public/js/components/page-header.js +13 -0
- package/lib/public/js/components/pairings.js +15 -2
- package/lib/public/js/components/providers.js +216 -142
- package/lib/public/js/components/scope-picker.js +1 -1
- package/lib/public/js/components/secret-input.js +1 -0
- package/lib/public/js/components/telegram-workspace.js +37 -49
- package/lib/public/js/components/toast.js +34 -5
- package/lib/public/js/components/toggle-switch.js +25 -0
- package/lib/public/js/components/update-action-button.js +13 -53
- package/lib/public/js/components/watchdog-tab.js +312 -0
- package/lib/public/js/components/webhooks.js +1010 -0
- package/lib/public/js/components/welcome.js +2 -1
- package/lib/public/js/lib/api.js +102 -1
- package/lib/public/js/lib/model-config.js +0 -5
- package/lib/server/alphaclaw-version.js +5 -3
- package/lib/server/constants.js +35 -0
- package/lib/server/discord-api.js +48 -0
- package/lib/server/gateway.js +64 -4
- package/lib/server/log-writer.js +102 -0
- package/lib/server/onboarding/github.js +21 -1
- package/lib/server/openclaw-version.js +2 -6
- package/lib/server/restart-required-state.js +86 -0
- package/lib/server/routes/auth.js +9 -4
- package/lib/server/routes/proxy.js +12 -14
- package/lib/server/routes/system.js +61 -15
- package/lib/server/routes/telegram.js +17 -48
- package/lib/server/routes/watchdog.js +68 -0
- package/lib/server/routes/webhooks.js +214 -0
- package/lib/server/telegram-api.js +11 -0
- package/lib/server/watchdog-db.js +148 -0
- package/lib/server/watchdog-notify.js +93 -0
- package/lib/server/watchdog.js +585 -0
- package/lib/server/webhook-middleware.js +195 -0
- package/lib/server/webhooks-db.js +265 -0
- package/lib/server/webhooks.js +238 -0
- package/lib/server.js +119 -4
- package/lib/setup/core-prompts/AGENTS.md +84 -0
- package/lib/setup/core-prompts/TOOLS.md +13 -0
- package/lib/setup/core-prompts/UI-DRY-OPPORTUNITIES.md +50 -0
- package/lib/setup/gitignore +2 -0
- package/package.json +11 -1
|
@@ -0,0 +1,1010 @@
|
|
|
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
|
+
createWebhook,
|
|
7
|
+
deleteWebhook,
|
|
8
|
+
fetchWebhookDetail,
|
|
9
|
+
fetchWebhookRequests,
|
|
10
|
+
fetchWebhooks,
|
|
11
|
+
} from "../lib/api.js";
|
|
12
|
+
import { showToast } from "./toast.js";
|
|
13
|
+
import { PageHeader } from "./page-header.js";
|
|
14
|
+
import { ConfirmDialog } from "./confirm-dialog.js";
|
|
15
|
+
import { ActionButton } from "./action-button.js";
|
|
16
|
+
import { ModalShell } from "./modal-shell.js";
|
|
17
|
+
import { CloseIcon } from "./icons.js";
|
|
18
|
+
|
|
19
|
+
const html = htm.bind(h);
|
|
20
|
+
const kNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
21
|
+
const kStatusFilters = ["all", "success", "error"];
|
|
22
|
+
|
|
23
|
+
const formatDateTime = (value) => {
|
|
24
|
+
if (!value) return "—";
|
|
25
|
+
try {
|
|
26
|
+
return new Date(value).toLocaleString();
|
|
27
|
+
} catch {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const formatLastReceived = (value) => {
|
|
33
|
+
if (!value) return "—";
|
|
34
|
+
try {
|
|
35
|
+
const timestamp = new Date(value);
|
|
36
|
+
const now = new Date();
|
|
37
|
+
const isSameDay =
|
|
38
|
+
timestamp.getFullYear() === now.getFullYear() &&
|
|
39
|
+
timestamp.getMonth() === now.getMonth() &&
|
|
40
|
+
timestamp.getDate() === now.getDate();
|
|
41
|
+
return isSameDay
|
|
42
|
+
? timestamp.toLocaleTimeString()
|
|
43
|
+
: timestamp.toLocaleString();
|
|
44
|
+
} catch {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const formatBytes = (size) => {
|
|
50
|
+
const bytes = Number(size || 0);
|
|
51
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0B";
|
|
52
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
53
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
54
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const healthClassName = (health) => {
|
|
58
|
+
if (health === "red") return "bg-red-500";
|
|
59
|
+
if (health === "yellow") return "bg-yellow-500";
|
|
60
|
+
return "bg-green-500";
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const getRequestStatusTone = (status) => {
|
|
64
|
+
if (status === "success") {
|
|
65
|
+
return {
|
|
66
|
+
dotClass: "bg-green-500/90",
|
|
67
|
+
textClass: "text-green-500/90",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (status === "error") {
|
|
71
|
+
return {
|
|
72
|
+
dotClass: "bg-red-500/90",
|
|
73
|
+
textClass: "text-red-500/90",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
dotClass: "bg-gray-500/70",
|
|
78
|
+
textClass: "text-gray-400",
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const jsonPretty = (value) => {
|
|
83
|
+
if (typeof value === "string") {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(value);
|
|
86
|
+
return JSON.stringify(parsed, null, 2);
|
|
87
|
+
} catch {
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return JSON.stringify(value || {}, null, 2);
|
|
93
|
+
} catch {
|
|
94
|
+
return String(value || "");
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const CreateWebhookModal = ({
|
|
99
|
+
visible,
|
|
100
|
+
name,
|
|
101
|
+
onNameChange,
|
|
102
|
+
canCreate,
|
|
103
|
+
creating,
|
|
104
|
+
onCreate,
|
|
105
|
+
onClose,
|
|
106
|
+
}) => {
|
|
107
|
+
if (!visible) return null;
|
|
108
|
+
const normalized = String(name || "")
|
|
109
|
+
.trim()
|
|
110
|
+
.toLowerCase();
|
|
111
|
+
const previewName = normalized || "{name}";
|
|
112
|
+
const previewUrl = `${window.location.origin}/hooks/${previewName}`;
|
|
113
|
+
return html`
|
|
114
|
+
<${ModalShell}
|
|
115
|
+
visible=${visible}
|
|
116
|
+
onClose=${onClose}
|
|
117
|
+
panelClassName="bg-modal border border-border rounded-xl p-5 max-w-lg w-full space-y-4"
|
|
118
|
+
>
|
|
119
|
+
<${PageHeader}
|
|
120
|
+
title="Create Webhook"
|
|
121
|
+
actions=${html`
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onclick=${onClose}
|
|
125
|
+
class="h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary"
|
|
126
|
+
aria-label="Close modal"
|
|
127
|
+
>
|
|
128
|
+
<${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
|
|
129
|
+
</button>
|
|
130
|
+
`}
|
|
131
|
+
/>
|
|
132
|
+
<div class="space-y-2">
|
|
133
|
+
<p class="text-xs text-gray-500">Name</p>
|
|
134
|
+
<input
|
|
135
|
+
type="text"
|
|
136
|
+
value=${name}
|
|
137
|
+
placeholder="fathom"
|
|
138
|
+
onInput=${(e) => onNameChange(e.target.value)}
|
|
139
|
+
onKeyDown=${(e) => {
|
|
140
|
+
if (e.key === "Enter" && canCreate && !creating) onCreate();
|
|
141
|
+
if (e.key === "Escape") onClose();
|
|
142
|
+
}}
|
|
143
|
+
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"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="border border-border rounded-lg overflow-hidden">
|
|
147
|
+
<table class="w-full text-xs">
|
|
148
|
+
<tbody>
|
|
149
|
+
<tr class="border-b border-border">
|
|
150
|
+
<td class="w-24 px-3 py-2 text-gray-500">Path</td>
|
|
151
|
+
<td class="px-3 py-2 text-gray-300 font-mono">
|
|
152
|
+
<code>/hooks/${previewName}</code>
|
|
153
|
+
</td>
|
|
154
|
+
</tr>
|
|
155
|
+
<tr class="border-b border-border">
|
|
156
|
+
<td class="w-24 px-3 py-2 text-gray-500">URL</td>
|
|
157
|
+
<td class="px-3 py-2 text-gray-300 font-mono break-all">
|
|
158
|
+
<code>${previewUrl}</code>
|
|
159
|
+
</td>
|
|
160
|
+
</tr>
|
|
161
|
+
<tr>
|
|
162
|
+
<td class="w-24 px-3 py-2 text-gray-500">Transform</td>
|
|
163
|
+
<td class="px-3 py-2 text-gray-300 font-mono">
|
|
164
|
+
<code
|
|
165
|
+
>hooks/transforms/${previewName}/${previewName}-transform.mjs</code
|
|
166
|
+
>
|
|
167
|
+
</td>
|
|
168
|
+
</tr>
|
|
169
|
+
</tbody>
|
|
170
|
+
</table>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="pt-1 flex items-center justify-end gap-2">
|
|
173
|
+
<${ActionButton}
|
|
174
|
+
onClick=${onClose}
|
|
175
|
+
tone="secondary"
|
|
176
|
+
size="md"
|
|
177
|
+
idleLabel="Cancel"
|
|
178
|
+
className="px-4 py-2 rounded-lg text-sm"
|
|
179
|
+
/>
|
|
180
|
+
<${ActionButton}
|
|
181
|
+
onClick=${onCreate}
|
|
182
|
+
disabled=${!canCreate || creating}
|
|
183
|
+
loading=${creating}
|
|
184
|
+
tone="primary"
|
|
185
|
+
size="md"
|
|
186
|
+
idleLabel="Create"
|
|
187
|
+
loadingLabel="Creating..."
|
|
188
|
+
className="px-4 py-2 rounded-lg text-sm"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
</${ModalShell}>
|
|
192
|
+
`;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const Webhooks = ({
|
|
196
|
+
selectedHookName = "",
|
|
197
|
+
onSelectHook = () => {},
|
|
198
|
+
onBackToList = () => {},
|
|
199
|
+
onRestartRequired = () => {},
|
|
200
|
+
}) => {
|
|
201
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
202
|
+
const [newName, setNewName] = useState("");
|
|
203
|
+
const [creating, setCreating] = useState(false);
|
|
204
|
+
const [deleting, setDeleting] = useState(false);
|
|
205
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
206
|
+
const [deleteTransformDir, setDeleteTransformDir] = useState(true);
|
|
207
|
+
const [authMode, setAuthMode] = useState("headers");
|
|
208
|
+
const [statusFilter, setStatusFilter] = useState("all");
|
|
209
|
+
const [expandedRows, setExpandedRows] = useState(() => new Set());
|
|
210
|
+
const [sendingTestWebhook, setSendingTestWebhook] = useState(false);
|
|
211
|
+
const [replayingRequestId, setReplayingRequestId] = useState(null);
|
|
212
|
+
|
|
213
|
+
const listPoll = usePolling(fetchWebhooks, 15000);
|
|
214
|
+
const webhooks = listPoll.data?.webhooks || [];
|
|
215
|
+
|
|
216
|
+
const detailPoll = usePolling(
|
|
217
|
+
async () => {
|
|
218
|
+
if (!selectedHookName) return null;
|
|
219
|
+
const data = await fetchWebhookDetail(selectedHookName);
|
|
220
|
+
return data.webhook || null;
|
|
221
|
+
},
|
|
222
|
+
10000,
|
|
223
|
+
{ enabled: !!selectedHookName },
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const requestsPoll = usePolling(
|
|
227
|
+
async () => {
|
|
228
|
+
if (!selectedHookName) return { requests: [] };
|
|
229
|
+
const data = await fetchWebhookRequests(selectedHookName, {
|
|
230
|
+
limit: 25,
|
|
231
|
+
offset: 0,
|
|
232
|
+
status: statusFilter,
|
|
233
|
+
});
|
|
234
|
+
return data;
|
|
235
|
+
},
|
|
236
|
+
5000,
|
|
237
|
+
{ enabled: !!selectedHookName },
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const selectedWebhook = detailPoll.data;
|
|
241
|
+
const requests = requestsPoll.data?.requests || [];
|
|
242
|
+
const webhookUrl =
|
|
243
|
+
selectedWebhook?.fullUrl || `.../hooks/${selectedHookName}`;
|
|
244
|
+
const webhookUrlWithQueryToken =
|
|
245
|
+
selectedWebhook?.queryStringUrl ||
|
|
246
|
+
`${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}token=<WEBHOOK_TOKEN>`;
|
|
247
|
+
const derivedTokenFromQuery = (() => {
|
|
248
|
+
try {
|
|
249
|
+
const parsed = new URL(webhookUrlWithQueryToken);
|
|
250
|
+
return String(parsed.searchParams.get("token") || "").trim();
|
|
251
|
+
} catch {
|
|
252
|
+
return "";
|
|
253
|
+
}
|
|
254
|
+
})();
|
|
255
|
+
const authHeaderValue =
|
|
256
|
+
selectedWebhook?.authHeaderValue ||
|
|
257
|
+
(derivedTokenFromQuery
|
|
258
|
+
? `Authorization: Bearer ${derivedTokenFromQuery}`
|
|
259
|
+
: "Authorization: Bearer <WEBHOOK_TOKEN>");
|
|
260
|
+
const bearerTokenValue = authHeaderValue.startsWith("Authorization: ")
|
|
261
|
+
? authHeaderValue.slice("Authorization: ".length)
|
|
262
|
+
: authHeaderValue;
|
|
263
|
+
const webhookTestPayload = useMemo(
|
|
264
|
+
() => ({
|
|
265
|
+
source: "manual-test",
|
|
266
|
+
message: `This is a test of the ${selectedHookName || "webhook"} webhook. Please acknowledge receipt.`,
|
|
267
|
+
}),
|
|
268
|
+
[selectedHookName],
|
|
269
|
+
);
|
|
270
|
+
const webhookTestPayloadJson = JSON.stringify(webhookTestPayload);
|
|
271
|
+
const curlCommandHeaders =
|
|
272
|
+
`curl -X POST "${webhookUrl}" ` +
|
|
273
|
+
`-H "Content-Type: application/json" ` +
|
|
274
|
+
`-H "${authHeaderValue}" ` +
|
|
275
|
+
`-d '${webhookTestPayloadJson}'`;
|
|
276
|
+
const curlCommandQuery =
|
|
277
|
+
`curl -X POST "${webhookUrlWithQueryToken}" ` +
|
|
278
|
+
`-H "Content-Type: application/json" ` +
|
|
279
|
+
`-d '${webhookTestPayloadJson}'`;
|
|
280
|
+
const activeCurlCommand =
|
|
281
|
+
authMode === "query" ? curlCommandQuery : curlCommandHeaders;
|
|
282
|
+
|
|
283
|
+
const canCreate = useMemo(() => {
|
|
284
|
+
const name = String(newName || "")
|
|
285
|
+
.trim()
|
|
286
|
+
.toLowerCase();
|
|
287
|
+
return kNamePattern.test(name);
|
|
288
|
+
}, [newName]);
|
|
289
|
+
|
|
290
|
+
const refreshAll = useCallback(() => {
|
|
291
|
+
listPoll.refresh();
|
|
292
|
+
detailPoll.refresh();
|
|
293
|
+
requestsPoll.refresh();
|
|
294
|
+
}, [listPoll.refresh, detailPoll.refresh, requestsPoll.refresh]);
|
|
295
|
+
|
|
296
|
+
const handleCreate = useCallback(async () => {
|
|
297
|
+
const candidateName = String(newName || "")
|
|
298
|
+
.trim()
|
|
299
|
+
.toLowerCase();
|
|
300
|
+
if (!kNamePattern.test(candidateName)) {
|
|
301
|
+
showToast(
|
|
302
|
+
"Name must be lowercase letters, numbers, and hyphens",
|
|
303
|
+
"error",
|
|
304
|
+
);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (creating) return;
|
|
308
|
+
setCreating(true);
|
|
309
|
+
try {
|
|
310
|
+
const data = await createWebhook(candidateName);
|
|
311
|
+
setIsCreating(false);
|
|
312
|
+
setNewName("");
|
|
313
|
+
onSelectHook(candidateName);
|
|
314
|
+
if (data.restartRequired) onRestartRequired(true);
|
|
315
|
+
showToast("Webhook created", "success");
|
|
316
|
+
if (data.syncWarning) {
|
|
317
|
+
showToast(
|
|
318
|
+
`Created, but git-sync failed: ${data.syncWarning}`,
|
|
319
|
+
"warning",
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
refreshAll();
|
|
323
|
+
} catch (err) {
|
|
324
|
+
showToast(err.message || "Could not create webhook", "error");
|
|
325
|
+
} finally {
|
|
326
|
+
setCreating(false);
|
|
327
|
+
}
|
|
328
|
+
}, [newName, creating, refreshAll, onSelectHook, onRestartRequired]);
|
|
329
|
+
|
|
330
|
+
const handleDeleteConfirmed = useCallback(async () => {
|
|
331
|
+
if (!selectedHookName || deleting) return;
|
|
332
|
+
setDeleting(true);
|
|
333
|
+
try {
|
|
334
|
+
const data = await deleteWebhook(selectedHookName, {
|
|
335
|
+
deleteTransformDir,
|
|
336
|
+
});
|
|
337
|
+
if (data.restartRequired) onRestartRequired(true);
|
|
338
|
+
onBackToList();
|
|
339
|
+
setShowDeleteConfirm(false);
|
|
340
|
+
setDeleteTransformDir(true);
|
|
341
|
+
showToast("Webhook removed", "success");
|
|
342
|
+
if (data.deletedTransformDir) {
|
|
343
|
+
showToast("Transform directory deleted", "success");
|
|
344
|
+
}
|
|
345
|
+
if (data.syncWarning) {
|
|
346
|
+
showToast(
|
|
347
|
+
`Deleted, but git-sync failed: ${data.syncWarning}`,
|
|
348
|
+
"warning",
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
refreshAll();
|
|
352
|
+
} catch (err) {
|
|
353
|
+
showToast(err.message || "Could not delete webhook", "error");
|
|
354
|
+
} finally {
|
|
355
|
+
setDeleting(false);
|
|
356
|
+
}
|
|
357
|
+
}, [
|
|
358
|
+
selectedHookName,
|
|
359
|
+
deleting,
|
|
360
|
+
deleteTransformDir,
|
|
361
|
+
refreshAll,
|
|
362
|
+
onBackToList,
|
|
363
|
+
onRestartRequired,
|
|
364
|
+
]);
|
|
365
|
+
|
|
366
|
+
const handleRequestRowToggle = useCallback((id, isOpen) => {
|
|
367
|
+
setExpandedRows((prev) => {
|
|
368
|
+
const next = new Set(prev);
|
|
369
|
+
if (isOpen) next.add(id);
|
|
370
|
+
else next.delete(id);
|
|
371
|
+
return next;
|
|
372
|
+
});
|
|
373
|
+
}, []);
|
|
374
|
+
|
|
375
|
+
const handleSendTestWebhook = useCallback(async () => {
|
|
376
|
+
if (!selectedHookName || sendingTestWebhook) return;
|
|
377
|
+
setSendingTestWebhook(true);
|
|
378
|
+
const requestUrl =
|
|
379
|
+
authMode === "query" ? webhookUrlWithQueryToken : webhookUrl;
|
|
380
|
+
const headers = { "Content-Type": "application/json" };
|
|
381
|
+
if (authMode === "headers") {
|
|
382
|
+
headers.Authorization = bearerTokenValue;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
const response = await fetch(requestUrl, {
|
|
386
|
+
method: "POST",
|
|
387
|
+
headers,
|
|
388
|
+
body: webhookTestPayloadJson,
|
|
389
|
+
});
|
|
390
|
+
const bodyText = await response.text();
|
|
391
|
+
let body = null;
|
|
392
|
+
try {
|
|
393
|
+
body = bodyText ? JSON.parse(bodyText) : null;
|
|
394
|
+
} catch {
|
|
395
|
+
body = null;
|
|
396
|
+
}
|
|
397
|
+
const errorMessage =
|
|
398
|
+
body?.ok === false
|
|
399
|
+
? body?.error || "Webhook rejected"
|
|
400
|
+
: !response.ok
|
|
401
|
+
? body?.error || bodyText || `HTTP ${response.status}`
|
|
402
|
+
: "";
|
|
403
|
+
if (errorMessage) {
|
|
404
|
+
showToast(`Test webhook failed: ${errorMessage}`, "error");
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
showToast("Test webhook sent", "success");
|
|
408
|
+
setTimeout(() => requestsPoll.refresh(), 0);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
showToast(err.message || "Could not send test webhook", "error");
|
|
411
|
+
} finally {
|
|
412
|
+
setSendingTestWebhook(false);
|
|
413
|
+
}
|
|
414
|
+
}, [
|
|
415
|
+
authMode,
|
|
416
|
+
bearerTokenValue,
|
|
417
|
+
requestsPoll.refresh,
|
|
418
|
+
selectedHookName,
|
|
419
|
+
sendingTestWebhook,
|
|
420
|
+
webhookTestPayloadJson,
|
|
421
|
+
webhookUrl,
|
|
422
|
+
webhookUrlWithQueryToken,
|
|
423
|
+
]);
|
|
424
|
+
|
|
425
|
+
const handleReplayRequest = useCallback(
|
|
426
|
+
async (item) => {
|
|
427
|
+
if (!item || replayingRequestId === item.id) return;
|
|
428
|
+
if (item.payloadTruncated) {
|
|
429
|
+
showToast("Cannot replay a truncated payload", "warning");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const requestUrl =
|
|
433
|
+
authMode === "query" ? webhookUrlWithQueryToken : webhookUrl;
|
|
434
|
+
const headers = { "Content-Type": "application/json" };
|
|
435
|
+
if (authMode === "headers") {
|
|
436
|
+
headers.Authorization = bearerTokenValue;
|
|
437
|
+
}
|
|
438
|
+
setReplayingRequestId(item.id);
|
|
439
|
+
try {
|
|
440
|
+
const response = await fetch(requestUrl, {
|
|
441
|
+
method: "POST",
|
|
442
|
+
headers,
|
|
443
|
+
body: String(item.payload || ""),
|
|
444
|
+
});
|
|
445
|
+
const bodyText = await response.text();
|
|
446
|
+
let body = null;
|
|
447
|
+
try {
|
|
448
|
+
body = bodyText ? JSON.parse(bodyText) : null;
|
|
449
|
+
} catch {
|
|
450
|
+
body = null;
|
|
451
|
+
}
|
|
452
|
+
const errorMessage =
|
|
453
|
+
body?.ok === false
|
|
454
|
+
? body?.error || "Webhook rejected"
|
|
455
|
+
: !response.ok
|
|
456
|
+
? body?.error || bodyText || `HTTP ${response.status}`
|
|
457
|
+
: "";
|
|
458
|
+
if (errorMessage) {
|
|
459
|
+
showToast(`Replay failed: ${errorMessage}`, "error");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
showToast("Request replayed", "success");
|
|
463
|
+
setTimeout(() => requestsPoll.refresh(), 0);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
showToast(err.message || "Could not replay request", "error");
|
|
466
|
+
} finally {
|
|
467
|
+
setReplayingRequestId(null);
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
[
|
|
471
|
+
authMode,
|
|
472
|
+
bearerTokenValue,
|
|
473
|
+
replayingRequestId,
|
|
474
|
+
requestsPoll.refresh,
|
|
475
|
+
webhookUrl,
|
|
476
|
+
webhookUrlWithQueryToken,
|
|
477
|
+
],
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const handleCopyRequestField = useCallback(async (value, label) => {
|
|
481
|
+
try {
|
|
482
|
+
await navigator.clipboard.writeText(String(value || ""));
|
|
483
|
+
showToast(`${label} copied`, "success");
|
|
484
|
+
} catch {
|
|
485
|
+
showToast(`Could not copy ${String(label || "value").toLowerCase()}`, "error");
|
|
486
|
+
}
|
|
487
|
+
}, []);
|
|
488
|
+
|
|
489
|
+
const isListLoading = !listPoll.data && !listPoll.error;
|
|
490
|
+
|
|
491
|
+
return html`
|
|
492
|
+
<div class="space-y-4">
|
|
493
|
+
<${PageHeader}
|
|
494
|
+
title="Webhooks"
|
|
495
|
+
leading=${selectedHookName
|
|
496
|
+
? html`
|
|
497
|
+
<button
|
|
498
|
+
class="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 transition-colors"
|
|
499
|
+
onclick=${onBackToList}
|
|
500
|
+
>
|
|
501
|
+
<svg
|
|
502
|
+
width="16"
|
|
503
|
+
height="16"
|
|
504
|
+
viewBox="0 0 16 16"
|
|
505
|
+
fill="currentColor"
|
|
506
|
+
>
|
|
507
|
+
<path
|
|
508
|
+
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"
|
|
509
|
+
/>
|
|
510
|
+
</svg>
|
|
511
|
+
Back
|
|
512
|
+
</button>
|
|
513
|
+
`
|
|
514
|
+
: null}
|
|
515
|
+
actions=${selectedHookName
|
|
516
|
+
? null
|
|
517
|
+
: html`
|
|
518
|
+
<button
|
|
519
|
+
class="text-xs px-3 py-1.5 rounded-lg ac-btn-secondary"
|
|
520
|
+
onclick=${() => setIsCreating((open) => !open)}
|
|
521
|
+
>
|
|
522
|
+
Create new
|
|
523
|
+
</button>
|
|
524
|
+
`}
|
|
525
|
+
/>
|
|
526
|
+
|
|
527
|
+
${selectedHookName
|
|
528
|
+
? html`
|
|
529
|
+
<div
|
|
530
|
+
class="bg-surface border border-border rounded-xl p-4 space-y-4"
|
|
531
|
+
>
|
|
532
|
+
<div>
|
|
533
|
+
<h2 class="font-semibold text-sm">
|
|
534
|
+
${selectedWebhook?.path || `/hooks/${selectedHookName}`}
|
|
535
|
+
</h2>
|
|
536
|
+
</div>
|
|
537
|
+
|
|
538
|
+
<div
|
|
539
|
+
class="bg-black/20 border border-border rounded-lg p-3 space-y-4"
|
|
540
|
+
>
|
|
541
|
+
<div class="space-y-2">
|
|
542
|
+
<p class="text-xs text-gray-500">Auth mode</p>
|
|
543
|
+
<div class="flex items-center gap-2">
|
|
544
|
+
<button
|
|
545
|
+
class="text-xs px-2 py-1 rounded border transition-colors ${authMode ===
|
|
546
|
+
"headers"
|
|
547
|
+
? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
|
|
548
|
+
: "border-border text-gray-400 hover:text-gray-200"}"
|
|
549
|
+
onclick=${() => setAuthMode("headers")}
|
|
550
|
+
>
|
|
551
|
+
Headers
|
|
552
|
+
</button>
|
|
553
|
+
<button
|
|
554
|
+
class="text-xs px-2 py-1 rounded border transition-colors ${authMode ===
|
|
555
|
+
"query"
|
|
556
|
+
? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
|
|
557
|
+
: "border-border text-gray-400 hover:text-gray-200"}"
|
|
558
|
+
onclick=${() => setAuthMode("query")}
|
|
559
|
+
>
|
|
560
|
+
Query string
|
|
561
|
+
</button>
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
<div class="space-y-2">
|
|
565
|
+
<p class="text-xs text-gray-500">Webhook URL</p>
|
|
566
|
+
<div class="flex items-center gap-2">
|
|
567
|
+
<input
|
|
568
|
+
type="text"
|
|
569
|
+
readonly
|
|
570
|
+
value=${authMode === "query"
|
|
571
|
+
? webhookUrlWithQueryToken
|
|
572
|
+
: webhookUrl}
|
|
573
|
+
class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono"
|
|
574
|
+
/>
|
|
575
|
+
<button
|
|
576
|
+
class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0"
|
|
577
|
+
onclick=${async () => {
|
|
578
|
+
try {
|
|
579
|
+
await navigator.clipboard.writeText(
|
|
580
|
+
authMode === "query"
|
|
581
|
+
? webhookUrlWithQueryToken
|
|
582
|
+
: webhookUrl,
|
|
583
|
+
);
|
|
584
|
+
showToast("Webhook URL copied", "success");
|
|
585
|
+
} catch {
|
|
586
|
+
showToast("Could not copy URL", "error");
|
|
587
|
+
}
|
|
588
|
+
}}
|
|
589
|
+
>
|
|
590
|
+
Copy
|
|
591
|
+
</button>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
${authMode === "headers"
|
|
595
|
+
? html`
|
|
596
|
+
<div class="space-y-2">
|
|
597
|
+
<p class="text-xs text-gray-500">Auth headers</p>
|
|
598
|
+
<div class="flex items-center gap-2">
|
|
599
|
+
<input
|
|
600
|
+
type="text"
|
|
601
|
+
readonly
|
|
602
|
+
value=${authHeaderValue}
|
|
603
|
+
class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono"
|
|
604
|
+
/>
|
|
605
|
+
<button
|
|
606
|
+
class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0"
|
|
607
|
+
onclick=${async () => {
|
|
608
|
+
try {
|
|
609
|
+
await navigator.clipboard.writeText(
|
|
610
|
+
bearerTokenValue,
|
|
611
|
+
);
|
|
612
|
+
showToast("Bearer token copied", "success");
|
|
613
|
+
} catch {
|
|
614
|
+
showToast(
|
|
615
|
+
"Could not copy bearer token",
|
|
616
|
+
"error",
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
}}
|
|
620
|
+
>
|
|
621
|
+
Copy
|
|
622
|
+
</button>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
`
|
|
626
|
+
: html`
|
|
627
|
+
<p class="text-xs text-yellow-300">
|
|
628
|
+
Always use auth headers when possible. Query string is
|
|
629
|
+
less secure.
|
|
630
|
+
</p>
|
|
631
|
+
`}
|
|
632
|
+
</div>
|
|
633
|
+
|
|
634
|
+
<div
|
|
635
|
+
class="bg-black/20 border border-border rounded-lg p-3 space-y-2"
|
|
636
|
+
>
|
|
637
|
+
<p class="text-xs text-gray-500">Test webhook</p>
|
|
638
|
+
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
639
|
+
<input
|
|
640
|
+
type="text"
|
|
641
|
+
readonly
|
|
642
|
+
value=${activeCurlCommand}
|
|
643
|
+
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"
|
|
644
|
+
/>
|
|
645
|
+
<div
|
|
646
|
+
class="grid grid-cols-2 gap-2 w-full sm:w-auto sm:flex sm:items-center"
|
|
647
|
+
>
|
|
648
|
+
<button
|
|
649
|
+
class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary w-full sm:w-auto sm:shrink-0"
|
|
650
|
+
onclick=${async () => {
|
|
651
|
+
try {
|
|
652
|
+
await navigator.clipboard.writeText(
|
|
653
|
+
activeCurlCommand,
|
|
654
|
+
);
|
|
655
|
+
showToast("curl command copied", "success");
|
|
656
|
+
} catch {
|
|
657
|
+
showToast("Could not copy curl command", "error");
|
|
658
|
+
}
|
|
659
|
+
}}
|
|
660
|
+
>
|
|
661
|
+
Copy
|
|
662
|
+
</button>
|
|
663
|
+
<button
|
|
664
|
+
class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary w-full sm:w-auto sm:shrink-0 disabled:opacity-60"
|
|
665
|
+
onclick=${handleSendTestWebhook}
|
|
666
|
+
disabled=${sendingTestWebhook}
|
|
667
|
+
>
|
|
668
|
+
${sendingTestWebhook ? "Sending..." : "Send"}
|
|
669
|
+
</button>
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
</div>
|
|
673
|
+
|
|
674
|
+
<div class="bg-black/20 border border-border rounded-lg p-3">
|
|
675
|
+
<div class="flex items-center gap-2 text-xs text-gray-300">
|
|
676
|
+
<span class="text-gray-500">Transform:</span>
|
|
677
|
+
<code
|
|
678
|
+
class="flex-1 min-w-0 truncate block"
|
|
679
|
+
title=${selectedWebhook?.transformPath || "—"}
|
|
680
|
+
>${selectedWebhook?.transformPath || "—"}</code
|
|
681
|
+
>
|
|
682
|
+
<span
|
|
683
|
+
class=${`ml-auto inline-flex items-center gap-1 px-1.5 py-0.5 rounded border font-sans ${
|
|
684
|
+
selectedWebhook?.transformExists
|
|
685
|
+
? "border-green-500/30 text-green-300 bg-green-500/10"
|
|
686
|
+
: "border-yellow-500/30 text-yellow-300 bg-yellow-500/10"
|
|
687
|
+
}`}
|
|
688
|
+
>
|
|
689
|
+
<span class="font-sans text-sm leading-none">
|
|
690
|
+
${selectedWebhook?.transformExists ? "✓" : "!"}
|
|
691
|
+
</span>
|
|
692
|
+
${selectedWebhook?.transformExists
|
|
693
|
+
? null
|
|
694
|
+
: html`<span>missing</span>`}
|
|
695
|
+
</span>
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
|
|
699
|
+
<div class="flex items-center justify-between gap-3">
|
|
700
|
+
<p class="text-xs text-gray-600">
|
|
701
|
+
Created: ${formatDateTime(selectedWebhook?.createdAt)}
|
|
702
|
+
</p>
|
|
703
|
+
<${ActionButton}
|
|
704
|
+
onClick=${() => {
|
|
705
|
+
if (deleting) return;
|
|
706
|
+
setDeleteTransformDir(true);
|
|
707
|
+
setShowDeleteConfirm(true);
|
|
708
|
+
}}
|
|
709
|
+
disabled=${deleting}
|
|
710
|
+
loading=${deleting}
|
|
711
|
+
tone="danger"
|
|
712
|
+
size="sm"
|
|
713
|
+
idleLabel="Delete"
|
|
714
|
+
loadingLabel="Deleting..."
|
|
715
|
+
className="shrink-0 px-2.5 py-1"
|
|
716
|
+
/>
|
|
717
|
+
</div>
|
|
718
|
+
</div>
|
|
719
|
+
|
|
720
|
+
<div
|
|
721
|
+
class="bg-surface border border-border rounded-xl p-4 space-y-3"
|
|
722
|
+
>
|
|
723
|
+
<div class="flex items-center justify-between gap-3">
|
|
724
|
+
<h3 class="card-label">Request history</h3>
|
|
725
|
+
<div class="flex items-center gap-2">
|
|
726
|
+
${kStatusFilters.map(
|
|
727
|
+
(filter) => html`
|
|
728
|
+
<button
|
|
729
|
+
class="text-xs px-2 py-1 rounded border ${statusFilter ===
|
|
730
|
+
filter
|
|
731
|
+
? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
|
|
732
|
+
: "border-border text-gray-400 hover:text-gray-200"}"
|
|
733
|
+
onclick=${() => {
|
|
734
|
+
setStatusFilter(filter);
|
|
735
|
+
setExpandedRows(new Set());
|
|
736
|
+
setTimeout(() => requestsPoll.refresh(), 0);
|
|
737
|
+
}}
|
|
738
|
+
>
|
|
739
|
+
${filter}
|
|
740
|
+
</button>
|
|
741
|
+
`,
|
|
742
|
+
)}
|
|
743
|
+
</div>
|
|
744
|
+
</div>
|
|
745
|
+
|
|
746
|
+
${requests.length === 0
|
|
747
|
+
? html`<p class="text-sm text-gray-500">
|
|
748
|
+
No requests logged yet.
|
|
749
|
+
</p>`
|
|
750
|
+
: html`
|
|
751
|
+
<div class="ac-history-list">
|
|
752
|
+
${requests.map(
|
|
753
|
+
(item) => {
|
|
754
|
+
const statusTone = getRequestStatusTone(item.status);
|
|
755
|
+
return html`
|
|
756
|
+
<details
|
|
757
|
+
class="ac-history-item"
|
|
758
|
+
open=${expandedRows.has(item.id)}
|
|
759
|
+
ontoggle=${(e) =>
|
|
760
|
+
handleRequestRowToggle(
|
|
761
|
+
item.id,
|
|
762
|
+
!!e.currentTarget?.open,
|
|
763
|
+
)}
|
|
764
|
+
>
|
|
765
|
+
<summary class="ac-history-summary">
|
|
766
|
+
<div class="ac-history-summary-row">
|
|
767
|
+
<span class="inline-flex items-center gap-2 min-w-0">
|
|
768
|
+
<span class="ac-history-toggle shrink-0" aria-hidden="true"
|
|
769
|
+
>▸</span
|
|
770
|
+
>
|
|
771
|
+
<span class="truncate text-xs text-gray-300">
|
|
772
|
+
${formatLastReceived(item.createdAt)}
|
|
773
|
+
</span>
|
|
774
|
+
</span>
|
|
775
|
+
<span class="inline-flex items-center gap-2 shrink-0">
|
|
776
|
+
<span class="text-xs text-gray-500"
|
|
777
|
+
>${formatBytes(item.payloadSize)}</span
|
|
778
|
+
>
|
|
779
|
+
<span
|
|
780
|
+
class=${`text-xs font-medium ${statusTone.textClass}`}
|
|
781
|
+
>${item.gatewayStatus || "n/a"}</span
|
|
782
|
+
>
|
|
783
|
+
<span class="inline-flex items-center">
|
|
784
|
+
<span
|
|
785
|
+
class=${`h-2.5 w-2.5 rounded-full ${statusTone.dotClass}`}
|
|
786
|
+
title=${item.status || "unknown"}
|
|
787
|
+
aria-label=${item.status || "unknown"}
|
|
788
|
+
></span>
|
|
789
|
+
</span>
|
|
790
|
+
</span>
|
|
791
|
+
</div>
|
|
792
|
+
</summary>
|
|
793
|
+
${expandedRows.has(item.id)
|
|
794
|
+
? html`
|
|
795
|
+
<div class="ac-history-body space-y-3">
|
|
796
|
+
<div>
|
|
797
|
+
<p class="text-[11px] text-gray-500 mb-1">
|
|
798
|
+
Headers
|
|
799
|
+
</p>
|
|
800
|
+
<pre
|
|
801
|
+
class="text-xs bg-black/30 border border-border rounded p-2 overflow-auto"
|
|
802
|
+
>
|
|
803
|
+
${jsonPretty(item.headers)}</pre
|
|
804
|
+
>
|
|
805
|
+
<div class="mt-2 flex justify-start">
|
|
806
|
+
<button
|
|
807
|
+
class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary"
|
|
808
|
+
onclick=${() =>
|
|
809
|
+
handleCopyRequestField(
|
|
810
|
+
jsonPretty(item.headers),
|
|
811
|
+
"Headers",
|
|
812
|
+
)}
|
|
813
|
+
>
|
|
814
|
+
Copy
|
|
815
|
+
</button>
|
|
816
|
+
</div>
|
|
817
|
+
</div>
|
|
818
|
+
<div>
|
|
819
|
+
<p class="text-[11px] text-gray-500 mb-1">
|
|
820
|
+
Payload
|
|
821
|
+
${item.payloadTruncated
|
|
822
|
+
? "(truncated)"
|
|
823
|
+
: ""}
|
|
824
|
+
</p>
|
|
825
|
+
<pre
|
|
826
|
+
class="text-xs bg-black/30 border border-border rounded p-2 overflow-auto"
|
|
827
|
+
>
|
|
828
|
+
${jsonPretty(item.payload)}</pre
|
|
829
|
+
>
|
|
830
|
+
<div class="mt-2 flex justify-start gap-2">
|
|
831
|
+
<button
|
|
832
|
+
class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary"
|
|
833
|
+
onclick=${() =>
|
|
834
|
+
handleCopyRequestField(
|
|
835
|
+
item.payload,
|
|
836
|
+
"Payload",
|
|
837
|
+
)}
|
|
838
|
+
>
|
|
839
|
+
Copy
|
|
840
|
+
</button>
|
|
841
|
+
<button
|
|
842
|
+
class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary disabled:opacity-60"
|
|
843
|
+
onclick=${() =>
|
|
844
|
+
handleReplayRequest(item)}
|
|
845
|
+
disabled=${item.payloadTruncated ||
|
|
846
|
+
replayingRequestId === item.id}
|
|
847
|
+
title=${item.payloadTruncated
|
|
848
|
+
? "Cannot replay truncated payload"
|
|
849
|
+
: "Replay this payload"}
|
|
850
|
+
>
|
|
851
|
+
${replayingRequestId === item.id
|
|
852
|
+
? "Replaying..."
|
|
853
|
+
: "Replay"}
|
|
854
|
+
</button>
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
<div>
|
|
858
|
+
<p class="text-[11px] text-gray-500 mb-1">
|
|
859
|
+
Gateway response
|
|
860
|
+
(${item.gatewayStatus || "n/a"})
|
|
861
|
+
</p>
|
|
862
|
+
<pre
|
|
863
|
+
class="text-xs bg-black/30 border border-border rounded p-2 overflow-auto"
|
|
864
|
+
>
|
|
865
|
+
${jsonPretty(item.gatewayBody)}</pre
|
|
866
|
+
>
|
|
867
|
+
<div class="mt-2 flex justify-start">
|
|
868
|
+
<button
|
|
869
|
+
class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary"
|
|
870
|
+
onclick=${() =>
|
|
871
|
+
handleCopyRequestField(
|
|
872
|
+
item.gatewayBody,
|
|
873
|
+
"Gateway response",
|
|
874
|
+
)}
|
|
875
|
+
>
|
|
876
|
+
Copy
|
|
877
|
+
</button>
|
|
878
|
+
</div>
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
`
|
|
882
|
+
: null}
|
|
883
|
+
</details>
|
|
884
|
+
`;
|
|
885
|
+
},
|
|
886
|
+
)}
|
|
887
|
+
</div>
|
|
888
|
+
`}
|
|
889
|
+
</div>
|
|
890
|
+
`
|
|
891
|
+
: html`
|
|
892
|
+
<div
|
|
893
|
+
class="bg-surface border border-border rounded-xl p-4 space-y-4"
|
|
894
|
+
>
|
|
895
|
+
${isListLoading
|
|
896
|
+
? html`<p class="text-xs text-gray-500">Loading webhooks...</p>`
|
|
897
|
+
: null}
|
|
898
|
+
${!isListLoading && webhooks.length === 0
|
|
899
|
+
? html`<p class="text-sm text-gray-500">
|
|
900
|
+
No webhooks configured yet. Create one to get started.
|
|
901
|
+
</p>`
|
|
902
|
+
: null}
|
|
903
|
+
${webhooks.length > 0
|
|
904
|
+
? html`
|
|
905
|
+
<div class="overflow-auto">
|
|
906
|
+
<table class="w-full text-sm">
|
|
907
|
+
<thead>
|
|
908
|
+
<tr
|
|
909
|
+
class="text-left text-xs text-gray-500 border-b border-border"
|
|
910
|
+
>
|
|
911
|
+
<th class="pb-2 pr-3">Path</th>
|
|
912
|
+
<th class="pb-2 pr-3">Last received</th>
|
|
913
|
+
<th class="pb-2 pr-3">Errors</th>
|
|
914
|
+
<th class="pb-2 pr-3">Health</th>
|
|
915
|
+
</tr>
|
|
916
|
+
</thead>
|
|
917
|
+
<tbody>
|
|
918
|
+
<tr aria-hidden="true">
|
|
919
|
+
<td class="h-2 p-0" colspan="4"></td>
|
|
920
|
+
</tr>
|
|
921
|
+
${webhooks.map(
|
|
922
|
+
(item) => html`
|
|
923
|
+
<tr
|
|
924
|
+
class="group cursor-pointer"
|
|
925
|
+
onclick=${() => {
|
|
926
|
+
onSelectHook(item.name);
|
|
927
|
+
setStatusFilter("all");
|
|
928
|
+
setExpandedRows(new Set());
|
|
929
|
+
}}
|
|
930
|
+
>
|
|
931
|
+
<td
|
|
932
|
+
class="px-3 py-2.5 group-hover:bg-white/5 first:rounded-l-lg transition-colors"
|
|
933
|
+
>
|
|
934
|
+
<code
|
|
935
|
+
>${item.path || `/hooks/${item.name}`}</code
|
|
936
|
+
>
|
|
937
|
+
</td>
|
|
938
|
+
<td
|
|
939
|
+
class="px-3 py-2.5 text-xs text-gray-400 group-hover:bg-white/5 transition-colors"
|
|
940
|
+
>
|
|
941
|
+
${formatLastReceived(item.lastReceived)}
|
|
942
|
+
</td>
|
|
943
|
+
<td
|
|
944
|
+
class="px-3 py-2.5 text-xs group-hover:bg-white/5 transition-colors"
|
|
945
|
+
>
|
|
946
|
+
${item.errorCount || 0}
|
|
947
|
+
</td>
|
|
948
|
+
<td
|
|
949
|
+
class="px-3 py-2.5 group-hover:bg-white/5 last:rounded-r-lg transition-colors"
|
|
950
|
+
>
|
|
951
|
+
<span
|
|
952
|
+
class="inline-block w-2.5 h-2.5 rounded-full ${healthClassName(
|
|
953
|
+
item.health,
|
|
954
|
+
)}"
|
|
955
|
+
title=${item.health}
|
|
956
|
+
/>
|
|
957
|
+
</td>
|
|
958
|
+
</tr>
|
|
959
|
+
`,
|
|
960
|
+
)}
|
|
961
|
+
</tbody>
|
|
962
|
+
</table>
|
|
963
|
+
</div>
|
|
964
|
+
`
|
|
965
|
+
: null}
|
|
966
|
+
</div>
|
|
967
|
+
`}
|
|
968
|
+
|
|
969
|
+
<${CreateWebhookModal}
|
|
970
|
+
visible=${isCreating && !selectedHookName}
|
|
971
|
+
name=${newName}
|
|
972
|
+
onNameChange=${setNewName}
|
|
973
|
+
canCreate=${canCreate}
|
|
974
|
+
creating=${creating}
|
|
975
|
+
onCreate=${handleCreate}
|
|
976
|
+
onClose=${() => setIsCreating(false)}
|
|
977
|
+
/>
|
|
978
|
+
<${ConfirmDialog}
|
|
979
|
+
visible=${showDeleteConfirm && !!selectedHookName}
|
|
980
|
+
title="Delete webhook?"
|
|
981
|
+
message=${`This removes "/hooks/${selectedHookName}" from openclaw.json.`}
|
|
982
|
+
details=${html`
|
|
983
|
+
<div class="rounded-lg border border-border bg-black/20 p-3">
|
|
984
|
+
<label
|
|
985
|
+
class="flex items-center gap-2 text-xs text-gray-300 select-none"
|
|
986
|
+
>
|
|
987
|
+
<input
|
|
988
|
+
type="checkbox"
|
|
989
|
+
checked=${deleteTransformDir}
|
|
990
|
+
onInput=${(event) =>
|
|
991
|
+
setDeleteTransformDir(!!event.target.checked)}
|
|
992
|
+
/>
|
|
993
|
+
Also delete <code>hooks/transforms/${selectedHookName}</code>
|
|
994
|
+
</label>
|
|
995
|
+
</div>
|
|
996
|
+
`}
|
|
997
|
+
confirmLabel="Delete webhook"
|
|
998
|
+
confirmLoadingLabel="Deleting..."
|
|
999
|
+
confirmLoading=${deleting}
|
|
1000
|
+
cancelLabel="Cancel"
|
|
1001
|
+
onCancel=${() => {
|
|
1002
|
+
if (deleting) return;
|
|
1003
|
+
setDeleteTransformDir(true);
|
|
1004
|
+
setShowDeleteConfirm(false);
|
|
1005
|
+
}}
|
|
1006
|
+
onConfirm=${handleDeleteConfirmed}
|
|
1007
|
+
/>
|
|
1008
|
+
</div>
|
|
1009
|
+
`;
|
|
1010
|
+
};
|