@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,34 @@
1
+ import type { LogFrame, TimelineFrame } from "@durablex/react";
2
+
3
+ export const ROOT_SCOPE = "@root";
4
+
5
+ // Stable empty array so a step with no logs passes the same reference every render.
6
+ export const NO_LOGS: LogFrame[] = [];
7
+
8
+ export interface RunLogs {
9
+ // Step-scoped logs keyed by `${name}:${attempt}`, matching one step-attempt row.
10
+ byStep: Map<string, LogFrame[]>;
11
+ // Handler-level logs (scope '@root'), which belong to the run, not a step.
12
+ root: LogFrame[];
13
+ }
14
+
15
+ export function stepLogKey(name: string, attempt: number): string {
16
+ return `${name}:${attempt}`;
17
+ }
18
+
19
+ export function groupLogs(frames: TimelineFrame[]): RunLogs {
20
+ const byStep = new Map<string, LogFrame[]>();
21
+ const root: LogFrame[] = [];
22
+ for (const f of frames) {
23
+ if (f.kind !== "log") continue;
24
+ if (f.scope === ROOT_SCOPE) {
25
+ root.push(f);
26
+ continue;
27
+ }
28
+ const key = stepLogKey(f.scope, f.attempt);
29
+ const list = byStep.get(key);
30
+ if (list) list.push(f);
31
+ else byStep.set(key, [f]);
32
+ }
33
+ return { byStep, root };
34
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Run, Step } from "@durablex/react";
3
+ import { currentStepNumber, expectedStepTotals, stepCellLabel, stepProgress } from "./run-progress";
4
+
5
+ function run(overrides: Partial<Run> = {}): Run {
6
+ return {
7
+ id: "run-1",
8
+ workflowName: "checkout",
9
+ app: "default",
10
+ status: "running",
11
+ errorCount: 0,
12
+ attempt: 0,
13
+ triggerKind: "event",
14
+ stepCount: 0,
15
+ currentStepIndex: -1,
16
+ startedAt: "2026-07-01T00:00:00.000Z",
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ function step(overrides: Partial<Step> = {}): Step {
22
+ return {
23
+ name: "charge",
24
+ index: 0,
25
+ attempt: 0,
26
+ status: "succeeded",
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ describe("currentStepNumber", () => {
32
+ it("is the 1-based current index when a step is live", () => {
33
+ expect(currentStepNumber(run({ currentStepIndex: 2, stepCount: 5 }))).toBe(3);
34
+ });
35
+
36
+ it("falls back to the step count when there is no live step", () => {
37
+ expect(currentStepNumber(run({ currentStepIndex: -1, stepCount: 4 }))).toBe(4);
38
+ });
39
+ });
40
+
41
+ describe("expectedStepTotals", () => {
42
+ it("ignores runs that have not ended", () => {
43
+ const totals = expectedStepTotals([run({ stepCount: 9, endedAt: undefined })]);
44
+ expect(totals.size).toBe(0);
45
+ });
46
+
47
+ it("records the largest finished step count per workflow", () => {
48
+ const runs = [
49
+ run({ workflowName: "checkout", stepCount: 3, endedAt: "2026-07-01T00:01:00.000Z" }),
50
+ run({ workflowName: "checkout", stepCount: 5, endedAt: "2026-07-01T00:02:00.000Z" }),
51
+ run({ workflowName: "checkout", stepCount: 2, endedAt: "2026-07-01T00:03:00.000Z" }),
52
+ run({ workflowName: "refund", stepCount: 4, endedAt: "2026-07-01T00:04:00.000Z" }),
53
+ ];
54
+ const totals = expectedStepTotals(runs);
55
+ expect(totals.get("checkout")).toBe(5);
56
+ expect(totals.get("refund")).toBe(4);
57
+ });
58
+ });
59
+
60
+ describe("stepCellLabel", () => {
61
+ it("shows the real total for a finished run", () => {
62
+ const finished = run({
63
+ currentStepIndex: -1,
64
+ stepCount: 4,
65
+ endedAt: "2026-07-01T00:01:00.000Z",
66
+ });
67
+ expect(stepCellLabel(finished, 9)).toBe("4/4");
68
+ });
69
+
70
+ it("uses the estimate as the denominator for a live run", () => {
71
+ const live = run({ currentStepIndex: 1, stepCount: 2 });
72
+ expect(stepCellLabel(live, 5)).toBe("2/5");
73
+ });
74
+
75
+ it("never reads below the steps a live run has already reached", () => {
76
+ const live = run({ currentStepIndex: 4, stepCount: 5 });
77
+ expect(stepCellLabel(live, 3)).toBe("5/5");
78
+ });
79
+
80
+ it("treats a missing estimate as zero for a live run", () => {
81
+ const live = run({ currentStepIndex: 1, stepCount: 2 });
82
+ expect(stepCellLabel(live)).toBe("2/2");
83
+ });
84
+ });
85
+
86
+ describe("stepProgress", () => {
87
+ it("reports 0/0 when there are no steps", () => {
88
+ expect(stepProgress(run(), [])).toEqual({ ix: 0, total: 0 });
89
+ });
90
+
91
+ it("anchors the index on the matched current step name", () => {
92
+ const steps = [step({ name: "a" }), step({ name: "b" }), step({ name: "c" })];
93
+ expect(stepProgress(run({ currentStepName: "b" }), steps)).toEqual({ ix: 2, total: 3 });
94
+ });
95
+
96
+ it("falls back to the count of finished steps when no current step matches", () => {
97
+ const steps = [
98
+ step({ name: "a", status: "succeeded" }),
99
+ step({ name: "b", status: "skipped" }),
100
+ step({ name: "c", status: "running" }),
101
+ ];
102
+ expect(stepProgress(run({ currentStepName: undefined }), steps)).toEqual({ ix: 2, total: 3 });
103
+ });
104
+
105
+ it("reads a terminal run as total/total via finished-step count", () => {
106
+ const steps = [step({ name: "a", status: "succeeded" }), step({ name: "b", status: "failed" })];
107
+ expect(stepProgress(run({ currentStepName: "missing" }), steps)).toEqual({ ix: 2, total: 2 });
108
+ });
109
+ });
@@ -0,0 +1,44 @@
1
+ import type { Run, Step, StepStatus } from "@durablex/react";
2
+
3
+ // 1-based ordinal of the run's current step, from the counts the runs list carries.
4
+ // A terminal run has no live current step, so it reads as total/total.
5
+ export function currentStepNumber(run: Run): number {
6
+ return run.currentStepIndex >= 0 ? run.currentStepIndex + 1 : run.stepCount;
7
+ }
8
+
9
+ // expectedStepTotals maps a workflow to the largest step count seen among its
10
+ // finished runs - the denominator estimate for a run still discovering its steps.
11
+ // A workflow's total isn't known up front (steps are discovered as the handler
12
+ // executes, and can branch), so the dashboard estimates it from prior runs.
13
+ export function expectedStepTotals(runs: Run[]): Map<string, number> {
14
+ const totals = new Map<string, number>();
15
+ for (const r of runs) {
16
+ if (!r.endedAt) continue;
17
+ if (r.stepCount > (totals.get(r.workflowName) ?? 0)) {
18
+ totals.set(r.workflowName, r.stepCount);
19
+ }
20
+ }
21
+ return totals;
22
+ }
23
+
24
+ // The runs-table step label as "{ix}/{total}". A finished run shows its real
25
+ // total; a live one uses the estimate from prior runs of the same workflow, never
26
+ // below the steps it has already reached (so it never reads as e.g. 5/3).
27
+ export function stepCellLabel(run: Run, expectedTotal?: number): string {
28
+ const ix = currentStepNumber(run);
29
+ const total = run.endedAt ? run.stepCount : Math.max(run.stepCount, expectedTotal ?? 0);
30
+ return `${ix}/${total}`;
31
+ }
32
+
33
+ const FINISHED: ReadonlySet<StepStatus> = new Set(["succeeded", "failed", "cancelled", "skipped"]);
34
+
35
+ // Position of the run within its step list, as "{ix}/{total}". The current step
36
+ // (matched by name) anchors the index; otherwise it falls back to the count of
37
+ // finished steps so terminal runs read as total/total.
38
+ export function stepProgress(run: Run, steps: Step[]): { ix: number; total: number } {
39
+ const total = steps.length;
40
+ if (total === 0) return { ix: 0, total: 0 };
41
+ const cur = run.currentStepName ? steps.findIndex((s) => s.name === run.currentStepName) : -1;
42
+ if (cur >= 0) return { ix: cur + 1, total };
43
+ return { ix: steps.filter((s) => FINISHED.has(s.status)).length, total };
44
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DEFAULT_RUN_SORT, RUN_SORT_KEYS, SORT_DIRS, toggleRunSort } from "./run-sort";
3
+
4
+ describe("run-sort constants", () => {
5
+ it("lists the sortable columns", () => {
6
+ expect(RUN_SORT_KEYS).toEqual(["workflow", "status", "app", "started", "duration"]);
7
+ });
8
+
9
+ it("lists the two directions", () => {
10
+ expect(SORT_DIRS).toEqual(["asc", "desc"]);
11
+ });
12
+
13
+ it("defaults to newest-started first", () => {
14
+ expect(DEFAULT_RUN_SORT).toEqual({ key: "started", dir: "desc" });
15
+ });
16
+ });
17
+
18
+ describe("toggleRunSort", () => {
19
+ it("flips direction when the same key is toggled", () => {
20
+ expect(toggleRunSort({ key: "started", dir: "asc" }, "started")).toEqual({
21
+ key: "started",
22
+ dir: "desc",
23
+ });
24
+ expect(toggleRunSort({ key: "started", dir: "desc" }, "started")).toEqual({
25
+ key: "started",
26
+ dir: "asc",
27
+ });
28
+ });
29
+
30
+ it("switches to the new key and resets direction to ascending", () => {
31
+ expect(toggleRunSort({ key: "started", dir: "desc" }, "duration")).toEqual({
32
+ key: "duration",
33
+ dir: "asc",
34
+ });
35
+ expect(toggleRunSort({ key: "app", dir: "asc" }, "workflow")).toEqual({
36
+ key: "workflow",
37
+ dir: "asc",
38
+ });
39
+ });
40
+ });
@@ -0,0 +1,19 @@
1
+ export const RUN_SORT_KEYS = ["workflow", "status", "app", "started", "duration"] as const;
2
+ export type RunSortKey = (typeof RUN_SORT_KEYS)[number];
3
+
4
+ export const SORT_DIRS = ["asc", "desc"] as const;
5
+ export type SortDir = (typeof SORT_DIRS)[number];
6
+
7
+ export interface RunSort {
8
+ key: RunSortKey;
9
+ dir: SortDir;
10
+ }
11
+
12
+ export const DEFAULT_RUN_SORT: RunSort = { key: "started", dir: "desc" };
13
+
14
+ export function toggleRunSort(prev: RunSort, key: RunSortKey): RunSort {
15
+ if (prev.key === key) {
16
+ return { key, dir: prev.dir === "asc" ? "desc" : "asc" };
17
+ }
18
+ return { key, dir: "asc" };
19
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { RunStatus, StepStatus } from "@durablex/react";
3
+ import { STATUS_LABELS } from "./status-label";
4
+
5
+ describe("STATUS_LABELS", () => {
6
+ it("presents waiting as Running so it reads as in progress", () => {
7
+ expect(STATUS_LABELS.waiting).toBe("Running");
8
+ expect(STATUS_LABELS.running).toBe("Running");
9
+ });
10
+
11
+ it("title-cases every other status", () => {
12
+ expect(STATUS_LABELS.queued).toBe("Queued");
13
+ expect(STATUS_LABELS.paused).toBe("Paused");
14
+ expect(STATUS_LABELS.succeeded).toBe("Succeeded");
15
+ expect(STATUS_LABELS.failed).toBe("Failed");
16
+ expect(STATUS_LABELS.cancelled).toBe("Cancelled");
17
+ expect(STATUS_LABELS.skipped).toBe("Skipped");
18
+ });
19
+
20
+ it("covers every run and step status", () => {
21
+ const statuses: (RunStatus | StepStatus)[] = [
22
+ "queued",
23
+ "running",
24
+ "waiting",
25
+ "paused",
26
+ "succeeded",
27
+ "failed",
28
+ "cancelled",
29
+ "skipped",
30
+ ];
31
+ for (const status of statuses) {
32
+ expect(STATUS_LABELS[status]).toBeTruthy();
33
+ }
34
+ });
35
+ });
@@ -0,0 +1,13 @@
1
+ import type { RunStatus, StepStatus } from "@durablex/react";
2
+
3
+ // "waiting" reads as "in progress" to users, so it displays as "Running".
4
+ export const STATUS_LABELS: Record<RunStatus | StepStatus, string> = {
5
+ queued: "Queued",
6
+ running: "Running",
7
+ waiting: "Running",
8
+ paused: "Paused",
9
+ succeeded: "Succeeded",
10
+ failed: "Failed",
11
+ cancelled: "Cancelled",
12
+ skipped: "Skipped",
13
+ };
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Step, StepStatus } from "@durablex/react";
3
+ import { initialStepSelection, stepErrorText, stepTabs, STEP_TAB_LABEL } from "./step-detail";
4
+
5
+ function step(overrides: Partial<Step> = {}): Step {
6
+ return {
7
+ name: "charge",
8
+ index: 0,
9
+ attempt: 0,
10
+ status: "succeeded",
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ describe("STEP_TAB_LABEL", () => {
16
+ it("maps every tab to a human label", () => {
17
+ expect(STEP_TAB_LABEL).toEqual({
18
+ error: "Error",
19
+ input: "Input",
20
+ output: "Output",
21
+ logs: "Logs",
22
+ });
23
+ });
24
+ });
25
+
26
+ describe("stepTabs", () => {
27
+ it("returns no tabs when the step has no content and no logs", () => {
28
+ expect(stepTabs(step({ input: null, output: null }), false)).toEqual([]);
29
+ });
30
+
31
+ it("includes error only when the step failed with an error", () => {
32
+ const failed = step({
33
+ status: "failed",
34
+ error: { message: "boom" },
35
+ input: null,
36
+ output: null,
37
+ });
38
+ expect(stepTabs(failed, false)).toEqual(["error"]);
39
+ expect(stepTabs(step({ input: null, output: null }), false)).toEqual([]);
40
+ });
41
+
42
+ it("includes input only when input is neither null nor undefined", () => {
43
+ expect(stepTabs(step({ input: { amount: 5 }, output: null }), false)).toEqual(["input"]);
44
+ expect(stepTabs(step({ input: null, output: null }), false)).toEqual([]);
45
+ expect(stepTabs(step({ input: undefined, output: null }), false)).toEqual([]);
46
+ });
47
+
48
+ it("treats falsy-but-present input and output as content", () => {
49
+ expect(stepTabs(step({ input: 0, output: false }), false)).toEqual(["input", "output"]);
50
+ expect(stepTabs(step({ input: "", output: null }), false)).toEqual(["input"]);
51
+ });
52
+
53
+ it("includes logs only when hasLogs is true", () => {
54
+ expect(stepTabs(step({ input: null, output: null }), true)).toEqual(["logs"]);
55
+ expect(stepTabs(step({ input: null, output: null }), false)).toEqual([]);
56
+ });
57
+
58
+ it("orders tabs error, input, output, logs when all are present", () => {
59
+ const full = step({
60
+ status: "failed",
61
+ error: { message: "boom" },
62
+ input: { a: 1 },
63
+ output: { b: 2 },
64
+ });
65
+ expect(stepTabs(full, true)).toEqual(["error", "input", "output", "logs"]);
66
+ });
67
+ });
68
+
69
+ describe("stepErrorText", () => {
70
+ it("returns an empty string when the step has no error", () => {
71
+ expect(stepErrorText(step())).toBe("");
72
+ });
73
+
74
+ it("returns the message alone when there is no stack", () => {
75
+ expect(stepErrorText(step({ error: { message: "boom" } }))).toBe("boom");
76
+ });
77
+
78
+ it("joins message and stack with a blank line", () => {
79
+ const text = stepErrorText(step({ error: { message: "boom", stack: "at f()\nat g()" } }));
80
+ expect(text).toBe("boom\n\nat f()\nat g()");
81
+ });
82
+ });
83
+
84
+ describe("initialStepSelection", () => {
85
+ it("returns 0 for an empty list", () => {
86
+ expect(initialStepSelection([])).toBe(0);
87
+ });
88
+
89
+ it("prefers the first failed step", () => {
90
+ const steps = [
91
+ step({ name: "a", status: "succeeded" }),
92
+ step({ name: "b", status: "running" }),
93
+ step({ name: "c", status: "failed" }),
94
+ ];
95
+ expect(initialStepSelection(steps)).toBe(2);
96
+ });
97
+
98
+ it("picks failed over an earlier running step", () => {
99
+ const steps = [step({ name: "a", status: "running" }), step({ name: "b", status: "failed" })];
100
+ expect(initialStepSelection(steps)).toBe(1);
101
+ });
102
+
103
+ it("falls back to the first running or waiting step when none failed", () => {
104
+ const running = [
105
+ step({ name: "a", status: "succeeded" }),
106
+ step({ name: "b", status: "running" }),
107
+ ];
108
+ expect(initialStepSelection(running)).toBe(1);
109
+
110
+ const waiting = [
111
+ step({ name: "a", status: "succeeded" }),
112
+ step({ name: "b", status: "waiting" }),
113
+ ];
114
+ expect(initialStepSelection(waiting)).toBe(1);
115
+ });
116
+
117
+ it("falls back to 0 when every step is settled and none failed", () => {
118
+ const settled: StepStatus[] = ["succeeded", "succeeded", "skipped"];
119
+ const steps = settled.map((status, i) => step({ name: `s${i}`, status }));
120
+ expect(initialStepSelection(steps)).toBe(0);
121
+ });
122
+ });
@@ -0,0 +1,35 @@
1
+ import type { Step } from "@durablex/react";
2
+ import { formatError } from "./format";
3
+
4
+ // A step's expandable panes: Error first when it failed, then Input, Output, and
5
+ // the ctx.log lines it emitted. Each is shown only when the step has that content.
6
+ export type StepTab = "error" | "input" | "output" | "logs";
7
+
8
+ export const STEP_TAB_LABEL: Record<StepTab, string> = {
9
+ error: "Error",
10
+ input: "Input",
11
+ output: "Output",
12
+ logs: "Logs",
13
+ };
14
+
15
+ export function stepTabs(step: Step, hasLogs: boolean): StepTab[] {
16
+ const tabs: StepTab[] = [];
17
+ if (step.error) tabs.push("error");
18
+ if (step.input !== null && step.input !== undefined) tabs.push("input");
19
+ if (step.output !== null && step.output !== undefined) tabs.push("output");
20
+ if (hasLogs) tabs.push("logs");
21
+ return tabs;
22
+ }
23
+
24
+ export function stepErrorText(step: Step): string {
25
+ return step.error ? formatError(step.error) : "";
26
+ }
27
+
28
+ // The step a steps view selects by default: the failed step, else the
29
+ // running/waiting one, else the first. Shared by the flow and timeline views.
30
+ export function initialStepSelection(steps: Step[]): number {
31
+ const failed = steps.findIndex((s) => s.status === "failed");
32
+ if (failed >= 0) return failed;
33
+ const active = steps.findIndex((s) => s.status === "running" || s.status === "waiting");
34
+ return active >= 0 ? active : 0;
35
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { stepDisplayStatus } from "./step-display";
3
+
4
+ describe("stepDisplayStatus", () => {
5
+ it("shows an in-flight step as paused when the run is paused", () => {
6
+ expect(stepDisplayStatus("running", true)).toBe("paused");
7
+ expect(stepDisplayStatus("waiting", true)).toBe("paused");
8
+ });
9
+
10
+ it("leaves a settled step untouched even under a paused run", () => {
11
+ expect(stepDisplayStatus("succeeded", true)).toBe("succeeded");
12
+ expect(stepDisplayStatus("failed", true)).toBe("failed");
13
+ });
14
+
15
+ it("never overrides when the run is not paused", () => {
16
+ expect(stepDisplayStatus("running", false)).toBe("running");
17
+ expect(stepDisplayStatus("waiting", false)).toBe("waiting");
18
+ });
19
+ });
@@ -0,0 +1,13 @@
1
+ import type { StepStatus } from "@durablex/react";
2
+
3
+ // A paused run leaves its in-flight step recorded as running/waiting in the engine
4
+ // (pause only flips the run's status), so the inspector would otherwise show a
5
+ // "Running" step under a "Paused" run. This derives a display-only status so that
6
+ // step reads as paused too; it never changes the stored step status.
7
+ export type StepDisplayStatus = StepStatus | "paused";
8
+
9
+ const IN_FLIGHT: ReadonlySet<StepStatus> = new Set(["running", "waiting"]);
10
+
11
+ export function stepDisplayStatus(status: StepStatus, runPaused: boolean): StepDisplayStatus {
12
+ return runPaused && IN_FLIGHT.has(status) ? "paused" : status;
13
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Step } from "@durablex/react";
3
+ import type { TimelineLane } from "./step-timeline";
4
+ import { buildTimelineLayout } from "./step-timeline";
5
+
6
+ function step(overrides: Partial<Step> = {}): Step {
7
+ return {
8
+ name: "charge",
9
+ index: 0,
10
+ attempt: 0,
11
+ status: "succeeded",
12
+ ...overrides,
13
+ };
14
+ }
15
+
16
+ function bar(lane: TimelineLane): { leftPct: number | null; widthPct: number } {
17
+ return { leftPct: lane.leftPct, widthPct: lane.widthPct };
18
+ }
19
+
20
+ const T0 = "2026-07-01T00:00:00.000Z";
21
+ const T0_MS = Date.parse(T0);
22
+
23
+ describe("buildTimelineLayout", () => {
24
+ it("keeps totalMs at least 1 for an empty step list", () => {
25
+ const { lanes, totalMs } = buildTimelineLayout([], T0_MS);
26
+ expect(lanes).toEqual([]);
27
+ expect(totalMs).toBe(1);
28
+ });
29
+
30
+ it("places a lone finished step at the start spanning the full axis", () => {
31
+ const steps = [step({ startedAt: T0, endedAt: "2026-07-01T00:00:01.000Z" })];
32
+ const { lanes, totalMs } = buildTimelineLayout(steps, T0_MS);
33
+ expect(totalMs).toBe(1000);
34
+ expect(lanes.map(bar)).toEqual([{ leftPct: 0, widthPct: 100 }]);
35
+ });
36
+
37
+ it("offsets lanes by their start and end relative to the earliest start", () => {
38
+ const steps = [
39
+ step({ name: "a", startedAt: T0, endedAt: "2026-07-01T00:00:01.000Z" }),
40
+ step({
41
+ name: "b",
42
+ startedAt: "2026-07-01T00:00:01.000Z",
43
+ endedAt: "2026-07-01T00:00:02.000Z",
44
+ }),
45
+ ];
46
+ const { lanes, totalMs } = buildTimelineLayout(steps, T0_MS);
47
+ expect(totalMs).toBe(2000);
48
+ expect(lanes.map(bar)).toEqual([
49
+ { leftPct: 0, widthPct: 50 },
50
+ { leftPct: 50, widthPct: 50 },
51
+ ]);
52
+ });
53
+
54
+ it("marks a step that never started with leftPct null and zero width", () => {
55
+ const steps = [step({ status: "skipped" })];
56
+ const { lanes } = buildTimelineLayout(steps, T0_MS);
57
+ expect(lanes.map(bar)).toEqual([{ leftPct: null, widthPct: 0 }]);
58
+ });
59
+
60
+ it("does not let a not-started step shift the axis origin", () => {
61
+ const steps = [
62
+ step({ name: "queued", status: "skipped" }),
63
+ step({ name: "ran", startedAt: T0, endedAt: "2026-07-01T00:00:01.000Z" }),
64
+ ];
65
+ const { lanes, totalMs } = buildTimelineLayout(steps, T0_MS);
66
+ expect(totalMs).toBe(1000);
67
+ expect(lanes.map(bar)).toEqual([
68
+ { leftPct: null, widthPct: 0 },
69
+ { leftPct: 0, widthPct: 100 },
70
+ ]);
71
+ });
72
+
73
+ it("clamps a sub-millisecond bar to the minimum visible width", () => {
74
+ const steps = [
75
+ step({ name: "blip", startedAt: T0, endedAt: T0 }),
76
+ step({ name: "long", startedAt: T0, endedAt: "2026-07-01T00:00:10.000Z" }),
77
+ ];
78
+ const { lanes } = buildTimelineLayout(steps, T0_MS);
79
+ expect(lanes.map((l) => l.widthPct)).toEqual([1.5, 100]);
80
+ });
81
+
82
+ it("extends an unfinished step to now", () => {
83
+ const now = T0_MS + 4000;
84
+ const steps = [step({ status: "running", startedAt: T0 })];
85
+ const { lanes, totalMs } = buildTimelineLayout(steps, now);
86
+ expect(totalMs).toBe(4000);
87
+ expect(lanes.map(bar)).toEqual([{ leftPct: 0, widthPct: 100 }]);
88
+ });
89
+ });
@@ -0,0 +1,50 @@
1
+ import type { Step } from "@durablex/react";
2
+
3
+ export interface TimelineLane {
4
+ step: Step;
5
+ // Percentage offsets along the run's span; leftPct is null when the step never
6
+ // started (queued/skipped), so the view renders a placeholder instead of a bar.
7
+ leftPct: number | null;
8
+ widthPct: number;
9
+ }
10
+
11
+ export interface TimelineLayout {
12
+ lanes: TimelineLane[];
13
+ totalMs: number;
14
+ }
15
+
16
+ // A bar narrower than this is widened to stay visible (a sub-millisecond step on a
17
+ // multi-second span would otherwise render as a hairline).
18
+ const MIN_BAR_PCT = 1.5;
19
+
20
+ // buildTimelineLayout places each step on a shared axis running from the earliest
21
+ // step start to the latest end. An unfinished step (running/waiting) extends to
22
+ // `now`, so its bar grows until it completes.
23
+ export function buildTimelineLayout(steps: Step[], now: number): TimelineLayout {
24
+ // Parse each step's timestamps once; a not-started step has start === null and
25
+ // does not extend the axis (its placeholder bar carries no span).
26
+ const spans = steps.map((step) => ({
27
+ step,
28
+ start: step.startedAt ? Date.parse(step.startedAt) : null,
29
+ end: step.endedAt ? Date.parse(step.endedAt) : now,
30
+ }));
31
+
32
+ let min = Infinity;
33
+ let max = 0;
34
+ for (const s of spans) {
35
+ if (s.start === null) continue;
36
+ if (s.start < min) min = s.start;
37
+ if (s.end > max) max = s.end;
38
+ }
39
+ if (min === Infinity) min = 0;
40
+ const totalMs = Math.max(max - min, 1);
41
+
42
+ const lanes = spans.map<TimelineLane>(({ step, start, end }) => {
43
+ if (start === null) return { step, leftPct: null, widthPct: 0 };
44
+ const leftPct = ((start - min) / totalMs) * 100;
45
+ const widthPct = Math.max(((end - start) / totalMs) * 100, MIN_BAR_PCT);
46
+ return { step, leftPct, widthPct };
47
+ });
48
+
49
+ return { lanes, totalMs };
50
+ }
@@ -0,0 +1,2 @@
1
+ export const TABLE_HEAD_CLASS =
2
+ "text-muted-foreground/70 h-7 px-2 text-[10px] tracking-wider uppercase";
@@ -0,0 +1,35 @@
1
+ export const THEME_PREFS = ["light", "system", "dark"] as const;
2
+ export type ThemePref = (typeof THEME_PREFS)[number];
3
+
4
+ const KEY = "dx.theme";
5
+ const query = "(prefers-color-scheme: dark)";
6
+
7
+ export function getThemePref(): ThemePref {
8
+ const v = localStorage.getItem(KEY);
9
+ return v === "light" || v === "dark" ? v : "system";
10
+ }
11
+
12
+ function resolveDark(pref: ThemePref): boolean {
13
+ return pref === "system" ? window.matchMedia(query).matches : pref === "dark";
14
+ }
15
+
16
+ export function applyThemePref(pref: ThemePref): void {
17
+ const dark = resolveDark(pref);
18
+ document.documentElement.classList.toggle("dark", dark);
19
+ document.documentElement.style.colorScheme = dark ? "dark" : "light";
20
+ }
21
+
22
+ export function setThemePref(pref: ThemePref): void {
23
+ localStorage.setItem(KEY, pref);
24
+ applyThemePref(pref);
25
+ }
26
+
27
+ // Re-apply on OS change so the "system" preference stays live.
28
+ export function watchSystemTheme(getPref: () => ThemePref): () => void {
29
+ const mq = window.matchMedia(query);
30
+ const onChange = () => {
31
+ if (getPref() === "system") applyThemePref("system");
32
+ };
33
+ mq.addEventListener("change", onChange);
34
+ return () => mq.removeEventListener("change", onChange);
35
+ }