@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.
Files changed (143) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/dist/index.d.ts +1078 -0
  4. package/dist/index.js +6407 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +86 -0
  7. package/src/components/AnimatedDurablexMark.tsx +35 -0
  8. package/src/components/AppStatusBadge.tsx +17 -0
  9. package/src/components/AppTag.tsx +17 -0
  10. package/src/components/AppsView.tsx +226 -0
  11. package/src/components/BulkReplayButton.tsx +52 -0
  12. package/src/components/CursorPager.tsx +50 -0
  13. package/src/components/DeliveriesSplit.tsx +187 -0
  14. package/src/components/DeliveryDetail.tsx +188 -0
  15. package/src/components/DurablexLogo.tsx +12 -0
  16. package/src/components/EndpointFormDialog.tsx +153 -0
  17. package/src/components/EndpointRow.tsx +172 -0
  18. package/src/components/EndpointsTab.tsx +83 -0
  19. package/src/components/EventsList.tsx +170 -0
  20. package/src/components/EventsView.tsx +24 -0
  21. package/src/components/Facts.tsx +14 -0
  22. package/src/components/FlowControlBadge.tsx +23 -0
  23. package/src/components/FlowControlSection.tsx +82 -0
  24. package/src/components/FlowSummary.tsx +47 -0
  25. package/src/components/FormField.tsx +10 -0
  26. package/src/components/GlyphBadge.tsx +41 -0
  27. package/src/components/JsonBlock.tsx +48 -0
  28. package/src/components/JsonEditor.tsx +91 -0
  29. package/src/components/LogList.tsx +45 -0
  30. package/src/components/Meta.tsx +31 -0
  31. package/src/components/OverviewView.tsx +39 -0
  32. package/src/components/PayloadTabs.tsx +70 -0
  33. package/src/components/ReceiverFormDialog.tsx +123 -0
  34. package/src/components/ReceiversTab.tsx +194 -0
  35. package/src/components/ReplayRunDialog.tsx +112 -0
  36. package/src/components/ResumeMark.tsx +38 -0
  37. package/src/components/RetryFromStepButton.tsx +44 -0
  38. package/src/components/RunCancelButton.tsx +23 -0
  39. package/src/components/RunControlHistory.tsx +71 -0
  40. package/src/components/RunInspector.test.tsx +78 -0
  41. package/src/components/RunInspector.tsx +297 -0
  42. package/src/components/RunInspectorActions.tsx +40 -0
  43. package/src/components/RunPauseButton.tsx +34 -0
  44. package/src/components/RunnerLiveBadge.tsx +11 -0
  45. package/src/components/RunsFilterBar.tsx +180 -0
  46. package/src/components/RunsTable.tsx +110 -0
  47. package/src/components/RunsTableHead.tsx +19 -0
  48. package/src/components/RunsTableLoader.tsx +10 -0
  49. package/src/components/RunsTablePlaceholder.tsx +19 -0
  50. package/src/components/RunsTableRow.tsx +103 -0
  51. package/src/components/RunsView.test.tsx +46 -0
  52. package/src/components/RunsView.tsx +243 -0
  53. package/src/components/ScheduledBadge.tsx +15 -0
  54. package/src/components/SecretReveal.tsx +45 -0
  55. package/src/components/SectionHeader.tsx +10 -0
  56. package/src/components/StatTileGrid.tsx +71 -0
  57. package/src/components/StatsTiles.tsx +66 -0
  58. package/src/components/StatusBadge.tsx +50 -0
  59. package/src/components/StepFlow.tsx +105 -0
  60. package/src/components/StepGlyph.tsx +25 -0
  61. package/src/components/StepInspector.tsx +44 -0
  62. package/src/components/StepRow.tsx +69 -0
  63. package/src/components/StepTabsView.tsx +51 -0
  64. package/src/components/StepTimeline.tsx +87 -0
  65. package/src/components/TableStatusRows.tsx +54 -0
  66. package/src/components/TriggerEventDialog.tsx +180 -0
  67. package/src/components/TriggerEventResult.tsx +61 -0
  68. package/src/components/WebhookBadges.tsx +69 -0
  69. package/src/components/WebhookStatusBadge.tsx +25 -0
  70. package/src/components/WebhooksView.tsx +69 -0
  71. package/src/components/WorkflowDetail.tsx +149 -0
  72. package/src/components/WorkflowRunAction.tsx +46 -0
  73. package/src/components/WorkflowRunDialog.tsx +187 -0
  74. package/src/components/WorkflowsView.tsx +168 -0
  75. package/src/components/charts/ChartCard.tsx +19 -0
  76. package/src/components/charts/RunCharts.tsx +31 -0
  77. package/src/components/charts/RunLatencyChart.tsx +71 -0
  78. package/src/components/charts/RunsOverTimeChart.tsx +60 -0
  79. package/src/components/filters/AppFilter.tsx +65 -0
  80. package/src/components/filters/FilterDropdown.tsx +33 -0
  81. package/src/components/filters/FilterDropdownButton.tsx +31 -0
  82. package/src/components/filters/FilterDropdownItem.tsx +37 -0
  83. package/src/components/filters/TimeRangeFilter.tsx +43 -0
  84. package/src/components/filters/TimeZoneFilter.tsx +40 -0
  85. package/src/components/filters/use-click-outside.ts +18 -0
  86. package/src/components/filters-pager.test.tsx +94 -0
  87. package/src/components/marks-geometry.ts +10 -0
  88. package/src/components/replay-dialog.test.tsx +18 -0
  89. package/src/components/run-components.test.tsx +126 -0
  90. package/src/components/run-controls.test.tsx +97 -0
  91. package/src/hooks/use-confirm-action.ts +19 -0
  92. package/src/hooks/use-copy.ts +22 -0
  93. package/src/hooks/use-keyset-pager.ts +34 -0
  94. package/src/hooks/use-mobile.ts +16 -0
  95. package/src/index.ts +165 -0
  96. package/src/lib/app-color.test.ts +32 -0
  97. package/src/lib/app-color.ts +8 -0
  98. package/src/lib/control-action.ts +36 -0
  99. package/src/lib/flow-control.ts +77 -0
  100. package/src/lib/format.test.ts +102 -0
  101. package/src/lib/format.ts +45 -0
  102. package/src/lib/json-highlight.test.ts +36 -0
  103. package/src/lib/json-highlight.ts +64 -0
  104. package/src/lib/run-filters.ts +8 -0
  105. package/src/lib/run-logs.test.ts +80 -0
  106. package/src/lib/run-logs.ts +34 -0
  107. package/src/lib/run-progress.test.ts +109 -0
  108. package/src/lib/run-progress.ts +44 -0
  109. package/src/lib/run-sort.test.ts +40 -0
  110. package/src/lib/run-sort.ts +19 -0
  111. package/src/lib/status-label.test.ts +35 -0
  112. package/src/lib/status-label.ts +13 -0
  113. package/src/lib/step-detail.test.ts +122 -0
  114. package/src/lib/step-detail.ts +35 -0
  115. package/src/lib/step-display.test.ts +19 -0
  116. package/src/lib/step-display.ts +13 -0
  117. package/src/lib/step-timeline.test.ts +89 -0
  118. package/src/lib/step-timeline.ts +50 -0
  119. package/src/lib/table.ts +2 -0
  120. package/src/lib/theme.ts +35 -0
  121. package/src/lib/time-range.ts +81 -0
  122. package/src/lib/utils.ts +6 -0
  123. package/src/lib/webhook-view.test.ts +176 -0
  124. package/src/lib/webhook-view.ts +113 -0
  125. package/src/lib/workflow-run.test.ts +55 -0
  126. package/src/lib/workflow-run.ts +45 -0
  127. package/src/shell/AppShell.tsx +34 -0
  128. package/src/shell/Sidebar.tsx +78 -0
  129. package/src/shell/Topbar.tsx +22 -0
  130. package/src/styles.css +2204 -0
  131. package/src/test-utils.tsx +130 -0
  132. package/src/ui/button.tsx +67 -0
  133. package/src/ui/chart.tsx +337 -0
  134. package/src/ui/dialog.tsx +145 -0
  135. package/src/ui/input.tsx +19 -0
  136. package/src/ui/resizable.tsx +40 -0
  137. package/src/ui/separator.tsx +28 -0
  138. package/src/ui/sheet.tsx +128 -0
  139. package/src/ui/sidebar.tsx +665 -0
  140. package/src/ui/skeleton.tsx +15 -0
  141. package/src/ui/sonner.tsx +35 -0
  142. package/src/ui/table.tsx +87 -0
  143. 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
+ }