@durablex/react-ui 0.1.0-beta.3
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 +202 -0
- package/NOTICE +5 -0
- package/dist/index.d.ts +1078 -0
- package/dist/index.js +6407 -0
- package/dist/index.js.map +1 -0
- package/package.json +86 -0
- package/src/components/AnimatedDurablexMark.tsx +35 -0
- package/src/components/AppStatusBadge.tsx +17 -0
- package/src/components/AppTag.tsx +17 -0
- package/src/components/AppsView.tsx +226 -0
- package/src/components/BulkReplayButton.tsx +52 -0
- package/src/components/CursorPager.tsx +50 -0
- package/src/components/DeliveriesSplit.tsx +187 -0
- package/src/components/DeliveryDetail.tsx +188 -0
- package/src/components/DurablexLogo.tsx +12 -0
- package/src/components/EndpointFormDialog.tsx +153 -0
- package/src/components/EndpointRow.tsx +172 -0
- package/src/components/EndpointsTab.tsx +83 -0
- package/src/components/EventsList.tsx +170 -0
- package/src/components/EventsView.tsx +24 -0
- package/src/components/Facts.tsx +14 -0
- package/src/components/FlowControlBadge.tsx +23 -0
- package/src/components/FlowControlSection.tsx +82 -0
- package/src/components/FlowSummary.tsx +47 -0
- package/src/components/FormField.tsx +10 -0
- package/src/components/GlyphBadge.tsx +41 -0
- package/src/components/JsonBlock.tsx +48 -0
- package/src/components/JsonEditor.tsx +91 -0
- package/src/components/LogList.tsx +45 -0
- package/src/components/Meta.tsx +31 -0
- package/src/components/OverviewView.tsx +39 -0
- package/src/components/PayloadTabs.tsx +70 -0
- package/src/components/ReceiverFormDialog.tsx +123 -0
- package/src/components/ReceiversTab.tsx +194 -0
- package/src/components/ReplayRunDialog.tsx +112 -0
- package/src/components/ResumeMark.tsx +38 -0
- package/src/components/RetryFromStepButton.tsx +44 -0
- package/src/components/RunCancelButton.tsx +23 -0
- package/src/components/RunControlHistory.tsx +71 -0
- package/src/components/RunInspector.test.tsx +78 -0
- package/src/components/RunInspector.tsx +297 -0
- package/src/components/RunInspectorActions.tsx +40 -0
- package/src/components/RunPauseButton.tsx +34 -0
- package/src/components/RunnerLiveBadge.tsx +11 -0
- package/src/components/RunsFilterBar.tsx +180 -0
- package/src/components/RunsTable.tsx +110 -0
- package/src/components/RunsTableHead.tsx +19 -0
- package/src/components/RunsTableLoader.tsx +10 -0
- package/src/components/RunsTablePlaceholder.tsx +19 -0
- package/src/components/RunsTableRow.tsx +103 -0
- package/src/components/RunsView.test.tsx +46 -0
- package/src/components/RunsView.tsx +243 -0
- package/src/components/ScheduledBadge.tsx +15 -0
- package/src/components/SecretReveal.tsx +45 -0
- package/src/components/SectionHeader.tsx +10 -0
- package/src/components/StatTileGrid.tsx +71 -0
- package/src/components/StatsTiles.tsx +66 -0
- package/src/components/StatusBadge.tsx +50 -0
- package/src/components/StepFlow.tsx +105 -0
- package/src/components/StepGlyph.tsx +25 -0
- package/src/components/StepInspector.tsx +44 -0
- package/src/components/StepRow.tsx +69 -0
- package/src/components/StepTabsView.tsx +51 -0
- package/src/components/StepTimeline.tsx +87 -0
- package/src/components/TableStatusRows.tsx +54 -0
- package/src/components/TriggerEventDialog.tsx +180 -0
- package/src/components/TriggerEventResult.tsx +61 -0
- package/src/components/WebhookBadges.tsx +69 -0
- package/src/components/WebhookStatusBadge.tsx +25 -0
- package/src/components/WebhooksView.tsx +69 -0
- package/src/components/WorkflowDetail.tsx +149 -0
- package/src/components/WorkflowRunAction.tsx +46 -0
- package/src/components/WorkflowRunDialog.tsx +187 -0
- package/src/components/WorkflowsView.tsx +168 -0
- package/src/components/charts/ChartCard.tsx +19 -0
- package/src/components/charts/RunCharts.tsx +31 -0
- package/src/components/charts/RunLatencyChart.tsx +71 -0
- package/src/components/charts/RunsOverTimeChart.tsx +60 -0
- package/src/components/filters/AppFilter.tsx +65 -0
- package/src/components/filters/FilterDropdown.tsx +33 -0
- package/src/components/filters/FilterDropdownButton.tsx +31 -0
- package/src/components/filters/FilterDropdownItem.tsx +37 -0
- package/src/components/filters/TimeRangeFilter.tsx +43 -0
- package/src/components/filters/TimeZoneFilter.tsx +40 -0
- package/src/components/filters/use-click-outside.ts +18 -0
- package/src/components/filters-pager.test.tsx +94 -0
- package/src/components/marks-geometry.ts +10 -0
- package/src/components/replay-dialog.test.tsx +18 -0
- package/src/components/run-components.test.tsx +126 -0
- package/src/components/run-controls.test.tsx +97 -0
- package/src/hooks/use-confirm-action.ts +19 -0
- package/src/hooks/use-copy.ts +22 -0
- package/src/hooks/use-keyset-pager.ts +34 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/index.ts +165 -0
- package/src/lib/app-color.test.ts +32 -0
- package/src/lib/app-color.ts +8 -0
- package/src/lib/control-action.ts +36 -0
- package/src/lib/flow-control.ts +77 -0
- package/src/lib/format.test.ts +102 -0
- package/src/lib/format.ts +45 -0
- package/src/lib/json-highlight.test.ts +36 -0
- package/src/lib/json-highlight.ts +64 -0
- package/src/lib/run-filters.ts +8 -0
- package/src/lib/run-logs.test.ts +80 -0
- package/src/lib/run-logs.ts +34 -0
- package/src/lib/run-progress.test.ts +109 -0
- package/src/lib/run-progress.ts +44 -0
- package/src/lib/run-sort.test.ts +40 -0
- package/src/lib/run-sort.ts +19 -0
- package/src/lib/status-label.test.ts +35 -0
- package/src/lib/status-label.ts +13 -0
- package/src/lib/step-detail.test.ts +122 -0
- package/src/lib/step-detail.ts +35 -0
- package/src/lib/step-display.test.ts +19 -0
- package/src/lib/step-display.ts +13 -0
- package/src/lib/step-timeline.test.ts +89 -0
- package/src/lib/step-timeline.ts +50 -0
- package/src/lib/table.ts +2 -0
- package/src/lib/theme.ts +35 -0
- package/src/lib/time-range.ts +81 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/webhook-view.test.ts +176 -0
- package/src/lib/webhook-view.ts +113 -0
- package/src/lib/workflow-run.test.ts +55 -0
- package/src/lib/workflow-run.ts +45 -0
- package/src/shell/AppShell.tsx +34 -0
- package/src/shell/Sidebar.tsx +78 -0
- package/src/shell/Topbar.tsx +22 -0
- package/src/styles.css +2204 -0
- package/src/test-utils.tsx +130 -0
- package/src/ui/button.tsx +67 -0
- package/src/ui/chart.tsx +337 -0
- package/src/ui/dialog.tsx +145 -0
- package/src/ui/input.tsx +19 -0
- package/src/ui/resizable.tsx +40 -0
- package/src/ui/separator.tsx +28 -0
- package/src/ui/sheet.tsx +128 -0
- package/src/ui/sidebar.tsx +665 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/sonner.tsx +35 -0
- package/src/ui/table.tsx +87 -0
- package/src/ui/tooltip.tsx +51 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { Loader2, RotateCw, X } from "lucide-react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import type { AttemptOutcome, WebhookDeliveryAttempt } from "@durablex/react";
|
|
4
|
+
import { useConfirmAction } from "../hooks/use-confirm-action";
|
|
5
|
+
import { useDelivery, useRedeliverDelivery } from "@durablex/react";
|
|
6
|
+
import { formatDuration, formatNextFire, formatTime } from "../lib/format";
|
|
7
|
+
import { JsonBlock } from "./JsonBlock";
|
|
8
|
+
import { Meta } from "./Meta";
|
|
9
|
+
import { WebhookStatusBadge } from "./WebhookStatusBadge";
|
|
10
|
+
|
|
11
|
+
const OUTCOME_CLASS: Record<AttemptOutcome, string> = {
|
|
12
|
+
succeeded: "text-emerald-600 dark:text-emerald-400",
|
|
13
|
+
http_error: "text-amber-600 dark:text-amber-400",
|
|
14
|
+
timeout: "text-amber-600 dark:text-amber-400",
|
|
15
|
+
connection_error: "text-red-600 dark:text-red-400",
|
|
16
|
+
skipped: "text-muted-foreground",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function AttemptRow({ a }: { a: WebhookDeliveryAttempt }) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="border-border flex flex-col gap-1 border-b px-3 py-2 last:border-b-0">
|
|
22
|
+
<div className="flex items-center gap-2 text-[11px]">
|
|
23
|
+
<span className="text-muted-foreground font-mono">#{a.attempt}</span>
|
|
24
|
+
<span className={"font-mono " + (OUTCOME_CLASS[a.outcome] ?? "text-foreground")}>
|
|
25
|
+
{a.outcome}
|
|
26
|
+
</span>
|
|
27
|
+
{a.statusCode != null && a.statusCode > 0 && (
|
|
28
|
+
<span className="font-mono tabular-nums">HTTP {a.statusCode}</span>
|
|
29
|
+
)}
|
|
30
|
+
<span className="text-muted-foreground ml-auto font-mono">
|
|
31
|
+
{formatDuration(a.durationMs)}
|
|
32
|
+
</span>
|
|
33
|
+
<span className="text-muted-foreground font-mono">{formatTime(a.createdAt)}</span>
|
|
34
|
+
</div>
|
|
35
|
+
{a.error && (
|
|
36
|
+
<div className="text-red-600 dark:text-red-400 font-mono text-[11px] break-words">
|
|
37
|
+
{a.error}
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
{a.responseSnippet && (
|
|
41
|
+
<div className="text-muted-foreground bg-muted/40 max-h-24 overflow-y-auto px-2 py-1 font-mono text-[11px] break-words whitespace-pre-wrap">
|
|
42
|
+
{a.responseSnippet}
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function DeliveryDetail({
|
|
50
|
+
deliveryId,
|
|
51
|
+
onClose,
|
|
52
|
+
onOpenRun,
|
|
53
|
+
embedded,
|
|
54
|
+
}: {
|
|
55
|
+
deliveryId: string;
|
|
56
|
+
onClose?: () => void;
|
|
57
|
+
onOpenRun?: (runId: string) => void;
|
|
58
|
+
embedded?: boolean;
|
|
59
|
+
}) {
|
|
60
|
+
const { data, isLoading, isError, error } = useDelivery(deliveryId);
|
|
61
|
+
|
|
62
|
+
if (isLoading) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="run-panel">
|
|
65
|
+
<div className="steps-loading">
|
|
66
|
+
<Loader2 className="size-4 animate-spin" />
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (isError || !data) {
|
|
72
|
+
return (
|
|
73
|
+
<div className="run-panel">
|
|
74
|
+
<div className="fi-empty">
|
|
75
|
+
{error instanceof Error ? error.message : "Delivery not found."}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="run-panel">
|
|
83
|
+
<div className="panel-head">
|
|
84
|
+
<div className="ph-top">
|
|
85
|
+
<h2>
|
|
86
|
+
<WebhookStatusBadge status={data.status} />
|
|
87
|
+
<span className="h2-name font-mono text-[13px]">{data.eventKind}</span>
|
|
88
|
+
</h2>
|
|
89
|
+
<div className="ph-actions">
|
|
90
|
+
{data.status !== "pending" && data.status !== "delivering" && (
|
|
91
|
+
<RedeliverButton id={data.id} />
|
|
92
|
+
)}
|
|
93
|
+
{!embedded && (
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
className="iconbtn focusable"
|
|
97
|
+
aria-label="Close"
|
|
98
|
+
onClick={onClose}
|
|
99
|
+
>
|
|
100
|
+
<X className="size-3.5" />
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="ph-status">
|
|
106
|
+
<span className="runid" title={data.url}>
|
|
107
|
+
<span className="runid-text">{data.url}</span>
|
|
108
|
+
</span>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
113
|
+
<div className="metagrid">
|
|
114
|
+
<Meta label="App" value={data.app || "-"} />
|
|
115
|
+
<Meta label="Attempts" value={`${data.attemptCount}/${data.maxAttempts}`} />
|
|
116
|
+
{data.sourceRunId != null && (
|
|
117
|
+
<SourceRunMeta runId={data.sourceRunId} onOpenRun={onOpenRun} />
|
|
118
|
+
)}
|
|
119
|
+
{data.endpointId && <Meta label="Endpoint" value={data.endpointId} />}
|
|
120
|
+
{data.nextAttemptAt && (
|
|
121
|
+
<Meta label="Next attempt" value={formatNextFire(data.nextAttemptAt)} />
|
|
122
|
+
)}
|
|
123
|
+
<Meta label="Created" value={formatTime(data.createdAt)} />
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div className="section">
|
|
127
|
+
<div className="text-muted-foreground border-border border-b px-3 py-1.5 text-[10px] tracking-wider uppercase">
|
|
128
|
+
Attempts ({data.attempts.length})
|
|
129
|
+
</div>
|
|
130
|
+
{data.attempts.length === 0 ? (
|
|
131
|
+
<div className="text-muted-foreground px-3 py-3 text-[11px]">
|
|
132
|
+
No attempts yet; awaiting the sweeper.
|
|
133
|
+
</div>
|
|
134
|
+
) : (
|
|
135
|
+
data.attempts.map((a) => <AttemptRow key={a.id} a={a} />)
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{data.payload != null && (
|
|
140
|
+
<div className="section">
|
|
141
|
+
<div className="text-muted-foreground border-border border-b px-3 py-1.5 text-[10px] tracking-wider uppercase">
|
|
142
|
+
Payload
|
|
143
|
+
</div>
|
|
144
|
+
<div className="px-3 py-2">
|
|
145
|
+
<JsonBlock value={data.payload} />
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function RedeliverButton({ id }: { id: string }) {
|
|
155
|
+
const redeliver = useRedeliverDelivery();
|
|
156
|
+
const { confirming, trigger } = useConfirmAction(() =>
|
|
157
|
+
redeliver.mutate(id, {
|
|
158
|
+
onSuccess: () => toast.success("Delivery re-queued"),
|
|
159
|
+
onError: (e: Error) => toast.error(`Could not redeliver: ${e.message}`),
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
return (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
className="btn focusable"
|
|
166
|
+
disabled={redeliver.isPending}
|
|
167
|
+
onClick={trigger}
|
|
168
|
+
>
|
|
169
|
+
{redeliver.isPending ? (
|
|
170
|
+
<Loader2 className="size-3 animate-spin" />
|
|
171
|
+
) : (
|
|
172
|
+
<RotateCw className="size-3" />
|
|
173
|
+
)}
|
|
174
|
+
{confirming ? "Confirm redeliver" : "Redeliver"}
|
|
175
|
+
</button>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function SourceRunMeta({ runId, onOpenRun }: { runId: string; onOpenRun?: (id: string) => void }) {
|
|
180
|
+
return (
|
|
181
|
+
<Meta
|
|
182
|
+
label="Source run"
|
|
183
|
+
value={runId}
|
|
184
|
+
onClick={onOpenRun ? () => onOpenRun(runId) : undefined}
|
|
185
|
+
title={`Open run ${runId}`}
|
|
186
|
+
/>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { AnimatedDurablexMark } from "./AnimatedDurablexMark";
|
|
2
|
+
|
|
3
|
+
export function DurablexLogo() {
|
|
4
|
+
return (
|
|
5
|
+
<span className="text-foreground inline-flex items-center gap-2.5">
|
|
6
|
+
<AnimatedDurablexMark size={30} className="shrink-0" />
|
|
7
|
+
<span className="text-xl font-semibold tracking-tight group-data-[collapsible=icon]:hidden">
|
|
8
|
+
Durable<span className="text-primary">x</span>
|
|
9
|
+
</span>
|
|
10
|
+
</span>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import {
|
|
4
|
+
type EndpointInput,
|
|
5
|
+
type EndpointPatch,
|
|
6
|
+
SUBSCRIBABLE_EVENT_KINDS,
|
|
7
|
+
type SubscribableEventKind,
|
|
8
|
+
type WebhookEndpoint,
|
|
9
|
+
} from "@durablex/react";
|
|
10
|
+
import { Button } from "../ui/button";
|
|
11
|
+
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
|
12
|
+
import { Input } from "../ui/input";
|
|
13
|
+
import { useCreateEndpoint, useUpdateEndpoint } from "@durablex/react";
|
|
14
|
+
import { FormField } from "./FormField";
|
|
15
|
+
import { SecretReveal } from "./SecretReveal";
|
|
16
|
+
export function EndpointFormDialog({
|
|
17
|
+
open,
|
|
18
|
+
onOpenChange,
|
|
19
|
+
endpoint,
|
|
20
|
+
}: {
|
|
21
|
+
open: boolean;
|
|
22
|
+
onOpenChange: (open: boolean) => void;
|
|
23
|
+
endpoint?: WebhookEndpoint;
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
27
|
+
<DialogContent className="sm:max-w-md">
|
|
28
|
+
{open && <EndpointForm endpoint={endpoint} onClose={() => onOpenChange(false)} />}
|
|
29
|
+
</DialogContent>
|
|
30
|
+
</Dialog>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function EndpointForm({ endpoint, onClose }: { endpoint?: WebhookEndpoint; onClose: () => void }) {
|
|
35
|
+
const editing = endpoint != null;
|
|
36
|
+
const [name, setName] = useState(endpoint?.name ?? "");
|
|
37
|
+
const [url, setUrl] = useState(endpoint?.url ?? "");
|
|
38
|
+
const [app, setApp] = useState(endpoint?.app ?? "");
|
|
39
|
+
const [kinds, setKinds] = useState<Set<SubscribableEventKind>>(
|
|
40
|
+
() => new Set((endpoint?.eventKinds ?? []).filter(isSubscribable)),
|
|
41
|
+
);
|
|
42
|
+
const [rotate, setRotate] = useState(false);
|
|
43
|
+
const [secret, setSecret] = useState<string | null>(null);
|
|
44
|
+
|
|
45
|
+
const create = useCreateEndpoint();
|
|
46
|
+
const update = useUpdateEndpoint();
|
|
47
|
+
const pending = create.isPending || update.isPending;
|
|
48
|
+
const canSubmit = url.trim() !== "" && kinds.size > 0 && !pending;
|
|
49
|
+
|
|
50
|
+
function toggleKind(k: SubscribableEventKind) {
|
|
51
|
+
setKinds((prev) => {
|
|
52
|
+
const next = new Set(prev);
|
|
53
|
+
if (next.has(k)) next.delete(k);
|
|
54
|
+
else next.add(k);
|
|
55
|
+
return next;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function submit() {
|
|
60
|
+
if (!canSubmit) return;
|
|
61
|
+
const eventKinds = [...kinds];
|
|
62
|
+
if (editing) {
|
|
63
|
+
const patch: EndpointPatch = {
|
|
64
|
+
name: name.trim(),
|
|
65
|
+
url: url.trim(),
|
|
66
|
+
app: app.trim(),
|
|
67
|
+
eventKinds,
|
|
68
|
+
rotateSecret: rotate,
|
|
69
|
+
};
|
|
70
|
+
update.mutate(
|
|
71
|
+
{ id: endpoint.id, patch },
|
|
72
|
+
{
|
|
73
|
+
onSuccess: (res) => {
|
|
74
|
+
if (res.secret) setSecret(res.secret);
|
|
75
|
+
else onClose();
|
|
76
|
+
},
|
|
77
|
+
onError: (e: Error) => toast.error(`Could not save endpoint: ${e.message}`),
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const input: EndpointInput = {
|
|
83
|
+
name: name.trim() || undefined,
|
|
84
|
+
url: url.trim(),
|
|
85
|
+
app: app.trim() || undefined,
|
|
86
|
+
eventKinds,
|
|
87
|
+
};
|
|
88
|
+
create.mutate(input, {
|
|
89
|
+
onSuccess: (res) => (res.secret ? setSecret(res.secret) : onClose()),
|
|
90
|
+
onError: (e: Error) => toast.error(`Could not save endpoint: ${e.message}`),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (secret) {
|
|
95
|
+
return <SecretReveal secret={secret} onDone={onClose} />;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<>
|
|
100
|
+
<DialogHeader>
|
|
101
|
+
<DialogTitle>{editing ? "Edit endpoint" : "Add endpoint"}</DialogTitle>
|
|
102
|
+
</DialogHeader>
|
|
103
|
+
<div className="flex flex-col gap-3 py-1">
|
|
104
|
+
<FormField label="Name (optional)">
|
|
105
|
+
<Input
|
|
106
|
+
value={name}
|
|
107
|
+
onChange={(e) => setName(e.target.value)}
|
|
108
|
+
placeholder="Acme Production"
|
|
109
|
+
/>
|
|
110
|
+
</FormField>
|
|
111
|
+
<FormField label="Destination URL">
|
|
112
|
+
<Input
|
|
113
|
+
value={url}
|
|
114
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
115
|
+
placeholder="https://hooks.example.com/dx"
|
|
116
|
+
/>
|
|
117
|
+
</FormField>
|
|
118
|
+
<FormField label="App (optional, restricts to one app)">
|
|
119
|
+
<Input value={app} onChange={(e) => setApp(e.target.value)} placeholder="all apps" />
|
|
120
|
+
</FormField>
|
|
121
|
+
<fieldset className="flex flex-col gap-1.5">
|
|
122
|
+
<legend className="text-muted-foreground mb-1 text-[11px] tracking-wide uppercase">
|
|
123
|
+
Subscribed events
|
|
124
|
+
</legend>
|
|
125
|
+
{SUBSCRIBABLE_EVENT_KINDS.map((k) => (
|
|
126
|
+
<label key={k} className="flex items-center gap-2 font-mono text-xs">
|
|
127
|
+
<input type="checkbox" checked={kinds.has(k)} onChange={() => toggleKind(k)} />
|
|
128
|
+
{k}
|
|
129
|
+
</label>
|
|
130
|
+
))}
|
|
131
|
+
</fieldset>
|
|
132
|
+
{editing && (
|
|
133
|
+
<label className="flex items-center gap-2 text-xs">
|
|
134
|
+
<input type="checkbox" checked={rotate} onChange={(e) => setRotate(e.target.checked)} />
|
|
135
|
+
Rotate signing secret (the new secret is shown once)
|
|
136
|
+
</label>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
<DialogFooter>
|
|
140
|
+
<Button size="sm" variant="ghost" onClick={onClose} disabled={pending}>
|
|
141
|
+
Cancel
|
|
142
|
+
</Button>
|
|
143
|
+
<Button size="sm" onClick={submit} disabled={!canSubmit}>
|
|
144
|
+
{editing ? "Save changes" : "Create endpoint"}
|
|
145
|
+
</Button>
|
|
146
|
+
</DialogFooter>
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isSubscribable(k: string): k is SubscribableEventKind {
|
|
152
|
+
return (SUBSCRIBABLE_EVENT_KINDS as readonly string[]).includes(k);
|
|
153
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { ChevronRight, ExternalLink, Pencil, Power, Trash2 } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import type { WebhookDelivery, WebhookEndpoint, WebhookEndpointStats } from "@durablex/react";
|
|
5
|
+
import { useConfirmAction } from "../hooks/use-confirm-action";
|
|
6
|
+
import { useDeleteEndpoint, useUpdateEndpoint } from "@durablex/react";
|
|
7
|
+
import { formatRelative } from "../lib/format";
|
|
8
|
+
import {
|
|
9
|
+
deliveryView,
|
|
10
|
+
endpointHealth,
|
|
11
|
+
endpointLabel,
|
|
12
|
+
shortUrl,
|
|
13
|
+
successRate,
|
|
14
|
+
} from "../lib/webhook-view";
|
|
15
|
+
import { Facts } from "./Facts";
|
|
16
|
+
import { CodePill, DeliveryBadge, EndpointBadge, EventChip } from "./WebhookBadges";
|
|
17
|
+
|
|
18
|
+
export function EndpointRow({
|
|
19
|
+
ep,
|
|
20
|
+
stats,
|
|
21
|
+
recent,
|
|
22
|
+
onEdit,
|
|
23
|
+
}: {
|
|
24
|
+
ep: WebhookEndpoint;
|
|
25
|
+
stats?: WebhookEndpointStats;
|
|
26
|
+
recent: WebhookDelivery[];
|
|
27
|
+
onEdit(): void;
|
|
28
|
+
}) {
|
|
29
|
+
const [open, setOpen] = useState(false);
|
|
30
|
+
const health = endpointHealth(ep, recent);
|
|
31
|
+
const rate = successRate(stats);
|
|
32
|
+
const dot =
|
|
33
|
+
health === "failing"
|
|
34
|
+
? "var(--st-failed-fg)"
|
|
35
|
+
: health === "disabled"
|
|
36
|
+
? "var(--muted-foreground)"
|
|
37
|
+
: "var(--primary)";
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<tr className="row app-row" aria-expanded={open} onClick={() => setOpen(!open)}>
|
|
42
|
+
<td>
|
|
43
|
+
<div className="app-cell">
|
|
44
|
+
<ChevronRight className="app-chev" />
|
|
45
|
+
<i className="sq" style={{ width: 8, height: 8, background: dot }} />
|
|
46
|
+
<div className="app-id">
|
|
47
|
+
<span className="nm">{endpointLabel(ep)}</span>
|
|
48
|
+
<span className="url">{shortUrl(ep.url)}</span>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</td>
|
|
52
|
+
<td>
|
|
53
|
+
<EndpointBadge health={health} />
|
|
54
|
+
</td>
|
|
55
|
+
<td>
|
|
56
|
+
<div className="wh-evchips">
|
|
57
|
+
{ep.eventKinds.slice(0, 2).map((e) => (
|
|
58
|
+
<EventChip key={e} name={e} />
|
|
59
|
+
))}
|
|
60
|
+
{ep.eventKinds.length > 2 && (
|
|
61
|
+
<span className="wh-evmore">+{ep.eventKinds.length - 2}</span>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
</td>
|
|
65
|
+
<td className="num">
|
|
66
|
+
<span className="dur">{rate == null ? "-" : `${rate}%`}</span>
|
|
67
|
+
</td>
|
|
68
|
+
<td className="num">
|
|
69
|
+
<span className="dur">{stats?.delivered ?? 0}</span>
|
|
70
|
+
</td>
|
|
71
|
+
<td>
|
|
72
|
+
<span className="ts cell-mut">
|
|
73
|
+
{stats?.lastDelivery ? formatRelative(stats.lastDelivery) : "-"}
|
|
74
|
+
</span>
|
|
75
|
+
</td>
|
|
76
|
+
</tr>
|
|
77
|
+
{open && (
|
|
78
|
+
<tr className="app-detailrow">
|
|
79
|
+
<td colSpan={6}>
|
|
80
|
+
<div className="wh-od">
|
|
81
|
+
<div className="wh-od-main">
|
|
82
|
+
<div className="wh-od-sec">
|
|
83
|
+
<div className="wh-od-h">Configuration</div>
|
|
84
|
+
<Facts
|
|
85
|
+
rows={[
|
|
86
|
+
["URL", ep.url],
|
|
87
|
+
["Endpoint id", ep.id],
|
|
88
|
+
["Signing secret", "sealed - rotate to reset"],
|
|
89
|
+
["Created", formatRelative(ep.createdAt)],
|
|
90
|
+
]}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="wh-od-sec">
|
|
94
|
+
<div className="wh-od-h">Subscribed events · {ep.eventKinds.length}</div>
|
|
95
|
+
<div className="wh-evchips wrap">
|
|
96
|
+
{ep.eventKinds.map((e) => (
|
|
97
|
+
<EventChip key={e} name={e} />
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
<EndpointActions ep={ep} onEdit={onEdit} />
|
|
102
|
+
</div>
|
|
103
|
+
<div className="wh-od-side">
|
|
104
|
+
<div className="wh-od-h">Recent deliveries · {recent.length}</div>
|
|
105
|
+
{recent.length ? (
|
|
106
|
+
<div className="wh-reclist">
|
|
107
|
+
{recent.slice(0, 12).map((d) => (
|
|
108
|
+
<div className="wh-rec" key={d.id}>
|
|
109
|
+
<span className="wh-rec-ev">{d.eventKind}</span>
|
|
110
|
+
<span className="wh-rec-meta">
|
|
111
|
+
<CodePill code={d.lastStatusCode} />
|
|
112
|
+
<DeliveryBadge view={deliveryView(d)} small />
|
|
113
|
+
<span className="wh-rec-at">{formatRelative(d.createdAt)}</span>
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
) : (
|
|
119
|
+
<div className="wh-rec-empty">No deliveries yet.</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</td>
|
|
124
|
+
</tr>
|
|
125
|
+
)}
|
|
126
|
+
</>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function EndpointActions({ ep, onEdit }: { ep: WebhookEndpoint; onEdit(): void }) {
|
|
131
|
+
const update = useUpdateEndpoint();
|
|
132
|
+
const del = useDeleteEndpoint();
|
|
133
|
+
const remove = useConfirmAction(() =>
|
|
134
|
+
del.mutate(ep.id, {
|
|
135
|
+
onSuccess: () => toast.success("Endpoint deleted"),
|
|
136
|
+
onError: (e: Error) => toast.error(`Could not delete endpoint: ${e.message}`),
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="app-actions" onClick={(e) => e.stopPropagation()}>
|
|
142
|
+
<button type="button" className="btn focusable" onClick={onEdit}>
|
|
143
|
+
<Pencil /> Edit
|
|
144
|
+
</button>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
className="btn focusable"
|
|
148
|
+
disabled={update.isPending}
|
|
149
|
+
onClick={() =>
|
|
150
|
+
update.mutate(
|
|
151
|
+
{ id: ep.id, patch: { enabled: !ep.enabled } },
|
|
152
|
+
{ onError: (e: Error) => toast.error(`Could not update endpoint: ${e.message}`) },
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
>
|
|
156
|
+
<Power /> {ep.enabled ? "Disable" : "Enable"}
|
|
157
|
+
</button>
|
|
158
|
+
<a className="btn focusable" href={ep.url} target="_blank" rel="noreferrer">
|
|
159
|
+
<ExternalLink /> Open endpoint
|
|
160
|
+
</a>
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
className="btn focusable"
|
|
164
|
+
style={{ color: "var(--st-failed-fg)" }}
|
|
165
|
+
disabled={del.isPending}
|
|
166
|
+
onClick={remove.trigger}
|
|
167
|
+
>
|
|
168
|
+
<Trash2 /> {remove.confirming ? "Confirm delete" : "Delete"}
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Plus } from "lucide-react";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import type { WebhookEndpoint } from "@durablex/react";
|
|
4
|
+
import { useDeliveries, useEndpoints, useEndpointStats } from "@durablex/react";
|
|
5
|
+
import { DELIVERY_LIST_LIMIT, groupDeliveriesByEndpoint } from "../lib/webhook-view";
|
|
6
|
+
import { EndpointFormDialog } from "./EndpointFormDialog";
|
|
7
|
+
import { EndpointRow } from "./EndpointRow";
|
|
8
|
+
|
|
9
|
+
const STATS_WINDOW = 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export function EndpointsTab() {
|
|
12
|
+
// Lazy initializer: reads the clock once at mount so the boundary is stable across re-renders.
|
|
13
|
+
const [since] = useState(() => new Date(Date.now() - STATS_WINDOW).toISOString());
|
|
14
|
+
const endpoints = useEndpoints().data ?? [];
|
|
15
|
+
const statsData = useEndpointStats(since).data;
|
|
16
|
+
const deliveriesData = useDeliveries({ limit: DELIVERY_LIST_LIMIT }).data?.deliveries;
|
|
17
|
+
const [adding, setAdding] = useState(false);
|
|
18
|
+
const [editing, setEditing] = useState<WebhookEndpoint | null>(null);
|
|
19
|
+
|
|
20
|
+
const statsById = useMemo(
|
|
21
|
+
() => new Map((statsData ?? []).map((s) => [s.endpointId, s])),
|
|
22
|
+
[statsData],
|
|
23
|
+
);
|
|
24
|
+
const recentById = useMemo(
|
|
25
|
+
() => groupDeliveriesByEndpoint(deliveriesData ?? []),
|
|
26
|
+
[deliveriesData],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="content">
|
|
31
|
+
<div className="tbar">
|
|
32
|
+
<span className="text-muted-foreground font-mono text-[11px]">
|
|
33
|
+
{endpoints.length} endpoints
|
|
34
|
+
</span>
|
|
35
|
+
<span className="tbar-spacer" />
|
|
36
|
+
<button type="button" className="btn focusable" onClick={() => setAdding(true)}>
|
|
37
|
+
<Plus /> Add endpoint
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
<div className="tablewrap">
|
|
41
|
+
{endpoints.length === 0 ? (
|
|
42
|
+
<div className="placeholder">
|
|
43
|
+
<div className="ph-inner">
|
|
44
|
+
<h3>No outbound endpoints</h3>
|
|
45
|
+
<p>Add an endpoint to deliver run lifecycle events to an external URL.</p>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
) : (
|
|
49
|
+
<table className="runs apps-table">
|
|
50
|
+
<thead>
|
|
51
|
+
<tr>
|
|
52
|
+
<th>Endpoint</th>
|
|
53
|
+
<th>Status</th>
|
|
54
|
+
<th>Events</th>
|
|
55
|
+
<th className="num">Success</th>
|
|
56
|
+
<th className="num">Sent · 24h</th>
|
|
57
|
+
<th>Last delivery</th>
|
|
58
|
+
</tr>
|
|
59
|
+
</thead>
|
|
60
|
+
<tbody>
|
|
61
|
+
{endpoints.map((ep) => (
|
|
62
|
+
<EndpointRow
|
|
63
|
+
key={ep.id}
|
|
64
|
+
ep={ep}
|
|
65
|
+
stats={statsById.get(ep.id)}
|
|
66
|
+
recent={recentById.get(ep.id) ?? []}
|
|
67
|
+
onEdit={() => setEditing(ep)}
|
|
68
|
+
/>
|
|
69
|
+
))}
|
|
70
|
+
</tbody>
|
|
71
|
+
</table>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<EndpointFormDialog open={adding} onOpenChange={setAdding} />
|
|
76
|
+
<EndpointFormDialog
|
|
77
|
+
open={editing != null}
|
|
78
|
+
onOpenChange={(o) => !o && setEditing(null)}
|
|
79
|
+
endpoint={editing ?? undefined}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|