@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,110 @@
|
|
|
1
|
+
import { AlertTriangle, Inbox } from "lucide-react";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import type { Run } from "@durablex/react";
|
|
4
|
+
import { expectedStepTotals } from "../lib/run-progress";
|
|
5
|
+
import type { RunSort, RunSortKey } from "../lib/run-sort";
|
|
6
|
+
import { RunsTableHead } from "./RunsTableHead";
|
|
7
|
+
import { RunsTableLoader } from "./RunsTableLoader";
|
|
8
|
+
import { RunsTablePlaceholder } from "./RunsTablePlaceholder";
|
|
9
|
+
import { RunsTableRow } from "./RunsTableRow";
|
|
10
|
+
|
|
11
|
+
interface RunsTableProps {
|
|
12
|
+
rows: Run[];
|
|
13
|
+
selectedId: string | null;
|
|
14
|
+
onSelect(id: string): void;
|
|
15
|
+
sort: RunSort;
|
|
16
|
+
onSort(key: RunSortKey): void;
|
|
17
|
+
loading: boolean;
|
|
18
|
+
isError: boolean;
|
|
19
|
+
error: Error | null;
|
|
20
|
+
emptyTitle: string;
|
|
21
|
+
emptyMessage: string;
|
|
22
|
+
hideColumns?: RunSortKey[];
|
|
23
|
+
onReplay?(id: string): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function RunsTable({
|
|
27
|
+
rows,
|
|
28
|
+
selectedId,
|
|
29
|
+
onSelect,
|
|
30
|
+
sort,
|
|
31
|
+
onSort,
|
|
32
|
+
loading,
|
|
33
|
+
isError,
|
|
34
|
+
error,
|
|
35
|
+
emptyTitle,
|
|
36
|
+
emptyMessage,
|
|
37
|
+
hideColumns,
|
|
38
|
+
onReplay,
|
|
39
|
+
}: RunsTableProps) {
|
|
40
|
+
const expectedSteps = useMemo(() => expectedStepTotals(rows), [rows]);
|
|
41
|
+
|
|
42
|
+
if (loading && rows.length === 0) {
|
|
43
|
+
return (
|
|
44
|
+
<div className="tablewrap">
|
|
45
|
+
<RunsTableLoader />
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (isError) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="tablewrap">
|
|
53
|
+
<RunsTablePlaceholder
|
|
54
|
+
icon={AlertTriangle}
|
|
55
|
+
title="Failed to load runs"
|
|
56
|
+
message={error?.message ?? "Something went wrong."}
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (rows.length === 0) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="tablewrap">
|
|
65
|
+
<RunsTablePlaceholder icon={Inbox} title={emptyTitle} message={emptyMessage} />
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="tablewrap">
|
|
72
|
+
<table className="runs">
|
|
73
|
+
<thead>
|
|
74
|
+
<tr>
|
|
75
|
+
{!hideColumns?.includes("workflow") && (
|
|
76
|
+
<RunsTableHead label="Workflow" sortKey="workflow" sort={sort} onSort={onSort} />
|
|
77
|
+
)}
|
|
78
|
+
<RunsTableHead label="Status" sortKey="status" sort={sort} onSort={onSort} />
|
|
79
|
+
{!hideColumns?.includes("app") && (
|
|
80
|
+
<RunsTableHead label="App" sortKey="app" sort={sort} onSort={onSort} />
|
|
81
|
+
)}
|
|
82
|
+
<th>Current step</th>
|
|
83
|
+
<RunsTableHead label="Started" sortKey="started" sort={sort} onSort={onSort} />
|
|
84
|
+
<RunsTableHead
|
|
85
|
+
label="Duration"
|
|
86
|
+
sortKey="duration"
|
|
87
|
+
sort={sort}
|
|
88
|
+
onSort={onSort}
|
|
89
|
+
numeric
|
|
90
|
+
/>
|
|
91
|
+
{onReplay && <th aria-label="Actions" />}
|
|
92
|
+
</tr>
|
|
93
|
+
</thead>
|
|
94
|
+
<tbody>
|
|
95
|
+
{rows.map((run) => (
|
|
96
|
+
<RunsTableRow
|
|
97
|
+
key={run.id}
|
|
98
|
+
run={run}
|
|
99
|
+
selected={selectedId === run.id}
|
|
100
|
+
onSelect={onSelect}
|
|
101
|
+
hideColumns={hideColumns}
|
|
102
|
+
stepTotal={expectedSteps.get(run.workflowName)}
|
|
103
|
+
onReplay={onReplay}
|
|
104
|
+
/>
|
|
105
|
+
))}
|
|
106
|
+
</tbody>
|
|
107
|
+
</table>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RunSort, RunSortKey } from "../lib/run-sort";
|
|
2
|
+
|
|
3
|
+
interface RunsTableHeadProps {
|
|
4
|
+
label: string;
|
|
5
|
+
sortKey: RunSortKey;
|
|
6
|
+
sort: RunSort;
|
|
7
|
+
onSort(key: RunSortKey): void;
|
|
8
|
+
numeric?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function RunsTableHead({ label, sortKey, sort, onSort, numeric }: RunsTableHeadProps) {
|
|
12
|
+
const active = sort.key === sortKey;
|
|
13
|
+
return (
|
|
14
|
+
<th className={numeric ? "sortable num" : "sortable"} onClick={() => onSort(sortKey)}>
|
|
15
|
+
{label}
|
|
16
|
+
{active && <span className="arrow">{sort.dir === "asc" ? "↑" : "↓"}</span>}
|
|
17
|
+
</th>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ResumeMark } from "./ResumeMark";
|
|
2
|
+
|
|
3
|
+
export function RunsTableLoader() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="runloader">
|
|
6
|
+
<ResumeMark size={44} variant="load" fault className="loadmark" title="Loading runs" />
|
|
7
|
+
<div className="runloader-cap">Replaying runs…</div>
|
|
8
|
+
</div>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LucideIcon } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
interface RunsTablePlaceholderProps {
|
|
4
|
+
icon: LucideIcon;
|
|
5
|
+
title: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function RunsTablePlaceholder({ icon: Icon, title, message }: RunsTablePlaceholderProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="placeholder">
|
|
12
|
+
<div className="ph-inner">
|
|
13
|
+
<Icon className="ico" />
|
|
14
|
+
<h3>{title}</h3>
|
|
15
|
+
<p>{message}</p>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { RotateCcw } from "lucide-react";
|
|
2
|
+
import type { KeyboardEvent } from "react";
|
|
3
|
+
import { type Run, TERMINAL_STATUSES } from "@durablex/react";
|
|
4
|
+
import { formatDuration, formatRelative } from "../lib/format";
|
|
5
|
+
import { stepCellLabel } from "../lib/run-progress";
|
|
6
|
+
import type { RunSortKey } from "../lib/run-sort";
|
|
7
|
+
import { AppTag } from "./AppTag";
|
|
8
|
+
import { ScheduledBadge } from "./ScheduledBadge";
|
|
9
|
+
import { StatusBadge } from "./StatusBadge";
|
|
10
|
+
|
|
11
|
+
interface RunsTableRowProps {
|
|
12
|
+
run: Run;
|
|
13
|
+
selected: boolean;
|
|
14
|
+
onSelect(id: string): void;
|
|
15
|
+
hideColumns?: RunSortKey[];
|
|
16
|
+
stepTotal?: number;
|
|
17
|
+
onReplay?(id: string): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function RunsTableRow({
|
|
21
|
+
run,
|
|
22
|
+
selected,
|
|
23
|
+
onSelect,
|
|
24
|
+
hideColumns,
|
|
25
|
+
stepTotal,
|
|
26
|
+
onReplay,
|
|
27
|
+
}: RunsTableRowProps) {
|
|
28
|
+
const onKeyDown = (e: KeyboardEvent<HTMLTableRowElement>) => {
|
|
29
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
onSelect(run.id);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<tr
|
|
37
|
+
className="row focusable"
|
|
38
|
+
tabIndex={0}
|
|
39
|
+
data-selected={selected ? "1" : "0"}
|
|
40
|
+
onClick={() => onSelect(run.id)}
|
|
41
|
+
onKeyDown={onKeyDown}
|
|
42
|
+
>
|
|
43
|
+
{!hideColumns?.includes("workflow") && (
|
|
44
|
+
<td>
|
|
45
|
+
<div className="wf-cell">
|
|
46
|
+
<span className="wf-name">{run.workflowName}</span>
|
|
47
|
+
{run.triggerKind === "cron" && <ScheduledBadge compact />}
|
|
48
|
+
</div>
|
|
49
|
+
</td>
|
|
50
|
+
)}
|
|
51
|
+
<td>
|
|
52
|
+
<StatusBadge status={run.status} />
|
|
53
|
+
</td>
|
|
54
|
+
{!hideColumns?.includes("app") && (
|
|
55
|
+
<td>
|
|
56
|
+
<AppTag app={run.app} />
|
|
57
|
+
</td>
|
|
58
|
+
)}
|
|
59
|
+
<td>
|
|
60
|
+
<div className="step-cell">
|
|
61
|
+
{run.stepCount > 0 ? (
|
|
62
|
+
<>
|
|
63
|
+
<span className="ix" style={{ color: `var(--st-${run.status}-fg)` }}>
|
|
64
|
+
{stepCellLabel(run, stepTotal)}
|
|
65
|
+
</span>
|
|
66
|
+
{run.currentStepName && <span>{run.currentStepName}</span>}
|
|
67
|
+
</>
|
|
68
|
+
) : (
|
|
69
|
+
<span className="cell-mut">-</span>
|
|
70
|
+
)}
|
|
71
|
+
{run.status === "failed" && run.error?.message && (
|
|
72
|
+
<span className="run-err" title={run.error.message}>
|
|
73
|
+
{run.error.message.split("\n")[0]}
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
</td>
|
|
78
|
+
<td>
|
|
79
|
+
<span className="ts cell-mut">{formatRelative(run.startedAt)}</span>
|
|
80
|
+
</td>
|
|
81
|
+
<td className="num">
|
|
82
|
+
<span className="dur">{formatDuration(run.durationMs)}</span>
|
|
83
|
+
</td>
|
|
84
|
+
{onReplay && (
|
|
85
|
+
<td className="row-actions">
|
|
86
|
+
{TERMINAL_STATUSES.has(run.status) && (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
className="row-action focusable"
|
|
90
|
+
title="Replay this run"
|
|
91
|
+
onClick={(e) => {
|
|
92
|
+
e.stopPropagation();
|
|
93
|
+
onReplay(run.id);
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<RotateCcw className="size-3.5" /> Replay
|
|
97
|
+
</button>
|
|
98
|
+
)}
|
|
99
|
+
</td>
|
|
100
|
+
)}
|
|
101
|
+
</tr>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fireEvent, screen } from "@testing-library/react";
|
|
3
|
+
import { fakeClient, renderWithClient } from "../test-utils";
|
|
4
|
+
import { ALL_FILTER } from "../lib/run-filters";
|
|
5
|
+
import { DEFAULT_RUN_SORT } from "../lib/run-sort";
|
|
6
|
+
import { RunsView, type RunsViewState } from "./RunsView";
|
|
7
|
+
|
|
8
|
+
const noop = () => {};
|
|
9
|
+
|
|
10
|
+
function baseState(overrides: Partial<RunsViewState> = {}): RunsViewState {
|
|
11
|
+
return {
|
|
12
|
+
status: ALL_FILTER,
|
|
13
|
+
app: ALL_FILTER,
|
|
14
|
+
runType: ALL_FILTER,
|
|
15
|
+
time: "1h",
|
|
16
|
+
q: "",
|
|
17
|
+
deep: false,
|
|
18
|
+
sort: DEFAULT_RUN_SORT,
|
|
19
|
+
run: null,
|
|
20
|
+
event: null,
|
|
21
|
+
replayOf: null,
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("RunsView", () => {
|
|
27
|
+
it("renders the filter bar and, by default, the write surface", () => {
|
|
28
|
+
renderWithClient(<RunsView state={baseState()} onChange={noop} />, fakeClient());
|
|
29
|
+
expect(screen.getByText("Status")).toBeTruthy();
|
|
30
|
+
expect(screen.getByText("Replay matching")).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("routes a filter change through onChange", () => {
|
|
34
|
+
const onChange = vi.fn();
|
|
35
|
+
renderWithClient(<RunsView state={baseState()} onChange={onChange} />, fakeClient());
|
|
36
|
+
fireEvent.click(screen.getByText("Status"));
|
|
37
|
+
fireEvent.click(screen.getByText("Cancelled"));
|
|
38
|
+
expect(onChange).toHaveBeenCalledWith({ status: "cancelled" });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("omits the write surface when readOnly", () => {
|
|
42
|
+
renderWithClient(<RunsView state={baseState()} onChange={noop} readOnly />, fakeClient());
|
|
43
|
+
expect(screen.getByText("Status")).toBeTruthy();
|
|
44
|
+
expect(screen.queryByText("Replay matching")).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { type TriggerKind, useReplayRun, useRuns, useRunStats } from "@durablex/react";
|
|
4
|
+
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "../ui/resizable";
|
|
5
|
+
import { useKeysetPager } from "../hooks/use-keyset-pager";
|
|
6
|
+
import { ALL_FILTER, type RunTypeFilter, type StatusFilter } from "../lib/run-filters";
|
|
7
|
+
import { type RunSort, type RunSortKey, toggleRunSort } from "../lib/run-sort";
|
|
8
|
+
import { timeLabel, windowSince } from "../lib/time-range";
|
|
9
|
+
import { BulkReplayButton } from "./BulkReplayButton";
|
|
10
|
+
import { CursorPager } from "./CursorPager";
|
|
11
|
+
import { RetryFromStepButton } from "./RetryFromStepButton";
|
|
12
|
+
import { RunControlHistory } from "./RunControlHistory";
|
|
13
|
+
import { RunInspector } from "./RunInspector";
|
|
14
|
+
import { RunInspectorActions } from "./RunInspectorActions";
|
|
15
|
+
import { RunsFilterBar } from "./RunsFilterBar";
|
|
16
|
+
import { RunsTable } from "./RunsTable";
|
|
17
|
+
import { StatsTiles } from "./StatsTiles";
|
|
18
|
+
|
|
19
|
+
const PAGE_SIZE = 30;
|
|
20
|
+
|
|
21
|
+
const RUN_TYPE_TO_TRIGGER: Record<RunTypeFilter, TriggerKind | undefined> = {
|
|
22
|
+
all: undefined,
|
|
23
|
+
triggered: "event",
|
|
24
|
+
scheduled: "cron",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface RunsViewState {
|
|
28
|
+
status: StatusFilter;
|
|
29
|
+
app: string;
|
|
30
|
+
runType: RunTypeFilter;
|
|
31
|
+
time: string;
|
|
32
|
+
q: string;
|
|
33
|
+
deep: boolean;
|
|
34
|
+
sort: RunSort;
|
|
35
|
+
run: string | null;
|
|
36
|
+
event: string | null;
|
|
37
|
+
replayOf: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RunsViewProps {
|
|
41
|
+
state: RunsViewState;
|
|
42
|
+
onChange: (patch: Partial<RunsViewState>, opts?: { push?: boolean }) => void;
|
|
43
|
+
// Omit the write surface (run controls, replay, bulk replay) for a read-only embed.
|
|
44
|
+
readOnly?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function RunsView({ state, onChange, readOnly = false }: RunsViewProps) {
|
|
48
|
+
const {
|
|
49
|
+
app,
|
|
50
|
+
runType,
|
|
51
|
+
time,
|
|
52
|
+
q: query,
|
|
53
|
+
deep,
|
|
54
|
+
sort,
|
|
55
|
+
run: selectedRunId,
|
|
56
|
+
event,
|
|
57
|
+
replayOf,
|
|
58
|
+
status,
|
|
59
|
+
} = state;
|
|
60
|
+
const openRun = (id: string) => onChange({ run: id }, { push: true });
|
|
61
|
+
const onSort = (key: RunSortKey) => onChange({ sort: toggleRunSort(sort, key) });
|
|
62
|
+
|
|
63
|
+
const replay = useReplayRun();
|
|
64
|
+
const onReplay = (id: string) =>
|
|
65
|
+
replay.mutate(
|
|
66
|
+
{ runId: id },
|
|
67
|
+
{
|
|
68
|
+
onSuccess: (forked) =>
|
|
69
|
+
toast.success("Replay started", {
|
|
70
|
+
action: { label: "View run", onClick: () => openRun(forked.id) },
|
|
71
|
+
}),
|
|
72
|
+
onError: (err) => toast.error(`Replay failed: ${err.message}`),
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const since = windowSince(time);
|
|
77
|
+
const appParam = app === ALL_FILTER ? undefined : app;
|
|
78
|
+
const statusParam = status === ALL_FILTER ? undefined : status;
|
|
79
|
+
const runTypeParam = RUN_TYPE_TO_TRIGGER[runType];
|
|
80
|
+
const search = query.trim() || undefined;
|
|
81
|
+
|
|
82
|
+
const filterKey = `${statusParam ?? ""}|${appParam ?? ""}|${runTypeParam ?? ""}|${event ?? ""}|${replayOf ?? ""}|${time}|${search ?? ""}|${deep ? "d" : ""}|${sort.key}-${sort.dir}`;
|
|
83
|
+
const { cursor, canNewer, range, goOlder, goNewer } = useKeysetPager(filterKey, PAGE_SIZE);
|
|
84
|
+
|
|
85
|
+
const { data, isLoading, isError, error } = useRuns({
|
|
86
|
+
app: appParam,
|
|
87
|
+
status: statusParam,
|
|
88
|
+
runType: runTypeParam,
|
|
89
|
+
eventId: event ?? undefined,
|
|
90
|
+
replayOf: replayOf ?? undefined,
|
|
91
|
+
q: search,
|
|
92
|
+
deep,
|
|
93
|
+
since,
|
|
94
|
+
sort: sort.key,
|
|
95
|
+
dir: sort.dir,
|
|
96
|
+
limit: PAGE_SIZE,
|
|
97
|
+
cursor,
|
|
98
|
+
});
|
|
99
|
+
const stats = useRunStats({ app: appParam, since });
|
|
100
|
+
|
|
101
|
+
const rows = data?.runs ?? [];
|
|
102
|
+
const nextCursor = data?.nextCursor ?? null;
|
|
103
|
+
const { start: rangeStart, end: rangeEnd } = range(rows.length);
|
|
104
|
+
|
|
105
|
+
const filtersActive = Boolean(
|
|
106
|
+
search || statusParam || appParam || runTypeParam || event || replayOf,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
let emptyMessage: string;
|
|
110
|
+
if (deep && search) {
|
|
111
|
+
emptyMessage = "No runs contain that text in their input, result, or error.";
|
|
112
|
+
} else if (filtersActive) {
|
|
113
|
+
emptyMessage = "No runs match the current filter. Clear filters to see all recent runs.";
|
|
114
|
+
} else {
|
|
115
|
+
emptyMessage = "Trigger a workflow to see its run appear here.";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<ResizablePanelGroup orientation="horizontal" className="min-h-0 flex-1">
|
|
120
|
+
<ResizablePanel minSize="30%">
|
|
121
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
122
|
+
<StatsTiles
|
|
123
|
+
stats={stats.data}
|
|
124
|
+
rangeLabel={timeLabel(time)}
|
|
125
|
+
activeStatus={status}
|
|
126
|
+
onFilterStatus={(s) => onChange({ status: status === s ? ALL_FILTER : s })}
|
|
127
|
+
/>
|
|
128
|
+
<RunsFilterBar
|
|
129
|
+
query={query}
|
|
130
|
+
onQuery={(v) => onChange({ q: v })}
|
|
131
|
+
deep={deep}
|
|
132
|
+
onDeep={(v) => onChange({ deep: v })}
|
|
133
|
+
time={time}
|
|
134
|
+
onTime={(v) => onChange({ time: v })}
|
|
135
|
+
status={status}
|
|
136
|
+
onStatus={(v) => onChange({ status: v })}
|
|
137
|
+
app={app}
|
|
138
|
+
onApp={(v) => onChange({ app: v })}
|
|
139
|
+
runType={runType}
|
|
140
|
+
onRunType={(v) => onChange({ runType: v })}
|
|
141
|
+
shown={rows.length}
|
|
142
|
+
action={
|
|
143
|
+
readOnly ? undefined : (
|
|
144
|
+
<BulkReplayButton
|
|
145
|
+
key={filterKey}
|
|
146
|
+
filter={{ app: appParam, status: statusParam, runType: runTypeParam, since }}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
/>
|
|
151
|
+
{event && (
|
|
152
|
+
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
|
153
|
+
<span className="text-muted-foreground text-xs">Runs triggered by event</span>
|
|
154
|
+
<span className="text-[11px]" title={event}>
|
|
155
|
+
{rows[0]?.eventName ?? event}
|
|
156
|
+
</span>
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
|
|
160
|
+
onClick={() => onChange({ event: null })}
|
|
161
|
+
>
|
|
162
|
+
<X className="size-3" /> clear
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
{replayOf && (
|
|
167
|
+
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
|
168
|
+
<span className="text-muted-foreground text-xs">Runs replayed from</span>
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
className="text-[11px] hover:underline"
|
|
172
|
+
title={`Open source run ${replayOf}`}
|
|
173
|
+
onClick={() => onChange({ run: replayOf }, { push: true })}
|
|
174
|
+
>
|
|
175
|
+
{rows[0]?.workflowName ?? replayOf}
|
|
176
|
+
</button>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
|
|
180
|
+
onClick={() => onChange({ replayOf: null })}
|
|
181
|
+
>
|
|
182
|
+
<X className="size-3" /> clear
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
<RunsTable
|
|
187
|
+
rows={rows}
|
|
188
|
+
selectedId={selectedRunId}
|
|
189
|
+
onSelect={openRun}
|
|
190
|
+
sort={sort}
|
|
191
|
+
onSort={onSort}
|
|
192
|
+
loading={isLoading}
|
|
193
|
+
isError={isError}
|
|
194
|
+
error={error}
|
|
195
|
+
emptyTitle={filtersActive ? "No runs match" : "No runs yet"}
|
|
196
|
+
emptyMessage={emptyMessage}
|
|
197
|
+
onReplay={readOnly ? undefined : onReplay}
|
|
198
|
+
/>
|
|
199
|
+
{!isLoading && rows.length > 0 && (
|
|
200
|
+
<CursorPager
|
|
201
|
+
rangeStart={rangeStart}
|
|
202
|
+
rangeEnd={rangeEnd}
|
|
203
|
+
canNewer={canNewer}
|
|
204
|
+
canOlder={nextCursor != null}
|
|
205
|
+
onNewer={goNewer}
|
|
206
|
+
onOlder={() => goOlder(nextCursor)}
|
|
207
|
+
/>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
</ResizablePanel>
|
|
211
|
+
{selectedRunId && (
|
|
212
|
+
<>
|
|
213
|
+
<ResizableHandle withHandle />
|
|
214
|
+
<ResizablePanel defaultSize="38%" minSize="24%" maxSize="65%">
|
|
215
|
+
<RunInspector
|
|
216
|
+
key={selectedRunId}
|
|
217
|
+
runId={selectedRunId}
|
|
218
|
+
onClose={() => onChange({ run: null })}
|
|
219
|
+
onFilterByEvent={(id) => onChange({ event: id, run: null })}
|
|
220
|
+
onOpenRun={openRun}
|
|
221
|
+
onShowReplays={(id) => onChange({ replayOf: id, run: null }, { push: true })}
|
|
222
|
+
renderActions={
|
|
223
|
+
readOnly
|
|
224
|
+
? undefined
|
|
225
|
+
: (run) => <RunInspectorActions run={run} onOpenRun={openRun} />
|
|
226
|
+
}
|
|
227
|
+
renderControlHistory={
|
|
228
|
+
readOnly ? undefined : (id) => <RunControlHistory runId={id} onOpenRun={openRun} />
|
|
229
|
+
}
|
|
230
|
+
renderStepRetry={
|
|
231
|
+
readOnly
|
|
232
|
+
? undefined
|
|
233
|
+
: (rid, stepName) => (
|
|
234
|
+
<RetryFromStepButton runId={rid} stepName={stepName} onOpenRun={openRun} />
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
/>
|
|
238
|
+
</ResizablePanel>
|
|
239
|
+
</>
|
|
240
|
+
)}
|
|
241
|
+
</ResizablePanelGroup>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Clock } from "lucide-react";
|
|
2
|
+
import { GlyphBadge } from "./GlyphBadge";
|
|
3
|
+
|
|
4
|
+
export function ScheduledBadge({ compact, className }: { compact?: boolean; className?: string }) {
|
|
5
|
+
return (
|
|
6
|
+
<GlyphBadge
|
|
7
|
+
icon={Clock}
|
|
8
|
+
tone="scheduled"
|
|
9
|
+
label="Scheduled"
|
|
10
|
+
ariaLabel="Scheduled (cron)"
|
|
11
|
+
compact={compact}
|
|
12
|
+
className={className}
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Check, Copy, TriangleAlert } from "lucide-react";
|
|
2
|
+
import { Button } from "../ui/button";
|
|
3
|
+
import { DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
|
4
|
+
import { useCopyToClipboard } from "../hooks/use-copy";
|
|
5
|
+
|
|
6
|
+
export function SecretReveal({ secret, onDone }: { secret: string; onDone: () => void }) {
|
|
7
|
+
const { copied, copy } = useCopyToClipboard();
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<DialogHeader>
|
|
12
|
+
<DialogTitle>Signing secret</DialogTitle>
|
|
13
|
+
</DialogHeader>
|
|
14
|
+
<div className="flex flex-col gap-3 py-1">
|
|
15
|
+
<div className="text-muted-foreground flex items-start gap-2 text-xs">
|
|
16
|
+
<TriangleAlert
|
|
17
|
+
className="mt-0.5 size-3.5 shrink-0"
|
|
18
|
+
style={{ color: "var(--st-waiting-fg)" }}
|
|
19
|
+
/>
|
|
20
|
+
<span>Copy this secret now. It is stored encrypted and cannot be shown again.</span>
|
|
21
|
+
</div>
|
|
22
|
+
<div className="bg-muted flex items-center gap-2 border px-2.5 py-2">
|
|
23
|
+
<code className="flex-1 truncate font-mono text-xs">{secret}</code>
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
className="iconbtn focusable"
|
|
27
|
+
aria-label="Copy secret"
|
|
28
|
+
onClick={() => copy(secret)}
|
|
29
|
+
>
|
|
30
|
+
{copied ? (
|
|
31
|
+
<Check className="size-3.5" style={{ color: "var(--st-succeeded-fg)" }} />
|
|
32
|
+
) : (
|
|
33
|
+
<Copy className="size-3.5" />
|
|
34
|
+
)}
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
<DialogFooter>
|
|
39
|
+
<Button size="sm" onClick={onDone}>
|
|
40
|
+
Done
|
|
41
|
+
</Button>
|
|
42
|
+
</DialogFooter>
|
|
43
|
+
</>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export function SectionHeader({ children, action }: { children: ReactNode; action?: ReactNode }) {
|
|
4
|
+
return (
|
|
5
|
+
<div className="text-muted-foreground flex min-h-9 items-center justify-between border-b px-3 py-2 text-xs font-medium tracking-tight">
|
|
6
|
+
<span>{children}</span>
|
|
7
|
+
{action}
|
|
8
|
+
</div>
|
|
9
|
+
);
|
|
10
|
+
}
|