@outputai/cli 0.3.1 → 0.3.2-next.1282dcf.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/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/dev/index.js +48 -13
- package/dist/commands/dev/index.spec.js +1 -2
- package/dist/components/status_icon.js +1 -1
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/docker.js +0 -1
- package/dist/utils/paths.d.ts +11 -0
- package/dist/utils/paths.js +14 -0
- package/dist/utils/scenario_resolver.js +3 -2
- package/dist/views/dev/chrome/divider.d.ts +8 -0
- package/dist/views/dev/chrome/divider.js +16 -0
- package/dist/views/dev/chrome/footer.d.ts +11 -0
- package/dist/views/dev/chrome/footer.js +10 -0
- package/dist/views/dev/chrome/header.d.ts +21 -0
- package/dist/views/dev/chrome/header.js +74 -0
- package/dist/views/dev/chrome/header.spec.d.ts +1 -0
- package/dist/views/dev/chrome/header.spec.js +50 -0
- package/dist/views/dev/chrome/loading_spinner.d.ts +9 -0
- package/dist/views/dev/chrome/loading_spinner.js +9 -0
- package/dist/views/dev/chrome/palette.d.ts +16 -0
- package/dist/views/dev/chrome/palette.js +16 -0
- package/dist/views/dev/chrome/search_bar.d.ts +5 -0
- package/dist/views/dev/chrome/search_bar.js +35 -0
- package/dist/views/dev/chrome/selection_indicator.d.ts +14 -0
- package/dist/views/dev/chrome/selection_indicator.js +13 -0
- package/dist/views/dev/chrome/tab_bar.d.ts +5 -0
- package/dist/views/dev/chrome/tab_bar.js +4 -0
- package/dist/views/dev/chrome/toasts.d.ts +2 -0
- package/dist/views/dev/chrome/toasts.js +40 -0
- package/dist/views/dev/components/master_detail_panel.d.ts +21 -0
- package/dist/views/dev/components/master_detail_panel.js +18 -0
- package/dist/views/{dev.d.ts → dev/dev_app.d.ts} +1 -0
- package/dist/views/dev/dev_app.js +146 -0
- package/dist/views/dev/hooks/use_docker_logs.d.ts +7 -0
- package/dist/views/dev/hooks/use_docker_logs.js +69 -0
- package/dist/views/dev/hooks/use_poll.d.ts +16 -0
- package/dist/views/dev/hooks/use_poll.js +95 -0
- package/dist/views/dev/hooks/use_run_detail.d.ts +21 -0
- package/dist/views/dev/hooks/use_run_detail.js +153 -0
- package/dist/views/dev/hooks/use_run_detail.spec.d.ts +1 -0
- package/dist/views/dev/hooks/use_run_detail.spec.js +86 -0
- package/dist/views/dev/hooks/use_workflow_catalog.d.ts +2 -0
- package/dist/views/dev/hooks/use_workflow_catalog.js +21 -0
- package/dist/views/dev/modals/expanded_json_modal.d.ts +2 -0
- package/dist/views/dev/modals/expanded_json_modal.js +44 -0
- package/dist/views/dev/modals/run_modal.d.ts +4 -0
- package/dist/views/dev/modals/run_modal.js +213 -0
- package/dist/views/dev/panels/help_panel.d.ts +2 -0
- package/dist/views/dev/panels/help_panel.js +53 -0
- package/dist/views/dev/panels/run_detail_view.d.ts +5 -0
- package/dist/views/dev/panels/run_detail_view.js +112 -0
- package/dist/views/dev/panels/runs_panel.d.ts +8 -0
- package/dist/views/dev/panels/runs_panel.js +204 -0
- package/dist/views/dev/panels/runs_panel.spec.d.ts +1 -0
- package/dist/views/dev/panels/runs_panel.spec.js +82 -0
- package/dist/views/dev/panels/services_panel.d.ts +14 -0
- package/dist/views/dev/panels/services_panel.js +155 -0
- package/dist/views/dev/panels/services_panel.spec.d.ts +1 -0
- package/dist/views/dev/panels/services_panel.spec.js +28 -0
- package/dist/views/dev/panels/workflows_panel.d.ts +7 -0
- package/dist/views/dev/panels/workflows_panel.js +111 -0
- package/dist/views/dev/services/docker_control.d.ts +5 -0
- package/dist/views/dev/services/docker_control.js +25 -0
- package/dist/views/dev/services/run_workflow.d.ts +10 -0
- package/dist/views/dev/services/run_workflow.js +14 -0
- package/dist/views/dev/services/scenario_io.d.ts +2 -0
- package/dist/views/dev/services/scenario_io.js +37 -0
- package/dist/views/dev/state/ui_state.d.ts +60 -0
- package/dist/views/dev/state/ui_state.js +64 -0
- package/dist/views/dev/utils/constants.d.ts +17 -0
- package/dist/views/dev/utils/constants.js +17 -0
- package/dist/views/dev/utils/json_editor.d.ts +21 -0
- package/dist/views/dev/utils/json_editor.js +117 -0
- package/dist/views/dev/utils/json_editor.spec.d.ts +1 -0
- package/dist/views/dev/utils/json_editor.spec.js +57 -0
- package/dist/views/dev/utils/json_render.d.ts +15 -0
- package/dist/views/dev/utils/json_render.js +77 -0
- package/dist/views/dev/utils/json_render.spec.d.ts +1 -0
- package/dist/views/dev/utils/json_render.spec.js +65 -0
- package/dist/views/dev/utils/panel_helpers.d.ts +16 -0
- package/dist/views/dev/utils/panel_helpers.js +32 -0
- package/dist/views/dev/utils/panel_helpers.spec.d.ts +1 -0
- package/dist/views/dev/utils/panel_helpers.spec.js +47 -0
- package/package.json +5 -5
- package/dist/components/command_footer.d.ts +0 -8
- package/dist/components/command_footer.js +0 -4
- package/dist/components/workflow_summary.d.ts +0 -10
- package/dist/components/workflow_summary.js +0 -4
- package/dist/views/dev.js +0 -187
- package/dist/views/workflow/list.d.ts +0 -6
- package/dist/views/workflow/list.js +0 -129
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { Footer } from '#views/dev/chrome/footer.js';
|
|
5
|
+
import { HorizontalRule } from '#views/dev/chrome/divider.js';
|
|
6
|
+
import { computeWindowStart } from '#views/dev/utils/panel_helpers.js';
|
|
7
|
+
const OverflowIndicator = ({ direction, count }) => (_jsxs(Text, { dimColor: true, children: [" ", direction === 'up' ? '↑' : '↓', " ", count, " more ", direction === 'up' ? 'above' : 'below'] }));
|
|
8
|
+
export const MasterDetailPanel = (props) => {
|
|
9
|
+
const { items, selectedIndex, visibleRows, renderHeader, renderRow, rowKey, detail, hints, itemLabel } = props;
|
|
10
|
+
const windowStart = computeWindowStart(selectedIndex, items.length, visibleRows);
|
|
11
|
+
const visible = items.slice(windowStart, windowStart + visibleRows);
|
|
12
|
+
const overflowAbove = windowStart;
|
|
13
|
+
const overflowBelow = items.length - (windowStart + visible.length);
|
|
14
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { flexDirection: "column", children: [renderHeader(), overflowAbove > 0 && _jsx(OverflowIndicator, { direction: "up", count: overflowAbove }), visible.map((item, i) => {
|
|
15
|
+
const absoluteIndex = windowStart + i;
|
|
16
|
+
return (_jsx(React.Fragment, { children: renderRow(item, absoluteIndex === selectedIndex, absoluteIndex) }, rowKey(item, absoluteIndex)));
|
|
17
|
+
}), overflowBelow > 0 && _jsx(OverflowIndicator, { direction: "down", count: overflowBelow })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(HorizontalRule, {}) }), detail, _jsx(Footer, { hints: hints, itemCount: items.length, itemLabel: itemLabel })] }));
|
|
18
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { Box, useApp, useInput, useStdout } from 'ink';
|
|
4
|
+
import { isServiceFailed, isServiceHealthy } from '#services/docker.js';
|
|
5
|
+
import { openUrl } from '#utils/open_url.js';
|
|
6
|
+
import { useHealthPolling, useStatusRefresh, useWorkflowRunsPolling } from '#views/dev/hooks/use_poll.js';
|
|
7
|
+
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 { HorizontalRule } from '#views/dev/chrome/divider.js';
|
|
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';
|
|
18
|
+
import { RunModal } from '#views/dev/modals/run_modal.js';
|
|
19
|
+
import { ExpandedJsonModal } from '#views/dev/modals/expanded_json_modal.js';
|
|
20
|
+
const TAB_NUMBER_KEYS = {
|
|
21
|
+
1: 'workflows',
|
|
22
|
+
2: 'runs',
|
|
23
|
+
3: 'services',
|
|
24
|
+
4: 'help'
|
|
25
|
+
};
|
|
26
|
+
const useGlobalInput = (opts) => {
|
|
27
|
+
const ui = useUiState();
|
|
28
|
+
const { exit } = useApp();
|
|
29
|
+
const isExitingRef = useRef(false);
|
|
30
|
+
useInput((input, key) => {
|
|
31
|
+
// Ctrl+C is the universal escape hatch — handle it regardless of which
|
|
32
|
+
// overlay is open so the user can always quit.
|
|
33
|
+
if (key.ctrl && input === 'c' && !isExitingRef.current) {
|
|
34
|
+
isExitingRef.current = true;
|
|
35
|
+
void opts.onCleanup()
|
|
36
|
+
.then(() => exit())
|
|
37
|
+
.catch(err => exit(err instanceof Error ? err : new Error(String(err))));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (ui.search.open || ui.runModal.open || ui.expandedJson.open) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Esc on a list view drops an active filter. Skip when we're on
|
|
44
|
+
// the run detail sub-view — the panel's own esc handler pops back
|
|
45
|
+
// to the list and the filter should still apply when we land
|
|
46
|
+
// there. The search bar's esc (close + clear) returns above, so
|
|
47
|
+
// it never reaches this branch.
|
|
48
|
+
if (key.escape && ui.search.query && ui.runsView === 'list') {
|
|
49
|
+
ui.clearSearch();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
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
|
+
if (key.tab && key.shift) {
|
|
62
|
+
ui.prevTab();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (key.tab) {
|
|
66
|
+
ui.nextTab();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (input === '/') {
|
|
70
|
+
ui.openSearch();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (input === '?') {
|
|
74
|
+
ui.setTab('help');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (input && TAB_NUMBER_KEYS[input]) {
|
|
78
|
+
ui.setTab(TAB_NUMBER_KEYS[input]);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (input === 'o' && ui.tab === 'services') {
|
|
82
|
+
openUrl('http://localhost:8080');
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
const computeWorkflowSummary = (runs) => {
|
|
87
|
+
if (runs.length === 0) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
running: runs.filter(r => r.status === 'running').length,
|
|
92
|
+
completed: runs.filter(r => r.status === 'completed').length,
|
|
93
|
+
failed: runs.filter(r => r.status === 'failed').length,
|
|
94
|
+
total: runs.length
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
98
|
+
const { exit } = useApp();
|
|
99
|
+
const ui = useUiState();
|
|
100
|
+
const [phase, setPhase] = useState('waiting');
|
|
101
|
+
const [services, setServices] = useState([]);
|
|
102
|
+
const [runs, setRuns] = useState([]);
|
|
103
|
+
useHealthPolling(dockerComposePath, phase === 'waiting', {
|
|
104
|
+
onServices: setServices,
|
|
105
|
+
onAllHealthy: () => setPhase('running'),
|
|
106
|
+
onFailure: () => setPhase('failed'),
|
|
107
|
+
onTimeout: () => exit(new Error('Timeout waiting for services to become healthy'))
|
|
108
|
+
});
|
|
109
|
+
useStatusRefresh(dockerComposePath, phase !== 'waiting', setServices);
|
|
110
|
+
useWorkflowRunsPolling(phase !== 'waiting', setRuns);
|
|
111
|
+
const workflows = useWorkflowCatalog(phase !== 'waiting');
|
|
112
|
+
const autoSwitchedRef = useRef(false);
|
|
113
|
+
const setTab = ui.setTab;
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (phase === 'running' &&
|
|
116
|
+
workflows.length > 0 &&
|
|
117
|
+
!autoSwitchedRef.current &&
|
|
118
|
+
ui.tab === 'services') {
|
|
119
|
+
autoSwitchedRef.current = true;
|
|
120
|
+
setTab('workflows');
|
|
121
|
+
}
|
|
122
|
+
}, [phase, workflows.length, ui.tab, setTab]);
|
|
123
|
+
useGlobalInput({ onCleanup });
|
|
124
|
+
const summary = useMemo(() => computeWorkflowSummary(runs), [runs]);
|
|
125
|
+
const failingServices = useMemo(() => services.filter(isServiceFailed).length, [services]);
|
|
126
|
+
const serviceBadge = useMemo(() => {
|
|
127
|
+
if (failingServices > 0) {
|
|
128
|
+
return 'failed';
|
|
129
|
+
}
|
|
130
|
+
if (phase === 'waiting' || services.length === 0 || !services.every(isServiceHealthy)) {
|
|
131
|
+
return 'starting';
|
|
132
|
+
}
|
|
133
|
+
return 'healthy';
|
|
134
|
+
}, [phase, services, failingServices]);
|
|
135
|
+
const counters = buildSummaryCounters(summary, workflows.length, serviceBadge, failingServices);
|
|
136
|
+
// `stdout.rows` is undefined on a small set of TTYs (mostly piped envs).
|
|
137
|
+
// 60 is a generous default — chrome alone is ~10 rows, and run-detail
|
|
138
|
+
// wants ~25 of content, so anything below 40 starts to clip step rows.
|
|
139
|
+
const { stdout } = useStdout();
|
|
140
|
+
const rows = stdout?.rows ?? 60;
|
|
141
|
+
if (ui.expandedJson.open) {
|
|
142
|
+
return (_jsx(Box, { flexDirection: "column", height: rows, paddingX: 2, paddingTop: 1, children: _jsx(ExpandedJsonModal, {}) }));
|
|
143
|
+
}
|
|
144
|
+
return (_jsxs(Box, { flexDirection: "column", height: rows, paddingX: 2, paddingTop: 1, children: [_jsx(Header, { counters: counters }), _jsx(TabBar, { active: ui.tab }), _jsx(HorizontalRule, {}), _jsx(SearchBar, { active: ui.search.open }), _jsx(Toasts, {}), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: [ui.tab === 'workflows' && !ui.runModal.open && _jsx(WorkflowsPanel, { workflows: workflows, runs: runs }), ui.tab === 'runs' && !ui.runModal.open && _jsx(RunsPanel, { runs: runs }), ui.tab === 'services' && !ui.runModal.open && (_jsx(ServicesPanel, { phase: phase, services: services, dockerComposePath: dockerComposePath })), ui.tab === 'help' && !ui.runModal.open && _jsx(HelpPanel, {}), ui.runModal.open && _jsx(RunModal, { workflowName: ui.runModal.workflowName })] })] }));
|
|
145
|
+
};
|
|
146
|
+
export const DevApp = ({ dockerComposePath, onCleanup }) => (_jsx(UiStateProvider, { children: _jsx(Shell, { dockerComposePath: dockerComposePath, onCleanup: onCleanup }) }));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface DockerLogsState {
|
|
2
|
+
lines: string[];
|
|
3
|
+
paused: boolean;
|
|
4
|
+
setPaused: (paused: boolean) => void;
|
|
5
|
+
clear: () => void;
|
|
6
|
+
}
|
|
7
|
+
export declare const useDockerLogs: (dockerComposePath: string, serviceName: string | null, enabled: boolean) => DockerLogsState;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { tailLogs } from '#views/dev/services/docker_control.js';
|
|
3
|
+
const MAX_BUFFER = 2000;
|
|
4
|
+
export const useDockerLogs = (dockerComposePath, serviceName, enabled) => {
|
|
5
|
+
const [lines, setLines] = useState([]);
|
|
6
|
+
const [paused, setPaused] = useState(false);
|
|
7
|
+
const pausedRef = useRef(paused);
|
|
8
|
+
pausedRef.current = paused;
|
|
9
|
+
const bufferRef = useRef([]);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
bufferRef.current = [];
|
|
12
|
+
setLines([]);
|
|
13
|
+
if (!enabled || !serviceName) {
|
|
14
|
+
return () => { };
|
|
15
|
+
}
|
|
16
|
+
const child = tailLogs(dockerComposePath, serviceName);
|
|
17
|
+
const pending = [];
|
|
18
|
+
const timer = { id: null };
|
|
19
|
+
const flush = () => {
|
|
20
|
+
if (pending.length === 0) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
bufferRef.current = [...bufferRef.current, ...pending].slice(-MAX_BUFFER);
|
|
24
|
+
pending.length = 0;
|
|
25
|
+
if (!pausedRef.current) {
|
|
26
|
+
setLines(bufferRef.current);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const onChunk = (chunk) => {
|
|
30
|
+
const text = chunk.toString();
|
|
31
|
+
const split = text.split('\n').filter((l, i, arr) => i < arr.length - 1 || l.length > 0);
|
|
32
|
+
pending.push(...split);
|
|
33
|
+
if (timer.id === null) {
|
|
34
|
+
timer.id = setTimeout(() => {
|
|
35
|
+
timer.id = null;
|
|
36
|
+
flush();
|
|
37
|
+
}, 100);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
child.stdout.on('data', onChunk);
|
|
41
|
+
child.stderr.on('data', onChunk);
|
|
42
|
+
// Swallow spawn errors. Docker isn't reachable in only two cases the
|
|
43
|
+
// user can't act on inside this panel: the daemon stopped (the
|
|
44
|
+
// services list will already show this) and the binary is missing
|
|
45
|
+
// (ruled out by `validateDockerEnvironment` at startup). The user
|
|
46
|
+
// can always tail logs from a host shell as a fallback.
|
|
47
|
+
child.on('error', () => { });
|
|
48
|
+
return () => {
|
|
49
|
+
child.kill('SIGTERM');
|
|
50
|
+
if (timer.id !== null) {
|
|
51
|
+
clearTimeout(timer.id);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}, [dockerComposePath, serviceName, enabled]);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!paused) {
|
|
57
|
+
setLines(bufferRef.current);
|
|
58
|
+
}
|
|
59
|
+
}, [paused]);
|
|
60
|
+
return {
|
|
61
|
+
lines,
|
|
62
|
+
paused,
|
|
63
|
+
setPaused,
|
|
64
|
+
clear: () => {
|
|
65
|
+
bufferRef.current = [];
|
|
66
|
+
setLines([]);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ServiceStatus } from '#services/docker.js';
|
|
2
|
+
import { type WorkflowRun } from '#services/workflow_runs.js';
|
|
3
|
+
export declare const POLL_INTERVAL_MS = 2000;
|
|
4
|
+
export declare const HEALTH_TIMEOUT_MS = 120000;
|
|
5
|
+
type TickResult = 'done' | 'continue';
|
|
6
|
+
export declare const usePoll: (enabled: boolean, intervalMs: number, onTick: () => Promise<TickResult>) => void;
|
|
7
|
+
export interface HealthPollingCallbacks {
|
|
8
|
+
onServices: (svcs: ServiceStatus[]) => void;
|
|
9
|
+
onAllHealthy: (svcs: ServiceStatus[]) => void;
|
|
10
|
+
onFailure: (svcs: ServiceStatus[]) => void;
|
|
11
|
+
onTimeout: () => void;
|
|
12
|
+
}
|
|
13
|
+
export declare const useHealthPolling: (dockerComposePath: string, enabled: boolean, callbacks: HealthPollingCallbacks) => void;
|
|
14
|
+
export declare const useStatusRefresh: (dockerComposePath: string, enabled: boolean, onServices: (svcs: ServiceStatus[]) => void) => void;
|
|
15
|
+
export declare const useWorkflowRunsPolling: (enabled: boolean, onRuns: (runs: WorkflowRun[]) => void) => void;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { getServiceStatus, isServiceHealthy, isServiceFailed } from '#services/docker.js';
|
|
3
|
+
import { fetchWorkflowRuns } from '#services/workflow_runs.js';
|
|
4
|
+
export const POLL_INTERVAL_MS = 2000;
|
|
5
|
+
export const HEALTH_TIMEOUT_MS = 120_000;
|
|
6
|
+
export const usePoll = (enabled, intervalMs, onTick) => {
|
|
7
|
+
// Ref-shadowed callback. The polling effect intentionally only depends
|
|
8
|
+
// on `enabled` and `intervalMs` so it doesn't tear down and re-subscribe
|
|
9
|
+
// on every render — but we still want each tick to invoke the latest
|
|
10
|
+
// callback closure. The ref reassignment on every render keeps that
|
|
11
|
+
// closure fresh without nudging the effect's dependency array.
|
|
12
|
+
const onTickRef = useRef(onTick);
|
|
13
|
+
onTickRef.current = onTick;
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const state = {
|
|
16
|
+
active: true,
|
|
17
|
+
timeout: undefined
|
|
18
|
+
};
|
|
19
|
+
const run = async () => {
|
|
20
|
+
if (!state.active) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const result = await onTickRef.current();
|
|
24
|
+
if (!state.active || result === 'done') {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
state.timeout = setTimeout(run, intervalMs);
|
|
28
|
+
};
|
|
29
|
+
if (enabled) {
|
|
30
|
+
void run();
|
|
31
|
+
}
|
|
32
|
+
return () => {
|
|
33
|
+
state.active = false;
|
|
34
|
+
clearTimeout(state.timeout);
|
|
35
|
+
};
|
|
36
|
+
}, [enabled, intervalMs]);
|
|
37
|
+
};
|
|
38
|
+
const fetchServices = async (dockerComposePath) => {
|
|
39
|
+
try {
|
|
40
|
+
return await getServiceStatus(dockerComposePath);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
export const useHealthPolling = (dockerComposePath, enabled, callbacks) => {
|
|
47
|
+
const callbacksRef = useRef(callbacks);
|
|
48
|
+
callbacksRef.current = callbacks;
|
|
49
|
+
const startTimeRef = useRef(Date.now());
|
|
50
|
+
usePoll(enabled, POLL_INTERVAL_MS, async () => {
|
|
51
|
+
if (Date.now() - startTimeRef.current > HEALTH_TIMEOUT_MS) {
|
|
52
|
+
callbacksRef.current.onTimeout();
|
|
53
|
+
return 'done';
|
|
54
|
+
}
|
|
55
|
+
const svcs = await fetchServices(dockerComposePath);
|
|
56
|
+
if (svcs === null) {
|
|
57
|
+
return 'continue';
|
|
58
|
+
}
|
|
59
|
+
callbacksRef.current.onServices(svcs);
|
|
60
|
+
if (svcs.length > 0 && svcs.every(isServiceHealthy)) {
|
|
61
|
+
callbacksRef.current.onAllHealthy(svcs);
|
|
62
|
+
return 'done';
|
|
63
|
+
}
|
|
64
|
+
if (svcs.length > 0 && svcs.find(isServiceFailed)) {
|
|
65
|
+
callbacksRef.current.onFailure(svcs);
|
|
66
|
+
return 'done';
|
|
67
|
+
}
|
|
68
|
+
return 'continue';
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
export const useStatusRefresh = (dockerComposePath, enabled, onServices) => {
|
|
72
|
+
const onServicesRef = useRef(onServices);
|
|
73
|
+
onServicesRef.current = onServices;
|
|
74
|
+
usePoll(enabled, POLL_INTERVAL_MS, async () => {
|
|
75
|
+
const svcs = await fetchServices(dockerComposePath);
|
|
76
|
+
if (svcs !== null) {
|
|
77
|
+
onServicesRef.current(svcs);
|
|
78
|
+
}
|
|
79
|
+
return 'continue';
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
export const useWorkflowRunsPolling = (enabled, onRuns) => {
|
|
83
|
+
const onRunsRef = useRef(onRuns);
|
|
84
|
+
onRunsRef.current = onRuns;
|
|
85
|
+
usePoll(enabled, POLL_INTERVAL_MS, async () => {
|
|
86
|
+
try {
|
|
87
|
+
const { runs } = await fetchWorkflowRuns({ limit: 100 });
|
|
88
|
+
onRunsRef.current(runs);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// API may not be ready yet
|
|
92
|
+
}
|
|
93
|
+
return 'continue';
|
|
94
|
+
});
|
|
95
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type WorkflowResultResponse } from '#api/generated/api.js';
|
|
2
|
+
import type { TraceData } from '#types/trace.js';
|
|
3
|
+
export interface RunStep {
|
|
4
|
+
index: number;
|
|
5
|
+
name: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
status: string;
|
|
8
|
+
durationMs: number;
|
|
9
|
+
input: unknown;
|
|
10
|
+
output: unknown;
|
|
11
|
+
error: unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface RunDetail {
|
|
14
|
+
result: WorkflowResultResponse | null;
|
|
15
|
+
trace: TraceData | null;
|
|
16
|
+
steps: RunStep[];
|
|
17
|
+
loading: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare const extractSteps: (trace: TraceData | null) => RunStep[];
|
|
20
|
+
export declare const isTerminalRunStatus: (status: string | null | undefined) => boolean;
|
|
21
|
+
export declare const useRunDetail: (workflowId: string | undefined, runId: string | undefined, status?: string) => RunDetail;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { getWorkflowIdResult, getWorkflowIdRunsRidResult, getWorkflowIdTraceLog, getWorkflowIdRunsRidTraceLog } from '#api/generated/api.js';
|
|
4
|
+
const EMPTY_DETAIL = {
|
|
5
|
+
result: null,
|
|
6
|
+
trace: null,
|
|
7
|
+
steps: [],
|
|
8
|
+
loading: false
|
|
9
|
+
};
|
|
10
|
+
const stepNameOf = (node) => {
|
|
11
|
+
if (node.name) {
|
|
12
|
+
return node.name;
|
|
13
|
+
}
|
|
14
|
+
const kind = node.kind || node.type || 'step';
|
|
15
|
+
const leaf = node.stepName ?? node.activityName ?? '?';
|
|
16
|
+
return `${kind}#${leaf}`;
|
|
17
|
+
};
|
|
18
|
+
const stepStatusOf = (node) => {
|
|
19
|
+
if (node.status) {
|
|
20
|
+
return node.status;
|
|
21
|
+
}
|
|
22
|
+
if (node.phase === 'error' || node.error) {
|
|
23
|
+
return 'failed';
|
|
24
|
+
}
|
|
25
|
+
if (node.phase === 'end') {
|
|
26
|
+
return 'completed';
|
|
27
|
+
}
|
|
28
|
+
return 'running';
|
|
29
|
+
};
|
|
30
|
+
const numericTimestamp = (...candidates) => {
|
|
31
|
+
for (const candidate of candidates) {
|
|
32
|
+
if (typeof candidate === 'number') {
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
38
|
+
const stepDurationOf = (node) => {
|
|
39
|
+
if (typeof node.duration === 'number') {
|
|
40
|
+
return node.duration;
|
|
41
|
+
}
|
|
42
|
+
const start = numericTimestamp(node.startTime, node.startedAt);
|
|
43
|
+
const end = numericTimestamp(node.endTime, node.endedAt);
|
|
44
|
+
if (start !== null && end !== null) {
|
|
45
|
+
return end - start;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
};
|
|
49
|
+
export const extractSteps = (trace) => {
|
|
50
|
+
if (!trace?.children) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
return trace.children
|
|
54
|
+
.filter(node => node.phase !== 'start')
|
|
55
|
+
.map((node, idx) => ({
|
|
56
|
+
index: idx + 1,
|
|
57
|
+
name: stepNameOf(node),
|
|
58
|
+
kind: node.kind || node.type || 'step',
|
|
59
|
+
status: stepStatusOf(node),
|
|
60
|
+
durationMs: stepDurationOf(node),
|
|
61
|
+
input: node.input ?? node.details?.input,
|
|
62
|
+
output: node.output ?? node.details?.output,
|
|
63
|
+
error: node.error
|
|
64
|
+
}));
|
|
65
|
+
};
|
|
66
|
+
const readTraceLog = async (source) => {
|
|
67
|
+
if (source.source === 'remote') {
|
|
68
|
+
return source.data;
|
|
69
|
+
}
|
|
70
|
+
const content = await readFile(source.localPath, 'utf-8');
|
|
71
|
+
return JSON.parse(content);
|
|
72
|
+
};
|
|
73
|
+
// Run detail and trace fetches are best-effort. Many statuses (in-progress,
|
|
74
|
+
// failed, canceled) don't have a fully-formed result or trace available at
|
|
75
|
+
// any given moment, and that's expected — the caller falls back to
|
|
76
|
+
// EMPTY_DETAIL and the UI renders whatever's there. Swallow everything.
|
|
77
|
+
const fetchTrace = async (workflowId, runId) => {
|
|
78
|
+
try {
|
|
79
|
+
const response = runId ?
|
|
80
|
+
await getWorkflowIdRunsRidTraceLog(workflowId, runId) :
|
|
81
|
+
await getWorkflowIdTraceLog(workflowId);
|
|
82
|
+
return await readTraceLog(response.data);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const fetchResult = async (workflowId, runId) => {
|
|
89
|
+
try {
|
|
90
|
+
const response = runId ?
|
|
91
|
+
await getWorkflowIdRunsRidResult(workflowId, runId) :
|
|
92
|
+
await getWorkflowIdResult(workflowId);
|
|
93
|
+
return response.data;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Statuses that mean the workflow has stopped advancing. The cache is
|
|
101
|
+
* intentionally only populated for these — partial results from a still-
|
|
102
|
+
* running workflow would otherwise stick and stall the UI when the run
|
|
103
|
+
* eventually finishes.
|
|
104
|
+
*/
|
|
105
|
+
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'canceled', 'terminated', 'timed_out']);
|
|
106
|
+
export const isTerminalRunStatus = (status) => Boolean(status && TERMINAL_STATUSES.has(status));
|
|
107
|
+
export const useRunDetail = (workflowId, runId, status) => {
|
|
108
|
+
const [detail, setDetail] = useState(EMPTY_DETAIL);
|
|
109
|
+
const cacheRef = useRef(new Map());
|
|
110
|
+
const fetchIdRef = useRef(0);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!workflowId) {
|
|
113
|
+
setDetail(EMPTY_DETAIL);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// The cache only ever holds terminal-status entries (see below), so
|
|
117
|
+
// a hit here is always safe to reuse without a network roundtrip.
|
|
118
|
+
const key = `${workflowId}:${runId ?? 'latest'}`;
|
|
119
|
+
const cached = cacheRef.current.get(key);
|
|
120
|
+
if (cached) {
|
|
121
|
+
setDetail(cached);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const id = ++fetchIdRef.current;
|
|
125
|
+
setDetail({ ...EMPTY_DETAIL, loading: true });
|
|
126
|
+
void Promise.all([
|
|
127
|
+
fetchResult(workflowId, runId),
|
|
128
|
+
fetchTrace(workflowId, runId)
|
|
129
|
+
]).then(([result, trace]) => {
|
|
130
|
+
if (fetchIdRef.current !== id) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const next = {
|
|
134
|
+
result,
|
|
135
|
+
trace,
|
|
136
|
+
steps: extractSteps(trace),
|
|
137
|
+
loading: false
|
|
138
|
+
};
|
|
139
|
+
// Only memoize the result once the workflow is done. While it's
|
|
140
|
+
// still running the API returns partial data; a follow-up status
|
|
141
|
+
// change re-fires this effect and we re-fetch fresh.
|
|
142
|
+
if (isTerminalRunStatus(result?.status)) {
|
|
143
|
+
cacheRef.current.set(key, next);
|
|
144
|
+
}
|
|
145
|
+
setDetail(next);
|
|
146
|
+
});
|
|
147
|
+
// `status` is in the dep array so a run flipping running → completed
|
|
148
|
+
// (the runs list polls every 2s) re-fires the effect and pulls the
|
|
149
|
+
// fresh output, instead of pinning to the partial result captured
|
|
150
|
+
// mid-run.
|
|
151
|
+
}, [workflowId, runId, status]);
|
|
152
|
+
return detail;
|
|
153
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { extractSteps, isTerminalRunStatus } from './use_run_detail.js';
|
|
3
|
+
describe('isTerminalRunStatus', () => {
|
|
4
|
+
it('returns true for completed states', () => {
|
|
5
|
+
expect(isTerminalRunStatus('completed')).toBe(true);
|
|
6
|
+
expect(isTerminalRunStatus('failed')).toBe(true);
|
|
7
|
+
expect(isTerminalRunStatus('canceled')).toBe(true);
|
|
8
|
+
expect(isTerminalRunStatus('terminated')).toBe(true);
|
|
9
|
+
expect(isTerminalRunStatus('timed_out')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it('returns false for in-progress states', () => {
|
|
12
|
+
expect(isTerminalRunStatus('running')).toBe(false);
|
|
13
|
+
expect(isTerminalRunStatus('continued')).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
it('returns false for nullish input', () => {
|
|
16
|
+
expect(isTerminalRunStatus(null)).toBe(false);
|
|
17
|
+
expect(isTerminalRunStatus(undefined)).toBe(false);
|
|
18
|
+
expect(isTerminalRunStatus('')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
const trace = (children) => ({
|
|
22
|
+
root: { workflowName: 'demo', workflowId: 'wf-1', startTime: 0 },
|
|
23
|
+
children
|
|
24
|
+
});
|
|
25
|
+
describe('extractSteps', () => {
|
|
26
|
+
it('returns an empty array when the trace has no children', () => {
|
|
27
|
+
expect(extractSteps(null)).toEqual([]);
|
|
28
|
+
expect(extractSteps(trace([]))).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
it('filters out start-phase events', () => {
|
|
31
|
+
const t = trace([
|
|
32
|
+
{ phase: 'start', name: 'should-skip' },
|
|
33
|
+
{ phase: 'end', name: 'kept', status: 'completed', duration: 100 }
|
|
34
|
+
]);
|
|
35
|
+
const steps = extractSteps(t);
|
|
36
|
+
expect(steps).toHaveLength(1);
|
|
37
|
+
expect(steps[0].name).toBe('kept');
|
|
38
|
+
});
|
|
39
|
+
it('maps phase=error and an error field to status=failed', () => {
|
|
40
|
+
const t = trace([
|
|
41
|
+
{ phase: 'error', name: 'boom', error: 'oops' },
|
|
42
|
+
{ phase: 'end', name: 'fine', error: 'still-failed' }
|
|
43
|
+
]);
|
|
44
|
+
const steps = extractSteps(t);
|
|
45
|
+
expect(steps[0].status).toBe('failed');
|
|
46
|
+
expect(steps[1].status).toBe('failed');
|
|
47
|
+
});
|
|
48
|
+
it('falls back to startTime/endTime for duration', () => {
|
|
49
|
+
const t = trace([
|
|
50
|
+
{ phase: 'end', name: 'with-times', startTime: 1000, endTime: 1500 }
|
|
51
|
+
]);
|
|
52
|
+
const steps = extractSteps(t);
|
|
53
|
+
expect(steps[0].durationMs).toBe(500);
|
|
54
|
+
});
|
|
55
|
+
it('prefers explicit duration when present', () => {
|
|
56
|
+
const t = trace([
|
|
57
|
+
{ phase: 'end', name: 'has-duration', duration: 250, startTime: 0, endTime: 9999 }
|
|
58
|
+
]);
|
|
59
|
+
const steps = extractSteps(t);
|
|
60
|
+
expect(steps[0].durationMs).toBe(250);
|
|
61
|
+
});
|
|
62
|
+
it('composes a fallback name from kind and stepName when name is missing', () => {
|
|
63
|
+
const t = trace([
|
|
64
|
+
{ phase: 'end', kind: 'activity', stepName: 'extract', status: 'completed' }
|
|
65
|
+
]);
|
|
66
|
+
const steps = extractSteps(t);
|
|
67
|
+
expect(steps[0].name).toBe('activity#extract');
|
|
68
|
+
});
|
|
69
|
+
it('reads input/output from `details` when not on the node directly', () => {
|
|
70
|
+
const t = trace([
|
|
71
|
+
{ phase: 'end', name: 'has-details', details: { input: { x: 1 }, output: { y: 2 } } }
|
|
72
|
+
]);
|
|
73
|
+
const steps = extractSteps(t);
|
|
74
|
+
expect(steps[0].input).toEqual({ x: 1 });
|
|
75
|
+
expect(steps[0].output).toEqual({ y: 2 });
|
|
76
|
+
});
|
|
77
|
+
it('numbers steps starting at 1', () => {
|
|
78
|
+
const t = trace([
|
|
79
|
+
{ phase: 'end', name: 'first' },
|
|
80
|
+
{ phase: 'end', name: 'second' },
|
|
81
|
+
{ phase: 'end', name: 'third' }
|
|
82
|
+
]);
|
|
83
|
+
const steps = extractSteps(t);
|
|
84
|
+
expect(steps.map(s => s.index)).toEqual([1, 2, 3]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { getWorkflowCatalog } from '#api/generated/api.js';
|
|
3
|
+
import { usePoll } from '#views/dev/hooks/use_poll.js';
|
|
4
|
+
const CATALOG_INTERVAL_MS = 10_000;
|
|
5
|
+
export const useWorkflowCatalog = (enabled) => {
|
|
6
|
+
const [workflows, setWorkflows] = useState([]);
|
|
7
|
+
usePoll(enabled, CATALOG_INTERVAL_MS, async () => {
|
|
8
|
+
try {
|
|
9
|
+
const response = await getWorkflowCatalog();
|
|
10
|
+
const data = response?.data;
|
|
11
|
+
if (data?.workflows) {
|
|
12
|
+
setWorkflows(data.workflows);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// API may not be ready yet
|
|
17
|
+
}
|
|
18
|
+
return 'continue';
|
|
19
|
+
});
|
|
20
|
+
return workflows;
|
|
21
|
+
};
|