Files
server-config-cli/src/screens/RunLog.jsx
2026-05-11 17:19:13 +08:00

180 lines
4.5 KiB
JavaScript

import React, { useEffect, useReducer } from "react";
import { Box, Text } from "ink";
import Spinner from "ink-spinner";
import SelectInput from "ink-select-input";
import { startRun } from "../lib/runner.js";
const MAX_LOG_LINES = 12;
function reducer(state, action) {
switch (action.type) {
case "step-start":
return {
...state,
currentIndex: action.index,
statuses: { ...state.statuses, [action.index]: "running" },
};
case "step-done":
return {
...state,
statuses: { ...state.statuses, [action.index]: action.status },
errors:
action.error != null
? { ...state.errors, [action.index]: action.error }
: state.errors,
};
case "log": {
const next = [...state.log, action];
if (next.length > MAX_LOG_LINES * 4) {
next.splice(0, next.length - MAX_LOG_LINES * 4);
}
return { ...state, log: next };
}
case "done":
return { ...state, finished: true, success: action.success, cancelled: !!action.cancelled };
default:
return state;
}
}
export default function RunLog({ nav, plan, origin }) {
const [state, dispatch] = useReducer(reducer, {
statuses: {},
errors: {},
currentIndex: -1,
log: [],
finished: false,
success: false,
cancelled: false,
});
useEffect(() => {
const run = startRun(plan.steps);
run.on("event", (event) => {
dispatch(event);
});
return () => run.cancel();
}, [plan]);
const recentLog = state.log.slice(-MAX_LOG_LINES);
return (
<Box flexDirection="column">
<Text bold>{plan.title}</Text>
<Box flexDirection="column" marginTop={1}>
{plan.steps.map((step, index) => (
<StepRow
key={index}
index={index}
step={step}
status={state.statuses[index]}
error={state.errors[index]}
/>
))}
</Box>
{recentLog.length > 0 ? (
<Box flexDirection="column" marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
{recentLog.map((entry, i) => (
<Text key={i} color={entry.stream === "stderr" ? "yellow" : undefined} dimColor>
{entry.text}
</Text>
))}
</Box>
) : null}
{state.finished ? <FinishedFooter state={state} nav={nav} origin={origin} /> : (
<Box marginTop={1}>
<Text color="cyan">
<Spinner type="dots" />
</Text>
<Text> running</Text>
</Box>
)}
</Box>
);
}
function StepRow({ index, step, status, error }) {
const symbol = symbolFor(status);
const color = colorFor(status);
return (
<Box>
<Box width={3}>
<Text color={color}>{symbol}</Text>
</Box>
<Box width={3}>
<Text dimColor>{`${index + 1}.`}</Text>
</Box>
<Box flexDirection="column">
<Text>{step.label}</Text>
{error ? <Text color="red"> {error}</Text> : null}
</Box>
</Box>
);
}
function symbolFor(status) {
switch (status) {
case "running":
return "◐";
case "ok":
return "✓";
case "failed":
return "✗";
case "cancelled":
return "⊘";
default:
return "·";
}
}
function colorFor(status) {
switch (status) {
case "running":
return "cyan";
case "ok":
return "green";
case "failed":
return "red";
case "cancelled":
return "yellow";
default:
return "gray";
}
}
function FinishedFooter({ state, nav, origin }) {
const items = [
{ label: "Back to main menu", value: "home" },
...(origin === "frp" ? [{ label: "Back to FRP menu", value: "frp" }] : []),
{ label: "Quit", value: "quit" },
];
const handleSelect = (item) => {
if (item.value === "home") {
nav.home();
} else if (item.value === "frp") {
nav.replace({ name: "frp" });
// collapse stack down to just main + frp
// (replace just swaps top; we want clean stack)
} else if (item.value === "quit") {
nav.exit();
}
};
return (
<Box flexDirection="column" marginTop={1}>
{state.cancelled ? (
<Text color="yellow">Cancelled.</Text>
) : state.success ? (
<Text color="green">All steps completed successfully.</Text>
) : (
<Text color="red">Run failed.</Text>
)}
<Box marginTop={1}>
<SelectInput items={items} onSelect={handleSelect} />
</Box>
</Box>
);
}