@outputai/cli 0.4.1-next.c0b98d8.0 → 0.4.1-next.d085dde.0
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/dist/api/generated/api.d.ts +21 -23
- package/dist/api/generated/api.js +0 -4
- package/dist/assets/docker/docker-compose-dev.yml +1 -4
- package/dist/generated/framework_version.json +1 -1
- package/dist/utils/format_workflow_result.spec.js +4 -0
- package/dist/views/dev/chrome/footer.d.ts +5 -4
- package/dist/views/dev/chrome/footer.js +12 -2
- package/dist/views/dev/chrome/header.d.ts +2 -1
- package/dist/views/dev/chrome/header.js +18 -47
- package/dist/views/dev/chrome/header.spec.js +6 -46
- package/dist/views/dev/chrome/layout_heights.spec.d.ts +1 -0
- package/dist/views/dev/chrome/layout_heights.spec.js +19 -0
- package/dist/views/dev/chrome/search_bar.d.ts +1 -1
- package/dist/views/dev/chrome/search_bar.js +10 -11
- package/dist/views/dev/chrome/tab_bar.d.ts +8 -1
- package/dist/views/dev/chrome/tab_bar.js +16 -2
- package/dist/views/dev/chrome/toasts.d.ts +1 -0
- package/dist/views/dev/chrome/toasts.js +8 -4
- package/dist/views/dev/components/content_title.d.ts +6 -0
- package/dist/views/dev/components/content_title.js +7 -0
- package/dist/views/dev/components/docker_service_status.d.ts +12 -0
- package/dist/views/dev/components/docker_service_status.js +19 -0
- package/dist/views/dev/components/inline_snippet.d.ts +4 -0
- package/dist/views/dev/components/inline_snippet.js +3 -0
- package/dist/views/dev/components/master_detail_panel.d.ts +9 -8
- package/dist/views/dev/components/master_detail_panel.js +8 -5
- package/dist/views/dev/components/run_info_sidebar.d.ts +7 -0
- package/dist/views/dev/components/run_info_sidebar.js +19 -0
- package/dist/views/dev/components/workflow_status.d.ts +12 -0
- package/dist/views/dev/components/workflow_status.js +19 -0
- package/dist/views/dev/dev_app.js +107 -31
- package/dist/views/dev/hooks/use_run_detail.js +6 -9
- package/dist/views/dev/hooks/use_run_detail.spec.js +7 -0
- package/dist/views/dev/modals/expanded_json_modal.js +5 -6
- package/dist/views/dev/modals/modal_frame.d.ts +13 -0
- package/dist/views/dev/modals/modal_frame.js +13 -0
- package/dist/views/dev/modals/run_modal.js +23 -13
- package/dist/views/dev/{panels/run_detail_view.d.ts → modals/steps_modal.d.ts} +2 -1
- package/dist/views/dev/modals/steps_modal.js +102 -0
- package/dist/views/dev/panels/help_panel.d.ts +14 -0
- package/dist/views/dev/panels/help_panel.js +19 -21
- package/dist/views/dev/panels/runs_panel.d.ts +6 -2
- package/dist/views/dev/panels/runs_panel.js +82 -83
- package/dist/views/dev/panels/runs_panel.spec.js +1 -28
- package/dist/views/dev/panels/services_panel.d.ts +6 -0
- package/dist/views/dev/panels/services_panel.js +53 -62
- package/dist/views/dev/panels/workflows_panel.d.ts +6 -0
- package/dist/views/dev/panels/workflows_panel.js +21 -29
- package/dist/views/dev/panels/workflows_panel.spec.d.ts +1 -0
- package/dist/views/dev/panels/workflows_panel.spec.js +39 -0
- package/dist/views/dev/state/ui_state.d.ts +7 -3
- package/dist/views/dev/state/ui_state.js +23 -6
- package/dist/views/dev/utils/constants.d.ts +2 -2
- package/dist/views/dev/utils/constants.js +2 -2
- package/dist/views/dev/utils/json_editor.js +3 -3
- package/dist/views/dev/utils/json_render.d.ts +2 -0
- package/dist/views/dev/utils/json_render.js +48 -6
- package/dist/views/dev/utils/json_render.spec.js +9 -1
- package/dist/views/dev/utils/panel_helpers.d.ts +15 -0
- package/dist/views/dev/utils/panel_helpers.js +30 -0
- package/dist/views/dev/utils/panel_helpers.spec.js +46 -1
- package/package.json +4 -4
- package/dist/components/status_icon.d.ts +0 -11
- package/dist/components/status_icon.js +0 -25
- package/dist/views/dev/chrome/divider.d.ts +0 -8
- package/dist/views/dev/chrome/divider.js +0 -16
- package/dist/views/dev/panels/run_detail_view.js +0 -112
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface StatusDisplay {
|
|
3
|
+
icon: string;
|
|
4
|
+
color: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const resolveWorkflowStatus: (status: string) => StatusDisplay;
|
|
7
|
+
export declare const workflowStatusColor: (status: string) => string;
|
|
8
|
+
/** Renders workflow, run, and step status without Docker status semantics. */
|
|
9
|
+
export declare const WorkflowStatusIcon: React.FC<{
|
|
10
|
+
status: string;
|
|
11
|
+
}>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
const WORKFLOW_STATUS_MAP = {
|
|
4
|
+
running: { icon: '●', color: 'yellow' },
|
|
5
|
+
completed: { icon: '●', color: 'green' },
|
|
6
|
+
failed: { icon: '✗', color: 'red' },
|
|
7
|
+
canceled: { icon: '○', color: 'gray' },
|
|
8
|
+
terminated: { icon: '✗', color: 'gray' },
|
|
9
|
+
timed_out: { icon: '✗', color: 'red' },
|
|
10
|
+
continued: { icon: '↻', color: 'blue' }
|
|
11
|
+
};
|
|
12
|
+
const DEFAULT_DISPLAY = { icon: '?', color: 'white' };
|
|
13
|
+
export const resolveWorkflowStatus = (status) => WORKFLOW_STATUS_MAP[status] ?? DEFAULT_DISPLAY;
|
|
14
|
+
export const workflowStatusColor = (status) => resolveWorkflowStatus(status).color;
|
|
15
|
+
/** Renders workflow, run, and step status without Docker status semantics. */
|
|
16
|
+
export const WorkflowStatusIcon = ({ status }) => {
|
|
17
|
+
const { icon, color } = resolveWorkflowStatus(status);
|
|
18
|
+
return _jsx(Text, { color: color, children: icon });
|
|
19
|
+
};
|
|
@@ -1,28 +1,56 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import { Box, useApp, useInput, useStdout } from 'ink';
|
|
3
|
+
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
4
4
|
import { isServiceFailed, isServiceHealthy } from '#services/docker.js';
|
|
5
|
-
import { openUrl } from '#utils/open_url.js';
|
|
6
5
|
import { useHealthPolling, useStatusRefresh, useWorkflowRunsPolling } from '#views/dev/hooks/use_poll.js';
|
|
7
6
|
import { useWorkflowCatalog } from '#views/dev/hooks/use_workflow_catalog.js';
|
|
8
|
-
import { Header, buildSummaryCounters } from '#views/dev/chrome/header.js';
|
|
9
|
-
import { TabBar } from '#views/dev/chrome/tab_bar.js';
|
|
10
|
-
import { SearchBar } from '#views/dev/chrome/search_bar.js';
|
|
11
|
-
import { Toasts } from '#views/dev/chrome/toasts.js';
|
|
12
|
-
import {
|
|
7
|
+
import { Header, buildSummaryCounters, getHeight as getHeaderHeight } from '#views/dev/chrome/header.js';
|
|
8
|
+
import { TabBar, getHeight as getTabBarHeight } from '#views/dev/chrome/tab_bar.js';
|
|
9
|
+
import { SearchBar, useHeight as useSearchBarHeight } from '#views/dev/chrome/search_bar.js';
|
|
10
|
+
import { Toasts, useHeight as useToastsHeight } from '#views/dev/chrome/toasts.js';
|
|
11
|
+
import { Footer, getHeight as getFooterHeight } from '#views/dev/chrome/footer.js';
|
|
12
|
+
import { RULE_PURPLE } from '#views/dev/chrome/palette.js';
|
|
13
13
|
import { UiStateProvider, useUiState } from '#views/dev/state/ui_state.js';
|
|
14
|
-
import { WorkflowsPanel } from '#views/dev/panels/workflows_panel.js';
|
|
15
|
-
import { RunsPanel } from '#views/dev/panels/runs_panel.js';
|
|
16
|
-
import { ServicesPanel } from '#views/dev/panels/services_panel.js';
|
|
17
|
-
import { HelpPanel } from '#views/dev/panels/help_panel.js';
|
|
14
|
+
import { WorkflowsPanel, WORKFLOWS_HINTS, WORKFLOWS_LOADING_HINTS, buildVisibleWorkflows } from '#views/dev/panels/workflows_panel.js';
|
|
15
|
+
import { RunsPanel, RUNS_EMPTY_HINTS, RUNS_HINTS, buildVisibleRuns } from '#views/dev/panels/runs_panel.js';
|
|
16
|
+
import { ServicesPanel, SERVICES_BOOT_HINTS, SERVICES_HINTS } from '#views/dev/panels/services_panel.js';
|
|
17
|
+
import { HELP_HINTS, HELP_SECTION_COUNT, HelpPanel } from '#views/dev/panels/help_panel.js';
|
|
18
18
|
import { RunModal } from '#views/dev/modals/run_modal.js';
|
|
19
19
|
import { ExpandedJsonModal } from '#views/dev/modals/expanded_json_modal.js';
|
|
20
|
+
import { StepsModal } from '#views/dev/modals/steps_modal.js';
|
|
21
|
+
import { MIN_TERMINAL_COLUMNS, MIN_TERMINAL_ROWS } from '#views/dev/utils/constants.js';
|
|
20
22
|
const TAB_NUMBER_KEYS = {
|
|
21
23
|
1: 'workflows',
|
|
22
24
|
2: 'runs',
|
|
23
25
|
3: 'services',
|
|
24
26
|
4: 'help'
|
|
25
27
|
};
|
|
28
|
+
const getContentRows = (opts) => {
|
|
29
|
+
return Math.max(1, opts.rows -
|
|
30
|
+
getHeaderHeight(opts.rows) -
|
|
31
|
+
getTabBarHeight() -
|
|
32
|
+
opts.searchHeight -
|
|
33
|
+
opts.toastsHeight -
|
|
34
|
+
getFooterHeight());
|
|
35
|
+
};
|
|
36
|
+
const useTerminalSize = () => {
|
|
37
|
+
const { stdout } = useStdout();
|
|
38
|
+
const readSize = () => ({
|
|
39
|
+
rows: stdout?.rows ?? 60,
|
|
40
|
+
cols: stdout?.columns ?? 80
|
|
41
|
+
});
|
|
42
|
+
const [size, setSize] = useState(readSize);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const update = () => setSize(readSize());
|
|
45
|
+
update();
|
|
46
|
+
stdout?.on('resize', update);
|
|
47
|
+
return () => {
|
|
48
|
+
stdout?.off('resize', update);
|
|
49
|
+
};
|
|
50
|
+
}, [stdout]);
|
|
51
|
+
return size;
|
|
52
|
+
};
|
|
53
|
+
const TerminalTooSmall = ({ rows, cols }) => (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, children: [_jsx(Text, { bold: true, children: "Terminal too small for Output dev UI." }), _jsxs(Text, { dimColor: true, children: ["Resize to at least ", MIN_TERMINAL_COLUMNS, "x", MIN_TERMINAL_ROWS, " characters. Current size: ", cols, "x", rows, "."] })] }));
|
|
26
54
|
const useGlobalInput = (opts) => {
|
|
27
55
|
const ui = useUiState();
|
|
28
56
|
const { exit } = useApp();
|
|
@@ -37,7 +65,7 @@ const useGlobalInput = (opts) => {
|
|
|
37
65
|
.catch(err => exit(err instanceof Error ? err : new Error(String(err))));
|
|
38
66
|
return;
|
|
39
67
|
}
|
|
40
|
-
if (ui.search.open || ui.runModal.open || ui.expandedJson.open) {
|
|
68
|
+
if (ui.search.open || ui.runModal.open || ui.expandedJson.open || opts.runDetailOpen) {
|
|
41
69
|
return;
|
|
42
70
|
}
|
|
43
71
|
// Esc on a list view drops an active filter. Skip when we're on
|
|
@@ -45,19 +73,10 @@ const useGlobalInput = (opts) => {
|
|
|
45
73
|
// to the list and the filter should still apply when we land
|
|
46
74
|
// there. The search bar's esc (close + clear) returns above, so
|
|
47
75
|
// it never reaches this branch.
|
|
48
|
-
if (key.escape && ui.search.query &&
|
|
76
|
+
if (key.escape && ui.search.query && !opts.runDetailOpen) {
|
|
49
77
|
ui.clearSearch();
|
|
50
78
|
return;
|
|
51
79
|
}
|
|
52
|
-
// Switching tabs is treated as leaving the current view, so any
|
|
53
|
-
// active filter goes with it. App-driven setTab calls (e.g. the
|
|
54
|
-
// run modal pre-filtering Recent Runs to a workflow) bypass this
|
|
55
|
-
// path on purpose.
|
|
56
|
-
if (key.tab || (input && TAB_NUMBER_KEYS[input])) {
|
|
57
|
-
if (ui.search.query) {
|
|
58
|
-
ui.clearSearch();
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
80
|
if (key.tab && key.shift) {
|
|
62
81
|
ui.prevTab();
|
|
63
82
|
return;
|
|
@@ -78,9 +97,6 @@ const useGlobalInput = (opts) => {
|
|
|
78
97
|
ui.setTab(TAB_NUMBER_KEYS[input]);
|
|
79
98
|
return;
|
|
80
99
|
}
|
|
81
|
-
if (input === 'o' && ui.tab === 'services') {
|
|
82
|
-
openUrl('http://localhost:8080');
|
|
83
|
-
}
|
|
84
100
|
});
|
|
85
101
|
};
|
|
86
102
|
const computeWorkflowSummary = (runs) => {
|
|
@@ -94,6 +110,40 @@ const computeWorkflowSummary = (runs) => {
|
|
|
94
110
|
total: runs.length
|
|
95
111
|
};
|
|
96
112
|
};
|
|
113
|
+
const footerFor = (opts) => {
|
|
114
|
+
if (opts.ui.tab === 'workflows') {
|
|
115
|
+
if (opts.workflowCount === 0) {
|
|
116
|
+
return { hints: WORKFLOWS_LOADING_HINTS };
|
|
117
|
+
}
|
|
118
|
+
return { hints: WORKFLOWS_HINTS, itemCount: opts.visibleWorkflowCount, itemLabel: 'workflows' };
|
|
119
|
+
}
|
|
120
|
+
if (opts.ui.tab === 'runs') {
|
|
121
|
+
if (opts.runCount === 0) {
|
|
122
|
+
return { hints: RUNS_EMPTY_HINTS };
|
|
123
|
+
}
|
|
124
|
+
return { hints: RUNS_HINTS, itemCount: opts.visibleRunCount, itemLabel: 'runs' };
|
|
125
|
+
}
|
|
126
|
+
if (opts.ui.tab === 'services') {
|
|
127
|
+
return {
|
|
128
|
+
hints: opts.phase === 'waiting' && opts.serviceCount === 0 ? SERVICES_BOOT_HINTS : SERVICES_HINTS,
|
|
129
|
+
itemCount: opts.serviceCount,
|
|
130
|
+
itemLabel: 'services'
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { hints: HELP_HINTS, itemCount: HELP_SECTION_COUNT, itemLabel: 'sections' };
|
|
134
|
+
};
|
|
135
|
+
const overlayFor = (opts) => {
|
|
136
|
+
if (opts.ui.expandedJson.open) {
|
|
137
|
+
return _jsx(ExpandedJsonModal, {});
|
|
138
|
+
}
|
|
139
|
+
if (opts.ui.runModal.open) {
|
|
140
|
+
return _jsx(RunModal, { workflowName: opts.ui.runModal.workflowName, workflowPath: opts.ui.runModal.workflowPath });
|
|
141
|
+
}
|
|
142
|
+
if (opts.ui.tab === 'runs' && opts.runDetailOpen && opts.detailRun) {
|
|
143
|
+
return _jsx(StepsModal, { run: opts.detailRun, height: opts.rows });
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
};
|
|
97
147
|
const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
98
148
|
const { exit } = useApp();
|
|
99
149
|
const ui = useUiState();
|
|
@@ -120,8 +170,14 @@ const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
|
120
170
|
setTab('workflows');
|
|
121
171
|
}
|
|
122
172
|
}, [phase, workflows.length, ui.tab, setTab]);
|
|
123
|
-
useGlobalInput({ onCleanup });
|
|
124
173
|
const summary = useMemo(() => computeWorkflowSummary(runs), [runs]);
|
|
174
|
+
const visibleWorkflows = useMemo(() => buildVisibleWorkflows(workflows, ui.search.query), [workflows, ui.search.query]);
|
|
175
|
+
const visibleRuns = useMemo(() => buildVisibleRuns(runs, ui.search.query), [runs, ui.search.query]);
|
|
176
|
+
const detailRun = ui.runsView === 'detail' ?
|
|
177
|
+
runs.find(r => r.runId === ui.selection.runId && r.workflowId === ui.selection.workflowId) :
|
|
178
|
+
undefined;
|
|
179
|
+
const runDetailOpen = ui.runsView === 'detail' && detailRun !== undefined;
|
|
180
|
+
useGlobalInput({ onCleanup, runDetailOpen });
|
|
125
181
|
const failingServices = useMemo(() => services.filter(isServiceFailed).length, [services]);
|
|
126
182
|
const serviceBadge = useMemo(() => {
|
|
127
183
|
if (failingServices > 0) {
|
|
@@ -136,11 +192,31 @@ const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
|
136
192
|
// `stdout.rows` is undefined on a small set of TTYs (mostly piped envs).
|
|
137
193
|
// 60 is a generous default — chrome alone is ~10 rows, and run-detail
|
|
138
194
|
// wants ~25 of content, so anything below 40 starts to clip step rows.
|
|
139
|
-
const {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
195
|
+
const { rows, cols } = useTerminalSize();
|
|
196
|
+
const searchHeight = useSearchBarHeight();
|
|
197
|
+
const toastsHeight = useToastsHeight();
|
|
198
|
+
const terminalTooSmall = cols < MIN_TERMINAL_COLUMNS || rows < MIN_TERMINAL_ROWS;
|
|
199
|
+
const contentRows = getContentRows({
|
|
200
|
+
rows,
|
|
201
|
+
searchHeight,
|
|
202
|
+
toastsHeight
|
|
203
|
+
});
|
|
204
|
+
const footer = footerFor({
|
|
205
|
+
ui,
|
|
206
|
+
workflowCount: workflows.length,
|
|
207
|
+
visibleWorkflowCount: visibleWorkflows.length,
|
|
208
|
+
runCount: runs.length,
|
|
209
|
+
visibleRunCount: visibleRuns.length,
|
|
210
|
+
serviceCount: services.length,
|
|
211
|
+
phase
|
|
212
|
+
});
|
|
213
|
+
const overlay = overlayFor({ ui, detailRun, runDetailOpen, rows });
|
|
214
|
+
if (terminalTooSmall) {
|
|
215
|
+
return _jsx(TerminalTooSmall, { rows: rows, cols: cols });
|
|
216
|
+
}
|
|
217
|
+
if (overlay) {
|
|
218
|
+
return (_jsx(Box, { flexDirection: "column", height: rows, paddingX: 1, children: overlay }));
|
|
143
219
|
}
|
|
144
|
-
return (_jsxs(Box, { flexDirection: "column", height: rows, paddingX:
|
|
220
|
+
return (_jsxs(Box, { flexDirection: "column", height: rows, paddingX: 1, children: [_jsx(Header, { counters: counters }), _jsx(TabBar, { active: ui.tab, borderColor: RULE_PURPLE }), _jsx(SearchBar, { active: ui.search.open }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, height: contentRows, overflow: "hidden", children: [ui.tab === 'workflows' && _jsx(WorkflowsPanel, { workflows: workflows, runs: runs }), ui.tab === 'runs' && _jsx(RunsPanel, { runs: runs, height: contentRows }), ui.tab === 'services' && (_jsx(ServicesPanel, { height: contentRows, phase: phase, services: services, dockerComposePath: dockerComposePath })), ui.tab === 'help' && _jsx(HelpPanel, {})] }), _jsx(Toasts, {}), _jsx(Footer, { hints: footer.hints, itemCount: footer.itemCount, itemLabel: footer.itemLabel })] }));
|
|
145
221
|
};
|
|
146
222
|
export const DevApp = ({ dockerComposePath, onCleanup }) => (_jsx(UiStateProvider, { children: _jsx(Shell, { dockerComposePath: dockerComposePath, onCleanup: onCleanup }) }));
|
|
@@ -7,6 +7,7 @@ const EMPTY_DETAIL = {
|
|
|
7
7
|
steps: [],
|
|
8
8
|
loading: false
|
|
9
9
|
};
|
|
10
|
+
const runDetailCache = new Map();
|
|
10
11
|
const stepNameOf = (node) => {
|
|
11
12
|
if (node.name) {
|
|
12
13
|
return node.name;
|
|
@@ -16,16 +17,13 @@ const stepNameOf = (node) => {
|
|
|
16
17
|
return `${kind}#${leaf}`;
|
|
17
18
|
};
|
|
18
19
|
const stepStatusOf = (node) => {
|
|
19
|
-
if (node.status) {
|
|
20
|
-
return node.status;
|
|
21
|
-
}
|
|
22
|
-
if (node.phase === 'error' || node.error) {
|
|
20
|
+
if (node.phase === 'error' || node.error || node.status === 'failed') {
|
|
23
21
|
return 'failed';
|
|
24
22
|
}
|
|
25
|
-
if (node.phase === 'end') {
|
|
23
|
+
if (node.phase === 'end' || node.status === 'completed' || node.endedAt !== undefined) {
|
|
26
24
|
return 'completed';
|
|
27
25
|
}
|
|
28
|
-
return 'running';
|
|
26
|
+
return node.status ?? 'running';
|
|
29
27
|
};
|
|
30
28
|
const numericTimestamp = (...candidates) => {
|
|
31
29
|
for (const candidate of candidates) {
|
|
@@ -106,7 +104,6 @@ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'canceled', 'terminate
|
|
|
106
104
|
export const isTerminalRunStatus = (status) => Boolean(status && TERMINAL_STATUSES.has(status));
|
|
107
105
|
export const useRunDetail = (workflowId, runId, status) => {
|
|
108
106
|
const [detail, setDetail] = useState(EMPTY_DETAIL);
|
|
109
|
-
const cacheRef = useRef(new Map());
|
|
110
107
|
const fetchIdRef = useRef(0);
|
|
111
108
|
useEffect(() => {
|
|
112
109
|
if (!workflowId) {
|
|
@@ -116,7 +113,7 @@ export const useRunDetail = (workflowId, runId, status) => {
|
|
|
116
113
|
// The cache only ever holds terminal-status entries (see below), so
|
|
117
114
|
// a hit here is always safe to reuse without a network roundtrip.
|
|
118
115
|
const key = `${workflowId}:${runId ?? 'latest'}`;
|
|
119
|
-
const cached =
|
|
116
|
+
const cached = runDetailCache.get(key);
|
|
120
117
|
if (cached) {
|
|
121
118
|
setDetail(cached);
|
|
122
119
|
return;
|
|
@@ -140,7 +137,7 @@ export const useRunDetail = (workflowId, runId, status) => {
|
|
|
140
137
|
// still running the API returns partial data; a follow-up status
|
|
141
138
|
// change re-fires this effect and we re-fetch fresh.
|
|
142
139
|
if (isTerminalRunStatus(result?.status)) {
|
|
143
|
-
|
|
140
|
+
runDetailCache.set(key, next);
|
|
144
141
|
}
|
|
145
142
|
setDetail(next);
|
|
146
143
|
});
|
|
@@ -45,6 +45,13 @@ describe('extractSteps', () => {
|
|
|
45
45
|
expect(steps[0].status).toBe('failed');
|
|
46
46
|
expect(steps[1].status).toBe('failed');
|
|
47
47
|
});
|
|
48
|
+
it('maps buildTraceTree-style ended nodes to status=completed', () => {
|
|
49
|
+
const t = trace([
|
|
50
|
+
{ name: 'done', kind: 'step', startedAt: 1000, endedAt: 1500, output: { ok: true } }
|
|
51
|
+
]);
|
|
52
|
+
const steps = extractSteps(t);
|
|
53
|
+
expect(steps[0].status).toBe('completed');
|
|
54
|
+
});
|
|
48
55
|
it('falls back to startTime/endTime for duration', () => {
|
|
49
56
|
const t = trace([
|
|
50
57
|
{ phase: 'end', name: 'with-times', startTime: 1000, endTime: 1500 }
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import { Text, useInput, useStdout } from 'ink';
|
|
4
4
|
import { useUiState } from '#views/dev/state/ui_state.js';
|
|
5
5
|
import { JsonView, countJsonLines } from '#views/dev/utils/json_render.js';
|
|
6
|
-
import {
|
|
7
|
-
const CHROME_HEIGHT =
|
|
6
|
+
import { ModalFrame } from '#views/dev/modals/modal_frame.js';
|
|
7
|
+
const CHROME_HEIGHT = 6;
|
|
8
8
|
const FALLBACK_ROWS = 24;
|
|
9
9
|
const PAGE_SIZE = 10;
|
|
10
10
|
export const ExpandedJsonModal = () => {
|
|
@@ -13,7 +13,6 @@ export const ExpandedJsonModal = () => {
|
|
|
13
13
|
const [offset, setOffset] = useState(0);
|
|
14
14
|
const { value, title } = ui.expandedJson;
|
|
15
15
|
const totalLines = countJsonLines(value);
|
|
16
|
-
const cols = stdout?.columns ?? 80;
|
|
17
16
|
const rows = stdout?.rows ?? FALLBACK_ROWS;
|
|
18
17
|
const visibleLines = Math.max(5, rows - CHROME_HEIGHT);
|
|
19
18
|
const maxOffset = Math.max(0, totalLines - visibleLines);
|
|
@@ -40,5 +39,5 @@ export const ExpandedJsonModal = () => {
|
|
|
40
39
|
}
|
|
41
40
|
});
|
|
42
41
|
const progress = totalLines === 0 ? 100 : Math.round(((clampedOffset + visibleLines) / totalLines) * 100);
|
|
43
|
-
return (
|
|
42
|
+
return (_jsx(ModalFrame, { title: title, titleRight: (_jsxs(Text, { dimColor: true, children: [Math.min(100, progress), "% line ", clampedOffset + 1, "-", Math.min(totalLines, clampedOffset + visibleLines), "/", totalLines] })), shortcuts: [['↑/↓', 'scroll'], ['pgup/pgdn', 'page'], ['esc', 'close']], children: _jsx(JsonView, { value: value, maxLines: visibleLines, offset: clampedOffset, truncateLine: true, showOverflowFooter: false }) }));
|
|
44
43
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export declare const getHeight: () => number;
|
|
3
|
+
export type ModalShortcut = readonly [key: string, label: string] | {
|
|
4
|
+
key: string;
|
|
5
|
+
label: string;
|
|
6
|
+
};
|
|
7
|
+
export declare const ModalFrame: React.FC<{
|
|
8
|
+
title: string;
|
|
9
|
+
titleRight?: React.ReactNode;
|
|
10
|
+
footer?: React.ReactNode;
|
|
11
|
+
shortcuts?: readonly ModalShortcut[];
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { RULE_PURPLE } from '#views/dev/chrome/palette.js';
|
|
4
|
+
const FRAME_BORDER_ROWS = 2;
|
|
5
|
+
const FRAME_TITLE_ROWS = 1;
|
|
6
|
+
const FRAME_FOOTER_ROWS = 1;
|
|
7
|
+
const FRAME_GAP_ROWS = 2;
|
|
8
|
+
export const getHeight = () => FRAME_BORDER_ROWS + FRAME_TITLE_ROWS + FRAME_FOOTER_ROWS + FRAME_GAP_ROWS;
|
|
9
|
+
const isShortcutTuple = (shortcut) => Array.isArray(shortcut);
|
|
10
|
+
const shortcutKey = (shortcut) => isShortcutTuple(shortcut) ? shortcut[0] : shortcut.key;
|
|
11
|
+
const shortcutLabel = (shortcut) => isShortcutTuple(shortcut) ? shortcut[1] : shortcut.label;
|
|
12
|
+
const ModalShortcutList = ({ shortcuts }) => (_jsx(Box, { columnGap: 2, children: shortcuts.map(shortcut => (_jsxs(Box, { columnGap: 1, children: [_jsx(Text, { bold: true, children: shortcutKey(shortcut) }), _jsx(Text, { dimColor: true, children: shortcutLabel(shortcut) })] }, shortcutKey(shortcut)))) }));
|
|
13
|
+
export const ModalFrame = ({ title, titleRight, footer, shortcuts, children }) => (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: RULE_PURPLE, paddingX: 1, gap: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: title }), titleRight] }), _jsx(Box, { flexDirection: "column", children: children }), shortcuts && shortcuts.length > 0 ? _jsx(ModalShortcutList, { shortcuts: shortcuts }) : footer] }));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
2
|
+
import { useMemo, useState } from 'react';
|
|
3
3
|
import { Box, Text, useInput } from 'ink';
|
|
4
4
|
import Spinner from 'ink-spinner';
|
|
5
5
|
import { listScenariosForWorkflow } from '#utils/scenario_resolver.js';
|
|
@@ -8,6 +8,7 @@ import { useUiState } from '#views/dev/state/ui_state.js';
|
|
|
8
8
|
import { startWorkflow } from '#views/dev/services/run_workflow.js';
|
|
9
9
|
import { readScenario, writeScenario } from '#views/dev/services/scenario_io.js';
|
|
10
10
|
import { JsonEditor } from '#views/dev/utils/json_editor.js';
|
|
11
|
+
import { ModalFrame } from '#views/dev/modals/modal_frame.js';
|
|
11
12
|
const CUSTOM_SEED = { '': '' };
|
|
12
13
|
const SCENARIO_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
13
14
|
const buildEntries = (scenarios) => {
|
|
@@ -16,11 +17,24 @@ const buildEntries = (scenarios) => {
|
|
|
16
17
|
label: s,
|
|
17
18
|
scenarioName: s
|
|
18
19
|
}));
|
|
19
|
-
list.push({ kind: 'custom', label: '
|
|
20
|
+
list.push({ kind: 'custom', label: '[Create new scenario]' });
|
|
20
21
|
return list;
|
|
21
22
|
};
|
|
22
|
-
const
|
|
23
|
-
|
|
23
|
+
const SELECT_SHORTCUTS = [
|
|
24
|
+
['↑/↓', 'navigate'],
|
|
25
|
+
['enter', 'run'],
|
|
26
|
+
['d', 'duplicate'],
|
|
27
|
+
['esc', 'cancel']
|
|
28
|
+
];
|
|
29
|
+
const NAME_SHORTCUTS = [
|
|
30
|
+
['enter', 'next'],
|
|
31
|
+
['esc', 'back']
|
|
32
|
+
];
|
|
33
|
+
const ERROR_SHORTCUTS = [
|
|
34
|
+
{ key: 'enter', label: 'return' },
|
|
35
|
+
{ key: 'esc', label: 'return' }
|
|
36
|
+
];
|
|
37
|
+
const TextPrompt = ({ label, value }) => (_jsxs(Box, { children: [_jsx(Text, { children: label }), _jsx(Text, { children: value }), _jsx(Text, { inverse: true, children: ' ' })] }));
|
|
24
38
|
export const RunModal = ({ workflowName, workflowPath }) => {
|
|
25
39
|
const ui = useUiState();
|
|
26
40
|
const scenarios = useMemo(() => listScenariosForWorkflow(workflowName, workflowPath), [workflowName, workflowPath]);
|
|
@@ -192,22 +206,18 @@ export const RunModal = ({ workflowName, workflowPath }) => {
|
|
|
192
206
|
}
|
|
193
207
|
});
|
|
194
208
|
if (mode === 'edit_content') {
|
|
195
|
-
return (_jsx(
|
|
209
|
+
return (_jsx(ModalFrame, { title: editFrameTitle, children: _jsx(JsonEditor, { seed: editSeed, title: `${editName}.json`, isActive: true, onSubmit: value => {
|
|
196
210
|
void handleEditorSubmit(value);
|
|
197
211
|
}, onCancel: handleEditorCancel }) }));
|
|
198
212
|
}
|
|
199
213
|
if (mode === 'edit_name') {
|
|
200
|
-
return (_jsxs(
|
|
214
|
+
return (_jsxs(ModalFrame, { title: editFrameTitle, shortcuts: NAME_SHORTCUTS, children: [_jsx(TextPrompt, { label: "Scenario name:", value: editName }), nameError ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", children: nameError }) })) : null] }));
|
|
201
215
|
}
|
|
202
216
|
if (mode === 'submitting') {
|
|
203
|
-
return (
|
|
217
|
+
return (_jsxs(ModalFrame, { title: `Run ${workflowName}`, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: "\u00A0Starting workflow\u2026" })] }));
|
|
204
218
|
}
|
|
205
219
|
if (mode === 'error') {
|
|
206
|
-
return (_jsx(
|
|
220
|
+
return (_jsx(ModalFrame, { title: `Run workflow "${workflowName}"`, shortcuts: ERROR_SHORTCUTS, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", errorMessage ?? 'Something went wrong.'] }) }));
|
|
207
221
|
}
|
|
208
|
-
return (
|
|
209
|
-
const prev = i > 0 ? entries[i - 1] : undefined;
|
|
210
|
-
const showSeparator = prev?.kind === 'scenario' && entry.kind !== 'scenario';
|
|
211
|
-
return (_jsxs(React.Fragment, { children: [showSeparator && (_jsx(Box, { marginY: 0, children: _jsx(Text, { dimColor: true, children: '─'.repeat(40) }) })), _jsxs(Box, { children: [_jsx(SelectionIndicator, { selected: i === index }), _jsxs(Text, { bold: i === index, children: [' ', entry.label] })] })] }, `${entry.kind}-${entry.scenarioName ?? i}`));
|
|
212
|
-
}) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "\u2191/\u2193" }), _jsx(Text, { children: " navigate " }), _jsx(Text, { dimColor: true, children: "enter" }), _jsx(Text, { children: " run " }), _jsx(Text, { dimColor: true, children: "d" }), _jsx(Text, { children: " duplicate " }), _jsx(Text, { dimColor: true, children: "esc" }), _jsx(Text, { children: " cancel" })] })] }));
|
|
222
|
+
return (_jsx(ModalFrame, { title: `Run ${workflowName}`, shortcuts: SELECT_SHORTCUTS, children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { dimColor: true, children: scenarios.length === 0 ? 'No scenarios found. Create a new one:' : 'Select scenarios:' }), _jsx(Box, { flexDirection: "column", children: entries.map((entry, i) => (_jsxs(Box, { children: [_jsx(SelectionIndicator, { selected: i === index }), _jsxs(Text, { bold: i === index, children: ["\u00A0", entry.label] })] }, `${entry.kind}-${entry.scenarioName ?? i}`))) })] }) }));
|
|
213
223
|
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { WorkflowStatusIcon } from '#views/dev/components/workflow_status.js';
|
|
4
|
+
import { formatDurationCompact } from '#utils/date_formatter.js';
|
|
5
|
+
import { TabBar, getHeight as getTabBarHeight } from '#views/dev/chrome/tab_bar.js';
|
|
6
|
+
import { LoadingSpinner } from '#views/dev/chrome/loading_spinner.js';
|
|
7
|
+
import { SelectionIndicator } from '#views/dev/chrome/selection_indicator.js';
|
|
8
|
+
import { useUiState } from '#views/dev/state/ui_state.js';
|
|
9
|
+
import { useRunDetail } from '#views/dev/hooks/use_run_detail.js';
|
|
10
|
+
import { JsonView } from '#views/dev/utils/json_render.js';
|
|
11
|
+
import { cycleValue, formatContentTitle, truncate, useListSelection } from '#views/dev/utils/panel_helpers.js';
|
|
12
|
+
import { RUN_DETAIL_VISIBLE_STEPS } from '#views/dev/utils/constants.js';
|
|
13
|
+
import { ContentTitle, getHeight as getContentTitleHeight } from '#views/dev/components/content_title.js';
|
|
14
|
+
import { MasterDetailPanel } from '#views/dev/components/master_detail_panel.js';
|
|
15
|
+
import { ModalFrame, getHeight as getModalFrameHeight } from '#views/dev/modals/modal_frame.js';
|
|
16
|
+
const RIGHT_PANE_ORDER = ['input', 'output', 'meta'];
|
|
17
|
+
const RIGHT_PANE_TABS = [
|
|
18
|
+
{ id: 'input', label: 'Input' },
|
|
19
|
+
{ id: 'output', label: 'Output' },
|
|
20
|
+
{ id: 'meta', label: 'Meta' }
|
|
21
|
+
];
|
|
22
|
+
const STEPS_MODAL_SHORTCUTS = [
|
|
23
|
+
['↑/↓', 'navigate'],
|
|
24
|
+
['←/→', 'switch pane'],
|
|
25
|
+
['e', 'expand'],
|
|
26
|
+
['esc', 'back']
|
|
27
|
+
];
|
|
28
|
+
const COL = {
|
|
29
|
+
indicator: 3,
|
|
30
|
+
icon: 3,
|
|
31
|
+
num: 3,
|
|
32
|
+
name: 50,
|
|
33
|
+
duration: 8
|
|
34
|
+
};
|
|
35
|
+
const HeaderRow = () => (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { children: "\u00A0" }) }), _jsx(Box, { width: COL.icon, children: _jsx(Text, { children: "\u00A0" }) }), _jsx(Box, { width: COL.num, children: _jsx(Text, { dimColor: true, bold: true, children: "#" }) }), _jsx(Box, { width: COL.name, children: _jsx(Text, { dimColor: true, bold: true, children: "NAME" }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: true, bold: true, children: "DURATION" }) })] }));
|
|
36
|
+
const StepRow = ({ step, selected }) => (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(SelectionIndicator, { selected: selected }) }), _jsx(Box, { width: COL.icon, children: _jsx(WorkflowStatusIcon, { status: step.status }) }), _jsx(Box, { width: COL.num, children: _jsx(Text, { bold: selected, children: step.index }) }), _jsx(Box, { width: COL.name, children: _jsx(Text, { bold: selected, children: truncate(step.name, COL.name - 1) }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: !selected, children: formatDurationCompact(step.durationMs) }) })] }));
|
|
37
|
+
const stepPaneValue = (step, activeTab) => {
|
|
38
|
+
if (activeTab === 'input') {
|
|
39
|
+
return step.input;
|
|
40
|
+
}
|
|
41
|
+
if (activeTab === 'output') {
|
|
42
|
+
return step.error ?? step.output;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
kind: step.kind,
|
|
46
|
+
status: step.status,
|
|
47
|
+
durationMs: step.durationMs,
|
|
48
|
+
hasError: Boolean(step.error)
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
const StepDetail = ({ step, activeTab, rows }) => {
|
|
52
|
+
if (!step) {
|
|
53
|
+
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Select a step to see input/output." }) }));
|
|
54
|
+
}
|
|
55
|
+
const tabContentRows = Math.max(1, rows - getContentTitleHeight() - getTabBarHeight());
|
|
56
|
+
return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(ContentTitle, { title: formatContentTitle([`Step "${step.name}"`, 'Result']) }), _jsx(TabBar, { active: activeTab, items: RIGHT_PANE_TABS }), _jsx(Box, { flexDirection: "column", children: _jsx(JsonView, { value: stepPaneValue(step, activeTab), maxLines: tabContentRows }) })] }));
|
|
57
|
+
};
|
|
58
|
+
export const StepsModal = ({ run, height }) => {
|
|
59
|
+
const ui = useUiState();
|
|
60
|
+
const { steps, loading } = useRunDetail(run.workflowId, run.runId, run.status);
|
|
61
|
+
const { selectedIndex: clamped, selectPrevious, selectNext } = useListSelection(steps.length);
|
|
62
|
+
const selectedStep = steps[clamped];
|
|
63
|
+
const activeTab = ui.runStepPaneTab;
|
|
64
|
+
useInput((input, key) => {
|
|
65
|
+
if (key.escape) {
|
|
66
|
+
ui.setRunsView('list');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (key.upArrow) {
|
|
70
|
+
selectPrevious();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (key.downArrow) {
|
|
74
|
+
selectNext();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (key.leftArrow) {
|
|
78
|
+
ui.setRunStepPaneTab(cycleValue(RIGHT_PANE_ORDER, activeTab, -1));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (key.rightArrow) {
|
|
82
|
+
ui.setRunStepPaneTab(cycleValue(RIGHT_PANE_ORDER, activeTab, 1));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (input === 'e' && selectedStep) {
|
|
86
|
+
const content = stepPaneValue(selectedStep, activeTab);
|
|
87
|
+
const label = `step ${selectedStep.index}: ${selectedStep.name} -> ${activeTab}`;
|
|
88
|
+
ui.openExpandedJson(content, label);
|
|
89
|
+
}
|
|
90
|
+
}, { isActive: ui.tab === 'runs' && ui.runsView === 'detail' && !ui.expandedJson.open });
|
|
91
|
+
const contentRows = Math.max(1, height - getModalFrameHeight());
|
|
92
|
+
const renderDetail = (rows) => {
|
|
93
|
+
if (loading && steps.length === 0) {
|
|
94
|
+
return (_jsx(LoadingSpinner, { label: "Loading steps..." }));
|
|
95
|
+
}
|
|
96
|
+
if (steps.length === 0) {
|
|
97
|
+
return (_jsx(Text, { dimColor: true, children: "No steps recorded for this run." }));
|
|
98
|
+
}
|
|
99
|
+
return _jsx(StepDetail, { step: selectedStep, activeTab: activeTab, rows: rows });
|
|
100
|
+
};
|
|
101
|
+
return (_jsx(ModalFrame, { title: formatContentTitle([`Workflow "${run.workflowType}"`, 'Steps']), shortcuts: STEPS_MODAL_SHORTCUTS, children: _jsx(MasterDetailPanel, { items: steps, selectedIndex: clamped, height: contentRows, visibleRows: RUN_DETAIL_VISIBLE_STEPS, renderHeader: () => _jsx(HeaderRow, {}), renderRow: (step, selected) => _jsx(StepRow, { step: step, selected: selected }), rowKey: (step, i) => `${step.index}-${step.name}-${i}`, detail: ({ detailRows }) => renderDetail(detailRows) }) }));
|
|
102
|
+
};
|
|
@@ -1,2 +1,16 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
export declare const Section: React.FC<{
|
|
3
|
+
children: React.ReactNode;
|
|
4
|
+
title: string;
|
|
5
|
+
direction?: string;
|
|
6
|
+
}>;
|
|
7
|
+
export declare const SubSection: React.FC<{
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
title: string;
|
|
10
|
+
}>;
|
|
11
|
+
export declare const HELP_HINTS: {
|
|
12
|
+
key: string;
|
|
13
|
+
label: string;
|
|
14
|
+
}[];
|
|
15
|
+
export declare const HELP_SECTION_COUNT: number;
|
|
2
16
|
export declare const HelpPanel: React.FC;
|