@yackey-labs/yauth-ui-solidjs 0.12.2 → 0.12.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yackey-labs/yauth-ui-solidjs",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/yackey-labs/yauth"
@@ -23,8 +23,8 @@
23
23
  "dependencies": {
24
24
  "@simplewebauthn/browser": "^13.0.0",
25
25
  "solid-js": "^1.9.5",
26
- "@yackey-labs/yauth-client": "0.12.2",
27
- "@yackey-labs/yauth-shared": "0.12.2"
26
+ "@yackey-labs/yauth-client": "0.12.4",
27
+ "@yackey-labs/yauth-shared": "0.12.4"
28
28
  },
29
29
  "devDependencies": {
30
30
  "happy-dom": "^20.9.0",
@@ -0,0 +1,410 @@
1
+ import { Show, createSignal } from "solid-js";
2
+ import {
3
+ type CreateDestinationInput,
4
+ createAuditDestinations,
5
+ } from "../hooks/create-audit-destinations";
6
+
7
+ /**
8
+ * Audit-export admin add-destination form (issue #96).
9
+ *
10
+ * Mirror of the Vue component — kind switcher (webhook / syslog / s3 /
11
+ * splunk / datadog), per-kind fields, and an HMAC secret password input
12
+ * for webhooks with a pointer to the docs verifier helper.
13
+ *
14
+ * Splunk + Datadog branches still warn the user that they're "follow-up";
15
+ * the warnings stay so the wire-shape feedback is unambiguous if the
16
+ * operator picks them in a version of yauth that pre-dates Phase B. The
17
+ * dispatcher itself does the real round-trip in Phase B.
18
+ */
19
+ export interface AuditDestinationCreateProps {
20
+ organizationId?: string | null;
21
+ onCreated?: () => void;
22
+ }
23
+
24
+ export function AuditDestinationCreate(props: AuditDestinationCreateProps) {
25
+ const { create, submitting, error } = createAuditDestinations(
26
+ () => props.organizationId ?? null,
27
+ );
28
+
29
+ const [kindTag, setKindTag] = createSignal<
30
+ "webhook" | "syslog" | "s3" | "splunk" | "datadog"
31
+ >("webhook");
32
+ const [name, setName] = createSignal("");
33
+
34
+ // Webhook
35
+ const [webhookUrl, setWebhookUrl] = createSignal("");
36
+ const [webhookFormat, setWebhookFormat] = createSignal<
37
+ "json" | "cef" | "rfc5424"
38
+ >("json");
39
+ const [webhookHmac, setWebhookHmac] = createSignal("");
40
+
41
+ // Syslog
42
+ const [syslogHost, setSyslogHost] = createSignal("");
43
+ const [syslogPort, setSyslogPort] = createSignal(6514);
44
+ const [syslogTransport, setSyslogTransport] = createSignal<
45
+ "tcp" | "udp" | "tls"
46
+ >("tcp");
47
+ const [syslogFacility, setSyslogFacility] = createSignal(13);
48
+
49
+ // S3
50
+ const [s3Bucket, setS3Bucket] = createSignal("");
51
+ const [s3Prefix, setS3Prefix] = createSignal("audit");
52
+ const [s3Region, setS3Region] = createSignal("us-east-1");
53
+ const [s3Partition, setS3Partition] = createSignal<
54
+ "by_date" | "by_org" | "by_date_and_org"
55
+ >("by_date");
56
+
57
+ // Splunk
58
+ const [splunkHecUrl, setSplunkHecUrl] = createSignal("");
59
+ const [splunkHecToken, setSplunkHecToken] = createSignal("");
60
+
61
+ // Datadog
62
+ const [ddSite, setDdSite] = createSignal("datadoghq.com");
63
+ const [ddApiKey, setDdApiKey] = createSignal("");
64
+
65
+ const handleSubmit = async (e: Event) => {
66
+ e.preventDefault();
67
+ let kind: CreateDestinationInput["kind"];
68
+ switch (kindTag()) {
69
+ case "webhook":
70
+ kind = {
71
+ type: "webhook",
72
+ url: webhookUrl().trim(),
73
+ format: webhookFormat(),
74
+ hmac_secret: webhookHmac().trim() || undefined,
75
+ };
76
+ break;
77
+ case "syslog":
78
+ kind = {
79
+ type: "syslog",
80
+ host: syslogHost().trim(),
81
+ port: syslogPort(),
82
+ transport: syslogTransport(),
83
+ facility: syslogFacility(),
84
+ };
85
+ break;
86
+ case "s3":
87
+ kind = {
88
+ type: "s3",
89
+ bucket: s3Bucket().trim(),
90
+ prefix: s3Prefix().trim(),
91
+ region: s3Region().trim(),
92
+ partition: s3Partition(),
93
+ };
94
+ break;
95
+ case "splunk":
96
+ kind = {
97
+ type: "splunk",
98
+ hec_url: splunkHecUrl().trim(),
99
+ hec_token: splunkHecToken().trim(),
100
+ };
101
+ break;
102
+ case "datadog":
103
+ kind = {
104
+ type: "datadog",
105
+ site: ddSite().trim(),
106
+ api_key: ddApiKey().trim(),
107
+ };
108
+ break;
109
+ }
110
+ const created = await create({
111
+ name: name().trim(),
112
+ organization_id: props.organizationId ?? null,
113
+ kind,
114
+ });
115
+ if (created) {
116
+ props.onCreated?.();
117
+ setName("");
118
+ setWebhookUrl("");
119
+ setWebhookHmac("");
120
+ setSyslogHost("");
121
+ setS3Bucket("");
122
+ setSplunkHecUrl("");
123
+ setSplunkHecToken("");
124
+ setDdApiKey("");
125
+ }
126
+ };
127
+
128
+ return (
129
+ <form
130
+ class="space-y-3 rounded-md border border-border p-4"
131
+ onSubmit={handleSubmit}
132
+ >
133
+ <h3 class="text-base font-semibold">Add destination</h3>
134
+
135
+ <Show when={error()}>
136
+ <div
137
+ class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
138
+ role="alert"
139
+ aria-live="polite"
140
+ >
141
+ {error()}
142
+ </div>
143
+ </Show>
144
+
145
+ <label class="block">
146
+ <span class="text-xs font-medium">Name</span>
147
+ <input
148
+ type="text"
149
+ required
150
+ value={name()}
151
+ onInput={(e) => setName(e.currentTarget.value)}
152
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
153
+ />
154
+ </label>
155
+
156
+ <label class="block">
157
+ <span class="text-xs font-medium">Kind</span>
158
+ <select
159
+ value={kindTag()}
160
+ onChange={(e) =>
161
+ setKindTag(
162
+ e.currentTarget.value as
163
+ | "webhook"
164
+ | "syslog"
165
+ | "s3"
166
+ | "splunk"
167
+ | "datadog",
168
+ )
169
+ }
170
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
171
+ >
172
+ <option value="webhook">Webhook (HTTPS POST)</option>
173
+ <option value="syslog">Syslog (RFC 5424)</option>
174
+ <option value="s3">S3-compatible object storage</option>
175
+ <option value="splunk">Splunk HEC</option>
176
+ <option value="datadog">Datadog logs</option>
177
+ </select>
178
+ </label>
179
+
180
+ <Show when={kindTag() === "webhook"}>
181
+ <fieldset class="space-y-2">
182
+ <label class="block">
183
+ <span class="text-xs font-medium">URL</span>
184
+ <input
185
+ type="url"
186
+ required
187
+ placeholder="https://hooks.example.com/yauth-audit"
188
+ value={webhookUrl()}
189
+ onInput={(e) => setWebhookUrl(e.currentTarget.value)}
190
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
191
+ />
192
+ </label>
193
+ <label class="block">
194
+ <span class="text-xs font-medium">Format</span>
195
+ <select
196
+ value={webhookFormat()}
197
+ onChange={(e) =>
198
+ setWebhookFormat(
199
+ e.currentTarget.value as "json" | "cef" | "rfc5424",
200
+ )
201
+ }
202
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
203
+ >
204
+ <option value="json">JSON</option>
205
+ <option value="cef">CEF</option>
206
+ <option value="rfc5424">RFC 5424 syslog body</option>
207
+ </select>
208
+ </label>
209
+ <label class="block">
210
+ <span class="text-xs font-medium">HMAC secret (optional)</span>
211
+ <input
212
+ type="password"
213
+ placeholder="cryptographic random, 32 bytes recommended"
214
+ value={webhookHmac()}
215
+ onInput={(e) => setWebhookHmac(e.currentTarget.value)}
216
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
217
+ />
218
+ <p class="mt-1 text-xs text-muted-foreground">
219
+ When set, each request carries{" "}
220
+ <code class="font-mono">
221
+ X-Yauth-Signature: t=&lt;unix&gt;,v1=&lt;hex&gt;
222
+ </code>
223
+ . See <code>docs/audit-export/webhook.md</code> for the verifier
224
+ helper (Node + Python).
225
+ </p>
226
+ </label>
227
+ </fieldset>
228
+ </Show>
229
+
230
+ <Show when={kindTag() === "syslog"}>
231
+ <fieldset class="space-y-2">
232
+ <label class="block">
233
+ <span class="text-xs font-medium">Host</span>
234
+ <input
235
+ type="text"
236
+ required
237
+ placeholder="siem.example.internal"
238
+ value={syslogHost()}
239
+ onInput={(e) => setSyslogHost(e.currentTarget.value)}
240
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
241
+ />
242
+ </label>
243
+ <label class="block">
244
+ <span class="text-xs font-medium">Port</span>
245
+ <input
246
+ type="number"
247
+ required
248
+ min="1"
249
+ max="65535"
250
+ value={syslogPort()}
251
+ onInput={(e) =>
252
+ setSyslogPort(Number(e.currentTarget.value) || 0)
253
+ }
254
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
255
+ />
256
+ </label>
257
+ <label class="block">
258
+ <span class="text-xs font-medium">Transport</span>
259
+ <select
260
+ value={syslogTransport()}
261
+ onChange={(e) =>
262
+ setSyslogTransport(
263
+ e.currentTarget.value as "tcp" | "udp" | "tls",
264
+ )
265
+ }
266
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
267
+ >
268
+ <option value="tcp">TCP (RFC 6587 octet-counted)</option>
269
+ <option value="udp">
270
+ UDP (unauthenticated, not recommended)
271
+ </option>
272
+ <option value="tls">TCP + TLS (port 6514)</option>
273
+ </select>
274
+ </label>
275
+ <label class="block">
276
+ <span class="text-xs font-medium">Facility (0–23)</span>
277
+ <input
278
+ type="number"
279
+ required
280
+ min="0"
281
+ max="23"
282
+ value={syslogFacility()}
283
+ onInput={(e) =>
284
+ setSyslogFacility(Number(e.currentTarget.value) || 0)
285
+ }
286
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
287
+ />
288
+ </label>
289
+ </fieldset>
290
+ </Show>
291
+
292
+ <Show when={kindTag() === "s3"}>
293
+ <fieldset class="space-y-2">
294
+ <label class="block">
295
+ <span class="text-xs font-medium">Bucket</span>
296
+ <input
297
+ type="text"
298
+ required
299
+ value={s3Bucket()}
300
+ onInput={(e) => setS3Bucket(e.currentTarget.value)}
301
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
302
+ />
303
+ </label>
304
+ <label class="block">
305
+ <span class="text-xs font-medium">Prefix</span>
306
+ <input
307
+ type="text"
308
+ value={s3Prefix()}
309
+ onInput={(e) => setS3Prefix(e.currentTarget.value)}
310
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
311
+ />
312
+ </label>
313
+ <label class="block">
314
+ <span class="text-xs font-medium">Region</span>
315
+ <input
316
+ type="text"
317
+ required
318
+ value={s3Region()}
319
+ onInput={(e) => setS3Region(e.currentTarget.value)}
320
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
321
+ />
322
+ </label>
323
+ <label class="block">
324
+ <span class="text-xs font-medium">Partition</span>
325
+ <select
326
+ value={s3Partition()}
327
+ onChange={(e) =>
328
+ setS3Partition(
329
+ e.currentTarget.value as
330
+ | "by_date"
331
+ | "by_org"
332
+ | "by_date_and_org",
333
+ )
334
+ }
335
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
336
+ >
337
+ <option value="by_date">By date</option>
338
+ <option value="by_org">By org</option>
339
+ <option value="by_date_and_org">By date and org</option>
340
+ </select>
341
+ </label>
342
+ </fieldset>
343
+ </Show>
344
+
345
+ <Show when={kindTag() === "splunk"}>
346
+ <fieldset class="space-y-2">
347
+ <label class="block">
348
+ <span class="text-xs font-medium">HEC URL</span>
349
+ <input
350
+ type="url"
351
+ required
352
+ placeholder="https://splunk.example.com:8088"
353
+ value={splunkHecUrl()}
354
+ onInput={(e) => setSplunkHecUrl(e.currentTarget.value)}
355
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
356
+ />
357
+ </label>
358
+ <label class="block">
359
+ <span class="text-xs font-medium">HEC token</span>
360
+ <input
361
+ type="password"
362
+ required
363
+ value={splunkHecToken()}
364
+ onInput={(e) => setSplunkHecToken(e.currentTarget.value)}
365
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
366
+ />
367
+ </label>
368
+ </fieldset>
369
+ </Show>
370
+
371
+ <Show when={kindTag() === "datadog"}>
372
+ <fieldset class="space-y-2">
373
+ <label class="block">
374
+ <span class="text-xs font-medium">Site</span>
375
+ <select
376
+ value={ddSite()}
377
+ onChange={(e) => setDdSite(e.currentTarget.value)}
378
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
379
+ >
380
+ <option value="datadoghq.com">datadoghq.com (US1)</option>
381
+ <option value="us3.datadoghq.com">us3.datadoghq.com (US3)</option>
382
+ <option value="us5.datadoghq.com">us5.datadoghq.com (US5)</option>
383
+ <option value="datadoghq.eu">datadoghq.eu (EU)</option>
384
+ <option value="ap1.datadoghq.com">ap1.datadoghq.com (AP1)</option>
385
+ <option value="ddog-gov.com">ddog-gov.com (US1-FED)</option>
386
+ </select>
387
+ </label>
388
+ <label class="block">
389
+ <span class="text-xs font-medium">API key</span>
390
+ <input
391
+ type="password"
392
+ required
393
+ value={ddApiKey()}
394
+ onInput={(e) => setDdApiKey(e.currentTarget.value)}
395
+ class="mt-1 block w-full rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm"
396
+ />
397
+ </label>
398
+ </fieldset>
399
+ </Show>
400
+
401
+ <button
402
+ type="submit"
403
+ disabled={submitting()}
404
+ class="inline-flex h-9 cursor-pointer items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50"
405
+ >
406
+ Add destination
407
+ </button>
408
+ </form>
409
+ );
410
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Smoke test for the SolidJS audit-destination hook (issue #96).
3
+ *
4
+ * We test the headless `createAuditDestinations` hook (signal-only, no
5
+ * DOM) so the test suite doesn't need a Solid DOM testing-library. The
6
+ * component itself is a thin wrapper around this hook — covered by
7
+ * typecheck + build.
8
+ */
9
+ import { configureClient } from "@yackey-labs/yauth-client";
10
+ import { createRoot } from "solid-js";
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { createAuditDestinations } from "../hooks/create-audit-destinations";
13
+
14
+ beforeEach(() => {
15
+ // The typed client reads its baseUrl from the global config; tests
16
+ // install an empty baseUrl so the mocked `fetch` sees the raw paths.
17
+ configureClient({ baseUrl: "" });
18
+ });
19
+
20
+ function mockFetch(response: unknown, status = 200) {
21
+ const text = typeof response === "string" ? response : JSON.stringify(response);
22
+ const fn = vi.fn().mockResolvedValue({
23
+ ok: status >= 200 && status < 300,
24
+ status,
25
+ statusText: status === 200 ? "OK" : "ERR",
26
+ text: () => Promise.resolve(text),
27
+ json: () => Promise.resolve(response),
28
+ });
29
+ (globalThis as unknown as { fetch: typeof fetch }).fetch =
30
+ fn as unknown as typeof fetch;
31
+ return fn;
32
+ }
33
+
34
+ afterEach(() => {
35
+ vi.restoreAllMocks();
36
+ });
37
+
38
+ describe("createAuditDestinations", () => {
39
+ it("fetches destinations on mount and exposes them via the signal", async () => {
40
+ mockFetch([
41
+ {
42
+ id: "019e36bf-1234-7000-8000-aaaaaaaaaaaa",
43
+ organization_id: null,
44
+ name: "prod-splunk",
45
+ kind_tag: "splunk",
46
+ kind: { type: "splunk", hec_url: "https://splunk.example.com" },
47
+ status: "active",
48
+ last_success_at: null,
49
+ last_failure_at: null,
50
+ created_at: "2026-05-17T12:00:00Z",
51
+ updated_at: "2026-05-17T12:00:00Z",
52
+ },
53
+ ]);
54
+
55
+ await createRoot(async (dispose) => {
56
+ const hook = createAuditDestinations(() => null);
57
+ // Allow the createEffect → refresh chain to run.
58
+ await new Promise((r) => setTimeout(r, 5));
59
+ expect(hook.destinations()).toHaveLength(1);
60
+ expect(hook.destinations()[0]?.name).toBe("prod-splunk");
61
+ expect(hook.error()).toBeNull();
62
+ dispose();
63
+ });
64
+ });
65
+
66
+ it("surfaces fetch errors via the error signal", async () => {
67
+ mockFetch({ error: "audit-export down" }, 500);
68
+ await createRoot(async (dispose) => {
69
+ const hook = createAuditDestinations(() => null);
70
+ await new Promise((r) => setTimeout(r, 5));
71
+ expect(hook.destinations()).toHaveLength(0);
72
+ expect(hook.error()).toMatch(/audit-export down/);
73
+ dispose();
74
+ });
75
+ });
76
+
77
+ it("includes scope query string based on the org accessor", async () => {
78
+ const fetchSpy = mockFetch([]);
79
+ await createRoot(async (dispose) => {
80
+ createAuditDestinations(() => "org-uuid-123");
81
+ await new Promise((r) => setTimeout(r, 5));
82
+ const callUrl = (fetchSpy.mock.calls[0]?.[0] as string) ?? "";
83
+ expect(callUrl).toContain("organization_id=org-uuid-123");
84
+ dispose();
85
+ });
86
+ });
87
+ });
@@ -0,0 +1,134 @@
1
+ import { For, Show, createMemo } from "solid-js";
2
+ import { createAuditDestinations } from "../hooks/create-audit-destinations";
3
+
4
+ /**
5
+ * Audit-export admin destinations list (issue #96).
6
+ *
7
+ * Mirror of the Vue component — shows deployment-wide OR per-org
8
+ * destinations with their sanitized kind JSON, status badge, and
9
+ * last_success_at / last_failure_at timestamps. No secrets are ever
10
+ * displayed (the server strips `hmac_secret`, `hec_token`, Datadog
11
+ * `api_key` before returning).
12
+ */
13
+ export interface AuditDestinationListProps {
14
+ /**
15
+ * `null` => deployment-wide destinations. A UUID string => per-org
16
+ * destinations for that org.
17
+ */
18
+ organizationId?: string | null;
19
+ }
20
+
21
+ function fmtDate(s: string | null | undefined): string {
22
+ if (!s) return "—";
23
+ try {
24
+ return new Date(s).toLocaleString();
25
+ } catch {
26
+ return s;
27
+ }
28
+ }
29
+
30
+ export function AuditDestinationList(props: AuditDestinationListProps) {
31
+ const scope = createMemo(() => props.organizationId ?? null);
32
+ const { destinations, loading, error, disable, remove, submitting, refresh } =
33
+ createAuditDestinations(() => scope());
34
+
35
+ return (
36
+ <section class="space-y-4">
37
+ <header class="flex items-center justify-between">
38
+ <h2 class="text-lg font-semibold">
39
+ {props.organizationId
40
+ ? "Per-org SIEM destinations"
41
+ : "Deployment-wide SIEM destinations"}
42
+ </h2>
43
+ <button
44
+ type="button"
45
+ class="inline-flex h-8 cursor-pointer items-center justify-center rounded-md border border-input bg-transparent px-3 text-xs font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
46
+ disabled={loading() || submitting()}
47
+ onClick={() => void refresh()}
48
+ >
49
+ Refresh
50
+ </button>
51
+ </header>
52
+
53
+ <Show when={error()}>
54
+ <div
55
+ class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
56
+ role="alert"
57
+ aria-live="polite"
58
+ >
59
+ {error()}
60
+ </div>
61
+ </Show>
62
+
63
+ <Show when={loading()}>
64
+ <p class="text-sm text-muted-foreground">Loading…</p>
65
+ </Show>
66
+
67
+ <Show
68
+ when={!loading() && destinations().length > 0}
69
+ fallback={
70
+ <Show when={!loading()}>
71
+ <p class="text-sm text-muted-foreground">
72
+ No destinations configured. Use the “Add destination” form below.
73
+ </p>
74
+ </Show>
75
+ }
76
+ >
77
+ <ul class="divide-y divide-border">
78
+ <For each={destinations()}>
79
+ {(d) => (
80
+ <li class="flex items-start justify-between gap-3 py-3">
81
+ <div class="min-w-0 flex-1 space-y-1">
82
+ <div class="flex flex-wrap items-center gap-2">
83
+ <span class="font-medium">{d.name}</span>
84
+ <span class="rounded-full bg-muted px-2 py-0.5 text-xs uppercase text-muted-foreground">
85
+ {d.kind_tag}
86
+ </span>
87
+ <span
88
+ classList={{
89
+ "rounded-full px-2 py-0.5 text-xs": true,
90
+ "bg-emerald-500/10 text-emerald-700":
91
+ d.status === "active",
92
+ "bg-muted text-muted-foreground":
93
+ d.status === "disabled",
94
+ }}
95
+ >
96
+ {d.status}
97
+ </span>
98
+ </div>
99
+ <pre class="overflow-x-auto rounded-md bg-muted/50 p-2 text-xs leading-snug">
100
+ {JSON.stringify(d.kind, null, 2)}
101
+ </pre>
102
+ <p class="text-xs text-muted-foreground">
103
+ Last success: {fmtDate(d.last_success_at)} · Last failure:{" "}
104
+ {fmtDate(d.last_failure_at)}
105
+ </p>
106
+ </div>
107
+ <div class="flex flex-col gap-2">
108
+ <Show when={d.status === "active"}>
109
+ <button
110
+ type="button"
111
+ class="inline-flex h-8 cursor-pointer items-center justify-center rounded-md border border-input bg-transparent px-3 text-xs font-medium shadow-sm hover:bg-accent disabled:pointer-events-none disabled:opacity-50"
112
+ disabled={submitting()}
113
+ onClick={() => void disable(d.id)}
114
+ >
115
+ Disable
116
+ </button>
117
+ </Show>
118
+ <button
119
+ type="button"
120
+ class="inline-flex h-8 cursor-pointer items-center justify-center rounded-md border border-destructive bg-transparent px-3 text-xs font-medium text-destructive shadow-sm hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
121
+ disabled={submitting()}
122
+ onClick={() => void remove(d.id)}
123
+ >
124
+ Delete
125
+ </button>
126
+ </div>
127
+ </li>
128
+ )}
129
+ </For>
130
+ </ul>
131
+ </Show>
132
+ </section>
133
+ );
134
+ }