@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,187 @@
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import { useTriggerEvent, type SendEventResult, type WorkflowDef } from "@durablex/react";
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "../ui/dialog";
11
+ import { Button } from "../ui/button";
12
+ import { Input } from "../ui/input";
13
+ import { parseJson } from "../lib/json-highlight";
14
+ import { workflowRunPlan, type EventRunOption } from "../lib/workflow-run";
15
+ import { AppTag } from "./AppTag";
16
+ import { JsonEditor } from "./JsonEditor";
17
+ import { TriggerEventResult } from "./TriggerEventResult";
18
+
19
+ export function WorkflowRunDialog({
20
+ workflow,
21
+ open,
22
+ onOpenChange,
23
+ onOpenRun,
24
+ }: {
25
+ workflow: WorkflowDef;
26
+ open: boolean;
27
+ onOpenChange: (open: boolean) => void;
28
+ onOpenRun: (runId: string) => void;
29
+ }) {
30
+ // A cron-only workflow has no fireable trigger, so RunForm (which reads options[0]) is
31
+ // only mounted when there is at least one option. WorkflowRunAction already gates on this.
32
+ const runnable = workflowRunPlan(workflow).options.length > 0;
33
+ return (
34
+ <Dialog open={open} onOpenChange={onOpenChange}>
35
+ <DialogContent className="sm:max-w-md">
36
+ {open && runnable && (
37
+ <RunForm
38
+ key={workflow.name}
39
+ workflow={workflow}
40
+ onClose={() => onOpenChange(false)}
41
+ onOpenRun={onOpenRun}
42
+ />
43
+ )}
44
+ </DialogContent>
45
+ </Dialog>
46
+ );
47
+ }
48
+
49
+ function RunForm({
50
+ workflow,
51
+ onClose,
52
+ onOpenRun,
53
+ }: {
54
+ workflow: WorkflowDef;
55
+ onClose: () => void;
56
+ onOpenRun: (runId: string) => void;
57
+ }) {
58
+ const { options } = workflowRunPlan(workflow);
59
+ const [selected, setSelected] = useState(0);
60
+ // RunForm is only mounted when options is non-empty; the fallback keeps the type total.
61
+ const option: EventRunOption = options[selected] ??
62
+ options[0] ?? { value: "", pattern: "", isWildcard: false };
63
+
64
+ const [eventName, setEventName] = useState(option.value);
65
+ const [payload, setPayload] = useState("{}");
66
+ const [result, setResult] = useState<SendEventResult | null>(null);
67
+ const fire = useTriggerEvent();
68
+
69
+ const parsed = useMemo(() => parseJson(payload), [payload]);
70
+ const canSubmit = eventName.trim() !== "" && parsed.ok && !fire.isPending;
71
+
72
+ function pick(index: number) {
73
+ const opt = options[index];
74
+ if (!opt) return;
75
+ setSelected(index);
76
+ setEventName(opt.value);
77
+ }
78
+
79
+ function submit() {
80
+ if (!parsed.ok) return;
81
+ fire.mutate(
82
+ { name: eventName.trim(), app: workflow.app, data: parsed.value },
83
+ { onSuccess: setResult },
84
+ );
85
+ }
86
+
87
+ return (
88
+ <>
89
+ <DialogHeader>
90
+ <DialogTitle>Run {workflow.name}</DialogTitle>
91
+ <DialogDescription>
92
+ Fires this workflow's triggering event. Any other workflow whose trigger matches the event
93
+ also runs.
94
+ </DialogDescription>
95
+ </DialogHeader>
96
+
97
+ <div className="flex flex-col gap-3">
98
+ <div className="flex flex-col gap-2 rounded-lg border border-border bg-muted/30 p-2.5 text-xs">
99
+ {options.length > 1 && (
100
+ <Row label="Trigger">
101
+ <div className="flex flex-wrap gap-1.5">
102
+ {options.map((o, i) => (
103
+ <button
104
+ key={o.pattern}
105
+ type="button"
106
+ className="chip focusable font-mono"
107
+ data-on={i === selected ? "1" : "0"}
108
+ aria-pressed={i === selected}
109
+ onClick={() => pick(i)}
110
+ >
111
+ {o.pattern}
112
+ </button>
113
+ ))}
114
+ </div>
115
+ </Row>
116
+ )}
117
+
118
+ <Row label="Event">
119
+ {option.isWildcard ? (
120
+ <Input
121
+ value={eventName}
122
+ onChange={(e) => setEventName(e.target.value)}
123
+ aria-label="Event name"
124
+ className="h-7"
125
+ autoFocus
126
+ />
127
+ ) : (
128
+ <span className="font-mono">{option.pattern}</span>
129
+ )}
130
+ </Row>
131
+
132
+ <Row label="App">
133
+ <AppTag app={workflow.app} />
134
+ </Row>
135
+
136
+ {option.isWildcard && (
137
+ <p className="text-muted-foreground">
138
+ <code className="font-mono">{option.pattern}</code> is a wildcard - complete the event
139
+ name above.
140
+ </p>
141
+ )}
142
+ {option.filter && (
143
+ <p className="text-muted-foreground">
144
+ Only runs when: <code className="font-mono">{option.filter}</code>
145
+ </p>
146
+ )}
147
+ </div>
148
+
149
+ <div className="flex flex-col gap-1.5">
150
+ <span className="text-xs font-medium">Payload</span>
151
+ <JsonEditor value={payload} onChange={setPayload} invalid={!parsed.ok} />
152
+ {!parsed.ok ? <span className="text-xs text-destructive">{parsed.error}</span> : null}
153
+ </div>
154
+
155
+ {result ? (
156
+ <TriggerEventResult
157
+ result={result}
158
+ onOpenRun={(id) => {
159
+ onClose();
160
+ onOpenRun(id);
161
+ }}
162
+ />
163
+ ) : null}
164
+ {fire.isError ? (
165
+ <span className="text-xs text-destructive">
166
+ Could not run: {fire.error instanceof Error ? fire.error.message : "unknown error"}
167
+ </span>
168
+ ) : null}
169
+ </div>
170
+
171
+ <DialogFooter showCloseButton>
172
+ <Button disabled={!canSubmit} onClick={submit}>
173
+ {fire.isPending ? "Running..." : "Run"}
174
+ </Button>
175
+ </DialogFooter>
176
+ </>
177
+ );
178
+ }
179
+
180
+ function Row({ label, children }: { label: string; children: ReactNode }) {
181
+ return (
182
+ <div className="flex items-center gap-2">
183
+ <span className="text-muted-foreground w-12 shrink-0">{label}</span>
184
+ {children}
185
+ </div>
186
+ );
187
+ }
@@ -0,0 +1,168 @@
1
+ import { type KeyboardEvent, type ReactNode, useState } from "react";
2
+ import { useWorkflows, type WorkflowDef } from "@durablex/react";
3
+ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "../ui/resizable";
4
+ import { hasFlowControl } from "../lib/flow-control";
5
+ import { ALL_FILTER } from "../lib/run-filters";
6
+ import { formatNextFire, formatTime } from "../lib/format";
7
+ import { AppFilter } from "./filters/AppFilter";
8
+ import { AppTag } from "./AppTag";
9
+ import { ScheduledBadge } from "./ScheduledBadge";
10
+ import { FlowControlBadge } from "./FlowControlBadge";
11
+ import { WorkflowDetail } from "./WorkflowDetail";
12
+
13
+ const WORKFLOW_COLUMNS = 6;
14
+ // A workflow is identified by (app, name); JSON-encode the pair so the key can't
15
+ // collide for different splits of the same concatenation (e.g. a|bc vs ab|c).
16
+ const workflowKey = (w: { app: string; name: string }) => JSON.stringify([w.app, w.name]);
17
+
18
+ export function WorkflowsView({
19
+ onOpenRun,
20
+ renderRunAction,
21
+ }: {
22
+ onOpenRun(runId: string): void;
23
+ renderRunAction?: (workflow: WorkflowDef) => ReactNode;
24
+ }) {
25
+ const { data, isLoading, isError, error } = useWorkflows();
26
+ const [app, setApp] = useState<string>(ALL_FILTER);
27
+ const [selectedKey, setSelectedKey] = useState<string | null>(null);
28
+
29
+ const rows = (data ?? []).filter((w) => app === ALL_FILTER || w.app === app);
30
+ const selected = (data ?? []).find((w) => workflowKey(w) === selectedKey) ?? null;
31
+
32
+ return (
33
+ <ResizablePanelGroup orientation="horizontal" className="min-h-0 flex-1">
34
+ <ResizablePanel minSize="30%">
35
+ <div className="content">
36
+ <div className="tbar">
37
+ <span className="meta">{rows.length} workflows</span>
38
+ <span className="tbar-spacer" />
39
+ <AppFilter app={app} onApp={setApp} align="right" />
40
+ </div>
41
+
42
+ <div className="tablewrap">
43
+ <table className="runs">
44
+ <thead>
45
+ <tr>
46
+ <th>Name</th>
47
+ <th>App</th>
48
+ <th>Schedule</th>
49
+ <th>Next run</th>
50
+ <th className="num">Max attempts</th>
51
+ <th>Backoff</th>
52
+ </tr>
53
+ </thead>
54
+ <tbody>
55
+ {isLoading && rows.length === 0 ? (
56
+ <tr className="table-status">
57
+ <td colSpan={WORKFLOW_COLUMNS}>Loading workflows…</td>
58
+ </tr>
59
+ ) : isError ? (
60
+ <tr className="table-status">
61
+ <td colSpan={WORKFLOW_COLUMNS}>
62
+ {error instanceof Error ? error.message : "Failed to load workflows"}
63
+ </td>
64
+ </tr>
65
+ ) : rows.length === 0 ? (
66
+ <tr className="table-status">
67
+ <td colSpan={WORKFLOW_COLUMNS}>
68
+ No workflows registered. Start a runner to register one.
69
+ </td>
70
+ </tr>
71
+ ) : (
72
+ rows.map((w) => (
73
+ <WorkflowRow
74
+ key={workflowKey(w)}
75
+ workflow={w}
76
+ selected={workflowKey(w) === selectedKey}
77
+ onSelect={() => setSelectedKey(workflowKey(w))}
78
+ />
79
+ ))
80
+ )}
81
+ </tbody>
82
+ </table>
83
+ </div>
84
+ </div>
85
+ </ResizablePanel>
86
+ {selected && (
87
+ <>
88
+ <ResizableHandle withHandle />
89
+ <ResizablePanel defaultSize="42%" minSize="28%" maxSize="68%">
90
+ <WorkflowDetail
91
+ key={selectedKey ?? ""}
92
+ workflow={selected}
93
+ onClose={() => setSelectedKey(null)}
94
+ onOpenRun={onOpenRun}
95
+ renderRunAction={renderRunAction}
96
+ />
97
+ </ResizablePanel>
98
+ </>
99
+ )}
100
+ </ResizablePanelGroup>
101
+ );
102
+ }
103
+
104
+ function WorkflowRow({
105
+ workflow: w,
106
+ selected,
107
+ onSelect,
108
+ }: {
109
+ workflow: WorkflowDef;
110
+ selected: boolean;
111
+ onSelect(): void;
112
+ }) {
113
+ const onKeyDown = (e: KeyboardEvent<HTMLTableRowElement>) => {
114
+ if (e.key === "Enter" || e.key === " ") {
115
+ e.preventDefault();
116
+ onSelect();
117
+ }
118
+ };
119
+ const schedule = w.scheduled ? w.schedules?.[0] : undefined;
120
+
121
+ return (
122
+ <tr
123
+ className="row focusable"
124
+ tabIndex={0}
125
+ data-selected={selected ? "1" : "0"}
126
+ onClick={onSelect}
127
+ onKeyDown={onKeyDown}
128
+ >
129
+ <td>
130
+ <div className="wf-cell">
131
+ <span className="wf-name">{w.name}</span>
132
+ {hasFlowControl(w.flowControl) && <FlowControlBadge compact />}
133
+ </div>
134
+ </td>
135
+ <td>
136
+ <AppTag app={w.app} />
137
+ </td>
138
+ <td>
139
+ {schedule ? (
140
+ <div className="step-cell">
141
+ <ScheduledBadge compact />
142
+ <span className="font-mono text-xs">{schedule.cron}</span>
143
+ {w.schedules && w.schedules.length > 1 && (
144
+ <span className="cell-mut">+{w.schedules.length - 1}</span>
145
+ )}
146
+ </div>
147
+ ) : (
148
+ <span className="cell-mut">-</span>
149
+ )}
150
+ </td>
151
+ <td>
152
+ {schedule ? (
153
+ <span className="cell-mut" title={formatTime(schedule.nextFireAt)}>
154
+ {formatNextFire(schedule.nextFireAt)}
155
+ </span>
156
+ ) : (
157
+ <span className="cell-mut">-</span>
158
+ )}
159
+ </td>
160
+ <td className="num">
161
+ <span className="dur">{w.maxAttempts}</span>
162
+ </td>
163
+ <td>
164
+ <span className="cell-mut font-mono text-xs">{w.backoff}</span>
165
+ </td>
166
+ </tr>
167
+ );
168
+ }
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from "react";
2
+ import { SectionHeader } from "../SectionHeader";
3
+
4
+ export function ChartCard({
5
+ title,
6
+ action,
7
+ children,
8
+ }: {
9
+ title: string;
10
+ action?: ReactNode;
11
+ children: ReactNode;
12
+ }) {
13
+ return (
14
+ <section className="border-b">
15
+ <SectionHeader action={action}>{title}</SectionHeader>
16
+ <div className="p-3">{children}</div>
17
+ </section>
18
+ );
19
+ }
@@ -0,0 +1,31 @@
1
+ import { useState } from "react";
2
+ import type { RunSeries } from "@durablex/react";
3
+ import { TimeZoneFilter } from "../filters/TimeZoneFilter";
4
+ import type { TimeZoneMode } from "../../lib/time-range";
5
+ import { ChartCard } from "./ChartCard";
6
+ import { RunLatencyChart } from "./RunLatencyChart";
7
+ import { RunsOverTimeChart } from "./RunsOverTimeChart";
8
+
9
+ export function RunCharts({ series, loading }: { series?: RunSeries; loading: boolean }) {
10
+ // tz governs the wall clock both time axes render in; the toggle lives on the
11
+ // first chart since it applies to the whole pair.
12
+ const [tz, setTz] = useState<TimeZoneMode>("local");
13
+
14
+ if (!series || series.buckets.length === 0) {
15
+ return (
16
+ <div className="text-muted-foreground grid h-40 place-items-center text-xs">
17
+ {loading ? "Loading…" : "No runs in this window yet."}
18
+ </div>
19
+ );
20
+ }
21
+ return (
22
+ <>
23
+ <ChartCard title="Runs over time" action={<TimeZoneFilter tz={tz} onTz={setTz} />}>
24
+ <RunsOverTimeChart series={series} tz={tz} />
25
+ </ChartCard>
26
+ <ChartCard title="Run latency">
27
+ <RunLatencyChart series={series} tz={tz} />
28
+ </ChartCard>
29
+ </>
30
+ );
31
+ }
@@ -0,0 +1,71 @@
1
+ import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
2
+ import type { RunSeries } from "@durablex/react";
3
+ import {
4
+ type ChartConfig,
5
+ ChartContainer,
6
+ ChartLegend,
7
+ ChartLegendContent,
8
+ ChartTooltip,
9
+ ChartTooltipContent,
10
+ } from "../../ui/chart";
11
+ import { bucketTickLabel, type TimeZoneMode } from "../../lib/time-range";
12
+
13
+ const CONFIG: ChartConfig = {
14
+ avgMs: { label: "Avg", color: "var(--chart-1)" },
15
+ maxMs: { label: "Max", color: "var(--chart-4)" },
16
+ };
17
+
18
+ // formatMs renders a duration compactly for the axis/tooltip: sub-second in ms,
19
+ // otherwise seconds.
20
+ function formatMs(ms: number): string {
21
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
22
+ }
23
+
24
+ export function RunLatencyChart({ series, tz }: { series: RunSeries; tz: TimeZoneMode }) {
25
+ const rows = series.buckets.map((b) => ({
26
+ ts: b.ts,
27
+ avgMs: b.avgMs ?? null,
28
+ maxMs: b.maxMs ?? null,
29
+ }));
30
+ return (
31
+ <ChartContainer config={CONFIG} className="aspect-auto h-56 w-full">
32
+ <LineChart data={rows} margin={{ left: 4, right: 8, top: 8 }}>
33
+ <CartesianGrid vertical={false} />
34
+ <XAxis
35
+ dataKey="ts"
36
+ tickLine={false}
37
+ axisLine={false}
38
+ tickMargin={8}
39
+ minTickGap={24}
40
+ tickFormatter={(ts) => bucketTickLabel(ts, series.bucketSeconds, tz)}
41
+ />
42
+ <YAxis tickLine={false} axisLine={false} width={40} tickFormatter={formatMs} />
43
+ <ChartTooltip
44
+ content={
45
+ <ChartTooltipContent
46
+ formatter={(v) => formatMs(Number(v))}
47
+ labelFormatter={(_, p) =>
48
+ bucketTickLabel(String(p[0]?.payload.ts), series.bucketSeconds, tz)
49
+ }
50
+ />
51
+ }
52
+ />
53
+ <ChartLegend content={<ChartLegendContent />} />
54
+ <Line
55
+ dataKey="avgMs"
56
+ type="monotone"
57
+ stroke="var(--color-avgMs)"
58
+ dot={false}
59
+ connectNulls
60
+ />
61
+ <Line
62
+ dataKey="maxMs"
63
+ type="monotone"
64
+ stroke="var(--color-maxMs)"
65
+ dot={false}
66
+ connectNulls
67
+ />
68
+ </LineChart>
69
+ </ChartContainer>
70
+ );
71
+ }
@@ -0,0 +1,60 @@
1
+ import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
2
+ import type { RunSeries } from "@durablex/react";
3
+ import {
4
+ type ChartConfig,
5
+ ChartContainer,
6
+ ChartLegend,
7
+ ChartLegendContent,
8
+ ChartTooltip,
9
+ ChartTooltipContent,
10
+ } from "../../ui/chart";
11
+ import { bucketTickLabel, type TimeZoneMode } from "../../lib/time-range";
12
+
13
+ // The statuses stacked into each bucket bar, in draw order, each themed by the
14
+ // shared status token so the chart reads the same as the run badges.
15
+ const STATUS_SERIES = [
16
+ { key: "succeeded", label: "Succeeded", color: "var(--st-succeeded-fg)" },
17
+ { key: "failed", label: "Failed", color: "var(--st-failed-fg)" },
18
+ { key: "cancelled", label: "Cancelled", color: "var(--st-cancelled-fg)" },
19
+ { key: "running", label: "Running", color: "var(--st-running-fg)" },
20
+ { key: "waiting", label: "Waiting", color: "var(--st-running-fg)" },
21
+ { key: "queued", label: "Queued", color: "var(--st-queued-fg)" },
22
+ { key: "paused", label: "Paused", color: "var(--st-paused-fg)" },
23
+ ] as const;
24
+
25
+ const CONFIG: ChartConfig = Object.fromEntries(
26
+ STATUS_SERIES.map((s) => [s.key, { label: s.label, color: s.color }]),
27
+ );
28
+
29
+ export function RunsOverTimeChart({ series, tz }: { series: RunSeries; tz: TimeZoneMode }) {
30
+ const rows = series.buckets.map((b) => ({ ts: b.ts, ...b.counts }));
31
+ return (
32
+ <ChartContainer config={CONFIG} className="aspect-auto h-56 w-full">
33
+ <BarChart data={rows} margin={{ left: 4, right: 8, top: 8 }}>
34
+ <CartesianGrid vertical={false} />
35
+ <XAxis
36
+ dataKey="ts"
37
+ tickLine={false}
38
+ axisLine={false}
39
+ tickMargin={8}
40
+ minTickGap={24}
41
+ tickFormatter={(ts) => bucketTickLabel(ts, series.bucketSeconds, tz)}
42
+ />
43
+ <YAxis tickLine={false} axisLine={false} width={28} allowDecimals={false} />
44
+ <ChartTooltip
45
+ content={
46
+ <ChartTooltipContent
47
+ labelFormatter={(_, p) =>
48
+ bucketTickLabel(String(p[0]?.payload.ts), series.bucketSeconds, tz)
49
+ }
50
+ />
51
+ }
52
+ />
53
+ <ChartLegend content={<ChartLegendContent />} />
54
+ {STATUS_SERIES.map((s) => (
55
+ <Bar key={s.key} dataKey={s.key} stackId="runs" fill={`var(--color-${s.key})`} />
56
+ ))}
57
+ </BarChart>
58
+ </ChartContainer>
59
+ );
60
+ }
@@ -0,0 +1,65 @@
1
+ import { useMemo } from "react";
2
+ import { useWorkflows } from "@durablex/react";
3
+ import { appHue } from "../../lib/app-color";
4
+ import { ALL_FILTER } from "../../lib/run-filters";
5
+ import { FilterDropdown } from "./FilterDropdown";
6
+ import { FilterDropdownButton } from "./FilterDropdownButton";
7
+ import { FilterDropdownItem } from "./FilterDropdownItem";
8
+
9
+ export function AppFilter({
10
+ app,
11
+ onApp,
12
+ align,
13
+ width = 190,
14
+ }: {
15
+ app: string;
16
+ onApp(app: string): void;
17
+ align?: "right";
18
+ width?: number;
19
+ }) {
20
+ const { data } = useWorkflows();
21
+ const apps = useMemo(() => [...new Set((data ?? []).map((w) => w.app))].sort(), [data]);
22
+
23
+ return (
24
+ <FilterDropdown
25
+ align={align}
26
+ width={width}
27
+ trigger={(open, toggle) => (
28
+ <FilterDropdownButton
29
+ open={open}
30
+ toggle={toggle}
31
+ keyLabel="App"
32
+ value={app === ALL_FILTER ? "All" : app}
33
+ dot={app === ALL_FILTER ? null : appHue(app)}
34
+ />
35
+ )}
36
+ >
37
+ {(close) => (
38
+ <>
39
+ <FilterDropdownItem
40
+ active={app === ALL_FILTER}
41
+ dot={null}
42
+ label="All apps"
43
+ onClick={() => {
44
+ onApp(ALL_FILTER);
45
+ close();
46
+ }}
47
+ />
48
+ <div className="dd-sep" />
49
+ {apps.map((a) => (
50
+ <FilterDropdownItem
51
+ key={a}
52
+ active={app === a}
53
+ dot={appHue(a)}
54
+ label={a}
55
+ onClick={() => {
56
+ onApp(a);
57
+ close();
58
+ }}
59
+ />
60
+ ))}
61
+ </>
62
+ )}
63
+ </FilterDropdown>
64
+ );
65
+ }
@@ -0,0 +1,33 @@
1
+ import { useCallback, useRef, useState, type ReactNode } from "react";
2
+ import { useClickOutside } from "./use-click-outside";
3
+
4
+ export function FilterDropdown({
5
+ trigger,
6
+ align,
7
+ width,
8
+ children,
9
+ }: {
10
+ trigger: (open: boolean, toggle: () => void) => ReactNode;
11
+ align?: "right";
12
+ width?: number;
13
+ children: (close: () => void) => ReactNode;
14
+ }) {
15
+ const [open, setOpen] = useState(false);
16
+ const ref = useRef<HTMLDivElement>(null);
17
+ const close = useCallback(() => setOpen(false), []);
18
+ useClickOutside(ref, close);
19
+
20
+ return (
21
+ <div className="dd" ref={ref}>
22
+ {trigger(open, () => setOpen((o) => !o))}
23
+ {open && (
24
+ <div
25
+ className={align === "right" ? "dd-menu right" : "dd-menu"}
26
+ style={width ? { minWidth: width } : undefined}
27
+ >
28
+ {children(close)}
29
+ </div>
30
+ )}
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,31 @@
1
+ import { ChevronDown } from "lucide-react";
2
+
3
+ export function FilterDropdownButton({
4
+ open,
5
+ toggle,
6
+ keyLabel,
7
+ value,
8
+ dot,
9
+ }: {
10
+ open: boolean;
11
+ toggle: () => void;
12
+ keyLabel?: string;
13
+ value: string;
14
+ dot?: string | null;
15
+ }) {
16
+ return (
17
+ <button
18
+ type="button"
19
+ className="dd-btn focusable"
20
+ data-open={open ? "1" : "0"}
21
+ onClick={toggle}
22
+ >
23
+ {keyLabel && <span className="dd-key">{keyLabel}</span>}
24
+ <span className="dd-val">
25
+ {dot && <span className="vdot" style={{ background: dot }} />}
26
+ {value}
27
+ </span>
28
+ <ChevronDown className="dd-chev" />
29
+ </button>
30
+ );
31
+ }