@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
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "@durablex/react-ui",
3
+ "version": "0.1.0-beta.3",
4
+ "description": "Styled, themeable React components for durablex - the shell, run tables, and status views built on @durablex/react.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "homepage": "https://github.com/easydep/durablex#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/easydep/durablex.git",
11
+ "directory": "packages/react-ui"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/easydep/durablex/issues"
15
+ },
16
+ "keywords": [
17
+ "durable",
18
+ "workflow",
19
+ "react",
20
+ "components",
21
+ "ui",
22
+ "durablex"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "bun": "./src/index.ts",
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./styles.css": "./src/styles.css"
32
+ },
33
+ "main": "./dist/index.js",
34
+ "module": "./dist/index.js",
35
+ "types": "./dist/index.d.ts",
36
+ "sideEffects": [
37
+ "**/*.css"
38
+ ],
39
+ "files": [
40
+ "dist",
41
+ "src",
42
+ "LICENSE",
43
+ "NOTICE"
44
+ ],
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "provenance": true
51
+ },
52
+ "peerDependencies": {
53
+ "react": ">=18",
54
+ "react-dom": ">=18"
55
+ },
56
+ "dependencies": {
57
+ "@durablex/react": "0.1.0-beta.3",
58
+ "class-variance-authority": "^0.7.1",
59
+ "clsx": "^2.1.1",
60
+ "lucide-react": "^1.18.0",
61
+ "radix-ui": "^1.5.0",
62
+ "react-resizable-panels": "^4.11.2",
63
+ "recharts": "3.8.0",
64
+ "sonner": "^2.0.7",
65
+ "tailwind-merge": "^3.6.0"
66
+ },
67
+ "scripts": {
68
+ "build": "tsdown",
69
+ "prepublishOnly": "tsdown",
70
+ "typecheck": "tsc --noEmit",
71
+ "test": "vitest run"
72
+ },
73
+ "devDependencies": {
74
+ "@tanstack/react-query": "^5.101.2",
75
+ "@testing-library/dom": "^10.4.1",
76
+ "@testing-library/react": "^16.3.2",
77
+ "@types/react": "^19.2.14",
78
+ "@types/react-dom": "^19.2.3",
79
+ "jsdom": "^29.1.1",
80
+ "react": "^19.2.6",
81
+ "react-dom": "^19.2.6",
82
+ "tsdown": "^0.22.3",
83
+ "typescript": "^5",
84
+ "vitest": "^4.1.9"
85
+ }
86
+ }
@@ -0,0 +1,35 @@
1
+ import { MARK_BARS, RESUME_TRIANGLE } from "./marks-geometry";
2
+
3
+ export function AnimatedDurablexMark({
4
+ size = 22,
5
+ className,
6
+ }: {
7
+ size?: number;
8
+ className?: string;
9
+ }) {
10
+ return (
11
+ <svg
12
+ width={size}
13
+ height={size}
14
+ viewBox="0 0 24 24"
15
+ fill="none"
16
+ className={className ? `dx-mark ${className}` : "dx-mark"}
17
+ role="img"
18
+ aria-label="Durablex: a run crashes, then resumes from its last checkpoint"
19
+ >
20
+ <g className="dx-bars">
21
+ {MARK_BARS.map((b, i) => (
22
+ <rect key={i} {...b} fill="currentColor" />
23
+ ))}
24
+ </g>
25
+ <g className="dx-fault">
26
+ {MARK_BARS.map((b, i) => (
27
+ <rect key={i} {...b} fill="var(--dx-fault)" />
28
+ ))}
29
+ </g>
30
+ <g className="dx-resume">
31
+ <path d={RESUME_TRIANGLE} fill="var(--primary)" />
32
+ </g>
33
+ </svg>
34
+ );
35
+ }
@@ -0,0 +1,17 @@
1
+ import type { AppStatus } from "@durablex/react";
2
+ import { StatusBadge } from "./StatusBadge";
3
+
4
+ const APP_BADGE: Record<
5
+ AppStatus,
6
+ { status: "succeeded" | "running" | "failed" | "cancelled"; label: string }
7
+ > = {
8
+ connected: { status: "succeeded", label: "Connected" },
9
+ syncing: { status: "running", label: "Syncing" },
10
+ error: { status: "failed", label: "Error" },
11
+ disconnected: { status: "cancelled", label: "Disconnected" },
12
+ };
13
+
14
+ export function AppStatusBadge({ status, small }: { status: AppStatus; small?: boolean }) {
15
+ const m = APP_BADGE[status];
16
+ return <StatusBadge status={m.status} label={m.label} small={small} />;
17
+ }
@@ -0,0 +1,17 @@
1
+ import { appHue } from "../lib/app-color";
2
+ import { cn } from "../lib/utils";
3
+
4
+ export function AppTag({ app, className }: { app: string; className?: string }) {
5
+ return (
6
+ <span className={cn("inline-flex items-center gap-[6px] text-xs", className)}>
7
+ <span
8
+ className="size-[9px] shrink-0 border"
9
+ style={{
10
+ backgroundColor: appHue(app),
11
+ borderColor: "color-mix(in oklch, var(--foreground) 18%, transparent)",
12
+ }}
13
+ />
14
+ {app}
15
+ </span>
16
+ );
17
+ }
@@ -0,0 +1,226 @@
1
+ import { useMemo, useState } from "react";
2
+ import { useApps, type AppInfo, type AppStatus, type Runner } from "@durablex/react";
3
+ import { appHue } from "../lib/app-color";
4
+ import { formatRelative } from "../lib/format";
5
+ import { AppStatusBadge } from "./AppStatusBadge";
6
+ import { Meta } from "./Meta";
7
+ import { RunnerLiveBadge } from "./RunnerLiveBadge";
8
+ import { StatusBadge } from "./StatusBadge";
9
+
10
+ interface AppTile {
11
+ label: string;
12
+ value: number;
13
+ sub: string;
14
+ color: string;
15
+ }
16
+
17
+ export function AppsView() {
18
+ const { data: apps, isLoading } = useApps();
19
+
20
+ const tiles = useMemo<AppTile[]>(() => {
21
+ const by = (s: AppStatus) => apps.filter((a) => a.status === s).length;
22
+ const issues = by("error") + by("disconnected");
23
+ return [
24
+ {
25
+ label: "Apps",
26
+ value: apps.length,
27
+ sub: "connected endpoints",
28
+ color: "var(--muted-foreground)",
29
+ },
30
+ {
31
+ label: "Connected",
32
+ value: by("connected"),
33
+ sub: "healthy",
34
+ color: "var(--st-succeeded-fg)",
35
+ },
36
+ { label: "Syncing", value: by("syncing"), sub: "in progress", color: "var(--st-running-fg)" },
37
+ {
38
+ label: "Issues",
39
+ value: issues,
40
+ sub: issues ? "need attention" : "all clear",
41
+ color: issues ? "var(--st-failed-fg)" : "var(--muted-foreground)",
42
+ },
43
+ ];
44
+ }, [apps]);
45
+
46
+ return (
47
+ <div className="content">
48
+ <div className="stats">
49
+ {tiles.map((t) => (
50
+ <div className="stat" key={t.label}>
51
+ <div className="stat-top">
52
+ <i className="stat-dot" style={{ background: t.color }} />
53
+ <span className="stat-label">{t.label}</span>
54
+ </div>
55
+ <div className="stat-val">{t.value}</div>
56
+ <div className="stat-sub">
57
+ <span>{t.sub}</span>
58
+ </div>
59
+ </div>
60
+ ))}
61
+ </div>
62
+
63
+ <div className="tbar">
64
+ <span className="meta">{apps.length} apps</span>
65
+ </div>
66
+
67
+ <div className="tablewrap">
68
+ <table className="runs apps-table">
69
+ <thead>
70
+ <tr>
71
+ <th>App</th>
72
+ <th>Status</th>
73
+ <th className="num">Workflows</th>
74
+ <th className="num">Runs</th>
75
+ <th>Last activity</th>
76
+ </tr>
77
+ </thead>
78
+ <tbody>
79
+ {isLoading && apps.length === 0 ? (
80
+ <tr className="table-status">
81
+ <td colSpan={5}>Loading apps…</td>
82
+ </tr>
83
+ ) : apps.length === 0 ? (
84
+ <tr className="table-status">
85
+ <td colSpan={5}>No apps yet. Start a runner to register one via the SDK.</td>
86
+ </tr>
87
+ ) : (
88
+ apps.map((app) => <AppRow key={app.name} app={app} />)
89
+ )}
90
+ </tbody>
91
+ </table>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ function AppRow({ app }: { app: AppInfo }) {
98
+ const [open, setOpen] = useState(false);
99
+
100
+ return (
101
+ <>
102
+ <tr className="row app-row" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
103
+ <td>
104
+ <div className="app-cell">
105
+ <ChevronGlyph />
106
+ <i className="sq" style={{ background: appHue(app.name) }} />
107
+ <div className="app-id">
108
+ <span className="nm">{app.name}</span>
109
+ </div>
110
+ </div>
111
+ </td>
112
+ <td>
113
+ <AppStatusBadge status={app.status} />
114
+ </td>
115
+ <td className="num">
116
+ <span className="dur">{app.workflowCount}</span>
117
+ </td>
118
+ <td className="num">
119
+ <span className="dur">{app.runCount}</span>
120
+ </td>
121
+ <td>
122
+ <span className="ts cell-mut">
123
+ {app.lastSyncAt ? formatRelative(app.lastSyncAt) : "-"}
124
+ </span>
125
+ </td>
126
+ </tr>
127
+ {open && (
128
+ <tr className="app-detailrow">
129
+ <td colSpan={5}>
130
+ <div className="app-detail-list">
131
+ <div className="app-sub">Synced workflows · {app.workflows.length}</div>
132
+ {app.workflows.length ? (
133
+ app.workflows.map((w) => (
134
+ <div className="app-wf" key={w.name}>
135
+ <span className="app-wf-name">{w.name}</span>
136
+ {w.lastStatus ? (
137
+ <StatusBadge status={w.lastStatus} small />
138
+ ) : (
139
+ <span className="cell-mut" style={{ fontSize: 11 }}>
140
+ no runs
141
+ </span>
142
+ )}
143
+ </div>
144
+ ))
145
+ ) : (
146
+ <div className="cell-mut" style={{ fontSize: 12, padding: "4px 0" }}>
147
+ No workflows registered.
148
+ </div>
149
+ )}
150
+
151
+ <div className="app-sub">Connected runners · {app.runners.length}</div>
152
+ {app.runners.length ? (
153
+ app.runners.map((rn) => <RunnerRow key={rn.runnerId} runner={rn} />)
154
+ ) : (
155
+ <div className="cell-mut" style={{ fontSize: 12, padding: "4px 0" }}>
156
+ No runners connected.
157
+ </div>
158
+ )}
159
+ </div>
160
+ </td>
161
+ </tr>
162
+ )}
163
+ </>
164
+ );
165
+ }
166
+
167
+ function RunnerRow({ runner }: { runner: Runner }) {
168
+ const rows = runnerMetaRows(runner);
169
+ return (
170
+ <div className="runner-block">
171
+ <div className="app-wf">
172
+ <span className="app-wf-name" title={runner.url}>
173
+ {runner.runnerId}
174
+ </span>
175
+ <span className="runner-row-end">
176
+ <span className="cell-mut" style={{ fontSize: 11 }}>
177
+ {runner.lastSeenAt ? formatRelative(runner.lastSeenAt) : "-"}
178
+ </span>
179
+ <RunnerLiveBadge live={runner.live} small />
180
+ </span>
181
+ </div>
182
+ {rows.length > 0 && (
183
+ <div className="metagrid runner-meta">
184
+ {rows.map((r) => (
185
+ <Meta key={r.label} label={r.label} value={r.value} />
186
+ ))}
187
+ </div>
188
+ )}
189
+ </div>
190
+ );
191
+ }
192
+
193
+ // runnerMetaRows builds the handshake fields a runner actually reported - absent
194
+ // ones are dropped rather than shown blank (no mock fields).
195
+ function runnerMetaRows(r: Runner): { label: string; value: string }[] {
196
+ const rows: { label: string; value: string }[] = [];
197
+ if (r.framework) rows.push({ label: "framework", value: r.framework });
198
+ if (r.runtime) rows.push({ label: "runtime", value: r.runtime });
199
+ const sdk = [r.sdkName, r.version].filter(Boolean).join(" ");
200
+ if (sdk) rows.push({ label: "sdk", value: sdk });
201
+ if (r.region) rows.push({ label: "region", value: r.region });
202
+ if (r.keyFingerprint) {
203
+ const match =
204
+ r.keyMatches === true ? " (matches)" : r.keyMatches === false ? " (mismatch)" : "";
205
+ rows.push({ label: "key", value: r.keyFingerprint + match });
206
+ }
207
+ if (r.url) rows.push({ label: "endpoint", value: r.url });
208
+ return rows;
209
+ }
210
+
211
+ function ChevronGlyph() {
212
+ return (
213
+ <svg
214
+ className="app-chev"
215
+ viewBox="0 0 24 24"
216
+ fill="none"
217
+ stroke="currentColor"
218
+ strokeWidth={1.8}
219
+ strokeLinecap="round"
220
+ strokeLinejoin="round"
221
+ aria-hidden="true"
222
+ >
223
+ <path d="m9 6 6 6-6 6" />
224
+ </svg>
225
+ );
226
+ }
@@ -0,0 +1,52 @@
1
+ import { Loader2, RotateCcw } from "lucide-react";
2
+ import { toast } from "sonner";
3
+ import { useBulkReplay, type BulkReplayFilter } from "@durablex/react";
4
+ import { useConfirmAction } from "../hooks/use-confirm-action";
5
+
6
+ // Two-click confirm since it forks every matching finished run at once; the engine
7
+ // caps the fan-out and reports the outcome counts.
8
+ export function BulkReplayButton({ filter }: { filter: BulkReplayFilter }) {
9
+ const bulk = useBulkReplay();
10
+ const run = () =>
11
+ bulk.mutate(filter, {
12
+ onSuccess: (res) =>
13
+ toast.success(`Replayed ${res.replayed} run${res.replayed === 1 ? "" : "s"}`, {
14
+ description: res.capped
15
+ ? "Only the newest matches were replayed; narrow the filter."
16
+ : undefined,
17
+ }),
18
+ onError: (err) => toast.error(`Bulk replay failed: ${err.message}`),
19
+ });
20
+ const { confirming, trigger } = useConfirmAction(run);
21
+
22
+ if (bulk.isPending) {
23
+ return (
24
+ <span className="fb-btn" aria-disabled>
25
+ <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" /> Replaying
26
+ </span>
27
+ );
28
+ }
29
+
30
+ if (bulk.isSuccess) {
31
+ const r = bulk.data;
32
+ const title = `matched ${r.matched}, skipped ${r.skipped}, failed ${r.failed}${r.capped ? ", capped" : ""}`;
33
+ return (
34
+ <span className="fb-meta" title={title}>
35
+ Replayed {r.replayed}
36
+ {r.capped ? "+" : ""}
37
+ </span>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <button
43
+ type="button"
44
+ className="fb-btn focusable"
45
+ title="Replay all finished runs matching the current filter"
46
+ onClick={trigger}
47
+ >
48
+ <RotateCcw style={{ width: 12, height: 12 }} />{" "}
49
+ {confirming ? "Confirm replay" : "Replay matching"}
50
+ </button>
51
+ );
52
+ }
@@ -0,0 +1,50 @@
1
+ import { ChevronLeft, ChevronRight } from "lucide-react";
2
+
3
+ interface CursorPagerProps {
4
+ rangeStart: number;
5
+ rangeEnd: number;
6
+ canNewer: boolean;
7
+ canOlder: boolean;
8
+ onNewer(): void;
9
+ onOlder(): void;
10
+ }
11
+
12
+ export function CursorPager({
13
+ rangeStart,
14
+ rangeEnd,
15
+ canNewer,
16
+ canOlder,
17
+ onNewer,
18
+ onOlder,
19
+ }: CursorPagerProps) {
20
+ if (rangeEnd === 0) return null;
21
+
22
+ return (
23
+ <div className="pager">
24
+ <span className="pager-range">
25
+ {rangeStart}-{rangeEnd}
26
+ </span>
27
+ <span className="fb-spacer" />
28
+ <div className="pager-nav">
29
+ <button
30
+ type="button"
31
+ className="pg-btn focusable"
32
+ disabled={!canNewer}
33
+ aria-label="Newer runs"
34
+ onClick={onNewer}
35
+ >
36
+ <ChevronLeft />
37
+ </button>
38
+ <button
39
+ type="button"
40
+ className="pg-btn focusable"
41
+ disabled={!canOlder}
42
+ aria-label="Older runs"
43
+ onClick={onOlder}
44
+ >
45
+ <ChevronRight />
46
+ </button>
47
+ </div>
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,187 @@
1
+ import { Inbox, RotateCw, Search, Send, X } from "lucide-react";
2
+ import { useMemo, useState } from "react";
3
+ import type { WebhookDelivery } from "@durablex/react";
4
+ import { useDeliveries, useEndpoints } from "@durablex/react";
5
+ import { formatRelative } from "../lib/format";
6
+ import {
7
+ DELIVERY_LIST_LIMIT,
8
+ type DeliveryView,
9
+ deliveryView,
10
+ endpointMeta,
11
+ groupDeliveriesByEndpoint,
12
+ shortUrl,
13
+ } from "../lib/webhook-view";
14
+ import { CodePill, DeliveryBadge } from "./WebhookBadges";
15
+ import { DeliveryDetail } from "./DeliveryDetail";
16
+
17
+ const SPLIT_FILTERS: [DeliveryView | "all", string][] = [
18
+ ["all", "All"],
19
+ ["delivered", "Delivered"],
20
+ ["retrying", "Retrying"],
21
+ ["failed", "Failed"],
22
+ ["queued", "Queued"],
23
+ ];
24
+
25
+ function DeliveryListItem({
26
+ d,
27
+ label,
28
+ failing,
29
+ selected,
30
+ onClick,
31
+ }: {
32
+ d: WebhookDelivery;
33
+ label: string;
34
+ failing: boolean;
35
+ selected: boolean;
36
+ onClick(): void;
37
+ }) {
38
+ const view = deliveryView(d);
39
+ return (
40
+ <button
41
+ type="button"
42
+ className="whs-item"
43
+ data-selected={selected ? "1" : "0"}
44
+ onClick={onClick}
45
+ >
46
+ <span className="whs-accent" />
47
+ <div className="whs-item-main">
48
+ <div className="whs-item-top">
49
+ <span className="whs-ev">{d.eventKind}</span>
50
+ <span className="whs-time">{formatRelative(d.createdAt)}</span>
51
+ </div>
52
+ <div className="whs-item-bot">
53
+ <DeliveryBadge view={view} small />
54
+ <span className="whs-ep">
55
+ <i
56
+ className="sq"
57
+ style={{ background: failing ? "var(--st-failed-fg)" : "var(--primary)" }}
58
+ />
59
+ {label}
60
+ </span>
61
+ {d.attemptCount > 1 && (
62
+ <span
63
+ className={
64
+ "whs-att-tag" + (view === "failed" ? " err" : view === "retrying" ? " warn" : "")
65
+ }
66
+ >
67
+ <RotateCw />
68
+ {d.attemptCount}/{d.maxAttempts}
69
+ </span>
70
+ )}
71
+ <CodePill code={d.lastStatusCode} />
72
+ </div>
73
+ </div>
74
+ </button>
75
+ );
76
+ }
77
+
78
+ export function DeliveriesSplit({ onOpenRun }: { onOpenRun?: (runId: string) => void }) {
79
+ const { data } = useDeliveries({ limit: DELIVERY_LIST_LIMIT });
80
+ const endpointsData = useEndpoints().data;
81
+ const endpoints = useMemo(() => endpointsData ?? [], [endpointsData]);
82
+ const all = useMemo(() => data?.deliveries ?? [], [data]);
83
+ const [view, setView] = useState<DeliveryView | "all">("all");
84
+ const [query, setQuery] = useState("");
85
+
86
+ const byEndpoint = useMemo(() => groupDeliveriesByEndpoint(all), [all]);
87
+
88
+ const rows = useMemo(() => {
89
+ let r = all;
90
+ if (view !== "all") r = r.filter((d) => deliveryView(d) === view);
91
+ const q = query.trim().toLowerCase();
92
+ if (q) {
93
+ r = r.filter((d) => {
94
+ const { label } = endpointMeta(d, endpoints, byEndpoint);
95
+ return (
96
+ d.eventKind.toLowerCase().includes(q) ||
97
+ d.id.toLowerCase().includes(q) ||
98
+ shortUrl(d.url).toLowerCase().includes(q) ||
99
+ label.toLowerCase().includes(q)
100
+ );
101
+ });
102
+ }
103
+ return r;
104
+ }, [all, endpoints, byEndpoint, view, query]);
105
+
106
+ const [selId, setSelId] = useState<string | null>(null);
107
+ const sel = rows.find((d) => d.id === selId) ?? rows[0] ?? null;
108
+
109
+ return (
110
+ <div className="whs">
111
+ <div className="whs-list">
112
+ <div className="whs-toolbar">
113
+ <div className="fb-search-field whs-search2">
114
+ <Search />
115
+ <input
116
+ placeholder="Search deliveries…"
117
+ value={query}
118
+ onChange={(e) => setQuery(e.target.value)}
119
+ onKeyDown={(e) => {
120
+ if (e.key === "Escape") setQuery("");
121
+ }}
122
+ />
123
+ {query && (
124
+ <span className="x" onClick={() => setQuery("")} title="Clear">
125
+ <X style={{ width: 12, height: 12 }} />
126
+ </span>
127
+ )}
128
+ </div>
129
+ <div className="whs-chips">
130
+ {SPLIT_FILTERS.map(([k, label]) => (
131
+ <button
132
+ key={k}
133
+ type="button"
134
+ className="chip"
135
+ data-on={view === k ? "1" : "0"}
136
+ onClick={() => setView(k)}
137
+ >
138
+ {label}
139
+ </button>
140
+ ))}
141
+ </div>
142
+ <div className="whs-count">
143
+ {rows.length === all.length
144
+ ? `${all.length} deliveries`
145
+ : `${rows.length} of ${all.length} deliveries`}
146
+ </div>
147
+ </div>
148
+ <div className="whs-scroll">
149
+ {rows.length ? (
150
+ rows.map((d) => {
151
+ const { label, failing } = endpointMeta(d, endpoints, byEndpoint);
152
+ return (
153
+ <DeliveryListItem
154
+ key={d.id}
155
+ d={d}
156
+ label={label}
157
+ failing={failing}
158
+ selected={sel?.id === d.id}
159
+ onClick={() => setSelId(d.id)}
160
+ />
161
+ );
162
+ })
163
+ ) : (
164
+ <div className="whs-empty">
165
+ <Inbox className="ico" />
166
+ <span>No deliveries match.</span>
167
+ </div>
168
+ )}
169
+ </div>
170
+ </div>
171
+
172
+ <div className="whs-detail">
173
+ {sel ? (
174
+ <DeliveryDetail key={sel.id} deliveryId={sel.id} embedded onOpenRun={onOpenRun} />
175
+ ) : (
176
+ <div className="panel-empty">
177
+ <div>
178
+ <Send className="mx-auto mb-2.5 size-6 opacity-60" />
179
+ <div className="text-foreground mb-1 font-semibold">No delivery selected</div>
180
+ <div>Pick a delivery from the list to inspect its payload and attempts.</div>
181
+ </div>
182
+ </div>
183
+ )}
184
+ </div>
185
+ </div>
186
+ );
187
+ }