@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,70 @@
|
|
|
1
|
+
import { TriangleAlert } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { LogFrame, Run } from "@durablex/react";
|
|
4
|
+
import { formatError } from "../lib/format";
|
|
5
|
+
import { JsonBlock } from "./JsonBlock";
|
|
6
|
+
import { LogList } from "./LogList";
|
|
7
|
+
|
|
8
|
+
// The run's panes: Error first (a failed run lands on it), then Input, Result, and
|
|
9
|
+
// the run-level logs. Error and Result are mutually exclusive (a run either failed
|
|
10
|
+
// or produced a result).
|
|
11
|
+
const PAYLOAD_TAB_LABEL = {
|
|
12
|
+
error: "Error",
|
|
13
|
+
input: "Input",
|
|
14
|
+
result: "Result",
|
|
15
|
+
logs: "Logs",
|
|
16
|
+
} as const;
|
|
17
|
+
type PayloadTab = keyof typeof PAYLOAD_TAB_LABEL;
|
|
18
|
+
|
|
19
|
+
function resultPlaceholder(status: Run["status"]): string {
|
|
20
|
+
if (status === "failed") return "- no result (run failed)";
|
|
21
|
+
if (status === "running" || status === "waiting" || status === "queued") {
|
|
22
|
+
return "- pending (still running)";
|
|
23
|
+
}
|
|
24
|
+
return "null";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function PayloadTabs({ run, logs }: { run: Run; logs: LogFrame[] }) {
|
|
28
|
+
const hasResult = run.result !== null && run.result !== undefined;
|
|
29
|
+
const tabs: PayloadTab[] = [];
|
|
30
|
+
if (run.error) tabs.push("error");
|
|
31
|
+
tabs.push("input", "result");
|
|
32
|
+
if (logs.length > 0) tabs.push("logs");
|
|
33
|
+
const [tab, setTab] = useState<PayloadTab>(() => (run.error ? "error" : "input"));
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="section">
|
|
37
|
+
<div className="io-tabs">
|
|
38
|
+
{tabs.map((t) => (
|
|
39
|
+
<button
|
|
40
|
+
key={t}
|
|
41
|
+
type="button"
|
|
42
|
+
className="io-tab"
|
|
43
|
+
data-kind={t}
|
|
44
|
+
data-on={tab === t ? "1" : "0"}
|
|
45
|
+
onClick={() => setTab(t)}
|
|
46
|
+
>
|
|
47
|
+
{t === "error" && <TriangleAlert className="step-tab-ico" />}
|
|
48
|
+
{PAYLOAD_TAB_LABEL[t]}
|
|
49
|
+
{t === "result" && !hasResult && <span className="io-null">null</span>}
|
|
50
|
+
</button>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
{tab === "error" && run.error ? (
|
|
54
|
+
<pre className="errbox">{formatError(run.error)}</pre>
|
|
55
|
+
) : tab === "logs" ? (
|
|
56
|
+
<LogList logs={logs} />
|
|
57
|
+
) : tab === "input" ? (
|
|
58
|
+
<JsonBlock value={run.input} />
|
|
59
|
+
) : hasResult ? (
|
|
60
|
+
<JsonBlock value={run.result} />
|
|
61
|
+
) : (
|
|
62
|
+
<div className="jsonwrap">
|
|
63
|
+
<pre className="json">
|
|
64
|
+
<span className="nul">{resultPlaceholder(run.status)}</span>
|
|
65
|
+
</pre>
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import type { ReceiverInput, ReceiverPatch, WebhookReceiver } from "@durablex/react";
|
|
4
|
+
import { Button } from "../ui/button";
|
|
5
|
+
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
|
6
|
+
import { Input } from "../ui/input";
|
|
7
|
+
import { useCreateReceiver, useUpdateReceiver } from "@durablex/react";
|
|
8
|
+
import { FormField } from "./FormField";
|
|
9
|
+
import { SecretReveal } from "./SecretReveal";
|
|
10
|
+
export function ReceiverFormDialog({
|
|
11
|
+
open,
|
|
12
|
+
onOpenChange,
|
|
13
|
+
receiver,
|
|
14
|
+
}: {
|
|
15
|
+
open: boolean;
|
|
16
|
+
onOpenChange: (open: boolean) => void;
|
|
17
|
+
receiver?: WebhookReceiver;
|
|
18
|
+
}) {
|
|
19
|
+
return (
|
|
20
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
21
|
+
<DialogContent className="sm:max-w-md">
|
|
22
|
+
{open && <ReceiverForm receiver={receiver} onClose={() => onOpenChange(false)} />}
|
|
23
|
+
</DialogContent>
|
|
24
|
+
</Dialog>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ReceiverForm({ receiver, onClose }: { receiver?: WebhookReceiver; onClose: () => void }) {
|
|
29
|
+
const editing = receiver != null;
|
|
30
|
+
const [name, setName] = useState(receiver?.name ?? "");
|
|
31
|
+
const [eventName, setEventName] = useState(receiver?.eventName ?? "");
|
|
32
|
+
const [app, setApp] = useState(receiver?.app ?? "");
|
|
33
|
+
const [rotate, setRotate] = useState(false);
|
|
34
|
+
const [secret, setSecret] = useState<string | null>(null);
|
|
35
|
+
|
|
36
|
+
const create = useCreateReceiver();
|
|
37
|
+
const update = useUpdateReceiver();
|
|
38
|
+
const pending = create.isPending || update.isPending;
|
|
39
|
+
const canSubmit = eventName.trim() !== "" && !pending;
|
|
40
|
+
|
|
41
|
+
function submit() {
|
|
42
|
+
if (!canSubmit) return;
|
|
43
|
+
if (editing) {
|
|
44
|
+
const patch: ReceiverPatch = {
|
|
45
|
+
name: name.trim(),
|
|
46
|
+
eventName: eventName.trim(),
|
|
47
|
+
app: app.trim(),
|
|
48
|
+
rotateSecret: rotate,
|
|
49
|
+
};
|
|
50
|
+
update.mutate(
|
|
51
|
+
{ id: receiver.id, patch },
|
|
52
|
+
{
|
|
53
|
+
onSuccess: (res) => (res.secret ? setSecret(res.secret) : onClose()),
|
|
54
|
+
onError: (e: Error) => toast.error(`Could not save receiver: ${e.message}`),
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const input: ReceiverInput = {
|
|
60
|
+
name: name.trim() || undefined,
|
|
61
|
+
eventName: eventName.trim(),
|
|
62
|
+
app: app.trim() || undefined,
|
|
63
|
+
};
|
|
64
|
+
create.mutate(input, {
|
|
65
|
+
onSuccess: (res) => (res.secret ? setSecret(res.secret) : onClose()),
|
|
66
|
+
onError: (e: Error) => toast.error(`Could not save receiver: ${e.message}`),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (secret) {
|
|
71
|
+
return <SecretReveal secret={secret} onDone={onClose} />;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
<DialogHeader>
|
|
77
|
+
<DialogTitle>{editing ? "Edit receiver" : "Add receiver"}</DialogTitle>
|
|
78
|
+
</DialogHeader>
|
|
79
|
+
<div className="flex flex-col gap-3 py-1">
|
|
80
|
+
<FormField label="Name (optional)">
|
|
81
|
+
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Stripe" />
|
|
82
|
+
</FormField>
|
|
83
|
+
<FormField label="Maps to event">
|
|
84
|
+
<Input
|
|
85
|
+
value={eventName}
|
|
86
|
+
onChange={(e) => setEventName(e.target.value)}
|
|
87
|
+
placeholder="stripe.charge"
|
|
88
|
+
/>
|
|
89
|
+
</FormField>
|
|
90
|
+
<FormField label="App (optional)">
|
|
91
|
+
<Input value={app} onChange={(e) => setApp(e.target.value)} placeholder="default app" />
|
|
92
|
+
</FormField>
|
|
93
|
+
{editing && (
|
|
94
|
+
<>
|
|
95
|
+
<FormField label="Receive URL (immutable)">
|
|
96
|
+
<Input
|
|
97
|
+
value={`/webhooks/${receiver.slug}`}
|
|
98
|
+
readOnly
|
|
99
|
+
className="text-muted-foreground"
|
|
100
|
+
/>
|
|
101
|
+
</FormField>
|
|
102
|
+
<label className="flex items-center gap-2 text-xs">
|
|
103
|
+
<input
|
|
104
|
+
type="checkbox"
|
|
105
|
+
checked={rotate}
|
|
106
|
+
onChange={(e) => setRotate(e.target.checked)}
|
|
107
|
+
/>
|
|
108
|
+
Rotate signing secret (the new secret is shown once)
|
|
109
|
+
</label>
|
|
110
|
+
</>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
<DialogFooter>
|
|
114
|
+
<Button size="sm" variant="ghost" onClick={onClose} disabled={pending}>
|
|
115
|
+
Cancel
|
|
116
|
+
</Button>
|
|
117
|
+
<Button size="sm" onClick={submit} disabled={!canSubmit}>
|
|
118
|
+
{editing ? "Save changes" : "Create receiver"}
|
|
119
|
+
</Button>
|
|
120
|
+
</DialogFooter>
|
|
121
|
+
</>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Check, ChevronRight, Copy, Pencil, Plus, Power, Trash2 } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import type { WebhookReceiver } from "@durablex/react";
|
|
5
|
+
import { useConfirmAction } from "../hooks/use-confirm-action";
|
|
6
|
+
import { useReceivers, useDeleteReceiver, useUpdateReceiver } from "@durablex/react";
|
|
7
|
+
import { formatRelative } from "../lib/format";
|
|
8
|
+
import { receiverLabel } from "../lib/webhook-view";
|
|
9
|
+
import { Facts } from "./Facts";
|
|
10
|
+
import { EndpointBadge } from "./WebhookBadges";
|
|
11
|
+
import { ReceiverFormDialog } from "./ReceiverFormDialog";
|
|
12
|
+
|
|
13
|
+
export function ReceiversTab() {
|
|
14
|
+
const receivers = useReceivers().data ?? [];
|
|
15
|
+
const [adding, setAdding] = useState(false);
|
|
16
|
+
const [editing, setEditing] = useState<WebhookReceiver | null>(null);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="content">
|
|
20
|
+
<div className="tbar">
|
|
21
|
+
<span className="text-muted-foreground font-mono text-[11px]">
|
|
22
|
+
{receivers.length} receivers
|
|
23
|
+
</span>
|
|
24
|
+
<span className="tbar-spacer" />
|
|
25
|
+
<button type="button" className="btn focusable" onClick={() => setAdding(true)}>
|
|
26
|
+
<Plus /> Add receiver
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
<div className="tablewrap">
|
|
30
|
+
{receivers.length === 0 ? (
|
|
31
|
+
<div className="placeholder">
|
|
32
|
+
<div className="ph-inner">
|
|
33
|
+
<h3>No inbound receivers</h3>
|
|
34
|
+
<p>Add a receiver to map a verified external POST onto a durablex event.</p>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
) : (
|
|
38
|
+
<table className="runs apps-table">
|
|
39
|
+
<thead>
|
|
40
|
+
<tr>
|
|
41
|
+
<th>Source</th>
|
|
42
|
+
<th>Status</th>
|
|
43
|
+
<th>Verification</th>
|
|
44
|
+
<th>Maps to event</th>
|
|
45
|
+
<th>App</th>
|
|
46
|
+
<th>Created</th>
|
|
47
|
+
</tr>
|
|
48
|
+
</thead>
|
|
49
|
+
<tbody>
|
|
50
|
+
{receivers.map((rc) => (
|
|
51
|
+
<ReceiverRow key={rc.id} rc={rc} onEdit={() => setEditing(rc)} />
|
|
52
|
+
))}
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<ReceiverFormDialog open={adding} onOpenChange={setAdding} />
|
|
59
|
+
<ReceiverFormDialog
|
|
60
|
+
open={editing != null}
|
|
61
|
+
onOpenChange={(o) => !o && setEditing(null)}
|
|
62
|
+
receiver={editing ?? undefined}
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ReceiverRow({ rc, onEdit }: { rc: WebhookReceiver; onEdit(): void }) {
|
|
69
|
+
const [open, setOpen] = useState(false);
|
|
70
|
+
const dot = rc.enabled ? "var(--primary)" : "var(--muted-foreground)";
|
|
71
|
+
const path = `/webhooks/${rc.slug}`;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
<tr className="row app-row" aria-expanded={open} onClick={() => setOpen(!open)}>
|
|
76
|
+
<td>
|
|
77
|
+
<div className="app-cell">
|
|
78
|
+
<ChevronRight className="app-chev" />
|
|
79
|
+
<i className="sq" style={{ width: 8, height: 8, background: dot }} />
|
|
80
|
+
<div className="app-id">
|
|
81
|
+
<span className="nm">{receiverLabel(rc)}</span>
|
|
82
|
+
<span className="url">{path}</span>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</td>
|
|
86
|
+
<td>
|
|
87
|
+
<EndpointBadge health={rc.enabled ? "active" : "disabled"} />
|
|
88
|
+
</td>
|
|
89
|
+
<td>
|
|
90
|
+
<span className="wh-verify ok">
|
|
91
|
+
<Check /> Signed
|
|
92
|
+
</span>
|
|
93
|
+
</td>
|
|
94
|
+
<td>
|
|
95
|
+
<span className="wf-name text-xs">{rc.eventName}</span>
|
|
96
|
+
</td>
|
|
97
|
+
<td>
|
|
98
|
+
<span className="text-muted-foreground text-[11px]">{rc.app || "-"}</span>
|
|
99
|
+
</td>
|
|
100
|
+
<td>
|
|
101
|
+
<span className="ts cell-mut">{formatRelative(rc.createdAt)}</span>
|
|
102
|
+
</td>
|
|
103
|
+
</tr>
|
|
104
|
+
{open && (
|
|
105
|
+
<tr className="app-detailrow">
|
|
106
|
+
<td colSpan={6}>
|
|
107
|
+
<div className="wh-od">
|
|
108
|
+
<div className="wh-od-main">
|
|
109
|
+
<div className="wh-od-sec">
|
|
110
|
+
<div className="wh-od-h">Configuration</div>
|
|
111
|
+
<Facts
|
|
112
|
+
rows={[
|
|
113
|
+
["Receive URL", path],
|
|
114
|
+
["Receiver id", rc.id],
|
|
115
|
+
["Signature", rc.scheme],
|
|
116
|
+
["Maps to", rc.eventName],
|
|
117
|
+
]}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
<ReceiverActions rc={rc} path={path} onEdit={onEdit} />
|
|
121
|
+
</div>
|
|
122
|
+
<div className="wh-od-side">
|
|
123
|
+
<div className="wh-od-h">Verification</div>
|
|
124
|
+
<p className="text-muted-foreground text-xs">
|
|
125
|
+
Inbound posts are HMAC-verified against this receiver's signing secret. Rotate the
|
|
126
|
+
secret from Edit; the new value is shown once.
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</td>
|
|
131
|
+
</tr>
|
|
132
|
+
)}
|
|
133
|
+
</>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function ReceiverActions({
|
|
138
|
+
rc,
|
|
139
|
+
path,
|
|
140
|
+
onEdit,
|
|
141
|
+
}: {
|
|
142
|
+
rc: WebhookReceiver;
|
|
143
|
+
path: string;
|
|
144
|
+
onEdit(): void;
|
|
145
|
+
}) {
|
|
146
|
+
const update = useUpdateReceiver();
|
|
147
|
+
const del = useDeleteReceiver();
|
|
148
|
+
const remove = useConfirmAction(() =>
|
|
149
|
+
del.mutate(rc.id, {
|
|
150
|
+
onSuccess: () => toast.success("Receiver deleted"),
|
|
151
|
+
onError: (e: Error) => toast.error(`Could not delete receiver: ${e.message}`),
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
function copyUrl() {
|
|
156
|
+
void navigator.clipboard?.writeText(`${window.location.origin}${path}`).then(
|
|
157
|
+
() => toast.success("Receive URL copied"),
|
|
158
|
+
() => undefined,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div className="app-actions" onClick={(e) => e.stopPropagation()}>
|
|
164
|
+
<button type="button" className="btn focusable" onClick={onEdit}>
|
|
165
|
+
<Pencil /> Edit
|
|
166
|
+
</button>
|
|
167
|
+
<button type="button" className="btn focusable" onClick={copyUrl}>
|
|
168
|
+
<Copy /> Copy URL
|
|
169
|
+
</button>
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
className="btn focusable"
|
|
173
|
+
disabled={update.isPending}
|
|
174
|
+
onClick={() =>
|
|
175
|
+
update.mutate(
|
|
176
|
+
{ id: rc.id, patch: { enabled: !rc.enabled } },
|
|
177
|
+
{ onError: (e: Error) => toast.error(`Could not update receiver: ${e.message}`) },
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
>
|
|
181
|
+
<Power /> {rc.enabled ? "Disable" : "Enable"}
|
|
182
|
+
</button>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
className="btn focusable"
|
|
186
|
+
style={{ color: "var(--st-failed-fg)" }}
|
|
187
|
+
disabled={del.isPending}
|
|
188
|
+
onClick={remove.trigger}
|
|
189
|
+
>
|
|
190
|
+
<Trash2 /> {remove.confirming ? "Confirm delete" : "Delete"}
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { useReplayRun, type Run } from "@durablex/react";
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from "../ui/dialog";
|
|
12
|
+
import { Button } from "../ui/button";
|
|
13
|
+
import { parseJson } from "../lib/json-highlight";
|
|
14
|
+
import { JsonEditor } from "./JsonEditor";
|
|
15
|
+
|
|
16
|
+
export function ReplayRunDialog({
|
|
17
|
+
run,
|
|
18
|
+
open,
|
|
19
|
+
onOpenChange,
|
|
20
|
+
onOpenRun,
|
|
21
|
+
}: {
|
|
22
|
+
run: Run;
|
|
23
|
+
open: boolean;
|
|
24
|
+
onOpenChange: (open: boolean) => void;
|
|
25
|
+
onOpenRun?: (runId: string) => void;
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
29
|
+
<DialogContent className="sm:max-w-md">
|
|
30
|
+
{open && (
|
|
31
|
+
<ReplayForm
|
|
32
|
+
key={run.id}
|
|
33
|
+
run={run}
|
|
34
|
+
onOpenRun={onOpenRun}
|
|
35
|
+
onClose={() => onOpenChange(false)}
|
|
36
|
+
/>
|
|
37
|
+
)}
|
|
38
|
+
</DialogContent>
|
|
39
|
+
</Dialog>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ReplayForm({
|
|
44
|
+
run,
|
|
45
|
+
onOpenRun,
|
|
46
|
+
onClose,
|
|
47
|
+
}: {
|
|
48
|
+
run: Run;
|
|
49
|
+
onOpenRun?: (runId: string) => void;
|
|
50
|
+
onClose: () => void;
|
|
51
|
+
}) {
|
|
52
|
+
const original = useMemo(() => JSON.stringify(run.input ?? {}, null, 2), [run.input]);
|
|
53
|
+
const [payload, setPayload] = useState(original);
|
|
54
|
+
const replay = useReplayRun();
|
|
55
|
+
|
|
56
|
+
const parsed = useMemo(() => parseJson(payload), [payload]);
|
|
57
|
+
const canSubmit = parsed.ok && !replay.isPending;
|
|
58
|
+
|
|
59
|
+
function submit() {
|
|
60
|
+
if (!parsed.ok) return;
|
|
61
|
+
// Send an override only when the payload actually changed, so an unedited
|
|
62
|
+
// replay records as a plain replay rather than an edited-payload one.
|
|
63
|
+
const edited = JSON.stringify(parsed.value) !== JSON.stringify(run.input ?? {});
|
|
64
|
+
replay.mutate(
|
|
65
|
+
{ runId: run.id, input: edited ? parsed.value : undefined },
|
|
66
|
+
{
|
|
67
|
+
onSuccess: (forked) => {
|
|
68
|
+
toast.success("Replay started", {
|
|
69
|
+
action: onOpenRun
|
|
70
|
+
? { label: "View run", onClick: () => onOpenRun(forked.id) }
|
|
71
|
+
: undefined,
|
|
72
|
+
});
|
|
73
|
+
onClose();
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<DialogHeader>
|
|
82
|
+
<DialogTitle>Replay {run.workflowName}</DialogTitle>
|
|
83
|
+
<DialogDescription>
|
|
84
|
+
Forks a new run from this input. The original run stays as history.
|
|
85
|
+
</DialogDescription>
|
|
86
|
+
</DialogHeader>
|
|
87
|
+
|
|
88
|
+
<div className="flex flex-col gap-1.5">
|
|
89
|
+
<span className="text-xs font-medium">Input</span>
|
|
90
|
+
<JsonEditor
|
|
91
|
+
value={payload}
|
|
92
|
+
onChange={setPayload}
|
|
93
|
+
invalid={!parsed.ok}
|
|
94
|
+
ariaLabel="Replay input"
|
|
95
|
+
/>
|
|
96
|
+
{!parsed.ok ? <span className="text-xs text-destructive">{parsed.error}</span> : null}
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{replay.isError ? (
|
|
100
|
+
<span className="text-xs text-destructive">
|
|
101
|
+
Could not replay: {replay.error instanceof Error ? replay.error.message : "unknown error"}
|
|
102
|
+
</span>
|
|
103
|
+
) : null}
|
|
104
|
+
|
|
105
|
+
<DialogFooter showCloseButton>
|
|
106
|
+
<Button disabled={!canSubmit} onClick={submit}>
|
|
107
|
+
{replay.isPending ? "Replaying..." : "Replay"}
|
|
108
|
+
</Button>
|
|
109
|
+
</DialogFooter>
|
|
110
|
+
</>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
import { MARK_BARS, RESUME_TRIANGLE } from "./marks-geometry";
|
|
3
|
+
|
|
4
|
+
type ResumeMarkProps = {
|
|
5
|
+
size?: number;
|
|
6
|
+
variant?: "run" | "load";
|
|
7
|
+
fault?: boolean;
|
|
8
|
+
className?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function ResumeMark({ size = 16, variant, fault, className, title }: ResumeMarkProps) {
|
|
13
|
+
return (
|
|
14
|
+
<span
|
|
15
|
+
className={cn("dxmark", variant && `dxm-${variant}`, className)}
|
|
16
|
+
style={{ width: size, height: size }}
|
|
17
|
+
role={title ? "img" : undefined}
|
|
18
|
+
aria-label={title}
|
|
19
|
+
aria-hidden={title ? undefined : true}
|
|
20
|
+
>
|
|
21
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
|
22
|
+
<g className="dxm-bars">
|
|
23
|
+
{MARK_BARS.map((b, i) => (
|
|
24
|
+
<rect key={i} {...b} fill="currentColor" />
|
|
25
|
+
))}
|
|
26
|
+
</g>
|
|
27
|
+
{fault && (
|
|
28
|
+
<g className="dxm-fault">
|
|
29
|
+
{MARK_BARS.map((b, i) => (
|
|
30
|
+
<rect key={i} {...b} fill="var(--st-failed-fg)" />
|
|
31
|
+
))}
|
|
32
|
+
</g>
|
|
33
|
+
)}
|
|
34
|
+
<path className="dxm-tri" d={RESUME_TRIANGLE} fill="var(--dxm-accent, var(--primary))" />
|
|
35
|
+
</svg>
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Loader2, RotateCw } from "lucide-react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { useRetryFromStep } from "@durablex/react";
|
|
4
|
+
import { useConfirmAction } from "../hooks/use-confirm-action";
|
|
5
|
+
|
|
6
|
+
// Retry forks a new run that re-executes from this step, so like Cancel it takes a
|
|
7
|
+
// two-click confirm: the first click arms, the second within the window forks.
|
|
8
|
+
export function RetryFromStepButton({
|
|
9
|
+
runId,
|
|
10
|
+
stepName,
|
|
11
|
+
onOpenRun,
|
|
12
|
+
}: {
|
|
13
|
+
runId: string;
|
|
14
|
+
stepName: string;
|
|
15
|
+
onOpenRun?: (runId: string) => void;
|
|
16
|
+
}) {
|
|
17
|
+
const retry = useRetryFromStep();
|
|
18
|
+
const run = () =>
|
|
19
|
+
retry.mutate(
|
|
20
|
+
{ runId, step: stepName },
|
|
21
|
+
{
|
|
22
|
+
onSuccess: (forked) =>
|
|
23
|
+
toast.success("Retry started", {
|
|
24
|
+
action: onOpenRun
|
|
25
|
+
? { label: "View run", onClick: () => onOpenRun(forked.id) }
|
|
26
|
+
: undefined,
|
|
27
|
+
}),
|
|
28
|
+
onError: (err) => toast.error(`Retry failed: ${err.message}`),
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
const { confirming, trigger } = useConfirmAction(run);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
className="btn focusable fi-retry"
|
|
37
|
+
disabled={retry.isPending}
|
|
38
|
+
onClick={trigger}
|
|
39
|
+
>
|
|
40
|
+
{retry.isPending ? <Loader2 className="animate-spin" /> : <RotateCw />}{" "}
|
|
41
|
+
{confirming ? "Confirm retry" : "Retry from here"}
|
|
42
|
+
</button>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Loader2, X } from "lucide-react";
|
|
2
|
+
import { useCancelRun } from "@durablex/react";
|
|
3
|
+
import { useConfirmAction } from "../hooks/use-confirm-action";
|
|
4
|
+
|
|
5
|
+
// Cancel is irreversible, so it takes a two-click confirm: the first click arms,
|
|
6
|
+
// the second within the window cancels.
|
|
7
|
+
export function RunCancelButton({ id }: { id: string }) {
|
|
8
|
+
const cancel = useCancelRun();
|
|
9
|
+
const { confirming, trigger } = useConfirmAction(() => cancel.mutate(id));
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
className="btn focusable"
|
|
15
|
+
style={{ color: "var(--st-failed-fg)" }}
|
|
16
|
+
disabled={cancel.isPending}
|
|
17
|
+
onClick={trigger}
|
|
18
|
+
>
|
|
19
|
+
{cancel.isPending ? <Loader2 className="animate-spin" /> : <X />}{" "}
|
|
20
|
+
{cancel.isPending ? "Cancelling" : confirming ? "Confirm cancel" : "Cancel"}
|
|
21
|
+
</button>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ArrowLeft, ArrowRight } from "lucide-react";
|
|
2
|
+
import { useControlActions, type ControlAction } from "@durablex/react";
|
|
3
|
+
import { ACTION_LABELS, actorLabel, describeControlDetail } from "../lib/control-action";
|
|
4
|
+
import { formatRelative } from "../lib/format";
|
|
5
|
+
|
|
6
|
+
// The runId filter matches entries where this run is the target OR the fork. Show the
|
|
7
|
+
// other run relative to the one being viewed: the fork it produced, or the source it came from.
|
|
8
|
+
function lineage(a: ControlAction, runId: string): { run: string; incoming: boolean } | null {
|
|
9
|
+
if (a.newRunId && a.newRunId === runId && a.runId) return { run: a.runId, incoming: true };
|
|
10
|
+
if (a.newRunId && a.runId === runId) return { run: a.newRunId, incoming: false };
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function RunLink({ id, onOpenRun }: { id: string; onOpenRun?: (runId: string) => void }) {
|
|
15
|
+
if (!onOpenRun) return <span className="font-mono text-[11px]">{id}</span>;
|
|
16
|
+
return (
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
className="hover:text-foreground font-mono text-[11px] underline underline-offset-2"
|
|
20
|
+
onClick={() => onOpenRun(id)}
|
|
21
|
+
>
|
|
22
|
+
{id}
|
|
23
|
+
</button>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function RunControlHistory({
|
|
28
|
+
runId,
|
|
29
|
+
onOpenRun,
|
|
30
|
+
}: {
|
|
31
|
+
runId: string;
|
|
32
|
+
onOpenRun?: (runId: string) => void;
|
|
33
|
+
}) {
|
|
34
|
+
const { data: actions } = useControlActions({ runId });
|
|
35
|
+
if (!actions || actions.length === 0) return null;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="mt-3 rounded-md border">
|
|
39
|
+
<div className="border-b px-2 py-1.5">
|
|
40
|
+
<span className="sec-title">Control history</span>
|
|
41
|
+
</div>
|
|
42
|
+
<ul className="divide-y">
|
|
43
|
+
{actions.map((a) => {
|
|
44
|
+
const detail = describeControlDetail(a);
|
|
45
|
+
const link = lineage(a, runId);
|
|
46
|
+
return (
|
|
47
|
+
<li key={a.id} className="flex items-center gap-2 px-2 py-1.5 text-xs">
|
|
48
|
+
<span className="font-medium">{ACTION_LABELS[a.action]}</span>
|
|
49
|
+
{detail && (
|
|
50
|
+
<span className="text-muted-foreground font-mono text-[11px]">{detail}</span>
|
|
51
|
+
)}
|
|
52
|
+
{link && (
|
|
53
|
+
<span className="text-muted-foreground flex items-center gap-1">
|
|
54
|
+
{link.incoming ? (
|
|
55
|
+
<ArrowLeft className="size-3 shrink-0" />
|
|
56
|
+
) : (
|
|
57
|
+
<ArrowRight className="size-3 shrink-0" />
|
|
58
|
+
)}
|
|
59
|
+
<RunLink id={link.run} onOpenRun={onOpenRun} />
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
<span className="text-muted-foreground ml-auto truncate font-mono text-[11px] whitespace-nowrap">
|
|
63
|
+
{actorLabel(a.actor)} · {formatRelative(a.createdAt)}
|
|
64
|
+
</span>
|
|
65
|
+
</li>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</ul>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|