@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,180 @@
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import { ZapIcon } from "lucide-react";
3
+ import {
4
+ useTriggerEvent,
5
+ useWorkflows,
6
+ type SendEventResult,
7
+ type WorkflowDef,
8
+ } from "@durablex/react";
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogFooter,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ DialogTrigger,
17
+ } from "../ui/dialog";
18
+ import { Button } from "../ui/button";
19
+ import { Input } from "../ui/input";
20
+ import { parseJson } from "../lib/json-highlight";
21
+ import { ALL_FILTER } from "../lib/run-filters";
22
+ import { AppFilter } from "./filters/AppFilter";
23
+ import { JsonEditor } from "./JsonEditor";
24
+ import { TriggerEventResult } from "./TriggerEventResult";
25
+
26
+ export function TriggerEventDialog({ onOpenRun }: { onOpenRun?: (runId: string) => void }) {
27
+ const [open, setOpen] = useState(false);
28
+
29
+ return (
30
+ <Dialog open={open} onOpenChange={setOpen}>
31
+ <DialogTrigger asChild>
32
+ <Button size="sm" variant="outline">
33
+ <ZapIcon /> Trigger event
34
+ </Button>
35
+ </DialogTrigger>
36
+ <DialogContent className="sm:max-w-md">
37
+ {open && <TriggerForm onClose={() => setOpen(false)} onOpenRun={onOpenRun} />}
38
+ </DialogContent>
39
+ </Dialog>
40
+ );
41
+ }
42
+
43
+ // Concrete (non-wildcard) event names a workflow can be triggered by, for the
44
+ // event-name autocomplete - a workflow with no triggers fires on its own name.
45
+ function knownEventNames(workflows: WorkflowDef[] | undefined): string[] {
46
+ const names = new Set<string>();
47
+ for (const w of workflows ?? []) {
48
+ const triggers = w.triggers ?? [];
49
+ if (triggers.length === 0) names.add(w.name);
50
+ for (const t of triggers) {
51
+ if (t.event && !t.event.endsWith("*")) names.add(t.event);
52
+ }
53
+ }
54
+ return [...names].sort();
55
+ }
56
+
57
+ function TriggerForm({
58
+ onClose,
59
+ onOpenRun,
60
+ }: {
61
+ onClose: () => void;
62
+ onOpenRun?: (runId: string) => void;
63
+ }) {
64
+ const { data: workflows } = useWorkflows();
65
+ const eventNames = useMemo(() => knownEventNames(workflows), [workflows]);
66
+
67
+ const [name, setName] = useState("");
68
+ const [app, setApp] = useState<string>(ALL_FILTER);
69
+ const [payload, setPayload] = useState("{}");
70
+ const [result, setResult] = useState<SendEventResult | null>(null);
71
+ const fire = useTriggerEvent();
72
+
73
+ const parsed = useMemo(() => parseJson(payload), [payload]);
74
+ const canSubmit = name.trim() !== "" && parsed.ok && !fire.isPending;
75
+
76
+ function submit() {
77
+ if (!parsed.ok) return;
78
+ fire.mutate(
79
+ { name: name.trim(), app: app === ALL_FILTER ? undefined : app, data: parsed.value },
80
+ { onSuccess: setResult },
81
+ );
82
+ }
83
+
84
+ return (
85
+ <>
86
+ <DialogHeader>
87
+ <DialogTitle>Trigger event</DialogTitle>
88
+ <DialogDescription>
89
+ Fire an event into the engine. Every workflow whose trigger matches the name runs.
90
+ </DialogDescription>
91
+ </DialogHeader>
92
+
93
+ <div className="flex flex-col gap-3">
94
+ <Field label="Event name" htmlFor="trigger-name">
95
+ <Input
96
+ id="trigger-name"
97
+ list="trigger-event-names"
98
+ value={name}
99
+ onChange={(e) => setName(e.target.value)}
100
+ placeholder="order.placed"
101
+ autoFocus
102
+ />
103
+ <datalist id="trigger-event-names">
104
+ {eventNames.map((n) => (
105
+ <option key={n} value={n} />
106
+ ))}
107
+ </datalist>
108
+ </Field>
109
+
110
+ <div className="flex flex-col gap-1.5">
111
+ <span className="text-xs font-medium">App</span>
112
+ <div>
113
+ <AppFilter app={app} onApp={setApp} />
114
+ </div>
115
+ <span className="text-muted-foreground text-[11px]">
116
+ "All apps" fires the event namespace-wide.
117
+ </span>
118
+ </div>
119
+
120
+ <Field label="Payload" htmlFor="trigger-payload" hint="JSON">
121
+ <JsonEditor
122
+ value={payload}
123
+ onChange={setPayload}
124
+ invalid={!parsed.ok}
125
+ ariaLabel="Event payload"
126
+ />
127
+ {!parsed.ok ? <span className="text-xs text-destructive">{parsed.error}</span> : null}
128
+ </Field>
129
+
130
+ {result ? (
131
+ <TriggerEventResult
132
+ result={result}
133
+ onOpenRun={
134
+ onOpenRun
135
+ ? (id) => {
136
+ onClose();
137
+ onOpenRun(id);
138
+ }
139
+ : undefined
140
+ }
141
+ />
142
+ ) : null}
143
+ {fire.isError ? (
144
+ <span className="text-xs text-destructive">
145
+ Could not fire event:{" "}
146
+ {fire.error instanceof Error ? fire.error.message : "unknown error"}
147
+ </span>
148
+ ) : null}
149
+ </div>
150
+
151
+ <DialogFooter showCloseButton>
152
+ <Button disabled={!canSubmit} onClick={submit}>
153
+ {fire.isPending ? "Firing..." : "Fire event"}
154
+ </Button>
155
+ </DialogFooter>
156
+ </>
157
+ );
158
+ }
159
+
160
+ function Field({
161
+ label,
162
+ htmlFor,
163
+ hint,
164
+ children,
165
+ }: {
166
+ label: string;
167
+ htmlFor: string;
168
+ hint?: string;
169
+ children: ReactNode;
170
+ }) {
171
+ return (
172
+ <div className="flex flex-col gap-1.5">
173
+ <label htmlFor={htmlFor} className="flex items-center gap-1.5 text-xs font-medium">
174
+ {label}
175
+ {hint ? <span className="font-normal text-muted-foreground">{hint}</span> : null}
176
+ </label>
177
+ {children}
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,61 @@
1
+ import type { SendEventResult, TriggeredOutcome } from "@durablex/react";
2
+
3
+ function triggerStatus(o: TriggeredOutcome): string {
4
+ if (o.dropped) return "dropped";
5
+ if (o.deduped) return "deduped";
6
+ if (o.debounced) return "debounced";
7
+ if (o.batched) return "batched";
8
+ if (o.skipped) return "skipped";
9
+ return "started";
10
+ }
11
+
12
+ function TriggerRow({
13
+ t,
14
+ onOpenRun,
15
+ }: {
16
+ t: TriggeredOutcome;
17
+ onOpenRun?: (runId: string) => void;
18
+ }) {
19
+ const status = triggerStatus(t);
20
+ const runId = t.runId;
21
+ return (
22
+ <li className="flex items-center justify-between gap-2 font-mono">
23
+ <span className="truncate">{t.workflow}</span>
24
+ {runId && onOpenRun ? (
25
+ <button
26
+ type="button"
27
+ className="text-primary hover:underline"
28
+ onClick={() => onOpenRun(runId)}
29
+ >
30
+ {status}
31
+ </button>
32
+ ) : (
33
+ <span className="text-muted-foreground">{status}</span>
34
+ )}
35
+ </li>
36
+ );
37
+ }
38
+
39
+ export function TriggerEventResult({
40
+ result,
41
+ onOpenRun,
42
+ }: {
43
+ result: SendEventResult;
44
+ onOpenRun?: (runId: string) => void;
45
+ }) {
46
+ const triggered = result.triggered ?? [];
47
+ return (
48
+ <div className="rounded-lg border border-border bg-muted/40 p-2.5 text-xs">
49
+ <div className="font-medium text-foreground">
50
+ Event accepted · woke {result.woke} {result.woke === 1 ? "run" : "runs"}
51
+ </div>
52
+ {triggered.length > 0 ? (
53
+ <ul className="mt-1.5 flex flex-col gap-1">
54
+ {triggered.map((t, i) => (
55
+ <TriggerRow key={`${t.workflow}-${i}`} t={t} onOpenRun={onOpenRun} />
56
+ ))}
57
+ </ul>
58
+ ) : null}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,69 @@
1
+ import { cn } from "../lib/utils";
2
+ import {
3
+ codePillClass,
4
+ DELIVERY_VIEW_LABEL,
5
+ DELIVERY_VIEW_TOKEN,
6
+ type DeliveryView,
7
+ type EndpointHealth,
8
+ } from "../lib/webhook-view";
9
+ import { ResumeMark } from "./ResumeMark";
10
+
11
+ function StatusPill({
12
+ token,
13
+ label,
14
+ small,
15
+ live,
16
+ }: {
17
+ token: string;
18
+ label: string;
19
+ small?: boolean;
20
+ live?: boolean;
21
+ }) {
22
+ return (
23
+ <span
24
+ className={cn(
25
+ "inline-flex items-center gap-1.5 border border-transparent font-medium leading-none whitespace-nowrap",
26
+ small ? "h-[17px] px-1.5 text-[10px]" : "h-[19px] px-1.5 text-[11px]",
27
+ )}
28
+ style={{ backgroundColor: `var(--st-${token}-bg)`, color: `var(--st-${token}-fg)` }}
29
+ >
30
+ {live ? <ResumeMark size={small ? 12 : 13} variant="run" /> : <i className="bdot" />}
31
+ {label}
32
+ </span>
33
+ );
34
+ }
35
+
36
+ export function DeliveryBadge({ view, small }: { view: DeliveryView; small?: boolean }) {
37
+ return (
38
+ <StatusPill
39
+ token={DELIVERY_VIEW_TOKEN[view]}
40
+ label={DELIVERY_VIEW_LABEL[view]}
41
+ small={small}
42
+ live={view === "retrying"}
43
+ />
44
+ );
45
+ }
46
+
47
+ const HEALTH_TOKEN: Record<EndpointHealth, string> = {
48
+ active: "succeeded",
49
+ failing: "failed",
50
+ disabled: "cancelled",
51
+ };
52
+ const HEALTH_LABEL: Record<EndpointHealth, string> = {
53
+ active: "Active",
54
+ failing: "Failing",
55
+ disabled: "Disabled",
56
+ };
57
+
58
+ export function EndpointBadge({ health, small }: { health: EndpointHealth; small?: boolean }) {
59
+ return <StatusPill token={HEALTH_TOKEN[health]} label={HEALTH_LABEL[health]} small={small} />;
60
+ }
61
+
62
+ export function CodePill({ code }: { code?: number }) {
63
+ const k = codePillClass(code);
64
+ return <span className={`wh-code ${k}`}>{k === "none" ? "-" : code}</span>;
65
+ }
66
+
67
+ export function EventChip({ name }: { name: string }) {
68
+ return <span className="wh-evchip">{name === "*" ? "all events" : name}</span>;
69
+ }
@@ -0,0 +1,25 @@
1
+ import type { DeliveryStatus } from "@durablex/react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ // failed = awaiting retry, exhausted = retries spent, dead = a non-retryable response.
5
+ const STATUS_CLASS: Record<DeliveryStatus, string> = {
6
+ pending: "text-muted-foreground border-border",
7
+ delivering: "border-sky-500/40 text-sky-600 dark:text-sky-400",
8
+ succeeded: "border-emerald-500/40 text-emerald-600 dark:text-emerald-400",
9
+ failed: "border-amber-500/40 text-amber-600 dark:text-amber-400",
10
+ exhausted: "border-red-500/40 text-red-600 dark:text-red-400",
11
+ dead: "border-red-500/40 text-red-600 dark:text-red-400",
12
+ };
13
+
14
+ export function WebhookStatusBadge({ status }: { status: DeliveryStatus }) {
15
+ return (
16
+ <span
17
+ className={cn(
18
+ "inline-flex border px-1.5 py-0.5 font-mono text-[10px] leading-none",
19
+ STATUS_CLASS[status],
20
+ )}
21
+ >
22
+ {status}
23
+ </span>
24
+ );
25
+ }
@@ -0,0 +1,69 @@
1
+ import { Globe, Inbox, Send } from "lucide-react";
2
+ import type { ComponentType } from "react";
3
+ import { useState } from "react";
4
+ import { DeliveriesSplit } from "./DeliveriesSplit";
5
+ import { EndpointsTab } from "./EndpointsTab";
6
+ import { ReceiversTab } from "./ReceiversTab";
7
+ import { useEndpoints, useReceivers } from "@durablex/react";
8
+ import type { WebhookTab } from "../lib/webhook-view";
9
+
10
+ const TABS = [
11
+ { key: "deliveries", label: "Deliveries", Icon: Send },
12
+ { key: "endpoints", label: "Endpoints", Icon: Globe },
13
+ { key: "receivers", label: "Receivers", Icon: Inbox },
14
+ ] as const;
15
+
16
+ export function WebhooksView({ onOpenRun }: { onOpenRun?: (runId: string) => void }) {
17
+ const [tab, setTab] = useState<WebhookTab>("deliveries");
18
+ const endpoints = useEndpoints().data?.length;
19
+ const receivers = useReceivers().data?.length;
20
+ const counts: Record<WebhookTab, number | undefined> = {
21
+ deliveries: undefined,
22
+ endpoints,
23
+ receivers,
24
+ };
25
+
26
+ return (
27
+ <div className="wh-root">
28
+ <div className="wh-subnav" role="tablist">
29
+ {TABS.map(({ key, label, Icon }) => (
30
+ <WebhookTab
31
+ key={key}
32
+ label={label}
33
+ Icon={Icon}
34
+ count={counts[key]}
35
+ selected={tab === key}
36
+ onSelect={() => setTab(key)}
37
+ />
38
+ ))}
39
+ </div>
40
+ <div className="wh-body">
41
+ {tab === "deliveries" && <DeliveriesSplit onOpenRun={onOpenRun} />}
42
+ {tab === "endpoints" && <EndpointsTab />}
43
+ {tab === "receivers" && <ReceiversTab />}
44
+ </div>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ function WebhookTab({
50
+ label,
51
+ Icon,
52
+ count,
53
+ selected,
54
+ onSelect,
55
+ }: {
56
+ label: string;
57
+ Icon: ComponentType<{ className?: string }>;
58
+ count?: number;
59
+ selected: boolean;
60
+ onSelect(): void;
61
+ }) {
62
+ return (
63
+ <button type="button" className="wh-tab" role="tab" aria-selected={selected} onClick={onSelect}>
64
+ <Icon className="wh-tab-ico" />
65
+ <span>{label}</span>
66
+ {count != null && <span className="wh-count tabular-nums">{count}</span>}
67
+ </button>
68
+ );
69
+ }
@@ -0,0 +1,149 @@
1
+ import { X } from "lucide-react";
2
+ import { useState, type ReactNode } from "react";
3
+ import { useRuns, useRunStats, useRunTimeSeries, type WorkflowDef } from "@durablex/react";
4
+ import { CursorPager } from "./CursorPager";
5
+ import { Meta } from "./Meta";
6
+ import { RunsTable } from "./RunsTable";
7
+ import { StatsTiles } from "./StatsTiles";
8
+ import { SectionHeader } from "./SectionHeader";
9
+ import { AppTag } from "./AppTag";
10
+ import { ScheduledBadge } from "./ScheduledBadge";
11
+ import { RunCharts } from "./charts/RunCharts";
12
+ import { FlowControlSection } from "./FlowControlSection";
13
+ import { TimeRangeFilter } from "./filters/TimeRangeFilter";
14
+ import { useKeysetPager } from "../hooks/use-keyset-pager";
15
+ import { hasFlowControl } from "../lib/flow-control";
16
+ import { formatNextFire, formatTime } from "../lib/format";
17
+ import { DEFAULT_RUN_SORT, type RunSortKey, toggleRunSort } from "../lib/run-sort";
18
+ import { DEFAULT_TIME_RANGE, seriesBucketSeconds, timeLabel, windowSince } from "../lib/time-range";
19
+
20
+ const RUNS_PAGE_SIZE = 30;
21
+
22
+ export function WorkflowDetail({
23
+ workflow,
24
+ onClose,
25
+ onOpenRun,
26
+ renderRunAction,
27
+ }: {
28
+ workflow: WorkflowDef;
29
+ onClose(): void;
30
+ onOpenRun(runId: string): void;
31
+ renderRunAction?: (workflow: WorkflowDef) => ReactNode;
32
+ }) {
33
+ const [time, setTime] = useState(DEFAULT_TIME_RANGE);
34
+ const [sort, setSort] = useState(DEFAULT_RUN_SORT);
35
+ const onSort = (key: RunSortKey) => setSort((prev) => toggleRunSort(prev, key));
36
+
37
+ const since = windowSince(time);
38
+ const bucket = seriesBucketSeconds(time);
39
+ const scope = { app: workflow.app, workflow: workflow.name };
40
+
41
+ // Reset to the newest page whenever the time range or sort changes.
42
+ const { cursor, canNewer, range, goOlder, goNewer } = useKeysetPager(
43
+ `${time}|${sort.key}-${sort.dir}`,
44
+ RUNS_PAGE_SIZE,
45
+ );
46
+
47
+ const stats = useRunStats({ ...scope, since });
48
+ // Concurrency in-flight/queued are point-in-time, so they read an unwindowed count
49
+ // (a run executing longer than the selected range still counts as in flight).
50
+ const liveStats = useRunStats(scope);
51
+ const { data: series, isLoading: seriesLoading } = useRunTimeSeries({ ...scope, since, bucket });
52
+ const runs = useRuns({
53
+ ...scope,
54
+ since,
55
+ sort: sort.key,
56
+ dir: sort.dir,
57
+ limit: RUNS_PAGE_SIZE,
58
+ cursor,
59
+ });
60
+ const rows = runs.data?.runs ?? [];
61
+ const nextCursor = runs.data?.nextCursor ?? null;
62
+ const { start: rangeStart, end: rangeEnd } = range(rows.length);
63
+ const schedule = workflow.scheduled ? workflow.schedules?.[0] : undefined;
64
+
65
+ return (
66
+ <div className="run-panel">
67
+ <div className="panel-head">
68
+ <div className="ph-top">
69
+ <h2>
70
+ <span className="h2-name">{workflow.name}</span>
71
+ </h2>
72
+ <div className="ph-actions">
73
+ {renderRunAction?.(workflow)}
74
+ <button
75
+ type="button"
76
+ className="iconbtn focusable"
77
+ aria-label="Close"
78
+ onClick={onClose}
79
+ >
80
+ <X />
81
+ </button>
82
+ </div>
83
+ </div>
84
+ <div className="ph-status">
85
+ <AppTag app={workflow.app} />
86
+ {schedule && (
87
+ <span className="step-cell">
88
+ <ScheduledBadge compact />
89
+ <span className="font-mono text-xs">{schedule.cron}</span>
90
+ </span>
91
+ )}
92
+ </div>
93
+ </div>
94
+
95
+ <div className="panel-body">
96
+ <div className="metagrid">
97
+ <Meta label="App" value={workflow.app} />
98
+ <Meta label="Max attempts" value={String(workflow.maxAttempts)} />
99
+ <Meta label="Backoff" value={workflow.backoff} />
100
+ <Meta label="Schedule" value={schedule ? schedule.cron : "Not scheduled"} />
101
+ {schedule && <Meta label="Next fire" value={formatNextFire(schedule.nextFireAt)} />}
102
+ <Meta label="Registered" value={formatTime(workflow.registeredAt)} />
103
+ <Meta label="Updated" value={formatTime(workflow.updatedAt)} />
104
+ </div>
105
+
106
+ {hasFlowControl(workflow.flowControl) && workflow.flowControl && (
107
+ <FlowControlSection
108
+ fc={workflow.flowControl}
109
+ app={workflow.app}
110
+ workflow={workflow.name}
111
+ stats={liveStats.data}
112
+ />
113
+ )}
114
+
115
+ <StatsTiles stats={stats.data} rangeLabel={timeLabel(time)} />
116
+ <div className="filterbar">
117
+ <TimeRangeFilter time={time} onTime={setTime} />
118
+ </div>
119
+
120
+ <RunCharts series={series} loading={seriesLoading} />
121
+
122
+ <SectionHeader>Runs</SectionHeader>
123
+ <RunsTable
124
+ rows={rows}
125
+ selectedId={null}
126
+ onSelect={onOpenRun}
127
+ sort={sort}
128
+ onSort={onSort}
129
+ loading={runs.isLoading}
130
+ isError={runs.isError}
131
+ error={runs.error}
132
+ emptyTitle="No runs yet"
133
+ emptyMessage="This workflow has no runs in the selected window."
134
+ hideColumns={["workflow", "app"]}
135
+ />
136
+ {!runs.isLoading && rows.length > 0 && (
137
+ <CursorPager
138
+ rangeStart={rangeStart}
139
+ rangeEnd={rangeEnd}
140
+ canNewer={canNewer}
141
+ canOlder={nextCursor != null}
142
+ onNewer={goNewer}
143
+ onOlder={() => goOlder(nextCursor)}
144
+ />
145
+ )}
146
+ </div>
147
+ </div>
148
+ );
149
+ }
@@ -0,0 +1,46 @@
1
+ import { useState } from "react";
2
+ import { Play } from "lucide-react";
3
+ import type { WorkflowDef } from "@durablex/react";
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
5
+ import { workflowRunPlan } from "../lib/workflow-run";
6
+ import { WorkflowRunDialog } from "./WorkflowRunDialog";
7
+
8
+ export function WorkflowRunAction({
9
+ workflow,
10
+ onOpenRun,
11
+ }: {
12
+ workflow: WorkflowDef;
13
+ onOpenRun(runId: string): void;
14
+ }) {
15
+ const [open, setOpen] = useState(false);
16
+
17
+ if (!workflowRunPlan(workflow).runnable) {
18
+ return (
19
+ <Tooltip>
20
+ <TooltipTrigger asChild>
21
+ <span className="btn" aria-disabled>
22
+ <Play /> Run
23
+ </span>
24
+ </TooltipTrigger>
25
+ <TooltipContent>
26
+ This workflow only runs on a schedule, so there is no event to fire. Manual fire-now lands
27
+ in a later release.
28
+ </TooltipContent>
29
+ </Tooltip>
30
+ );
31
+ }
32
+
33
+ return (
34
+ <>
35
+ <button type="button" className="btn focusable" onClick={() => setOpen(true)}>
36
+ <Play /> Run
37
+ </button>
38
+ <WorkflowRunDialog
39
+ workflow={workflow}
40
+ open={open}
41
+ onOpenChange={setOpen}
42
+ onOpenRun={onOpenRun}
43
+ />
44
+ </>
45
+ );
46
+ }