feat: refine codex live chat context flows
This commit is contained in:
@@ -4,16 +4,31 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import { DataStatePanel } from '../../components/dataStatePanel';
|
||||
import { copyText } from '../../app/main/mainChatPanel';
|
||||
import { fetchServerCommands, restartServerCommand } from './api';
|
||||
import type { ServerCommandItem, ServerCommandKey } from './types';
|
||||
import {
|
||||
ServerCommandApiError,
|
||||
fetchServerCommands,
|
||||
fetchServerRestartReservation,
|
||||
restartServerCommand,
|
||||
scheduleServerRestartReservation,
|
||||
} from './api';
|
||||
import type {
|
||||
RestartReservationWorkloadSummary,
|
||||
ServerCommandItem,
|
||||
ServerCommandKey,
|
||||
ServerRestartReservation,
|
||||
ServerRestartReservationAutoFix,
|
||||
ServerRestartReservationWorkItem,
|
||||
} from './types';
|
||||
import './serverCommand.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
type RestartErrorInfo = {
|
||||
tone: 'error' | 'warning';
|
||||
title: string;
|
||||
detail: string;
|
||||
missingScriptPath: string | null;
|
||||
canScheduleReservation: boolean;
|
||||
};
|
||||
|
||||
type LastActionInfo = {
|
||||
@@ -83,28 +98,120 @@ function buildRestartErrorInfo(targetLabel: string, detail: string): RestartErro
|
||||
const missingScriptPath = missingScriptMatch[1].trim();
|
||||
|
||||
return {
|
||||
tone: 'error',
|
||||
title: `${targetLabel} 재기동 실패`,
|
||||
detail: `재기동 스크립트 파일이 서버에 없습니다.\n배치 경로: ${missingScriptPath}\n\n원본 오류:\n${detail}`,
|
||||
missingScriptPath,
|
||||
canScheduleReservation: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: 'error',
|
||||
title: `${targetLabel} 재기동 실패`,
|
||||
detail,
|
||||
missingScriptPath: null,
|
||||
canScheduleReservation: false,
|
||||
};
|
||||
}
|
||||
|
||||
function formatWorkloadSummary(summary: RestartReservationWorkloadSummary | null) {
|
||||
if (!summary) {
|
||||
return '진행 중 작업이 있어 즉시 재기동할 수 없습니다.';
|
||||
}
|
||||
|
||||
return `Codex 실행 ${summary.codexRunningCount}건, Codex 대기 ${summary.codexQueuedCount}건, 자동화 실행 ${summary.automationRunningCount}건, 자동화 대기 ${summary.automationQueuedCount}건이 감지되었습니다.`;
|
||||
}
|
||||
|
||||
function buildRestartReservationInfo(targetLabel: string, summary: RestartReservationWorkloadSummary | null, detail: string) {
|
||||
return {
|
||||
tone: 'warning' as const,
|
||||
title: `${targetLabel} 즉시 재기동 보류`,
|
||||
detail: `${detail}\n\n${formatWorkloadSummary(summary)}\n현재 화면에서는 전체 재기동 예약으로 이어서 처리할 수 있습니다.`,
|
||||
missingScriptPath: null,
|
||||
canScheduleReservation: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReservationStatusTag(reservation: ServerRestartReservation) {
|
||||
switch (reservation.status) {
|
||||
case 'waiting':
|
||||
return <Tag color="gold">대기 중</Tag>;
|
||||
case 'ready':
|
||||
return <Tag color="blue">자동 실행 예정</Tag>;
|
||||
case 'executing':
|
||||
return <Tag color="processing">재기동 실행 중</Tag>;
|
||||
case 'recovering':
|
||||
return <Tag color="purple">Codex 자동 개선 중</Tag>;
|
||||
case 'completed':
|
||||
return <Tag color="success">완료</Tag>;
|
||||
case 'failed':
|
||||
return <Tag color="error">실패</Tag>;
|
||||
case 'cancelled':
|
||||
return <Tag>취소됨</Tag>;
|
||||
default:
|
||||
return <Tag>대기 없음</Tag>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatReservationWorkItemTag(item: ServerRestartReservationWorkItem) {
|
||||
if (item.kind === 'automation') {
|
||||
if (item.status === 'running') {
|
||||
return <Tag color="processing">자동화 실행</Tag>;
|
||||
}
|
||||
if (item.status === 'queued') {
|
||||
return <Tag color="blue">자동화 대기열</Tag>;
|
||||
}
|
||||
return <Tag color="gold">자동화 선행대기</Tag>;
|
||||
}
|
||||
|
||||
if (item.status === 'running') {
|
||||
return <Tag color="processing">Codex 실행</Tag>;
|
||||
}
|
||||
if (item.status === 'queued') {
|
||||
return <Tag color="blue">Codex 대기열</Tag>;
|
||||
}
|
||||
return <Tag color="gold">Codex 대기</Tag>;
|
||||
}
|
||||
|
||||
function resolveAutoFixTone(autoFix: ServerRestartReservationAutoFix) {
|
||||
if (autoFix.status === 'failed') {
|
||||
return 'error' as const;
|
||||
}
|
||||
|
||||
if (autoFix.status === 'completed') {
|
||||
return 'success' as const;
|
||||
}
|
||||
|
||||
return 'info' as const;
|
||||
}
|
||||
|
||||
function formatAutoFixStatusLabel(status: ServerRestartReservationAutoFix['status']) {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return '요청 대기';
|
||||
case 'running':
|
||||
return '개선 실행 중';
|
||||
case 'completed':
|
||||
return '개선 완료';
|
||||
case 'failed':
|
||||
return '개선 실패';
|
||||
default:
|
||||
return '대기 없음';
|
||||
}
|
||||
}
|
||||
|
||||
export function ServerCommandPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<ServerCommandItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [reservation, setReservation] = useState<ServerRestartReservation | null>(null);
|
||||
const [restartingKey, setRestartingKey] = useState<ServerCommandKey | null>(null);
|
||||
const [restartErrorInfo, setRestartErrorInfo] = useState<RestartErrorInfo | null>(null);
|
||||
const [copyingRestartError, setCopyingRestartError] = useState(false);
|
||||
const [schedulingReservation, setSchedulingReservation] = useState(false);
|
||||
const [lastActionByKey, setLastActionByKey] = useState<Record<ServerCommandKey, LastActionInfo>>({
|
||||
test: { output: null, executedAt: '', restartState: 'completed' },
|
||||
rel: { output: null, executedAt: '', restartState: 'completed' },
|
||||
@@ -127,17 +234,59 @@ export function ServerCommandPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadReservation = async (options?: { silent?: boolean }) => {
|
||||
try {
|
||||
const nextReservation = await fetchServerRestartReservation();
|
||||
setReservation(nextReservation);
|
||||
return nextReservation;
|
||||
} catch (error) {
|
||||
if (!options?.silent) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '재기동 예약 상태를 불러오지 못했습니다.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
setErrorMessage(null);
|
||||
setReservation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadItems();
|
||||
void Promise.all([
|
||||
loadItems(),
|
||||
loadReservation({ silent: true }),
|
||||
]);
|
||||
}, [hasAccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldPoll =
|
||||
reservation?.enabled
|
||||
|| reservation?.status === 'recovering'
|
||||
|| reservation?.autoFix.enabled
|
||||
|| restartingKey === 'test'
|
||||
|| restartingKey === 'work-server';
|
||||
|
||||
if (!shouldPoll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = window.setInterval(() => {
|
||||
void loadReservation({ silent: true });
|
||||
}, 4000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timerId);
|
||||
};
|
||||
}, [hasAccess, reservation, restartingKey]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return items.reduce(
|
||||
(result, item) => {
|
||||
@@ -156,6 +305,7 @@ export function ServerCommandPage() {
|
||||
try {
|
||||
const result = await restartServerCommand(key);
|
||||
setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item)));
|
||||
void loadReservation({ silent: true });
|
||||
setLastActionByKey((previous) => ({
|
||||
...previous,
|
||||
[result.item.key]: {
|
||||
@@ -169,6 +319,11 @@ export function ServerCommandPage() {
|
||||
);
|
||||
} catch (error) {
|
||||
const targetLabel = items.find((item) => item.key === key)?.label ?? key.toUpperCase();
|
||||
if (error instanceof ServerCommandApiError && error.status === 409 && (key === 'test' || key === 'work-server')) {
|
||||
setRestartErrorInfo(buildRestartReservationInfo(targetLabel, error.workloadSummary, error.message));
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
|
||||
setRestartErrorInfo(buildRestartErrorInfo(targetLabel, detail));
|
||||
} finally {
|
||||
@@ -193,6 +348,26 @@ export function ServerCommandPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleReservation = async () => {
|
||||
if (schedulingReservation) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSchedulingReservation(true);
|
||||
|
||||
try {
|
||||
await scheduleServerRestartReservation();
|
||||
setRestartErrorInfo(null);
|
||||
await loadReservation({ silent: true });
|
||||
messageApi.success('전체 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.');
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : '재기동 예약 등록에 실패했습니다.';
|
||||
setRestartErrorInfo(buildRestartErrorInfo('전체 서버', detail));
|
||||
} finally {
|
||||
setSchedulingReservation(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card className="server-command-page__card" bordered={false}>
|
||||
@@ -229,7 +404,16 @@ export function ServerCommandPage() {
|
||||
</Col>
|
||||
</Row>
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadItems()} loading={loading}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
void Promise.all([
|
||||
loadItems(),
|
||||
loadReservation({ silent: true }),
|
||||
]);
|
||||
}}
|
||||
loading={loading}
|
||||
>
|
||||
새로고침
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -239,8 +423,8 @@ export function ServerCommandPage() {
|
||||
{restartErrorInfo ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="재기동 에러"
|
||||
type={restartErrorInfo.tone}
|
||||
message={restartErrorInfo.tone === 'warning' ? '재기동 예약 필요' : '재기동 에러'}
|
||||
description={
|
||||
<Space direction="vertical" size={8} className="server-command-page__alert-body">
|
||||
<Text strong>{restartErrorInfo.title}</Text>
|
||||
@@ -253,20 +437,129 @@ export function ServerCommandPage() {
|
||||
</Space>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingRestartError}
|
||||
aria-label="에러 메시지 복사"
|
||||
onClick={() => {
|
||||
void handleCopyRestartError();
|
||||
}}
|
||||
/>
|
||||
<Space size={4}>
|
||||
{restartErrorInfo.canScheduleReservation ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
loading={schedulingReservation}
|
||||
onClick={() => {
|
||||
void handleScheduleReservation();
|
||||
}}
|
||||
>
|
||||
재기동 예약
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingRestartError}
|
||||
aria-label="에러 메시지 복사"
|
||||
onClick={() => {
|
||||
void handleCopyRestartError();
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{reservation && (reservation.enabled || reservation.status !== 'idle' || reservation.autoFix.enabled) ? (
|
||||
<Card className="server-command-page__card server-command-page__reservation-card" bordered={false}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Space size={8} wrap>
|
||||
<Title level={5} className="server-command-page__server-title">
|
||||
재기동 예약 상태
|
||||
</Title>
|
||||
{resolveReservationStatusTag(reservation)}
|
||||
</Space>
|
||||
|
||||
<Paragraph className="server-command-page__summary">
|
||||
{reservation.waitingReason?.trim()
|
||||
|| (reservation.status === 'completed'
|
||||
? '예약된 TEST / WORK 서버 재기동이 완료되었습니다.'
|
||||
: '예약 상태를 확인했습니다.')}
|
||||
</Paragraph>
|
||||
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={1}
|
||||
className="server-command-page__meta"
|
||||
items={[
|
||||
{
|
||||
key: 'requested-at',
|
||||
label: '요청시각',
|
||||
children: formatDateTime(reservation.requestedAt),
|
||||
},
|
||||
{
|
||||
key: 'auto-execute-at',
|
||||
label: '자동실행',
|
||||
children: formatDateTime(reservation.autoExecuteAt),
|
||||
},
|
||||
{
|
||||
key: 'updated-at',
|
||||
label: '마지막 갱신',
|
||||
children: formatDateTime(reservation.updatedAt),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{reservation.workItems.length > 0 ? (
|
||||
<Space direction="vertical" size={8} className="server-command-page__work-list">
|
||||
<Text strong>현재 진행 작업</Text>
|
||||
{reservation.workItems.map((item, index) => (
|
||||
<div
|
||||
key={`${item.kind}-${item.requestId ?? item.title}-${index}`}
|
||||
className="server-command-page__work-item"
|
||||
>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Space size={8} wrap>
|
||||
{formatReservationWorkItemTag(item)}
|
||||
<Text strong>{item.title}</Text>
|
||||
</Space>
|
||||
{item.detail ? (
|
||||
<Text type="secondary" className="server-command-page__work-detail">
|
||||
{item.detail}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
) : null}
|
||||
|
||||
{reservation.autoFix.enabled ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type={resolveAutoFixTone(reservation.autoFix)}
|
||||
message="Codex 자동 개선"
|
||||
description={
|
||||
<Space direction="vertical" size={4} className="server-command-page__alert-body">
|
||||
<Text strong>
|
||||
{reservation.autoFix.summary?.trim() || '빌드 오류 자동 개선 상태를 추적 중입니다.'}
|
||||
</Text>
|
||||
{reservation.autoFix.detail ? (
|
||||
<span className="server-command-page__alert-text">{reservation.autoFix.detail}</span>
|
||||
) : null}
|
||||
<Text type="secondary">
|
||||
상태: {formatAutoFixStatusLabel(reservation.autoFix.status)}
|
||||
{reservation.autoFix.targetKey ? ` · 대상 ${reservation.autoFix.targetKey.toUpperCase()}` : ''}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{reservation.lastError ? (
|
||||
<Text type="danger" className="server-command-page__preview">
|
||||
{reservation.lastError}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<DataStatePanel state="loading" title="서버 상태를 확인하는 중입니다." />
|
||||
) : errorMessage ? (
|
||||
@@ -275,7 +568,15 @@ export function ServerCommandPage() {
|
||||
title="서버 명령 메뉴를 불러오지 못했습니다."
|
||||
description={errorMessage}
|
||||
actions={
|
||||
<Button type="primary" onClick={() => void loadItems()}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
void Promise.all([
|
||||
loadItems(),
|
||||
loadReservation({ silent: true }),
|
||||
]);
|
||||
}}
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user