@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/src/index.ts ADDED
@@ -0,0 +1,165 @@
1
+ export { AppShell } from "./shell/AppShell";
2
+ export { Sidebar } from "./shell/Sidebar";
3
+ export type { NavGroup, NavItem } from "./shell/Sidebar";
4
+ export { Topbar } from "./shell/Topbar";
5
+
6
+ export { RunInspector } from "./components/RunInspector";
7
+ export type { RunInspectorProps } from "./components/RunInspector";
8
+ export { RunInspectorActions } from "./components/RunInspectorActions";
9
+ export { RunsView } from "./components/RunsView";
10
+ export type { RunsViewProps, RunsViewState } from "./components/RunsView";
11
+ export { Meta } from "./components/Meta";
12
+ export { JsonBlock } from "./components/JsonBlock";
13
+ export { LogList } from "./components/LogList";
14
+ export { RunsTable } from "./components/RunsTable";
15
+ export { RunsTableHead } from "./components/RunsTableHead";
16
+ export { RunsTableRow } from "./components/RunsTableRow";
17
+ export { RunsTableLoader } from "./components/RunsTableLoader";
18
+ export { RunsTablePlaceholder } from "./components/RunsTablePlaceholder";
19
+ export { RunsFilterBar } from "./components/RunsFilterBar";
20
+ export { CursorPager } from "./components/CursorPager";
21
+ export { BulkReplayButton } from "./components/BulkReplayButton";
22
+ export { RunPauseButton } from "./components/RunPauseButton";
23
+ export { RunCancelButton } from "./components/RunCancelButton";
24
+ export { RetryFromStepButton } from "./components/RetryFromStepButton";
25
+ export { RunControlHistory } from "./components/RunControlHistory";
26
+ export { AppFilter } from "./components/filters/AppFilter";
27
+ export { TimeRangeFilter } from "./components/filters/TimeRangeFilter";
28
+ export { TimeZoneFilter } from "./components/filters/TimeZoneFilter";
29
+ export { FilterDropdown } from "./components/filters/FilterDropdown";
30
+ export { FilterDropdownButton } from "./components/filters/FilterDropdownButton";
31
+ export { FilterDropdownItem } from "./components/filters/FilterDropdownItem";
32
+ export { StatsTiles } from "./components/StatsTiles";
33
+ export { StatTileGrid } from "./components/StatTileGrid";
34
+ export type { StatTile } from "./components/StatTileGrid";
35
+
36
+ export { StatusBadge } from "./components/StatusBadge";
37
+ export { AppTag } from "./components/AppTag";
38
+ export { ScheduledBadge } from "./components/ScheduledBadge";
39
+ export { GlyphBadge } from "./components/GlyphBadge";
40
+ export { ResumeMark } from "./components/ResumeMark";
41
+ export { AnimatedDurablexMark } from "./components/AnimatedDurablexMark";
42
+ export { DurablexLogo } from "./components/DurablexLogo";
43
+ export { MARK_BARS, RESUME_TRIANGLE } from "./components/marks-geometry";
44
+
45
+ export { Button, buttonVariants } from "./ui/button";
46
+ export { Input } from "./ui/input";
47
+ export { Separator } from "./ui/separator";
48
+ export {
49
+ Dialog,
50
+ DialogClose,
51
+ DialogContent,
52
+ DialogDescription,
53
+ DialogFooter,
54
+ DialogHeader,
55
+ DialogOverlay,
56
+ DialogPortal,
57
+ DialogTitle,
58
+ DialogTrigger,
59
+ } from "./ui/dialog";
60
+ export { JsonEditor } from "./components/JsonEditor";
61
+ export { SectionHeader } from "./components/SectionHeader";
62
+ export { ReplayRunDialog } from "./components/ReplayRunDialog";
63
+ export { RunCharts } from "./components/charts/RunCharts";
64
+ export { WorkflowsView } from "./components/WorkflowsView";
65
+ export { WorkflowDetail } from "./components/WorkflowDetail";
66
+ export { FlowControlSection } from "./components/FlowControlSection";
67
+ export { FlowControlBadge } from "./components/FlowControlBadge";
68
+ export { RunnerLiveBadge } from "./components/RunnerLiveBadge";
69
+ export { AppsView } from "./components/AppsView";
70
+ export { AppStatusBadge } from "./components/AppStatusBadge";
71
+ export { EventsView } from "./components/EventsView";
72
+ export { EventsList } from "./components/EventsList";
73
+ export { TriggerEventDialog } from "./components/TriggerEventDialog";
74
+ export { TriggerEventResult } from "./components/TriggerEventResult";
75
+ export { WorkflowRunAction } from "./components/WorkflowRunAction";
76
+ export { WorkflowRunDialog } from "./components/WorkflowRunDialog";
77
+ export { TableStatusRows } from "./components/TableStatusRows";
78
+ export { WebhooksView } from "./components/WebhooksView";
79
+ export { Facts } from "./components/Facts";
80
+ export { OverviewView } from "./components/OverviewView";
81
+ export { FlowSummary } from "./components/FlowSummary";
82
+ export {
83
+ Table,
84
+ TableBody,
85
+ TableCell,
86
+ TableHead,
87
+ TableHeader,
88
+ TableRow,
89
+ TableFooter,
90
+ TableCaption,
91
+ } from "./ui/table";
92
+ export { TABLE_HEAD_CLASS } from "./lib/table";
93
+ export { workflowRunPlan } from "./lib/workflow-run";
94
+ export type { EventRunOption, WorkflowRunPlan } from "./lib/workflow-run";
95
+ export { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable";
96
+ export { useKeysetPager } from "./hooks/use-keyset-pager";
97
+ export { FLOW_ADAPTERS, hasFlowControl, flowControlRows } from "./lib/flow-control";
98
+ export type { FlowAdapter, FlowRow } from "./lib/flow-control";
99
+ export {
100
+ Sheet,
101
+ SheetTrigger,
102
+ SheetClose,
103
+ SheetContent,
104
+ SheetHeader,
105
+ SheetFooter,
106
+ SheetTitle,
107
+ SheetDescription,
108
+ } from "./ui/sheet";
109
+ export { Skeleton } from "./ui/skeleton";
110
+ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
111
+ export { Toaster } from "./ui/sonner";
112
+
113
+ export { useIsMobile } from "./hooks/use-mobile";
114
+ export { useCopyToClipboard } from "./hooks/use-copy";
115
+ export { useConfirmAction } from "./hooks/use-confirm-action";
116
+ export { useClickOutside } from "./components/filters/use-click-outside";
117
+
118
+ export { cn } from "./lib/utils";
119
+ export {
120
+ THEME_PREFS,
121
+ getThemePref,
122
+ setThemePref,
123
+ applyThemePref,
124
+ watchSystemTheme,
125
+ } from "./lib/theme";
126
+ export type { ThemePref } from "./lib/theme";
127
+ export {
128
+ highlightedJsonSpans,
129
+ parseJson,
130
+ tokenizeJson,
131
+ JSON_TOKEN_COLOR,
132
+ } from "./lib/json-highlight";
133
+ export type { JsonTokenKind, ParsedJson } from "./lib/json-highlight";
134
+ export {
135
+ formatError,
136
+ formatDuration,
137
+ formatTime,
138
+ formatNextFire,
139
+ formatRelative,
140
+ } from "./lib/format";
141
+ export { STATUS_LABELS } from "./lib/status-label";
142
+ export {
143
+ currentStepNumber,
144
+ expectedStepTotals,
145
+ stepCellLabel,
146
+ stepProgress,
147
+ } from "./lib/run-progress";
148
+ export { RUN_SORT_KEYS, SORT_DIRS, DEFAULT_RUN_SORT, toggleRunSort } from "./lib/run-sort";
149
+ export type { RunSort, RunSortKey, SortDir } from "./lib/run-sort";
150
+ export { RUN_TYPE_FILTERS, ALL_FILTER } from "./lib/run-filters";
151
+ export type { RunTypeFilter, StatusFilter } from "./lib/run-filters";
152
+ export { appHue } from "./lib/app-color";
153
+ export {
154
+ DEFAULT_TIME_RANGE,
155
+ TIME_OPTIONS,
156
+ TIME_ZONES,
157
+ timeLabel,
158
+ windowSince,
159
+ parseWindowMs,
160
+ seriesBucketSeconds,
161
+ bucketTickLabel,
162
+ localTimeZone,
163
+ } from "./lib/time-range";
164
+ export type { TimeZoneMode } from "./lib/time-range";
165
+ export { ACTION_LABELS, actorLabel, describeControlDetail } from "./lib/control-action";
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { appHue } from "./app-color";
3
+
4
+ const OKLCH = /^oklch\(0\.62 0\.15 (\d+(?:\.\d+)?)\)$/;
5
+
6
+ describe("appHue", () => {
7
+ it("produces a well-formed oklch color with fixed lightness and chroma", () => {
8
+ const match = OKLCH.exec(appHue("checkout"));
9
+ expect(match).not.toBeNull();
10
+ });
11
+
12
+ it("keeps the hue within 0-359", () => {
13
+ for (const name of ["a", "billing", "orders", "a-very-long-app-name"]) {
14
+ const match = OKLCH.exec(appHue(name));
15
+ const hue = Number(match?.[1]);
16
+ expect(hue).toBeGreaterThanOrEqual(0);
17
+ expect(hue).toBeLessThan(360);
18
+ }
19
+ });
20
+
21
+ it("is deterministic for the same name", () => {
22
+ expect(appHue("checkout")).toBe(appHue("checkout"));
23
+ });
24
+
25
+ it("gives different names different hues", () => {
26
+ expect(appHue("checkout")).not.toBe(appHue("refund"));
27
+ });
28
+
29
+ it("handles the empty name as a hue of 0", () => {
30
+ expect(appHue("")).toBe("oklch(0.62 0.15 0)");
31
+ });
32
+ });
@@ -0,0 +1,8 @@
1
+ // Deterministic per-app accent. The engine does not persist an app color yet, so we
2
+ // derive a stable hue from the name. All app colors share the same lightness/chroma
3
+ // and vary only by hue, per the design brief.
4
+ export function appHue(name: string): string {
5
+ let h = 0;
6
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
7
+ return `oklch(0.62 0.15 ${((h % 360) + 360) % 360})`;
8
+ }
@@ -0,0 +1,36 @@
1
+ import type { ControlAction, ControlActionKind } from "@durablex/react";
2
+
3
+ export const ACTION_LABELS: Record<ControlActionKind, string> = {
4
+ cancel: "Cancelled",
5
+ pause: "Paused",
6
+ resume: "Resumed",
7
+ replay: "Replayed",
8
+ retry_from_step: "Retried from step",
9
+ bulk_replay: "Bulk replay",
10
+ };
11
+
12
+ // An action recorded with no API-key label was driven by an unauthenticated (self-hosted) engine.
13
+ export function actorLabel(actor?: string): string {
14
+ return actor || "self-hosted";
15
+ }
16
+
17
+ function isRecord(v: unknown): v is Record<string, unknown> {
18
+ return typeof v === "object" && v !== null && !Array.isArray(v);
19
+ }
20
+
21
+ function asNum(v: unknown): number {
22
+ return typeof v === "number" ? v : 0;
23
+ }
24
+
25
+ export function describeControlDetail(a: ControlAction): string {
26
+ const d = a.detail;
27
+ if (!isRecord(d)) return "";
28
+ if (a.action === "retry_from_step" && typeof d.step === "string") {
29
+ return `from "${d.step}"`;
30
+ }
31
+ if (a.action === "bulk_replay") {
32
+ const summary = `${asNum(d.replayed)}/${asNum(d.matched)} replayed`;
33
+ return d.capped ? `${summary}, capped` : summary;
34
+ }
35
+ return "";
36
+ }
@@ -0,0 +1,77 @@
1
+ import type { FlowControl } from "@durablex/react";
2
+ import { formatDuration } from "./format";
3
+
4
+ export const FLOW_ADAPTERS = [
5
+ "concurrency",
6
+ "throttle",
7
+ "rateLimit",
8
+ "debounce",
9
+ "batch",
10
+ "priority",
11
+ "singleton",
12
+ "idempotency",
13
+ ] as const;
14
+ export type FlowAdapter = (typeof FLOW_ADAPTERS)[number];
15
+
16
+ const LABELS: Record<FlowAdapter, string> = {
17
+ concurrency: "Concurrency",
18
+ throttle: "Throttle",
19
+ rateLimit: "Rate limit",
20
+ debounce: "Debounce",
21
+ batch: "Batch",
22
+ priority: "Priority",
23
+ singleton: "Singleton",
24
+ idempotency: "Idempotency",
25
+ };
26
+
27
+ export function hasFlowControl(fc?: FlowControl): boolean {
28
+ return !!fc && FLOW_ADAPTERS.some((a) => fc[a] != null);
29
+ }
30
+
31
+ export interface FlowRow {
32
+ adapter: FlowAdapter;
33
+ label: string;
34
+ value: string;
35
+ scopeKey?: string;
36
+ }
37
+
38
+ // One display row per configured adapter, in FLOW_ADAPTERS order. value is the
39
+ // human-readable policy; scopeKey (when set) is the event field the policy is
40
+ // keyed on.
41
+ export function flowControlRows(fc: FlowControl): FlowRow[] {
42
+ const rows: FlowRow[] = [];
43
+ const push = (adapter: FlowAdapter, value: string, scopeKey?: string) =>
44
+ rows.push({ adapter, label: LABELS[adapter], value, scopeKey });
45
+
46
+ if (fc.concurrency) push("concurrency", `limit ${fc.concurrency.limit}`, fc.concurrency.key);
47
+ if (fc.throttle)
48
+ push(
49
+ "throttle",
50
+ `${fc.throttle.limit} per ${formatDuration(fc.throttle.perMs)}`,
51
+ fc.throttle.key,
52
+ );
53
+ if (fc.rateLimit)
54
+ push(
55
+ "rateLimit",
56
+ `${fc.rateLimit.limit} per ${formatDuration(fc.rateLimit.perMs)}`,
57
+ fc.rateLimit.key,
58
+ );
59
+ if (fc.debounce)
60
+ push("debounce", `${formatDuration(fc.debounce.periodMs)} window`, fc.debounce.key);
61
+ if (fc.batch)
62
+ push("batch", `up to ${fc.batch.maxSize}, ${formatDuration(fc.batch.timeoutMs)}`, fc.batch.key);
63
+ if (fc.priority) {
64
+ // A positive shift moves the run earlier in the queue (higher priority); negative defers it.
65
+ const { shiftMs } = fc.priority;
66
+ push("priority", `${formatDuration(Math.abs(shiftMs))} ${shiftMs < 0 ? "later" : "earlier"}`);
67
+ }
68
+ if (fc.singleton) push("singleton", fc.singleton.mode || "-", fc.singleton.key);
69
+ if (fc.idempotency) {
70
+ push(
71
+ "idempotency",
72
+ fc.idempotency.periodMs ? `${formatDuration(fc.idempotency.periodMs)} window` : "on",
73
+ fc.idempotency.key,
74
+ );
75
+ }
76
+ return rows;
77
+ }
@@ -0,0 +1,102 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { formatDuration, formatError, formatNextFire, formatRelative, formatTime } from "./format";
3
+
4
+ describe("formatError", () => {
5
+ it("returns the message alone when there is no stack", () => {
6
+ expect(formatError({ message: "boom" })).toBe("boom");
7
+ });
8
+
9
+ it("joins message and stack with a blank line", () => {
10
+ expect(formatError({ message: "boom", stack: "at f()" })).toBe("boom\n\nat f()");
11
+ });
12
+ });
13
+
14
+ describe("formatDuration", () => {
15
+ it("renders a placeholder for null or undefined", () => {
16
+ expect(formatDuration(null)).toBe("-");
17
+ expect(formatDuration(undefined)).toBe("-");
18
+ });
19
+
20
+ it("renders sub-second durations in milliseconds", () => {
21
+ expect(formatDuration(0)).toBe("0ms");
22
+ expect(formatDuration(999)).toBe("999ms");
23
+ });
24
+
25
+ it("crosses into seconds at exactly 1000ms", () => {
26
+ expect(formatDuration(1000)).toBe("1.0s");
27
+ expect(formatDuration(1500)).toBe("1.5s");
28
+ expect(formatDuration(59_000)).toBe("59.0s");
29
+ });
30
+
31
+ it("crosses into minutes at 60 seconds", () => {
32
+ expect(formatDuration(60_000)).toBe("1.0m");
33
+ expect(formatDuration(90_000)).toBe("1.5m");
34
+ });
35
+
36
+ it("crosses into hours at 60 minutes", () => {
37
+ expect(formatDuration(3_600_000)).toBe("1.0h");
38
+ expect(formatDuration(5_400_000)).toBe("1.5h");
39
+ });
40
+ });
41
+
42
+ describe("formatTime", () => {
43
+ it("renders a placeholder for empty input", () => {
44
+ expect(formatTime(null)).toBe("-");
45
+ expect(formatTime(undefined)).toBe("-");
46
+ expect(formatTime("")).toBe("-");
47
+ });
48
+
49
+ it("renders a 24-hour time for a valid instant", () => {
50
+ expect(formatTime("2026-07-01T13:45:07.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
51
+ });
52
+ });
53
+
54
+ describe("time-relative formatters", () => {
55
+ const NOW = new Date("2026-07-01T12:00:00.000Z");
56
+
57
+ beforeEach(() => {
58
+ vi.useFakeTimers();
59
+ vi.setSystemTime(NOW);
60
+ });
61
+
62
+ afterEach(() => {
63
+ vi.useRealTimers();
64
+ });
65
+
66
+ describe("formatNextFire", () => {
67
+ it("renders a placeholder for empty input", () => {
68
+ expect(formatNextFire(null)).toBe("-");
69
+ expect(formatNextFire(undefined)).toBe("-");
70
+ });
71
+
72
+ it("reads a non-positive delta as due", () => {
73
+ expect(formatNextFire("2026-07-01T12:00:00.000Z")).toBe("due");
74
+ expect(formatNextFire("2026-07-01T11:59:00.000Z")).toBe("due");
75
+ });
76
+
77
+ it("floors sub-minute, sub-hour, sub-day and multi-day horizons", () => {
78
+ expect(formatNextFire("2026-07-01T12:00:30.000Z")).toBe("in 30s");
79
+ expect(formatNextFire("2026-07-01T12:03:00.000Z")).toBe("in 3m");
80
+ expect(formatNextFire("2026-07-01T15:00:00.000Z")).toBe("in 3h");
81
+ expect(formatNextFire("2026-07-03T12:00:00.000Z")).toBe("in 2d");
82
+ });
83
+ });
84
+
85
+ describe("formatRelative", () => {
86
+ it("renders a placeholder for empty input", () => {
87
+ expect(formatRelative(null)).toBe("-");
88
+ expect(formatRelative(undefined)).toBe("-");
89
+ });
90
+
91
+ it("clamps a future instant to 0s ago", () => {
92
+ expect(formatRelative("2026-07-01T12:05:00.000Z")).toBe("0s ago");
93
+ });
94
+
95
+ it("floors sub-minute, sub-hour, sub-day and multi-day ages", () => {
96
+ expect(formatRelative("2026-07-01T11:59:30.000Z")).toBe("30s ago");
97
+ expect(formatRelative("2026-07-01T11:57:00.000Z")).toBe("3m ago");
98
+ expect(formatRelative("2026-07-01T09:00:00.000Z")).toBe("3h ago");
99
+ expect(formatRelative("2026-06-29T12:00:00.000Z")).toBe("2d ago");
100
+ });
101
+ });
102
+ });
@@ -0,0 +1,45 @@
1
+ import type { StepErrorInfo } from "@durablex/react";
2
+
3
+ export function formatError(error: StepErrorInfo): string {
4
+ return error.stack ? `${error.message}\n\n${error.stack}` : error.message;
5
+ }
6
+
7
+ export function formatDuration(ms?: number | null): string {
8
+ if (ms === null || ms === undefined) return "-";
9
+ if (ms < 1000) return `${ms}ms`;
10
+ const s = ms / 1000;
11
+ if (s < 60) return `${s.toFixed(1)}s`;
12
+ const m = s / 60;
13
+ if (m < 60) return `${m.toFixed(1)}m`;
14
+ return `${(m / 60).toFixed(1)}h`;
15
+ }
16
+
17
+ export function formatTime(iso?: string | null): string {
18
+ if (!iso) return "-";
19
+ return new Date(iso).toLocaleTimeString([], { hour12: false });
20
+ }
21
+
22
+ // Time until a scheduled fire, as "in 3m". A non-positive delta reads as "due"
23
+ // (the sweep runs on an interval, so a just-passed tick has not fired yet).
24
+ export function formatNextFire(iso?: string | null): string {
25
+ if (!iso) return "-";
26
+ const sec = (new Date(iso).getTime() - Date.now()) / 1000;
27
+ if (sec <= 0) return "due";
28
+ if (sec < 60) return `in ${Math.floor(sec)}s`;
29
+ const min = sec / 60;
30
+ if (min < 60) return `in ${Math.floor(min)}m`;
31
+ const hr = min / 60;
32
+ if (hr < 24) return `in ${Math.floor(hr)}h`;
33
+ return `in ${Math.floor(hr / 24)}d`;
34
+ }
35
+
36
+ export function formatRelative(iso?: string | null): string {
37
+ if (!iso) return "-";
38
+ const sec = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
39
+ if (sec < 60) return `${Math.floor(sec)}s ago`;
40
+ const min = sec / 60;
41
+ if (min < 60) return `${Math.floor(min)}m ago`;
42
+ const hr = min / 60;
43
+ if (hr < 24) return `${Math.floor(hr)}h ago`;
44
+ return `${Math.floor(hr / 24)}d ago`;
45
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseJson, tokenizeJson } from "./json-highlight";
3
+
4
+ describe("parseJson", () => {
5
+ it("treats blank input as an empty object (the form default)", () => {
6
+ expect(parseJson(" ")).toEqual({ ok: true, value: {} });
7
+ });
8
+ it("parses valid JSON", () => {
9
+ expect(parseJson('{"a":1}')).toEqual({ ok: true, value: { a: 1 } });
10
+ });
11
+ it("reports invalid JSON without throwing", () => {
12
+ const r = parseJson("{not json}");
13
+ expect(r.ok).toBe(false);
14
+ if (!r.ok) expect(r.error).toMatch(/valid JSON/);
15
+ });
16
+ });
17
+
18
+ describe("tokenizeJson", () => {
19
+ it("classifies keys, strings, numbers, booleans, and null", () => {
20
+ const kinds = (src: string) => tokenizeJson(src).map((t) => t.kind);
21
+ expect(kinds('{"k":"v"}')).toContain("key");
22
+ expect(kinds('{"k":"v"}')).toContain("str");
23
+ expect(kinds('{"n":42}')).toContain("num");
24
+ expect(kinds('{"b":true}')).toContain("bool");
25
+ expect(kinds('{"z":null}')).toContain("null");
26
+ });
27
+
28
+ it("round-trips the exact source text when tokens are concatenated", () => {
29
+ const src = '{\n "amount": 12.5,\n "ok": false,\n "note": null\n}';
30
+ expect(
31
+ tokenizeJson(src)
32
+ .map((t) => t.text)
33
+ .join(""),
34
+ ).toBe(src);
35
+ });
36
+ });
@@ -0,0 +1,64 @@
1
+ export type JsonTokenKind = "key" | "str" | "num" | "bool" | "null" | "punc";
2
+
3
+ export const JSON_TOKEN_COLOR: Record<JsonTokenKind, string> = {
4
+ key: "var(--foreground)",
5
+ str: "var(--st-succeeded-fg)",
6
+ num: "var(--st-running-fg)",
7
+ bool: "var(--primary)",
8
+ null: "var(--muted-foreground)",
9
+ punc: "color-mix(in oklch, var(--muted-foreground) 70%, transparent)",
10
+ };
11
+
12
+ const TOKEN =
13
+ /"(?:\\.|[^"\\])*"(\s*:)?|\b(?:true|false)\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
14
+
15
+ export function tokenizeJson(src: string): { text: string; kind: JsonTokenKind }[] {
16
+ const out: { text: string; kind: JsonTokenKind }[] = [];
17
+ let last = 0;
18
+ let m: RegExpExecArray | null;
19
+ TOKEN.lastIndex = 0;
20
+ while ((m = TOKEN.exec(src)) !== null) {
21
+ if (m.index > last) out.push({ text: src.slice(last, m.index), kind: "punc" });
22
+ const s = m[0];
23
+ if (s[0] === '"') {
24
+ if (m[1]) {
25
+ const q = s.lastIndexOf('"') + 1;
26
+ out.push({ text: s.slice(0, q), kind: "key" });
27
+ out.push({ text: s.slice(q), kind: "punc" });
28
+ } else {
29
+ out.push({ text: s, kind: "str" });
30
+ }
31
+ } else if (s === "true" || s === "false") {
32
+ out.push({ text: s, kind: "bool" });
33
+ } else if (s === "null") {
34
+ out.push({ text: s, kind: "null" });
35
+ } else {
36
+ out.push({ text: s, kind: "num" });
37
+ }
38
+ last = TOKEN.lastIndex;
39
+ }
40
+ if (last < src.length) out.push({ text: src.slice(last), kind: "punc" });
41
+ return out;
42
+ }
43
+
44
+ export type ParsedJson = { ok: true; value: unknown } | { ok: false; error: string };
45
+
46
+ export function parseJson(raw: string): ParsedJson {
47
+ const text = raw.trim();
48
+ if (text === "") return { ok: true, value: {} };
49
+ try {
50
+ return { ok: true, value: JSON.parse(text) };
51
+ } catch {
52
+ return { ok: false, error: "Payload must be valid JSON" };
53
+ }
54
+ }
55
+
56
+ export function highlightedJsonSpans(text: string) {
57
+ return tokenizeJson(text).map((tok, i) => ({
58
+ key: i,
59
+ text: tok.text,
60
+ color: JSON_TOKEN_COLOR[tok.kind],
61
+ bold: tok.kind === "bool",
62
+ italic: tok.kind === "null",
63
+ }));
64
+ }
@@ -0,0 +1,8 @@
1
+ import type { RunStatus } from "@durablex/react";
2
+
3
+ export const ALL_FILTER = "all" as const;
4
+
5
+ export type StatusFilter = typeof ALL_FILTER | Exclude<RunStatus, "waiting">;
6
+
7
+ export const RUN_TYPE_FILTERS = [ALL_FILTER, "triggered", "scheduled"] as const;
8
+ export type RunTypeFilter = (typeof RUN_TYPE_FILTERS)[number];
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { LogFrame, LogLevel, StepStatusFrame, TimelineFrame } from "@durablex/react";
3
+ import { groupLogs, NO_LOGS, ROOT_SCOPE, stepLogKey } from "./run-logs";
4
+
5
+ function log(scope: string, attempt: number, message: string): LogFrame {
6
+ return {
7
+ kind: "log",
8
+ seq: 1,
9
+ ts: "2026-07-01T00:00:00.000Z",
10
+ runId: "run-1",
11
+ level: "info" as LogLevel,
12
+ message,
13
+ scope,
14
+ attempt,
15
+ };
16
+ }
17
+
18
+ describe("stepLogKey", () => {
19
+ it("joins name and attempt with a colon", () => {
20
+ expect(stepLogKey("charge", 0)).toBe("charge:0");
21
+ expect(stepLogKey("charge", 2)).toBe("charge:2");
22
+ });
23
+ });
24
+
25
+ describe("NO_LOGS", () => {
26
+ it("is an empty array reused by reference", () => {
27
+ expect(NO_LOGS).toEqual([]);
28
+ expect(NO_LOGS).toBe(NO_LOGS);
29
+ });
30
+ });
31
+
32
+ describe("groupLogs", () => {
33
+ it("returns empty groups for empty input", () => {
34
+ const { byStep, root } = groupLogs([]);
35
+ expect(root).toEqual([]);
36
+ expect(byStep.size).toBe(0);
37
+ });
38
+
39
+ it("routes @root-scoped logs to root and step-scoped logs to byStep", () => {
40
+ const frames: TimelineFrame[] = [
41
+ log(ROOT_SCOPE, 0, "handler start"),
42
+ log("charge", 0, "charging"),
43
+ log(ROOT_SCOPE, 0, "handler done"),
44
+ ];
45
+ const { byStep, root } = groupLogs(frames);
46
+ expect(root.map((f) => f.message)).toEqual(["handler start", "handler done"]);
47
+ expect(byStep.get("charge:0")?.map((f) => f.message)).toEqual(["charging"]);
48
+ });
49
+
50
+ it("keys step logs by name:attempt and preserves order within a key", () => {
51
+ const frames: TimelineFrame[] = [log("charge", 0, "first"), log("charge", 0, "second")];
52
+ const { byStep } = groupLogs(frames);
53
+ expect(byStep.get("charge:0")?.map((f) => f.message)).toEqual(["first", "second"]);
54
+ });
55
+
56
+ it("separates the same step name across different attempts into distinct keys", () => {
57
+ const frames: TimelineFrame[] = [log("charge", 0, "attempt 0"), log("charge", 1, "attempt 1")];
58
+ const { byStep } = groupLogs(frames);
59
+ expect(byStep.get("charge:0")?.map((f) => f.message)).toEqual(["attempt 0"]);
60
+ expect(byStep.get("charge:1")?.map((f) => f.message)).toEqual(["attempt 1"]);
61
+ expect(byStep.size).toBe(2);
62
+ });
63
+
64
+ it("ignores non-log frames", () => {
65
+ const stepFrame: StepStatusFrame = {
66
+ kind: "step_status",
67
+ seq: 2,
68
+ ts: "2026-07-01T00:00:00.000Z",
69
+ runId: "run-1",
70
+ name: "charge",
71
+ status: "succeeded",
72
+ attempt: 0,
73
+ };
74
+ const frames: TimelineFrame[] = [stepFrame, log("charge", 0, "kept")];
75
+ const { byStep, root } = groupLogs(frames);
76
+ expect(root).toEqual([]);
77
+ expect(byStep.get("charge:0")?.map((f) => f.message)).toEqual(["kept"]);
78
+ expect(byStep.size).toBe(1);
79
+ });
80
+ });