@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,71 @@
|
|
|
1
|
+
import type { StatusFilter } from "../lib/run-filters";
|
|
2
|
+
import { ResumeMark } from "./ResumeMark";
|
|
3
|
+
|
|
4
|
+
export interface StatTile {
|
|
5
|
+
label: string;
|
|
6
|
+
value: number;
|
|
7
|
+
sub: string;
|
|
8
|
+
token: string;
|
|
9
|
+
mark?: boolean;
|
|
10
|
+
trend?: "up" | "down";
|
|
11
|
+
// When set, the tile is a button that filters the runs to this status (and shows
|
|
12
|
+
// an active state while that filter is on). Tiles without it stay static.
|
|
13
|
+
status?: StatusFilter;
|
|
14
|
+
active?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function StatTileGrid({
|
|
18
|
+
tiles,
|
|
19
|
+
onSelect,
|
|
20
|
+
}: {
|
|
21
|
+
tiles: StatTile[];
|
|
22
|
+
onSelect?: (status: StatusFilter) => void;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="stats">
|
|
26
|
+
{tiles.map((t) => {
|
|
27
|
+
const inner = (
|
|
28
|
+
<>
|
|
29
|
+
<div className="stat-top">
|
|
30
|
+
{t.mark ? (
|
|
31
|
+
<ResumeMark variant="run" size={12} className="stat-mark" />
|
|
32
|
+
) : (
|
|
33
|
+
<i className="stat-dot" style={{ background: `var(${t.token})` }} />
|
|
34
|
+
)}
|
|
35
|
+
<span className="stat-label">{t.label}</span>
|
|
36
|
+
</div>
|
|
37
|
+
<div className="stat-val">{t.value}</div>
|
|
38
|
+
<div className="stat-sub">
|
|
39
|
+
<span className={t.trend ?? ""}>{t.sub}</span>
|
|
40
|
+
</div>
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
if (t.status && onSelect) {
|
|
44
|
+
const status = t.status;
|
|
45
|
+
return (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className="stat stat-btn focusable"
|
|
49
|
+
key={t.label}
|
|
50
|
+
data-active={t.active ? "1" : "0"}
|
|
51
|
+
style={t.active ? { boxShadow: `inset 0 -2px 0 var(${t.token})` } : undefined}
|
|
52
|
+
title={
|
|
53
|
+
t.active
|
|
54
|
+
? `Clear the ${t.label.toLowerCase()} filter`
|
|
55
|
+
: `Show ${t.label.toLowerCase()} runs`
|
|
56
|
+
}
|
|
57
|
+
onClick={() => onSelect(status)}
|
|
58
|
+
>
|
|
59
|
+
{inner}
|
|
60
|
+
</button>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return (
|
|
64
|
+
<div className="stat" key={t.label}>
|
|
65
|
+
{inner}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { RunStats } from "@durablex/react";
|
|
2
|
+
import type { StatusFilter } from "../lib/run-filters";
|
|
3
|
+
import { StatTileGrid } from "./StatTileGrid";
|
|
4
|
+
|
|
5
|
+
const EMPTY: RunStats = {
|
|
6
|
+
total: 0,
|
|
7
|
+
active: 0,
|
|
8
|
+
queued: 0,
|
|
9
|
+
running: 0,
|
|
10
|
+
succeeded: 0,
|
|
11
|
+
failed: 0,
|
|
12
|
+
successRate: 100,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function StatsTiles({
|
|
16
|
+
stats,
|
|
17
|
+
rangeLabel,
|
|
18
|
+
activeStatus,
|
|
19
|
+
onFilterStatus,
|
|
20
|
+
}: {
|
|
21
|
+
stats?: RunStats;
|
|
22
|
+
rangeLabel?: string;
|
|
23
|
+
activeStatus?: StatusFilter;
|
|
24
|
+
onFilterStatus?: (status: StatusFilter) => void;
|
|
25
|
+
}) {
|
|
26
|
+
const s = stats ?? EMPTY;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<StatTileGrid
|
|
30
|
+
onSelect={onFilterStatus}
|
|
31
|
+
tiles={[
|
|
32
|
+
{
|
|
33
|
+
label: "Recent",
|
|
34
|
+
value: s.total,
|
|
35
|
+
sub: rangeLabel ?? "recent",
|
|
36
|
+
token: "--muted-foreground",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
label: "Active",
|
|
40
|
+
value: s.active,
|
|
41
|
+
sub: "in progress",
|
|
42
|
+
token: "--st-running-fg",
|
|
43
|
+
mark: true,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
label: "Succeeded",
|
|
47
|
+
value: s.succeeded,
|
|
48
|
+
sub: `${s.successRate}% success`,
|
|
49
|
+
token: "--st-succeeded-fg",
|
|
50
|
+
trend: "up",
|
|
51
|
+
status: "succeeded",
|
|
52
|
+
active: activeStatus === "succeeded",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
label: "Failed",
|
|
56
|
+
value: s.failed,
|
|
57
|
+
sub: s.failed ? "need attention" : "all clear",
|
|
58
|
+
token: "--st-failed-fg",
|
|
59
|
+
trend: s.failed ? "down" : undefined,
|
|
60
|
+
status: "failed",
|
|
61
|
+
active: activeStatus === "failed",
|
|
62
|
+
},
|
|
63
|
+
]}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { RunStatus, StepStatus } from "@durablex/react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { ResumeMark } from "./ResumeMark";
|
|
4
|
+
import { STATUS_LABELS } from "../lib/status-label";
|
|
5
|
+
|
|
6
|
+
type Status = RunStatus | StepStatus;
|
|
7
|
+
|
|
8
|
+
const LIVE_STATUSES: ReadonlySet<Status> = new Set(["running", "waiting"]);
|
|
9
|
+
|
|
10
|
+
export function StatusBadge({
|
|
11
|
+
status,
|
|
12
|
+
small,
|
|
13
|
+
label,
|
|
14
|
+
className,
|
|
15
|
+
}: {
|
|
16
|
+
status: Status;
|
|
17
|
+
small?: boolean;
|
|
18
|
+
label?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
}) {
|
|
21
|
+
const live = LIVE_STATUSES.has(status);
|
|
22
|
+
return (
|
|
23
|
+
<span
|
|
24
|
+
className={cn(
|
|
25
|
+
"inline-flex items-center gap-1.5 border border-transparent font-medium leading-none whitespace-nowrap",
|
|
26
|
+
small ? "h-[17px] px-1.5 text-[10px]" : "h-[19px] px-1.5 text-[11px]",
|
|
27
|
+
status === "skipped" && "border-dashed",
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
style={{
|
|
31
|
+
backgroundColor: `var(--st-${status}-bg)`,
|
|
32
|
+
color: `var(--st-${status}-fg)`,
|
|
33
|
+
borderColor:
|
|
34
|
+
status === "skipped"
|
|
35
|
+
? "color-mix(in oklch, var(--st-skipped-fg) 40%, transparent)"
|
|
36
|
+
: undefined,
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
{live ? (
|
|
40
|
+
<ResumeMark variant="run" size={small ? 12 : 13} className="badge-mark" />
|
|
41
|
+
) : (
|
|
42
|
+
<span
|
|
43
|
+
className="size-[6px] shrink-0 rounded-full"
|
|
44
|
+
style={{ backgroundColor: `var(--st-${status}-fg)` }}
|
|
45
|
+
/>
|
|
46
|
+
)}
|
|
47
|
+
{label ?? STATUS_LABELS[status] ?? status}
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ChevronRight, TriangleAlert } from "lucide-react";
|
|
2
|
+
import { Fragment, type ReactNode, useState } from "react";
|
|
3
|
+
import type { Step } from "@durablex/react";
|
|
4
|
+
import { formatDuration } from "../lib/format";
|
|
5
|
+
import { NO_LOGS, type RunLogs, stepLogKey } from "../lib/run-logs";
|
|
6
|
+
import { STATUS_LABELS } from "../lib/status-label";
|
|
7
|
+
import { initialStepSelection } from "../lib/step-detail";
|
|
8
|
+
import { stepDisplayStatus } from "../lib/step-display";
|
|
9
|
+
import { StepGlyph } from "./StepGlyph";
|
|
10
|
+
import { StepInspector } from "./StepInspector";
|
|
11
|
+
|
|
12
|
+
function FlowNode({
|
|
13
|
+
step,
|
|
14
|
+
selected,
|
|
15
|
+
runPaused,
|
|
16
|
+
onSelect,
|
|
17
|
+
}: {
|
|
18
|
+
step: Step;
|
|
19
|
+
selected: boolean;
|
|
20
|
+
runPaused: boolean;
|
|
21
|
+
onSelect(): void;
|
|
22
|
+
}) {
|
|
23
|
+
const display = stepDisplayStatus(step.status, runPaused);
|
|
24
|
+
return (
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
className={"flow-node focusable" + (selected ? " sel" : "")}
|
|
28
|
+
data-status={display}
|
|
29
|
+
aria-pressed={selected}
|
|
30
|
+
onClick={onSelect}
|
|
31
|
+
>
|
|
32
|
+
<span className="fn-rail" />
|
|
33
|
+
<span className="fn-node">
|
|
34
|
+
<StepGlyph status={display} className="fn-gly" />
|
|
35
|
+
</span>
|
|
36
|
+
<span className="fn-name">{step.name}</span>
|
|
37
|
+
{step.attempt > 1 && <span className="fn-att">×{step.attempt}</span>}
|
|
38
|
+
{display !== "succeeded" && (
|
|
39
|
+
<span className="fn-status">{STATUS_LABELS[display] ?? display}</span>
|
|
40
|
+
)}
|
|
41
|
+
{step.error && <TriangleAlert className="fn-errflag" />}
|
|
42
|
+
<span className="fn-dur">{formatDuration(step.durationMs)}</span>
|
|
43
|
+
</button>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function StepFlow({
|
|
48
|
+
steps,
|
|
49
|
+
workflowName,
|
|
50
|
+
runPaused,
|
|
51
|
+
logs,
|
|
52
|
+
renderStepRetry,
|
|
53
|
+
}: {
|
|
54
|
+
steps: Step[];
|
|
55
|
+
workflowName: string;
|
|
56
|
+
runPaused: boolean;
|
|
57
|
+
logs: RunLogs;
|
|
58
|
+
renderStepRetry?: (stepName: string) => ReactNode;
|
|
59
|
+
}) {
|
|
60
|
+
const [sel, setSel] = useState(() => initialStepSelection(steps));
|
|
61
|
+
const step = steps[sel] ?? steps[0];
|
|
62
|
+
if (!step) return null;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="stepflow">
|
|
66
|
+
<div className="flow-canvas">
|
|
67
|
+
<div className="flow-trigger">
|
|
68
|
+
<span className="flow-trigger-dot" />
|
|
69
|
+
<span>trigger</span>
|
|
70
|
+
<span className="flow-trigger-wf">{workflowName}</span>
|
|
71
|
+
</div>
|
|
72
|
+
{steps.map((s, i) => {
|
|
73
|
+
const prev = steps[i - 1];
|
|
74
|
+
return (
|
|
75
|
+
<Fragment key={`${s.index}-${s.attempt}`}>
|
|
76
|
+
<div
|
|
77
|
+
className="flow-edge"
|
|
78
|
+
data-status={prev ? prev.status : "trigger"}
|
|
79
|
+
aria-hidden="true"
|
|
80
|
+
>
|
|
81
|
+
<span className="fe-line" />
|
|
82
|
+
<ChevronRight className="fe-arrow" />
|
|
83
|
+
</div>
|
|
84
|
+
<FlowNode
|
|
85
|
+
step={s}
|
|
86
|
+
selected={i === sel}
|
|
87
|
+
runPaused={runPaused}
|
|
88
|
+
onSelect={() => setSel(i)}
|
|
89
|
+
/>
|
|
90
|
+
</Fragment>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</div>
|
|
94
|
+
<StepInspector
|
|
95
|
+
key={sel}
|
|
96
|
+
step={step}
|
|
97
|
+
logs={logs.byStep.get(stepLogKey(step.name, step.attempt)) ?? NO_LOGS}
|
|
98
|
+
index={sel}
|
|
99
|
+
total={steps.length}
|
|
100
|
+
runPaused={runPaused}
|
|
101
|
+
renderStepRetry={renderStepRetry}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Check, CircleDot, Clock, Loader2, Pause, X } from "lucide-react";
|
|
2
|
+
import type { ComponentType } from "react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import type { StepDisplayStatus } from "../lib/step-display";
|
|
5
|
+
|
|
6
|
+
const GLYPH: Record<StepDisplayStatus, ComponentType<{ className?: string }>> = {
|
|
7
|
+
succeeded: Check,
|
|
8
|
+
failed: X,
|
|
9
|
+
cancelled: X,
|
|
10
|
+
running: Loader2,
|
|
11
|
+
waiting: Clock,
|
|
12
|
+
skipped: CircleDot,
|
|
13
|
+
paused: Pause,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function StepGlyph({
|
|
17
|
+
status,
|
|
18
|
+
className,
|
|
19
|
+
}: {
|
|
20
|
+
status: StepDisplayStatus;
|
|
21
|
+
className?: string;
|
|
22
|
+
}) {
|
|
23
|
+
const Glyph = GLYPH[status] ?? CircleDot;
|
|
24
|
+
return <Glyph className={cn(className, status === "running" && "animate-spin")} />;
|
|
25
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type ReactNode, useState } from "react";
|
|
2
|
+
import type { LogFrame, Step } from "@durablex/react";
|
|
3
|
+
import { formatDuration } from "../lib/format";
|
|
4
|
+
import { stepTabs, type StepTab } from "../lib/step-detail";
|
|
5
|
+
import { stepDisplayStatus } from "../lib/step-display";
|
|
6
|
+
import { StepTabsView } from "./StepTabsView";
|
|
7
|
+
|
|
8
|
+
// Keyed by selection in the parent so the tab resets on step change without a
|
|
9
|
+
// setState-in-effect.
|
|
10
|
+
export function StepInspector({
|
|
11
|
+
step,
|
|
12
|
+
logs,
|
|
13
|
+
index,
|
|
14
|
+
total,
|
|
15
|
+
runPaused,
|
|
16
|
+
renderStepRetry,
|
|
17
|
+
}: {
|
|
18
|
+
step: Step;
|
|
19
|
+
logs: LogFrame[];
|
|
20
|
+
index: number;
|
|
21
|
+
total: number;
|
|
22
|
+
runPaused: boolean;
|
|
23
|
+
renderStepRetry?: (stepName: string) => ReactNode;
|
|
24
|
+
}) {
|
|
25
|
+
const tabs = stepTabs(step, logs.length > 0);
|
|
26
|
+
const [tab, setTab] = useState<StepTab | null>(tabs[0] ?? null);
|
|
27
|
+
return (
|
|
28
|
+
<div className="flow-inspect">
|
|
29
|
+
<div className="fi-head">
|
|
30
|
+
<span className="fi-dot" data-status={stepDisplayStatus(step.status, runPaused)} />
|
|
31
|
+
<span className="fi-name">{step.name}</span>
|
|
32
|
+
<span className="fi-meta">
|
|
33
|
+
step {index + 1}/{total} · {formatDuration(step.durationMs)}
|
|
34
|
+
</span>
|
|
35
|
+
{renderStepRetry?.(step.name)}
|
|
36
|
+
</div>
|
|
37
|
+
{tabs.length > 0 ? (
|
|
38
|
+
<StepTabsView step={step} logs={logs} tabs={tabs} tab={tab} onTab={setTab} />
|
|
39
|
+
) : (
|
|
40
|
+
<div className="fi-empty">No output, error, or logs recorded for this step.</div>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ChevronRight } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useState } from "react";
|
|
3
|
+
import type { LogFrame, Step } from "@durablex/react";
|
|
4
|
+
import { formatDuration } from "../lib/format";
|
|
5
|
+
import { STATUS_LABELS } from "../lib/status-label";
|
|
6
|
+
import { stepTabs, type StepTab } from "../lib/step-detail";
|
|
7
|
+
import { stepDisplayStatus } from "../lib/step-display";
|
|
8
|
+
import { StepGlyph } from "./StepGlyph";
|
|
9
|
+
import { StepTabsView } from "./StepTabsView";
|
|
10
|
+
|
|
11
|
+
export function StepRow({
|
|
12
|
+
step,
|
|
13
|
+
index,
|
|
14
|
+
current,
|
|
15
|
+
runPaused,
|
|
16
|
+
logs,
|
|
17
|
+
renderStepRetry,
|
|
18
|
+
}: {
|
|
19
|
+
step: Step;
|
|
20
|
+
index: number;
|
|
21
|
+
current: boolean;
|
|
22
|
+
runPaused: boolean;
|
|
23
|
+
logs: LogFrame[];
|
|
24
|
+
renderStepRetry?: (stepName: string) => ReactNode;
|
|
25
|
+
}) {
|
|
26
|
+
const tabs = stepTabs(step, logs.length > 0);
|
|
27
|
+
const retry = renderStepRetry?.(step.name);
|
|
28
|
+
const expandable = tabs.length > 0 || retry != null;
|
|
29
|
+
const [open, setOpen] = useState(step.status === "failed");
|
|
30
|
+
const [tab, setTab] = useState<StepTab | null>(tabs[0] ?? null);
|
|
31
|
+
const display = stepDisplayStatus(step.status, runPaused);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
className={"step" + (open ? " open" : "")}
|
|
36
|
+
data-status={display}
|
|
37
|
+
data-current={current ? "1" : undefined}
|
|
38
|
+
>
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
className={"step-row focusable" + (expandable ? "" : " static")}
|
|
42
|
+
aria-expanded={expandable ? open : undefined}
|
|
43
|
+
onClick={() => expandable && setOpen(!open)}
|
|
44
|
+
>
|
|
45
|
+
<span className="step-node">
|
|
46
|
+
<StepGlyph status={display} className="step-gly" />
|
|
47
|
+
</span>
|
|
48
|
+
<span className="step-ix">{index}</span>
|
|
49
|
+
<span className="step-name">{step.name}</span>
|
|
50
|
+
<span className="step-meta">
|
|
51
|
+
{display !== "succeeded" && (
|
|
52
|
+
<span className="step-status">{STATUS_LABELS[display] ?? display}</span>
|
|
53
|
+
)}
|
|
54
|
+
{step.attempt > 1 && <span className="step-att">×{step.attempt}</span>}
|
|
55
|
+
<span className="step-dur">{formatDuration(step.durationMs)}</span>
|
|
56
|
+
{expandable ? <ChevronRight className="step-chev" /> : <span className="step-chev-sp" />}
|
|
57
|
+
</span>
|
|
58
|
+
</button>
|
|
59
|
+
{open && expandable && (
|
|
60
|
+
<div className="step-body">
|
|
61
|
+
{tabs.length > 0 && (
|
|
62
|
+
<StepTabsView step={step} logs={logs} tabs={tabs} tab={tab} onTab={setTab} />
|
|
63
|
+
)}
|
|
64
|
+
{retry && <div className="step-actions">{retry}</div>}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { TriangleAlert } from "lucide-react";
|
|
2
|
+
import type { LogFrame, Step } from "@durablex/react";
|
|
3
|
+
import { STEP_TAB_LABEL, stepErrorText, type StepTab } from "../lib/step-detail";
|
|
4
|
+
import { JsonBlock } from "./JsonBlock";
|
|
5
|
+
import { LogList } from "./LogList";
|
|
6
|
+
|
|
7
|
+
export function StepTabsView({
|
|
8
|
+
step,
|
|
9
|
+
logs,
|
|
10
|
+
tabs,
|
|
11
|
+
tab,
|
|
12
|
+
onTab,
|
|
13
|
+
}: {
|
|
14
|
+
step: Step;
|
|
15
|
+
logs: LogFrame[];
|
|
16
|
+
tabs: StepTab[];
|
|
17
|
+
tab: StepTab | null;
|
|
18
|
+
onTab(t: StepTab): void;
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<div className="step-tabs" role="tablist">
|
|
23
|
+
{tabs.map((t) => (
|
|
24
|
+
<button
|
|
25
|
+
key={t}
|
|
26
|
+
type="button"
|
|
27
|
+
role="tab"
|
|
28
|
+
aria-selected={tab === t}
|
|
29
|
+
className="step-tab"
|
|
30
|
+
data-kind={t}
|
|
31
|
+
onClick={() => onTab(t)}
|
|
32
|
+
>
|
|
33
|
+
{t === "error" && <TriangleAlert className="step-tab-ico" />}
|
|
34
|
+
{STEP_TAB_LABEL[t]}
|
|
35
|
+
</button>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
<div className="step-pane">
|
|
39
|
+
{tab === "error" ? (
|
|
40
|
+
<pre className="errbox">{stepErrorText(step)}</pre>
|
|
41
|
+
) : tab === "logs" ? (
|
|
42
|
+
<LogList logs={logs} />
|
|
43
|
+
) : tab === "input" ? (
|
|
44
|
+
<JsonBlock value={step.input} />
|
|
45
|
+
) : (
|
|
46
|
+
<JsonBlock value={step.output} />
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { Step } from "@durablex/react";
|
|
3
|
+
import { formatDuration } from "../lib/format";
|
|
4
|
+
import { NO_LOGS, type RunLogs, stepLogKey } from "../lib/run-logs";
|
|
5
|
+
import { initialStepSelection } from "../lib/step-detail";
|
|
6
|
+
import { stepDisplayStatus } from "../lib/step-display";
|
|
7
|
+
import { buildTimelineLayout } from "../lib/step-timeline";
|
|
8
|
+
import { StepInspector } from "./StepInspector";
|
|
9
|
+
|
|
10
|
+
export function StepTimeline({
|
|
11
|
+
steps,
|
|
12
|
+
runPaused,
|
|
13
|
+
logs,
|
|
14
|
+
renderStepRetry,
|
|
15
|
+
}: {
|
|
16
|
+
steps: Step[];
|
|
17
|
+
runPaused: boolean;
|
|
18
|
+
logs: RunLogs;
|
|
19
|
+
renderStepRetry?: (stepName: string) => ReactNode;
|
|
20
|
+
}) {
|
|
21
|
+
const [sel, setSel] = useState(() => initialStepSelection(steps));
|
|
22
|
+
// An unfinished step's bar extends to "now"; tick it while any step is in
|
|
23
|
+
// flight so the bar grows, and leave it frozen for a terminal or paused run.
|
|
24
|
+
const ticking = !runPaused && steps.some((s) => s.status === "running" || s.status === "waiting");
|
|
25
|
+
const [now, setNow] = useState(() => Date.now());
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!ticking) return;
|
|
28
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
29
|
+
return () => clearInterval(id);
|
|
30
|
+
}, [ticking]);
|
|
31
|
+
|
|
32
|
+
const layout = useMemo(() => buildTimelineLayout(steps, now), [steps, now]);
|
|
33
|
+
const step = steps[sel] ?? steps[0];
|
|
34
|
+
if (!step) return null;
|
|
35
|
+
|
|
36
|
+
// Axis ticks are cumulative offsets from the run's start, so every label carries a
|
|
37
|
+
// leading "+" (the first reads "+0ms"), keeping the offset convention uniform.
|
|
38
|
+
const ticks = [0, 0.25, 0.5, 0.75, 1].map(
|
|
39
|
+
(f) => `+${formatDuration(Math.round(layout.totalMs * f))}`,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="steptl">
|
|
44
|
+
<div className="tl-canvas">
|
|
45
|
+
<div className="tl-axis" aria-hidden="true">
|
|
46
|
+
<span className="tl-axis-scale">
|
|
47
|
+
{ticks.map((label, i) => (
|
|
48
|
+
<span key={i}>{label}</span>
|
|
49
|
+
))}
|
|
50
|
+
</span>
|
|
51
|
+
</div>
|
|
52
|
+
{layout.lanes.map((lane, i) => (
|
|
53
|
+
<button
|
|
54
|
+
key={`${lane.step.index}-${lane.step.attempt}`}
|
|
55
|
+
type="button"
|
|
56
|
+
className={"tl-row focusable" + (i === sel ? " sel" : "")}
|
|
57
|
+
aria-pressed={i === sel}
|
|
58
|
+
onClick={() => setSel(i)}
|
|
59
|
+
>
|
|
60
|
+
<span className="tl-label">{lane.step.name}</span>
|
|
61
|
+
<span className="tl-track">
|
|
62
|
+
{lane.leftPct === null ? (
|
|
63
|
+
<span className="tl-skip">not started</span>
|
|
64
|
+
) : (
|
|
65
|
+
<span
|
|
66
|
+
className="tl-bar"
|
|
67
|
+
data-status={stepDisplayStatus(lane.step.status, runPaused)}
|
|
68
|
+
style={{ left: `${lane.leftPct}%`, width: `${lane.widthPct}%` }}
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
71
|
+
</span>
|
|
72
|
+
<span className="tl-dur tnum">{formatDuration(lane.step.durationMs)}</span>
|
|
73
|
+
</button>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
<StepInspector
|
|
77
|
+
key={sel}
|
|
78
|
+
step={step}
|
|
79
|
+
logs={logs.byStep.get(stepLogKey(step.name, step.attempt)) ?? NO_LOGS}
|
|
80
|
+
index={sel}
|
|
81
|
+
total={steps.length}
|
|
82
|
+
runPaused={runPaused}
|
|
83
|
+
renderStepRetry={renderStepRetry}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Loader2 } from "lucide-react";
|
|
2
|
+
import { TableCell, TableRow } from "../ui/table";
|
|
3
|
+
|
|
4
|
+
interface TableStatusRowsProps {
|
|
5
|
+
colSpan: number;
|
|
6
|
+
isLoading: boolean;
|
|
7
|
+
hasData: boolean;
|
|
8
|
+
isError: boolean;
|
|
9
|
+
error: Error | null;
|
|
10
|
+
emptyMessage: string;
|
|
11
|
+
errorFallback: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function TableStatusRows({
|
|
15
|
+
colSpan,
|
|
16
|
+
isLoading,
|
|
17
|
+
hasData,
|
|
18
|
+
isError,
|
|
19
|
+
error,
|
|
20
|
+
emptyMessage,
|
|
21
|
+
errorFallback,
|
|
22
|
+
}: TableStatusRowsProps) {
|
|
23
|
+
if (isLoading && !hasData) {
|
|
24
|
+
return (
|
|
25
|
+
<TableRow>
|
|
26
|
+
<TableCell colSpan={colSpan} className="py-8 text-center">
|
|
27
|
+
<Loader2 className="text-muted-foreground mx-auto h-4 w-4 animate-spin" />
|
|
28
|
+
</TableCell>
|
|
29
|
+
</TableRow>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (isError) {
|
|
34
|
+
return (
|
|
35
|
+
<TableRow>
|
|
36
|
+
<TableCell colSpan={colSpan} className="text-destructive py-6 text-center text-sm">
|
|
37
|
+
{error?.message ?? errorFallback}
|
|
38
|
+
</TableCell>
|
|
39
|
+
</TableRow>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!hasData) {
|
|
44
|
+
return (
|
|
45
|
+
<TableRow>
|
|
46
|
+
<TableCell colSpan={colSpan} className="text-muted-foreground py-6 text-center text-sm">
|
|
47
|
+
{emptyMessage}
|
|
48
|
+
</TableCell>
|
|
49
|
+
</TableRow>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
}
|