@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.
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/dist/index.d.ts +1078 -0
- package/dist/index.js +6407 -0
- package/dist/index.js.map +1 -0
- package/package.json +86 -0
- package/src/components/AnimatedDurablexMark.tsx +35 -0
- package/src/components/AppStatusBadge.tsx +17 -0
- package/src/components/AppTag.tsx +17 -0
- package/src/components/AppsView.tsx +226 -0
- package/src/components/BulkReplayButton.tsx +52 -0
- package/src/components/CursorPager.tsx +50 -0
- package/src/components/DeliveriesSplit.tsx +187 -0
- package/src/components/DeliveryDetail.tsx +188 -0
- package/src/components/DurablexLogo.tsx +12 -0
- package/src/components/EndpointFormDialog.tsx +153 -0
- package/src/components/EndpointRow.tsx +172 -0
- package/src/components/EndpointsTab.tsx +83 -0
- package/src/components/EventsList.tsx +170 -0
- package/src/components/EventsView.tsx +24 -0
- package/src/components/Facts.tsx +14 -0
- package/src/components/FlowControlBadge.tsx +23 -0
- package/src/components/FlowControlSection.tsx +82 -0
- package/src/components/FlowSummary.tsx +47 -0
- package/src/components/FormField.tsx +10 -0
- package/src/components/GlyphBadge.tsx +41 -0
- package/src/components/JsonBlock.tsx +48 -0
- package/src/components/JsonEditor.tsx +91 -0
- package/src/components/LogList.tsx +45 -0
- package/src/components/Meta.tsx +31 -0
- package/src/components/OverviewView.tsx +39 -0
- package/src/components/PayloadTabs.tsx +70 -0
- package/src/components/ReceiverFormDialog.tsx +123 -0
- package/src/components/ReceiversTab.tsx +194 -0
- package/src/components/ReplayRunDialog.tsx +112 -0
- package/src/components/ResumeMark.tsx +38 -0
- package/src/components/RetryFromStepButton.tsx +44 -0
- package/src/components/RunCancelButton.tsx +23 -0
- package/src/components/RunControlHistory.tsx +71 -0
- package/src/components/RunInspector.test.tsx +78 -0
- package/src/components/RunInspector.tsx +297 -0
- package/src/components/RunInspectorActions.tsx +40 -0
- package/src/components/RunPauseButton.tsx +34 -0
- package/src/components/RunnerLiveBadge.tsx +11 -0
- package/src/components/RunsFilterBar.tsx +180 -0
- package/src/components/RunsTable.tsx +110 -0
- package/src/components/RunsTableHead.tsx +19 -0
- package/src/components/RunsTableLoader.tsx +10 -0
- package/src/components/RunsTablePlaceholder.tsx +19 -0
- package/src/components/RunsTableRow.tsx +103 -0
- package/src/components/RunsView.test.tsx +46 -0
- package/src/components/RunsView.tsx +243 -0
- package/src/components/ScheduledBadge.tsx +15 -0
- package/src/components/SecretReveal.tsx +45 -0
- package/src/components/SectionHeader.tsx +10 -0
- package/src/components/StatTileGrid.tsx +71 -0
- package/src/components/StatsTiles.tsx +66 -0
- package/src/components/StatusBadge.tsx +50 -0
- package/src/components/StepFlow.tsx +105 -0
- package/src/components/StepGlyph.tsx +25 -0
- package/src/components/StepInspector.tsx +44 -0
- package/src/components/StepRow.tsx +69 -0
- package/src/components/StepTabsView.tsx +51 -0
- package/src/components/StepTimeline.tsx +87 -0
- package/src/components/TableStatusRows.tsx +54 -0
- package/src/components/TriggerEventDialog.tsx +180 -0
- package/src/components/TriggerEventResult.tsx +61 -0
- package/src/components/WebhookBadges.tsx +69 -0
- package/src/components/WebhookStatusBadge.tsx +25 -0
- package/src/components/WebhooksView.tsx +69 -0
- package/src/components/WorkflowDetail.tsx +149 -0
- package/src/components/WorkflowRunAction.tsx +46 -0
- package/src/components/WorkflowRunDialog.tsx +187 -0
- package/src/components/WorkflowsView.tsx +168 -0
- package/src/components/charts/ChartCard.tsx +19 -0
- package/src/components/charts/RunCharts.tsx +31 -0
- package/src/components/charts/RunLatencyChart.tsx +71 -0
- package/src/components/charts/RunsOverTimeChart.tsx +60 -0
- package/src/components/filters/AppFilter.tsx +65 -0
- package/src/components/filters/FilterDropdown.tsx +33 -0
- package/src/components/filters/FilterDropdownButton.tsx +31 -0
- package/src/components/filters/FilterDropdownItem.tsx +37 -0
- package/src/components/filters/TimeRangeFilter.tsx +43 -0
- package/src/components/filters/TimeZoneFilter.tsx +40 -0
- package/src/components/filters/use-click-outside.ts +18 -0
- package/src/components/filters-pager.test.tsx +94 -0
- package/src/components/marks-geometry.ts +10 -0
- package/src/components/replay-dialog.test.tsx +18 -0
- package/src/components/run-components.test.tsx +126 -0
- package/src/components/run-controls.test.tsx +97 -0
- package/src/hooks/use-confirm-action.ts +19 -0
- package/src/hooks/use-copy.ts +22 -0
- package/src/hooks/use-keyset-pager.ts +34 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/index.ts +165 -0
- package/src/lib/app-color.test.ts +32 -0
- package/src/lib/app-color.ts +8 -0
- package/src/lib/control-action.ts +36 -0
- package/src/lib/flow-control.ts +77 -0
- package/src/lib/format.test.ts +102 -0
- package/src/lib/format.ts +45 -0
- package/src/lib/json-highlight.test.ts +36 -0
- package/src/lib/json-highlight.ts +64 -0
- package/src/lib/run-filters.ts +8 -0
- package/src/lib/run-logs.test.ts +80 -0
- package/src/lib/run-logs.ts +34 -0
- package/src/lib/run-progress.test.ts +109 -0
- package/src/lib/run-progress.ts +44 -0
- package/src/lib/run-sort.test.ts +40 -0
- package/src/lib/run-sort.ts +19 -0
- package/src/lib/status-label.test.ts +35 -0
- package/src/lib/status-label.ts +13 -0
- package/src/lib/step-detail.test.ts +122 -0
- package/src/lib/step-detail.ts +35 -0
- package/src/lib/step-display.test.ts +19 -0
- package/src/lib/step-display.ts +13 -0
- package/src/lib/step-timeline.test.ts +89 -0
- package/src/lib/step-timeline.ts +50 -0
- package/src/lib/table.ts +2 -0
- package/src/lib/theme.ts +35 -0
- package/src/lib/time-range.ts +81 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/webhook-view.test.ts +176 -0
- package/src/lib/webhook-view.ts +113 -0
- package/src/lib/workflow-run.test.ts +55 -0
- package/src/lib/workflow-run.ts +45 -0
- package/src/shell/AppShell.tsx +34 -0
- package/src/shell/Sidebar.tsx +78 -0
- package/src/shell/Topbar.tsx +22 -0
- package/src/styles.css +2204 -0
- package/src/test-utils.tsx +130 -0
- package/src/ui/button.tsx +67 -0
- package/src/ui/chart.tsx +337 -0
- package/src/ui/dialog.tsx +145 -0
- package/src/ui/input.tsx +19 -0
- package/src/ui/resizable.tsx +40 -0
- package/src/ui/separator.tsx +28 -0
- package/src/ui/sheet.tsx +128 -0
- package/src/ui/sidebar.tsx +665 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/sonner.tsx +35 -0
- package/src/ui/table.tsx +87 -0
- 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
|
+
}
|