@outputai/cli 0.3.2-next.5e221e8.0 → 0.3.3-dev.2650161.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/templates/project/src/workflows/blog_evaluator/prompts/signal_noise@v1.prompt.template +2 -1
- package/dist/templates/workflow/README.md.template +2 -1
- package/dist/templates/workflow/prompts/example@v1.prompt.template +2 -1
- package/dist/utils/paths.d.ts +11 -0
- package/dist/utils/paths.js +14 -0
- package/dist/utils/scenario_resolver.d.ts +2 -1
- package/dist/utils/scenario_resolver.js +57 -25
- package/dist/utils/scenario_resolver.spec.js +30 -1
- 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 +5 -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 +41 -0
- package/dist/views/dev/state/ui_state.d.ts +61 -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,112 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { StatusIcon, statusColor } from '#components/status_icon.js';
|
|
5
|
+
import { formatDurationCompact, formatDate, elapsedMs } from '#utils/date_formatter.js';
|
|
6
|
+
import { Footer } from '#views/dev/chrome/footer.js';
|
|
7
|
+
import { LoadingSpinner } from '#views/dev/chrome/loading_spinner.js';
|
|
8
|
+
import { SelectionIndicator } from '#views/dev/chrome/selection_indicator.js';
|
|
9
|
+
import { useUiState } from '#views/dev/state/ui_state.js';
|
|
10
|
+
import { useRunDetail } from '#views/dev/hooks/use_run_detail.js';
|
|
11
|
+
import { JsonView } from '#views/dev/utils/json_render.js';
|
|
12
|
+
import { truncate, computeWindowStart } from '#views/dev/utils/panel_helpers.js';
|
|
13
|
+
import { RUN_DETAIL_VISIBLE_STEPS, RUN_DETAIL_PREVIEW_LINES } from '#views/dev/utils/constants.js';
|
|
14
|
+
const RIGHT_PANE_ORDER = ['input', 'output', 'meta'];
|
|
15
|
+
const cycleRightPane = (current, direction) => {
|
|
16
|
+
const idx = RIGHT_PANE_ORDER.indexOf(current);
|
|
17
|
+
const next = (idx + direction + RIGHT_PANE_ORDER.length) % RIGHT_PANE_ORDER.length;
|
|
18
|
+
return RIGHT_PANE_ORDER[next];
|
|
19
|
+
};
|
|
20
|
+
const COL = {
|
|
21
|
+
num: 6,
|
|
22
|
+
icon: 3,
|
|
23
|
+
name: 50,
|
|
24
|
+
duration: 8
|
|
25
|
+
};
|
|
26
|
+
const StepRow = ({ step, selected }) => (_jsxs(Box, { children: [_jsxs(Box, { width: COL.num, children: [_jsx(SelectionIndicator, { selected: selected }), _jsx(Text, { bold: selected, children: ` ${step.index}` })] }), _jsx(Box, { width: COL.icon, children: _jsx(StatusIcon, { status: step.status }) }), _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) }) })] }));
|
|
27
|
+
const SidebarKV = ({ label, value, color }) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: label }), _jsx(Text, { color: color, wrap: "truncate-end", children: value })] }));
|
|
28
|
+
const Sidebar = ({ run, resultStatus }) => {
|
|
29
|
+
const status = resultStatus ?? run.status ?? 'unknown';
|
|
30
|
+
const duration = run.startedAt ? formatDurationCompact(elapsedMs(run.startedAt, run.completedAt)) : '-';
|
|
31
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { status: status }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: statusColor(status), children: status.toUpperCase() })] }), _jsx(SidebarKV, { label: "RUN ID", value: run.runId ?? '-' }), _jsx(SidebarKV, { label: "WORKFLOW ID", value: run.workflowId ?? '-' }), _jsx(SidebarKV, { label: "TYPE", value: run.workflowType ?? '-' }), _jsx(SidebarKV, { label: "DURATION", value: duration }), _jsx(SidebarKV, { label: "START", value: formatDate(run.startedAt) }), _jsx(SidebarKV, { label: "END", value: run.completedAt ? formatDate(run.completedAt) : '—' })] }));
|
|
32
|
+
};
|
|
33
|
+
const PaneTabs = ({ active }) => (_jsx(Box, { marginTop: 1, children: RIGHT_PANE_ORDER.map((tab, i) => (_jsx(Box, { marginRight: 2, children: tab === active ? (_jsx(Text, { inverse: true, bold: true, children: ` ${tab[0].toUpperCase()}${tab.slice(1)} ` })) : (_jsx(Text, { dimColor: true, children: `${tab[0].toUpperCase()}${tab.slice(1)}${i < RIGHT_PANE_ORDER.length - 1 ? '' : ''}` })) }, tab))) }));
|
|
34
|
+
const stepPaneValue = (step, activeTab) => {
|
|
35
|
+
if (activeTab === 'input') {
|
|
36
|
+
return step.input;
|
|
37
|
+
}
|
|
38
|
+
if (activeTab === 'output') {
|
|
39
|
+
return step.error ?? step.output;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
kind: step.kind,
|
|
43
|
+
status: step.status,
|
|
44
|
+
durationMs: step.durationMs,
|
|
45
|
+
hasError: Boolean(step.error)
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
const StepDetail = ({ step, activeTab }) => {
|
|
49
|
+
if (!step) {
|
|
50
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Select a step to see input/output." }) }));
|
|
51
|
+
}
|
|
52
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(PaneTabs, { active: activeTab }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(JsonView, { value: stepPaneValue(step, activeTab), maxLines: RUN_DETAIL_PREVIEW_LINES }) })] }));
|
|
53
|
+
};
|
|
54
|
+
const HINTS = [
|
|
55
|
+
{ key: '↑/↓', label: 'navigate' },
|
|
56
|
+
{ key: '←/→', label: 'switch pane' },
|
|
57
|
+
{ key: 'e', label: 'expand' },
|
|
58
|
+
{ key: 'esc', label: 'back' },
|
|
59
|
+
{ key: 'tab', label: 'next tab' }
|
|
60
|
+
];
|
|
61
|
+
export const RunDetailView = ({ run }) => {
|
|
62
|
+
const ui = useUiState();
|
|
63
|
+
const { result, steps, loading } = useRunDetail(run.workflowId, run.runId, run.status);
|
|
64
|
+
const [stepIndex, setStepIndex] = useState(0);
|
|
65
|
+
const isActive = ui.tab === 'runs' && ui.runsView === 'detail' && !ui.search.open && !ui.runModal.open && !ui.expandedJson.open;
|
|
66
|
+
const clamped = Math.min(stepIndex, Math.max(0, steps.length - 1));
|
|
67
|
+
const selectedStep = steps[clamped];
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (clamped !== stepIndex) {
|
|
70
|
+
setStepIndex(clamped);
|
|
71
|
+
}
|
|
72
|
+
}, [clamped, stepIndex]);
|
|
73
|
+
useInput((input, key) => {
|
|
74
|
+
if (key.escape) {
|
|
75
|
+
ui.setRunsView('list');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (key.upArrow) {
|
|
79
|
+
setStepIndex(i => Math.max(0, i - 1));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (key.downArrow) {
|
|
83
|
+
setStepIndex(i => Math.min(steps.length - 1, i + 1));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (key.leftArrow) {
|
|
87
|
+
ui.setRightPaneTab(cycleRightPane(ui.rightPaneTab, -1));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (key.rightArrow) {
|
|
91
|
+
ui.setRightPaneTab(cycleRightPane(ui.rightPaneTab, 1));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (input === 'e' && selectedStep) {
|
|
95
|
+
const content = stepPaneValue(selectedStep, ui.rightPaneTab);
|
|
96
|
+
const label = `step ${selectedStep.index}: ${selectedStep.name} → ${ui.rightPaneTab}`;
|
|
97
|
+
ui.openExpandedJson(content, label);
|
|
98
|
+
}
|
|
99
|
+
}, { isActive });
|
|
100
|
+
const windowStart = computeWindowStart(clamped, steps.length, RUN_DETAIL_VISIBLE_STEPS);
|
|
101
|
+
const visibleSteps = steps.slice(windowStart, windowStart + RUN_DETAIL_VISIBLE_STEPS);
|
|
102
|
+
const renderStepList = () => {
|
|
103
|
+
if (loading && steps.length === 0) {
|
|
104
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(LoadingSpinner, { label: "Loading steps\u2026" }) }));
|
|
105
|
+
}
|
|
106
|
+
if (steps.length === 0) {
|
|
107
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No steps recorded for this run." }) }));
|
|
108
|
+
}
|
|
109
|
+
return (_jsxs(_Fragment, { children: [windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more above"] }), visibleSteps.map((step, i) => (_jsx(StepRow, { step: step, selected: windowStart + i === clamped }, `${step.index}-${i}`))), windowStart + RUN_DETAIL_VISIBLE_STEPS < steps.length && (_jsxs(Text, { dimColor: true, children: [" \u2193 ", steps.length - windowStart - RUN_DETAIL_VISIBLE_STEPS, " more below"] }))] }));
|
|
110
|
+
};
|
|
111
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Recent Runs \u203A " }), _jsx(Text, { bold: true, children: run.workflowType }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { children: truncate(run.runId ?? '-', 28) })] }), _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, children: renderStepList() }), _jsx(Box, { flexDirection: "column", width: 40, borderStyle: "single", borderTop: false, borderBottom: false, borderRight: false, paddingLeft: 1, children: _jsx(Sidebar, { run: run, resultStatus: result?.status ?? null }) })] }), _jsx(StepDetail, { step: selectedStep, activeTab: ui.rightPaneTab }), _jsx(Footer, { hints: HINTS, itemCount: steps.length, itemLabel: "steps" })] }));
|
|
112
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { WorkflowRun } from '#services/workflow_runs.js';
|
|
3
|
+
import type { TraceData } from '#types/trace.js';
|
|
4
|
+
export declare const buildVisibleRuns: (runs: WorkflowRun[], query: string) => WorkflowRun[];
|
|
5
|
+
export declare const extractRunInput: (trace: TraceData | null) => unknown;
|
|
6
|
+
export declare const RunsPanel: React.FC<{
|
|
7
|
+
runs: WorkflowRun[];
|
|
8
|
+
}>;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { StatusIcon, statusColor } from '#components/status_icon.js';
|
|
5
|
+
import { elapsedMs, formatDurationCompact, formatDate } from '#utils/date_formatter.js';
|
|
6
|
+
import { openUrl } from '#utils/open_url.js';
|
|
7
|
+
import { Footer } from '#views/dev/chrome/footer.js';
|
|
8
|
+
import { LoadingSpinner } from '#views/dev/chrome/loading_spinner.js';
|
|
9
|
+
import { SelectionIndicator } from '#views/dev/chrome/selection_indicator.js';
|
|
10
|
+
import { useUiState } from '#views/dev/state/ui_state.js';
|
|
11
|
+
import { RunDetailView } from '#views/dev/panels/run_detail_view.js';
|
|
12
|
+
import { useRunDetail } from '#views/dev/hooks/use_run_detail.js';
|
|
13
|
+
import { JsonView } from '#views/dev/utils/json_render.js';
|
|
14
|
+
import { MasterDetailPanel } from '#views/dev/components/master_detail_panel.js';
|
|
15
|
+
import { truncate, formatStartedShort } from '#views/dev/utils/panel_helpers.js';
|
|
16
|
+
import { CATALOG_WORKFLOW_NAME, RUNS_VISIBLE_ROWS, RUNS_PREVIEW_LINES } from '#views/dev/utils/constants.js';
|
|
17
|
+
const TEMPORAL_UI_BASE = 'http://localhost:8080';
|
|
18
|
+
const STATUS_ORDER = {
|
|
19
|
+
running: 0,
|
|
20
|
+
failed: 1,
|
|
21
|
+
timed_out: 2,
|
|
22
|
+
terminated: 3,
|
|
23
|
+
canceled: 4,
|
|
24
|
+
continued: 5,
|
|
25
|
+
completed: 6
|
|
26
|
+
};
|
|
27
|
+
const sortRuns = (runs) => [...runs].sort((a, b) => {
|
|
28
|
+
const statusDiff = (STATUS_ORDER[a.status ?? ''] ?? Infinity) - (STATUS_ORDER[b.status ?? ''] ?? Infinity);
|
|
29
|
+
if (statusDiff !== 0) {
|
|
30
|
+
return statusDiff;
|
|
31
|
+
}
|
|
32
|
+
const aTime = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
|
33
|
+
const bTime = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
|
34
|
+
return bTime - aTime;
|
|
35
|
+
});
|
|
36
|
+
const matchesFilter = (run, query) => {
|
|
37
|
+
if (!query) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
const q = query.toLowerCase();
|
|
41
|
+
return (run.workflowType ?? '').toLowerCase().includes(q) ||
|
|
42
|
+
(run.workflowId ?? '').toLowerCase().includes(q) ||
|
|
43
|
+
(run.status ?? '').toLowerCase().includes(q);
|
|
44
|
+
};
|
|
45
|
+
export const buildVisibleRuns = (runs, query) => {
|
|
46
|
+
const visible = runs.filter(r => !(r.workflowType === CATALOG_WORKFLOW_NAME && r.status === 'completed'));
|
|
47
|
+
const filtered = query ? visible.filter(r => matchesFilter(r, query)) : visible;
|
|
48
|
+
return sortRuns(filtered);
|
|
49
|
+
};
|
|
50
|
+
export const extractRunInput = (trace) => {
|
|
51
|
+
if (!trace?.children) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const firstChild = trace.children[0];
|
|
55
|
+
if (!firstChild) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (firstChild.input !== undefined) {
|
|
59
|
+
return firstChild.input;
|
|
60
|
+
}
|
|
61
|
+
return firstChild.details?.input ?? null;
|
|
62
|
+
};
|
|
63
|
+
const COL = {
|
|
64
|
+
indicator: 3,
|
|
65
|
+
icon: 3,
|
|
66
|
+
status: 11,
|
|
67
|
+
type: 22,
|
|
68
|
+
id: 26,
|
|
69
|
+
duration: 9,
|
|
70
|
+
started: 14
|
|
71
|
+
};
|
|
72
|
+
const HeaderRow = () => (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.icon, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { dimColor: true, bold: true, children: "STATUS" }) }), _jsx(Box, { width: COL.type, children: _jsx(Text, { dimColor: true, bold: true, children: "TYPE" }) }), _jsx(Box, { width: COL.id, children: _jsx(Text, { dimColor: true, bold: true, children: "ID" }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: true, bold: true, children: "DURATION" }) }), _jsx(Box, { width: COL.started, marginLeft: 2, children: _jsx(Text, { dimColor: true, bold: true, children: "STARTED" }) })] }));
|
|
73
|
+
const RunRow = ({ run, selected }) => {
|
|
74
|
+
const status = run.status ?? 'running';
|
|
75
|
+
const color = statusColor(status);
|
|
76
|
+
const duration = run.startedAt ? formatDurationCompact(elapsedMs(run.startedAt, run.completedAt)) : '-';
|
|
77
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(SelectionIndicator, { selected: selected }) }), _jsx(Box, { width: COL.icon, children: _jsx(StatusIcon, { status: status }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { color: color, children: status }) }), _jsx(Box, { width: COL.type, children: _jsx(Text, { bold: selected, children: truncate(run.workflowType ?? '-', COL.type - 1) }) }), _jsx(Box, { width: COL.id, children: _jsx(Text, { dimColor: !selected, children: truncate(run.workflowId ?? '-', COL.id - 1) }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: !selected, children: duration }) }), _jsx(Box, { width: COL.started, marginLeft: 2, children: _jsx(Text, { dimColor: !selected, children: formatStartedShort(run.startedAt) }) })] }));
|
|
78
|
+
};
|
|
79
|
+
const PaneTabs = ({ active }) => (_jsx(Box, { children: ['input', 'output'].map((tab, i) => (_jsx(Box, { marginRight: i === 0 ? 1 : 0, children: tab === active ? (_jsx(Text, { inverse: true, bold: true, children: ` ${tab[0].toUpperCase()}${tab.slice(1)} ` })) : (_jsx(Text, { dimColor: true, children: ` ${tab[0].toUpperCase()}${tab.slice(1)} ` })) }, tab))) }));
|
|
80
|
+
const InlineKV = ({ label, value }) => (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [label, ": "] }), _jsx(Text, { children: value })] }));
|
|
81
|
+
const DetailPane = ({ run, pane }) => {
|
|
82
|
+
const ui = useUiState();
|
|
83
|
+
if (!run || !pane) {
|
|
84
|
+
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Select a run to see details." }) }));
|
|
85
|
+
}
|
|
86
|
+
const { input: runInput, output: runOutput, error: runError, status, loading } = pane;
|
|
87
|
+
const duration = run.startedAt ? formatDurationCompact(elapsedMs(run.startedAt, run.completedAt)) : '-';
|
|
88
|
+
const activePane = ui.rightPaneTab === 'input' ? 'input' : 'output';
|
|
89
|
+
const renderPane = () => {
|
|
90
|
+
if (activePane === 'input') {
|
|
91
|
+
if (loading && runInput === null) {
|
|
92
|
+
return _jsx(LoadingSpinner, {});
|
|
93
|
+
}
|
|
94
|
+
return _jsx(JsonView, { value: runInput, maxLines: RUNS_PREVIEW_LINES });
|
|
95
|
+
}
|
|
96
|
+
if (runError) {
|
|
97
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", bold: true, children: "ERROR" }), _jsx(Text, { color: "red", wrap: "wrap", children: truncate(String(runError), 400) })] }));
|
|
98
|
+
}
|
|
99
|
+
if (runOutput === undefined || runOutput === null) {
|
|
100
|
+
if (loading) {
|
|
101
|
+
return _jsx(LoadingSpinner, {});
|
|
102
|
+
}
|
|
103
|
+
return _jsx(Text, { dimColor: true, children: "No output yet." });
|
|
104
|
+
}
|
|
105
|
+
return _jsx(JsonView, { value: runOutput, maxLines: RUNS_PREVIEW_LINES });
|
|
106
|
+
};
|
|
107
|
+
const heading = `${run.workflowType ?? 'run'} : ${run.runId ?? run.workflowId ?? '-'}`;
|
|
108
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { bold: true, children: heading }) }), _jsxs(Box, { marginTop: 1, children: [_jsx(StatusIcon, { status: status }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: statusColor(status), children: status.toUpperCase() }), _jsx(Text, { dimColor: true, children: " " }), _jsx(InlineKV, { label: "DURATION", value: duration }), _jsx(Text, { dimColor: true, children: " " }), _jsx(InlineKV, { label: "STARTED", value: formatDate(run.startedAt) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(InlineKV, { label: "COMPLETED", value: run.completedAt ? formatDate(run.completedAt) : '—' })] }), _jsx(Box, { marginTop: 1, children: _jsx(PaneTabs, { active: activePane }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: renderPane() })] }));
|
|
109
|
+
};
|
|
110
|
+
const HINTS = [
|
|
111
|
+
{ key: '↑/↓', label: 'navigate' },
|
|
112
|
+
{ key: 'enter', label: 'open' },
|
|
113
|
+
{ key: '←/→', label: 'switch pane' },
|
|
114
|
+
{ key: 'e', label: 'expand' },
|
|
115
|
+
{ key: 'o', label: 'temporal' },
|
|
116
|
+
{ key: '/', label: 'filter' },
|
|
117
|
+
{ key: 'tab', label: 'next tab' }
|
|
118
|
+
];
|
|
119
|
+
export const RunsPanel = ({ runs }) => {
|
|
120
|
+
const ui = useUiState();
|
|
121
|
+
const filteredRuns = useMemo(() => buildVisibleRuns(runs, ui.search.query), [runs, ui.search.query]);
|
|
122
|
+
// Lazy initializer — runs once on mount. Restores the previously selected
|
|
123
|
+
// run after the expanded-JSON modal unmounts and remounts the panel.
|
|
124
|
+
const [selectedIndex, setSelectedIndex] = useState(() => {
|
|
125
|
+
const previousRunId = ui.selection.runId;
|
|
126
|
+
if (!previousRunId) {
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
const initial = buildVisibleRuns(runs, ui.search.query);
|
|
130
|
+
const i = initial.findIndex(r => r.runId === previousRunId);
|
|
131
|
+
return i >= 0 ? i : 0;
|
|
132
|
+
});
|
|
133
|
+
const isActive = ui.tab === 'runs' && ui.runsView === 'list' && !ui.search.open && !ui.runModal.open && !ui.expandedJson.open;
|
|
134
|
+
const clampedIndex = Math.min(selectedIndex, Math.max(0, filteredRuns.length - 1));
|
|
135
|
+
const selectedRun = filteredRuns[clampedIndex];
|
|
136
|
+
const { result, trace, loading } = useRunDetail(selectedRun?.workflowId, selectedRun?.runId, selectedRun?.status);
|
|
137
|
+
const pane = selectedRun ? {
|
|
138
|
+
input: extractRunInput(trace),
|
|
139
|
+
output: result?.output,
|
|
140
|
+
error: result?.error,
|
|
141
|
+
status: result?.status ?? selectedRun.status ?? 'unknown',
|
|
142
|
+
loading
|
|
143
|
+
} : null;
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (clampedIndex !== selectedIndex) {
|
|
146
|
+
setSelectedIndex(clampedIndex);
|
|
147
|
+
}
|
|
148
|
+
}, [clampedIndex, selectedIndex]);
|
|
149
|
+
const setSelection = ui.setSelection;
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
setSelection({
|
|
152
|
+
runId: selectedRun?.runId,
|
|
153
|
+
workflowId: selectedRun?.workflowId,
|
|
154
|
+
workflowName: selectedRun?.workflowType
|
|
155
|
+
});
|
|
156
|
+
}, [selectedRun?.runId, selectedRun?.workflowId, selectedRun?.workflowType, setSelection]);
|
|
157
|
+
useInput((input, key) => {
|
|
158
|
+
if (key.upArrow) {
|
|
159
|
+
setSelectedIndex(i => Math.max(0, i - 1));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (key.downArrow) {
|
|
163
|
+
setSelectedIndex(i => Math.min(filteredRuns.length - 1, i + 1));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (input === 'o' && selectedRun?.workflowId) {
|
|
167
|
+
openUrl(`${TEMPORAL_UI_BASE}/namespaces/default/workflows/${selectedRun.workflowId}`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (key.return && selectedRun?.workflowId) {
|
|
171
|
+
ui.setRunsView('detail');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (key.leftArrow || key.rightArrow) {
|
|
175
|
+
ui.setRightPaneTab(ui.rightPaneTab === 'input' ? 'output' : 'input');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (input === 'e' && pane) {
|
|
179
|
+
const content = ui.rightPaneTab === 'input' ?
|
|
180
|
+
pane.input :
|
|
181
|
+
(pane.error ?? pane.output);
|
|
182
|
+
const label = `${selectedRun?.workflowType ?? 'run'} → ${ui.rightPaneTab}`;
|
|
183
|
+
ui.openExpandedJson(content, label);
|
|
184
|
+
}
|
|
185
|
+
}, { isActive });
|
|
186
|
+
const detailRun = ui.runsView === 'detail' ?
|
|
187
|
+
(runs.find(r => r.runId === ui.selection.runId && r.workflowId === ui.selection.workflowId) ?? selectedRun) :
|
|
188
|
+
undefined;
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (ui.runsView === 'detail' && !detailRun) {
|
|
191
|
+
ui.setRunsView('list');
|
|
192
|
+
}
|
|
193
|
+
}, [ui, detailRun]);
|
|
194
|
+
if (ui.runsView === 'detail' && detailRun) {
|
|
195
|
+
return _jsx(RunDetailView, { run: detailRun });
|
|
196
|
+
}
|
|
197
|
+
if (runs.length === 0) {
|
|
198
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Recent Runs" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No runs yet. Trigger one from the Workflows tab or with `output workflow run \u2026`." }) }), _jsx(Footer, { hints: [{ key: 'tab', label: 'next tab' }, { key: '?', label: 'help' }] })] }));
|
|
199
|
+
}
|
|
200
|
+
if (filteredRuns.length === 0) {
|
|
201
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Recent Runs" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["No runs match `", ui.search.query, "`. Press "] }), _jsx(Text, { bold: true, children: "esc" }), _jsx(Text, { dimColor: true, children: " to clear the filter." })] }), _jsx(Footer, { hints: HINTS, itemCount: 0, itemLabel: "runs" })] }));
|
|
202
|
+
}
|
|
203
|
+
return (_jsx(MasterDetailPanel, { items: filteredRuns, selectedIndex: clampedIndex, visibleRows: RUNS_VISIBLE_ROWS, renderHeader: () => _jsx(HeaderRow, {}), renderRow: (run, selected) => _jsx(RunRow, { run: run, selected: selected }), rowKey: (run, i) => `${run.workflowId}-${run.runId ?? run.startedAt}-${i}`, detail: _jsx(DetailPane, { run: selectedRun, pane: pane }), hints: HINTS, itemLabel: "runs" }));
|
|
204
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildVisibleRuns, extractRunInput } from './runs_panel.js';
|
|
3
|
+
const run = (overrides) => ({
|
|
4
|
+
workflowId: 'wf',
|
|
5
|
+
workflowType: 'demo',
|
|
6
|
+
status: 'completed',
|
|
7
|
+
startedAt: '2026-04-28T18:56:53Z',
|
|
8
|
+
completedAt: '2026-04-28T18:56:57Z',
|
|
9
|
+
...overrides
|
|
10
|
+
});
|
|
11
|
+
describe('buildVisibleRuns', () => {
|
|
12
|
+
it('drops completed $catalog rows', () => {
|
|
13
|
+
const runs = [
|
|
14
|
+
run({ workflowType: '$catalog', status: 'completed' }),
|
|
15
|
+
run({ workflowType: 'demo', status: 'completed' })
|
|
16
|
+
];
|
|
17
|
+
const visible = buildVisibleRuns(runs, '');
|
|
18
|
+
expect(visible.map(r => r.workflowType)).toEqual(['demo']);
|
|
19
|
+
});
|
|
20
|
+
it('keeps non-completed $catalog rows for diagnostics', () => {
|
|
21
|
+
const runs = [
|
|
22
|
+
run({ workflowType: '$catalog', status: 'running' }),
|
|
23
|
+
run({ workflowType: '$catalog', status: 'failed' })
|
|
24
|
+
];
|
|
25
|
+
const visible = buildVisibleRuns(runs, '');
|
|
26
|
+
expect(visible).toHaveLength(2);
|
|
27
|
+
});
|
|
28
|
+
it('sorts running before failed before completed', () => {
|
|
29
|
+
const runs = [
|
|
30
|
+
run({ workflowType: 'a', status: 'completed' }),
|
|
31
|
+
run({ workflowType: 'b', status: 'running' }),
|
|
32
|
+
run({ workflowType: 'c', status: 'failed' })
|
|
33
|
+
];
|
|
34
|
+
const visible = buildVisibleRuns(runs, '');
|
|
35
|
+
expect(visible.map(r => r.status)).toEqual(['running', 'failed', 'completed']);
|
|
36
|
+
});
|
|
37
|
+
it('sorts within the same status by startedAt descending', () => {
|
|
38
|
+
const runs = [
|
|
39
|
+
run({ workflowId: 'old', startedAt: '2026-04-01T00:00:00Z' }),
|
|
40
|
+
run({ workflowId: 'new', startedAt: '2026-04-28T00:00:00Z' })
|
|
41
|
+
];
|
|
42
|
+
const visible = buildVisibleRuns(runs, '');
|
|
43
|
+
expect(visible.map(r => r.workflowId)).toEqual(['new', 'old']);
|
|
44
|
+
});
|
|
45
|
+
it('filters by query against workflowType, workflowId, and status', () => {
|
|
46
|
+
const runs = [
|
|
47
|
+
run({ workflowType: 'apple', workflowId: 'wf-1' }),
|
|
48
|
+
run({ workflowType: 'banana', workflowId: 'wf-2' })
|
|
49
|
+
];
|
|
50
|
+
expect(buildVisibleRuns(runs, 'apple')).toHaveLength(1);
|
|
51
|
+
expect(buildVisibleRuns(runs, 'wf-2')).toHaveLength(1);
|
|
52
|
+
expect(buildVisibleRuns(runs, 'completed')).toHaveLength(2);
|
|
53
|
+
expect(buildVisibleRuns(runs, 'no-match')).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('extractRunInput', () => {
|
|
57
|
+
it('returns null when the trace has no children', () => {
|
|
58
|
+
expect(extractRunInput(null)).toBeNull();
|
|
59
|
+
expect(extractRunInput({ root: { workflowName: 'x', workflowId: 'y', startTime: 0 }, children: [] })).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
it('reads from the first child input field directly', () => {
|
|
62
|
+
const trace = {
|
|
63
|
+
root: { workflowName: 'x', workflowId: 'y', startTime: 0 },
|
|
64
|
+
children: [{ input: { foo: 1 } }]
|
|
65
|
+
};
|
|
66
|
+
expect(extractRunInput(trace)).toEqual({ foo: 1 });
|
|
67
|
+
});
|
|
68
|
+
it('falls back to details.input when the top-level input is missing', () => {
|
|
69
|
+
const trace = {
|
|
70
|
+
root: { workflowName: 'x', workflowId: 'y', startTime: 0 },
|
|
71
|
+
children: [{ details: { input: { bar: 2 } } }]
|
|
72
|
+
};
|
|
73
|
+
expect(extractRunInput(trace)).toEqual({ bar: 2 });
|
|
74
|
+
});
|
|
75
|
+
it('returns null when neither input source is set', () => {
|
|
76
|
+
const trace = {
|
|
77
|
+
root: { workflowName: 'x', workflowId: 'y', startTime: 0 },
|
|
78
|
+
children: [{ name: 'first-step' }]
|
|
79
|
+
};
|
|
80
|
+
expect(extractRunInput(trace)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type ServiceStatus } from '#services/docker.js';
|
|
3
|
+
import type { Phase } from '#views/dev/dev_app.js';
|
|
4
|
+
/**
|
|
5
|
+
* Worker first (most-watched in development), then API, then everything
|
|
6
|
+
* else alphabetically. Promoting the priority list to a constant keeps
|
|
7
|
+
* the comparator three lines.
|
|
8
|
+
*/
|
|
9
|
+
export declare const compareService: (a: ServiceStatus, b: ServiceStatus) => number;
|
|
10
|
+
export declare const ServicesPanel: React.FC<{
|
|
11
|
+
phase: Phase;
|
|
12
|
+
services: ServiceStatus[];
|
|
13
|
+
dockerComposePath: string;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { isServiceFailed, SERVICE_HEALTH } from '#services/docker.js';
|
|
5
|
+
import { StatusIcon } from '#components/status_icon.js';
|
|
6
|
+
import { openUrl } from '#utils/open_url.js';
|
|
7
|
+
import { Footer } from '#views/dev/chrome/footer.js';
|
|
8
|
+
import { LoadingSpinner } from '#views/dev/chrome/loading_spinner.js';
|
|
9
|
+
import { SelectionIndicator } from '#views/dev/chrome/selection_indicator.js';
|
|
10
|
+
import { useUiState } from '#views/dev/state/ui_state.js';
|
|
11
|
+
import { useDockerLogs } from '#views/dev/hooks/use_docker_logs.js';
|
|
12
|
+
import { restartService, restartStack } from '#views/dev/services/docker_control.js';
|
|
13
|
+
import { MasterDetailPanel } from '#views/dev/components/master_detail_panel.js';
|
|
14
|
+
const VISIBLE_LOG_LINES = 18;
|
|
15
|
+
const resolveServiceStatus = (service) => service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
|
|
16
|
+
const SERVICE_URLS = {
|
|
17
|
+
'temporal-ui': 'http://localhost:8080',
|
|
18
|
+
api: 'http://localhost:3001',
|
|
19
|
+
temporal: 'http://localhost:7233',
|
|
20
|
+
redis: 'redis://localhost:6379'
|
|
21
|
+
};
|
|
22
|
+
const COL = {
|
|
23
|
+
indicator: 3,
|
|
24
|
+
icon: 3,
|
|
25
|
+
name: 16,
|
|
26
|
+
status: 11,
|
|
27
|
+
ports: 22
|
|
28
|
+
};
|
|
29
|
+
const HeaderRow = () => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Services" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.icon, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.name, children: _jsx(Text, { dimColor: true, bold: true, children: "SERVICE" }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { dimColor: true, bold: true, children: "STATUS" }) }), _jsx(Box, { width: COL.ports, children: _jsx(Text, { dimColor: true, bold: true, children: "PORTS" }) })] })] }));
|
|
30
|
+
const ServiceRow = ({ service, selected }) => {
|
|
31
|
+
const status = resolveServiceStatus(service);
|
|
32
|
+
const ports = service.ports.length ? service.ports.join(', ') : '-';
|
|
33
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(SelectionIndicator, { selected: selected }) }), _jsx(Box, { width: COL.icon, children: _jsx(StatusIcon, { status: status }) }), _jsx(Box, { width: COL.name, children: _jsx(Text, { bold: selected, wrap: "truncate-end", children: service.name }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { dimColor: !selected, wrap: "truncate-end", children: status }) }), _jsx(Box, { width: COL.ports, children: _jsx(Text, { dimColor: !selected, wrap: "truncate-end", children: ports }) })] }));
|
|
34
|
+
};
|
|
35
|
+
const FailureBanner = ({ services }) => {
|
|
36
|
+
const failed = services.filter(isServiceFailed);
|
|
37
|
+
if (failed.length === 0) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { backgroundColor: "red", color: "white", bold: true, children: [" \u26A0\uFE0F ", failed.length, " service(s) failing \u2014 see logs and press r to restart "] }) }));
|
|
41
|
+
};
|
|
42
|
+
const LogPane = ({ serviceName, lines, paused }) => {
|
|
43
|
+
if (!serviceName) {
|
|
44
|
+
return _jsx(Text, { dimColor: true, children: "Select a service to tail its logs." });
|
|
45
|
+
}
|
|
46
|
+
if (lines.length === 0) {
|
|
47
|
+
return _jsx(LoadingSpinner, { label: `Waiting for logs from ${serviceName}…` });
|
|
48
|
+
}
|
|
49
|
+
const visible = lines.slice(-VISIBLE_LOG_LINES);
|
|
50
|
+
return (_jsxs(Box, { flexDirection: "column", children: [paused && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { backgroundColor: "yellow", color: "black", children: " PAUSED " }), _jsx(Text, { dimColor: true, children: " press p to resume" })] })), visible.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line }, `${i}-${line.length}`)))] }));
|
|
51
|
+
};
|
|
52
|
+
const SERVICE_PRIORITY = ['worker', 'api'];
|
|
53
|
+
/**
|
|
54
|
+
* Worker first (most-watched in development), then API, then everything
|
|
55
|
+
* else alphabetically. Promoting the priority list to a constant keeps
|
|
56
|
+
* the comparator three lines.
|
|
57
|
+
*/
|
|
58
|
+
export const compareService = (a, b) => {
|
|
59
|
+
const ai = SERVICE_PRIORITY.indexOf(a.name);
|
|
60
|
+
const bi = SERVICE_PRIORITY.indexOf(b.name);
|
|
61
|
+
if (ai !== -1 || bi !== -1) {
|
|
62
|
+
return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi);
|
|
63
|
+
}
|
|
64
|
+
return a.name.localeCompare(b.name);
|
|
65
|
+
};
|
|
66
|
+
const HINTS = [
|
|
67
|
+
{ key: '↑/↓', label: 'navigate' },
|
|
68
|
+
{ key: 'r/R', label: 'restart one/all' },
|
|
69
|
+
{ key: 'p', label: 'pause logs' },
|
|
70
|
+
{ key: 'c', label: 'clear' },
|
|
71
|
+
{ key: 'o', label: 'open url' },
|
|
72
|
+
{ key: 'tab', label: 'next tab' },
|
|
73
|
+
{ key: 'ctrl+c', label: 'stop & quit' }
|
|
74
|
+
];
|
|
75
|
+
const HINTS_BOOT = [
|
|
76
|
+
{ key: 'tab', label: 'next tab' },
|
|
77
|
+
{ key: '?', label: 'help' },
|
|
78
|
+
{ key: 'ctrl+c', label: 'quit' }
|
|
79
|
+
];
|
|
80
|
+
const Detail = ({ service, services, lines, paused, banner }) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(FailureBanner, { services: services }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, children: service?.name ?? 'Logs' }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(LogPane, { serviceName: service?.name ?? null, lines: lines, paused: paused }) }), banner && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: banner }) }))] }));
|
|
81
|
+
export const ServicesPanel = ({ phase, services, dockerComposePath }) => {
|
|
82
|
+
const ui = useUiState();
|
|
83
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
84
|
+
const [banner, setBanner] = useState(null);
|
|
85
|
+
const sortedServices = useMemo(() => [...services].sort(compareService), [services]);
|
|
86
|
+
const isActive = ui.tab === 'services' && !ui.search.open && !ui.runModal.open;
|
|
87
|
+
const clamped = Math.min(selectedIndex, Math.max(0, sortedServices.length - 1));
|
|
88
|
+
const selectedService = sortedServices[clamped];
|
|
89
|
+
const enabledLogs = isActive && phase === 'running' && Boolean(selectedService);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (clamped !== selectedIndex) {
|
|
92
|
+
setSelectedIndex(clamped);
|
|
93
|
+
}
|
|
94
|
+
}, [clamped, selectedIndex]);
|
|
95
|
+
const logs = useDockerLogs(dockerComposePath, selectedService?.name ?? null, enabledLogs);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!banner) {
|
|
98
|
+
return () => { };
|
|
99
|
+
}
|
|
100
|
+
const id = setTimeout(() => setBanner(null), 3000);
|
|
101
|
+
return () => clearTimeout(id);
|
|
102
|
+
}, [banner]);
|
|
103
|
+
useInput((input, key) => {
|
|
104
|
+
if (key.upArrow) {
|
|
105
|
+
setSelectedIndex(i => Math.max(0, i - 1));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (key.downArrow) {
|
|
109
|
+
setSelectedIndex(i => Math.min(sortedServices.length - 1, i + 1));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!selectedService) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (input === 'p') {
|
|
116
|
+
logs.setPaused(!logs.paused);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (input === 'c') {
|
|
120
|
+
logs.clear();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (input === 'o') {
|
|
124
|
+
const url = SERVICE_URLS[selectedService.name];
|
|
125
|
+
if (url) {
|
|
126
|
+
openUrl(url);
|
|
127
|
+
setBanner(`Opened ${url}`);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
setBanner(`${selectedService.name} has no known URL`);
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (input === 'r') {
|
|
135
|
+
setBanner(`Restarting ${selectedService.name}…`);
|
|
136
|
+
restartService(dockerComposePath, selectedService.name)
|
|
137
|
+
.then(() => setBanner(`Restarted ${selectedService.name}`))
|
|
138
|
+
.catch(err => setBanner(`Restart failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (input === 'R') {
|
|
142
|
+
setBanner('Restarting all services…');
|
|
143
|
+
restartStack(dockerComposePath)
|
|
144
|
+
.then(() => setBanner('Restarted all services'))
|
|
145
|
+
.catch(err => setBanner(`Restart failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
146
|
+
}
|
|
147
|
+
}, { isActive });
|
|
148
|
+
if (phase === 'waiting' && services.length === 0) {
|
|
149
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(LoadingSpinner, { label: "Starting Docker services\u2026" }), _jsx(Footer, { hints: HINTS_BOOT })] }));
|
|
150
|
+
}
|
|
151
|
+
if (services.length === 0) {
|
|
152
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "No services running." }), _jsx(Footer, { hints: HINTS_BOOT })] }));
|
|
153
|
+
}
|
|
154
|
+
return (_jsx(MasterDetailPanel, { items: sortedServices, selectedIndex: clamped, visibleRows: sortedServices.length, renderHeader: () => _jsx(HeaderRow, {}), renderRow: (service, selected) => _jsx(ServiceRow, { service: service, selected: selected }), rowKey: service => service.name, detail: _jsx(Detail, { service: selectedService, services: services, lines: logs.lines, paused: logs.paused, banner: banner }), hints: phase === 'running' ? HINTS : HINTS_BOOT, itemLabel: "services" }));
|
|
155
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { compareService } from './services_panel.js';
|
|
3
|
+
const svc = (name) => ({
|
|
4
|
+
name,
|
|
5
|
+
state: 'running',
|
|
6
|
+
health: 'healthy',
|
|
7
|
+
ports: []
|
|
8
|
+
});
|
|
9
|
+
describe('compareService', () => {
|
|
10
|
+
it('puts worker before api', () => {
|
|
11
|
+
expect(compareService(svc('worker'), svc('api'))).toBeLessThan(0);
|
|
12
|
+
});
|
|
13
|
+
it('puts api before alphabetically-earlier names', () => {
|
|
14
|
+
expect(compareService(svc('api'), svc('aardvark'))).toBeLessThan(0);
|
|
15
|
+
});
|
|
16
|
+
it('puts worker before alphabetically-earlier names', () => {
|
|
17
|
+
expect(compareService(svc('worker'), svc('aardvark'))).toBeLessThan(0);
|
|
18
|
+
});
|
|
19
|
+
it('sorts non-priority services alphabetically', () => {
|
|
20
|
+
expect(compareService(svc('postgres'), svc('redis'))).toBeLessThan(0);
|
|
21
|
+
expect(compareService(svc('temporal'), svc('redis'))).toBeGreaterThan(0);
|
|
22
|
+
});
|
|
23
|
+
it('produces the expected full ordering for a real stack', () => {
|
|
24
|
+
const stack = [svc('temporal-ui'), svc('redis'), svc('postgres'), svc('api'), svc('worker'), svc('temporal')];
|
|
25
|
+
const sorted = [...stack].sort(compareService).map(s => s.name);
|
|
26
|
+
expect(sorted).toEqual(['worker', 'api', 'postgres', 'redis', 'temporal', 'temporal-ui']);
|
|
27
|
+
});
|
|
28
|
+
});
|