import { CheckCircleFilled, ClockCircleFilled, CloseCircleFilled, CopyOutlined, ExclamationCircleFilled, ReloadOutlined, SyncOutlined, } from '@ant-design/icons'; import { Alert, Button, Empty, Space, Tag, Typography, message } from 'antd'; import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from 'react'; import { useTokenAccess } from '../../app/main/tokenAccess'; import { DataStatePanel } from '../../components/dataStatePanel'; import { copyText } from '../../app/main/mainChatPanel'; import { ServerCommandApiError, deployWorkServerCommand, deployTestServerCommand, fetchServerCommands, fetchServerRestartReservation, fetchTestServerDeploymentStatus, fetchWorkServerDeploymentStatus, restartServerCommand, scheduleServerRestartReservation, } from './api'; import type { RestartReservationWorkloadSummary, ServerCommandItem, ServerCommandKey, ServerRestartReservation, ServerRestartReservationAutoFix, ServerRestartReservationExecutionPhase, ServerRestartReservationWorkItem, TestServerDeploymentPhase, TestServerDeploymentState, TestServerDeploymentStep, WorkServerDeploymentPhase, WorkServerDeploymentState, WorkServerDeploymentStep, } from './types'; import './serverCommand.css'; const { Paragraph, Text, Title } = Typography; type ServerCommandPageProps = { sharedAccess?: { shareToken: string; allowedKeys?: ServerCommandKey[]; } | null; }; type RestartErrorInfo = { tone: 'error' | 'warning'; title: string; detail: string; missingScriptPath: string | null; canScheduleReservation: boolean; }; type LastActionInfo = { output: string | null; executedAt: string; restartState: 'completed' | 'accepted'; }; type ServerActionKey = ServerCommandKey | 'test-deploy'; type SharedStatusTone = | 'online' | 'degraded' | 'offline' | 'latest' | 'update-available' | 'build-required' | 'unknown' | 'info'; type SharedStatusCard = { key: string; label: string; value: string; tone: SharedStatusTone; icon: ReactNode; }; type ServerControlCard = { key: ServerActionKey; label: string; statusTone: SharedStatusTone; statusLabel: string; executedAt: string | null; updatedAt: string | null; progressLabel?: string | null; actionLabel: string; actionLoading: boolean; actionType: 'primary' | 'default'; onAction: () => void; }; const SHARED_SERVER_KEY_ORDER: ServerCommandKey[] = ['work-server', 'test', 'rel', 'prod', 'command-runner']; function formatDateTime(value: string | null | undefined) { if (!value) { return '-'; } return new Date(value).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', }); } function formatResponseTime(value: number | null | undefined) { if (typeof value !== 'number' || Number.isNaN(value)) { return '-'; } return `${Math.max(0, Math.round(value))}ms`; } function formatStatusCode(value: number | null | undefined) { if (typeof value !== 'number' || Number.isNaN(value)) { return '-'; } return `${value}`; } function resolveHostLabel(value: string | null | undefined) { if (!value) { return '내부 전용'; } try { return new URL(value).host; } catch { return value; } } function resolveAvailabilityIcon(item: ServerCommandItem) { if (item.availability === 'online') { return ; } if (item.availability === 'degraded') { return ; } return ; } function resolveAvailabilityText(item: ServerCommandItem) { if (item.availability === 'online') { return '정상'; } if (item.availability === 'degraded') { return '주의'; } return '오프라인'; } function resolveAvailabilityTone(item: ServerCommandItem) { if (item.availability === 'online') { return 'online'; } if (item.availability === 'degraded') { return 'degraded'; } return 'offline'; } function resolveVersionTone(item: ServerCommandItem | null) { if (!item) { return 'unknown'; } if (item.buildRequired) { return 'build-required'; } if (item.updateAvailable) { return 'update-available'; } return 'latest'; } function resolveVersionStatusLabel(item: ServerCommandItem | null) { if (!item) { return '확인 필요'; } if (item.buildRequired) { return '빌드 필요'; } if (item.updateAvailable) { return '최신 반영 대기'; } return '최신 실행 중'; } function resolveWorkServerControlStatus( item: ServerCommandItem | null, deployment: WorkServerDeploymentState | null, isSubmitting: boolean, ): Pick { if (isSubmitting) { return { statusTone: 'online', statusLabel: '배포 요청 중', }; } if (deployment?.status === 'running') { return { statusTone: 'online', statusLabel: '배포 진행 중', }; } if (deployment?.status === 'failed') { return { statusTone: 'offline', statusLabel: '배포 실패', }; } if (!item) { return { statusTone: 'unknown', statusLabel: '확인 필요', }; } if (item.buildRequired) { return { statusTone: 'build-required', statusLabel: '빌드 필요', }; } if (item.updateAvailable) { return { statusTone: 'update-available', statusLabel: '최신 반영 대기', }; } if (item.availability === 'offline') { return { statusTone: 'offline', statusLabel: '응답 없음', }; } if (item.availability === 'degraded') { return { statusTone: 'degraded', statusLabel: '상태 확인 필요', }; } return { statusTone: 'online', statusLabel: '최신 실행 중', }; } function resolveRestartButtonLabel() { return '재기동'; } function resolveWorkServerDeploymentActionLabel() { return '배포'; } function resolveServerDisplayLabel(item: ServerCommandItem) { if (item.key === 'test') { return 'PREVIEW'; } if (item.key === 'work-server') { return 'WORK'; } return item.label; } function resolvePrimaryActionLabel(item: ServerCommandItem) { if (item.key === 'test') { return 'PREVIEW 재기동'; } if (item.key === 'work-server') { return 'WORK 재기동'; } return resolveRestartButtonLabel(); } function resolveAvailabilityShortLabel(item: ServerCommandItem) { if (item.availability === 'online') { return '정상'; } if (item.availability === 'degraded') { return '주의'; } return '장애'; } function resolveReservationTone(status: ServerRestartReservation['status'] | 'idle'): SharedStatusTone { switch (status) { case 'ready': case 'executing': case 'recovering': return 'online'; case 'waiting': return 'degraded'; case 'failed': return 'offline'; case 'completed': return 'latest'; case 'cancelled': return 'unknown'; default: return 'info'; } } function resolveReservationStatusIcon(status: ServerRestartReservation['status'] | 'idle') { switch (status) { case 'ready': return ; case 'executing': case 'recovering': return ; case 'waiting': return ; case 'completed': return ; case 'failed': return ; case 'cancelled': return ; default: return ; } } function buildSharedReservationCard(reservation: ServerRestartReservation | null): SharedStatusCard { const isWorkTarget = reservation && (reservation.target === 'work-server' || reservation.target === 'all'); const status = isWorkTarget ? reservation.status : 'idle'; switch (status) { case 'waiting': return { key: 'reservation', label: '재기동 예약', value: '등록 대기', tone: resolveReservationTone(status), icon: resolveReservationStatusIcon(status) }; case 'ready': return { key: 'reservation', label: '재기동 예약', value: '등록됨', tone: resolveReservationTone(status), icon: resolveReservationStatusIcon(status) }; case 'executing': return { key: 'reservation', label: '재기동 예약', value: '실행 중', tone: resolveReservationTone(status), icon: resolveReservationStatusIcon(status) }; case 'recovering': return { key: 'reservation', label: '재기동 예약', value: '자동 개선 중', tone: resolveReservationTone(status), icon: resolveReservationStatusIcon(status) }; case 'completed': return { key: 'reservation', label: '재기동 예약', value: '최근 완료', tone: resolveReservationTone(status), icon: resolveReservationStatusIcon(status) }; case 'failed': return { key: 'reservation', label: '재기동 예약', value: '실패', tone: resolveReservationTone(status), icon: resolveReservationStatusIcon(status) }; case 'cancelled': return { key: 'reservation', label: '재기동 예약', value: '취소됨', tone: resolveReservationTone(status), icon: resolveReservationStatusIcon(status) }; default: return { key: 'reservation', label: '재기동 예약', value: '미등록', tone: 'info', icon: }; } } function buildRestartErrorInfo(targetLabel: string, detail: string): RestartErrorInfo { const missingScriptMatch = detail.match(/cannot open\s+([^\n:]+\.sh)\s*:\s*No such file/i); if (missingScriptMatch?.[1]) { 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 대기 중; case 'ready': return 자동 실행 예정; case 'executing': return 재기동 실행 중; case 'recovering': return Codex 자동 개선 중; case 'completed': return 완료; case 'failed': return 실패; case 'cancelled': return 취소됨; default: return 대기 없음; } } function resolveWorkServerDeploymentPhaseLabel(phase: WorkServerDeploymentPhase) { switch (phase) { case 'build-target-slot': return '대기 슬롯 빌드'; case 'verify-target-health': return '새 슬롯 health 확인'; case 'switch-proxy': return '프록시 전환'; case 'drain-previous-slot': return '이전 슬롯 이관'; case 'rebuild-previous-slot': return '이전 슬롯 복구'; case 'recover-interrupted-chat': return '중단 요청 복구 확인'; case 'completed': return '배포 완료'; case 'failed': return '배포 실패'; default: return '대기'; } } function resolveWorkServerDeploymentStatusTag(deployment: WorkServerDeploymentState) { switch (deployment.status) { case 'running': return 배포 진행 중; case 'completed': return 배포 완료; case 'failed': return 배포 실패; default: return 대기; } } function resolveWorkServerDeploymentStepLabel(step: WorkServerDeploymentStep) { return resolveWorkServerDeploymentPhaseLabel(step.key); } function resolveTestServerDeploymentPhaseLabel(phase: TestServerDeploymentPhase) { switch (phase) { case 'commit-main-worktree': return 'main 작업트리 커밋'; case 'push-origin-main': return 'origin/main 푸시'; case 'build-test-app': return '테스트 앱 빌드'; case 'deploy-test-server': return '테스트 서버 배포'; case 'completed': return '배포 완료'; case 'failed': return '배포 실패'; default: return '대기'; } } function resolveTestServerDeploymentStatusTag(deployment: TestServerDeploymentState) { switch (deployment.status) { case 'running': return 배포 진행 중; case 'completed': return 배포 완료; case 'failed': return 배포 실패; default: return 대기; } } function resolveTestServerDeploymentStepLabel(step: TestServerDeploymentStep) { return resolveTestServerDeploymentPhaseLabel(step.key); } function resolveTestDeployControlStatus( item: ServerCommandItem | null, deployment: TestServerDeploymentState | null, isSubmitting: boolean, ): Pick { if (isSubmitting) { return { statusTone: 'online', statusLabel: '배포 요청 중', }; } if (deployment?.status === 'running') { return { statusTone: 'online', statusLabel: '배포 진행 중', }; } if (deployment?.status === 'failed') { return { statusTone: 'offline', statusLabel: '배포 실패', }; } return { statusTone: resolveVersionTone(item), statusLabel: resolveVersionStatusLabel(item), }; } function resolveWorkServerSlotLabel(slot: WorkServerDeploymentState['activeSlot']) { if (!slot) { return '-'; } return slot.toUpperCase(); } function getReservationTargetLabel(target: ServerRestartReservation['target']) { if (target === 'test') { return 'TEST 서버'; } if (target === 'work-server') { return 'WORK 서버'; } return 'TEST / WORK 서버'; } function formatReservationWorkItemTag(item: ServerRestartReservationWorkItem) { if (item.kind === 'automation') { if (item.status === 'running') { return 자동화 실행; } if (item.status === 'queued') { return 자동화 대기열; } return 자동화 선행대기; } if (item.status === 'running') { return Codex 실행; } if (item.status === 'queued') { return Codex 대기열; } return Codex 대기; } 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 '대기 없음'; } } function buildReservationExecutionSteps( phase: ServerRestartReservationExecutionPhase, target: ServerRestartReservation['target'], ) { const steps = [ { label: 'main 작업트리 커밋', phaseKey: 'commit-main-worktree' }, { label: 'TEST 재기동', phaseKey: 'restart-test' }, { label: 'WORK 재기동', phaseKey: 'restart-work-server' }, { label: '정상 기동 확인', phaseKey: 'verify-runtime' }, ] as const; const filteredSteps = steps.filter((step) => { if (target === 'work-server' && step.label === 'TEST 재기동') { return false; } if (target === 'test' && step.label === 'WORK 재기동') { return false; } return true; }); const activeIndex = filteredSteps.findIndex((step) => step.phaseKey === phase); return filteredSteps.map((step, index) => ({ label: step.label, done: activeIndex > index, active: activeIndex === index, })); } function resolveToneTagColor(tone: SharedStatusTone) { if (tone === 'online' || tone === 'latest') { return 'blue'; } if (tone === 'degraded' || tone === 'update-available') { return 'gold'; } if (tone === 'offline' || tone === 'build-required') { return 'error'; } return 'default'; } function resolveServerNote(item: ServerCommandItem, lastAction: LastActionInfo | undefined) { if (item.errorMessage) { return item.errorMessage; } if (item.updateSummary) { return item.updateSummary; } if (item.composeDetails) { return item.composeDetails; } if (item.responsePreview) { return item.responsePreview; } if (lastAction?.executedAt) { return `${lastAction.restartState === 'accepted' ? '최근 요청' : '최근 완료'} ${formatDateTime(lastAction.executedAt)}`; } if (item.key === 'work-server') { return '빌드 성공 후 슬롯 전환'; } if (item.key === 'test') { return '빌드 성공 후 preview 재기동'; } return '-'; } function resolveCardExecutedAt(item: ServerCommandItem | null, lastAction: LastActionInfo | undefined) { return lastAction?.executedAt || item?.startedAt || item?.checkedAt || null; } function resolveCardUpdatedAt(item: ServerCommandItem | null) { return item?.latestSourceChangeAt || item?.latestBuiltAt || item?.checkedAt || null; } export function ServerCommandPage({ sharedAccess = null }: ServerCommandPageProps) { const { hasAccess } = useTokenAccess(); const isSharedManageMode = Boolean(sharedAccess?.shareToken); const allowedKeysKey = useMemo( () => (sharedAccess?.allowedKeys ?? []).join('|'), [sharedAccess?.allowedKeys], ); const allowedKeysSet = useMemo(() => new Set(sharedAccess?.allowedKeys ?? []), [allowedKeysKey]); const [messageApi, contextHolder] = message.useMessage(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(null); const [reservation, setReservation] = useState(null); const [workServerDeployment, setWorkServerDeployment] = useState(null); const [testDeployment, setTestDeployment] = useState(null); const [runningActionKey, setRunningActionKey] = useState(null); const [restartErrorInfo, setRestartErrorInfo] = useState(null); const [copyingRestartError, setCopyingRestartError] = useState(false); const [schedulingReservation, setSchedulingReservation] = useState(false); const [lastActionByKey, setLastActionByKey] = useState>({ test: { output: null, executedAt: '', restartState: 'completed' }, rel: { output: null, executedAt: '', restartState: 'completed' }, prod: { output: null, executedAt: '', restartState: 'completed' }, 'work-server': { output: null, executedAt: '', restartState: 'completed' }, 'command-runner': { output: null, executedAt: '', restartState: 'completed' }, }); const workServerDeploymentNoticeKeyRef = useRef(''); const testDeploymentNoticeKeyRef = useRef(''); const loadItems = async (options?: { silent?: boolean }) => { if (!options?.silent) { setLoading(true); setErrorMessage(null); } try { const nextItems = await fetchServerCommands(isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined); setItems( isSharedManageMode && allowedKeysSet.size > 0 ? nextItems.filter((item) => allowedKeysSet.has(item.key)) : nextItems, ); } catch (error) { if (!options?.silent) { setErrorMessage(error instanceof Error ? error.message : '서버 정보를 불러오지 못했습니다.'); } } finally { if (!options?.silent) { setLoading(false); } } }; const loadReservation = async (options?: { silent?: boolean }) => { try { const nextReservation = await fetchServerRestartReservation(isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined); setReservation(nextReservation); return nextReservation; } catch (error) { if (!options?.silent) { setErrorMessage(error instanceof Error ? error.message : '재기동 예약 상태를 불러오지 못했습니다.'); } return null; } }; const loadWorkServerDeployment = async (options?: { silent?: boolean }) => { try { const nextDeployment = await fetchWorkServerDeploymentStatus( isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined, ); setWorkServerDeployment(nextDeployment); return nextDeployment; } catch (error) { if (!options?.silent) { setErrorMessage(error instanceof Error ? error.message : 'WORK 서버 배포 상태를 불러오지 못했습니다.'); } return null; } }; const loadTestDeployment = async (options?: { silent?: boolean }) => { try { const nextDeployment = await fetchTestServerDeploymentStatus( isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined, ); setTestDeployment(nextDeployment); return nextDeployment; } catch (error) { if (!options?.silent) { setErrorMessage(error instanceof Error ? error.message : 'TEST 서버 배포 상태를 불러오지 못했습니다.'); } return null; } }; const refreshServerCommandState = useEffectEvent((options?: { silent?: boolean }) => Promise.all([ loadItems(options), loadReservation({ silent: true }), loadWorkServerDeployment({ silent: true }), loadTestDeployment({ silent: true }), ])); useEffect(() => { if (!hasAccess && !isSharedManageMode) { setItems([]); setLoading(false); setErrorMessage(null); setReservation(null); setWorkServerDeployment(null); setTestDeployment(null); return; } void refreshServerCommandState(); }, [allowedKeysKey, hasAccess, isSharedManageMode, sharedAccess?.shareToken]); useEffect(() => { if (!hasAccess && !isSharedManageMode) { return undefined; } const shouldTrackDeployment = runningActionKey === 'work-server' || runningActionKey === 'test-deploy' || workServerDeployment?.status === 'running' || testDeployment?.status === 'running'; if (!shouldTrackDeployment) { return undefined; } let cancelled = false; const refresh = async () => { try { await refreshServerCommandState({ silent: true }); } catch { if (!cancelled) { // ignore polling errors and keep the latest visible state } } }; void refresh(); const intervalId = window.setInterval(() => { void refresh(); }, 2000); return () => { cancelled = true; window.clearInterval(intervalId); }; }, [hasAccess, isSharedManageMode, runningActionKey, sharedAccess?.shareToken, testDeployment?.status, workServerDeployment?.status]); useEffect(() => { if (!hasAccess && !isSharedManageMode) { return undefined; } const handleWindowAttention = () => { if (document.visibilityState !== 'visible') { return; } void refreshServerCommandState({ silent: true }); }; window.addEventListener('focus', handleWindowAttention); document.addEventListener('visibilitychange', handleWindowAttention); return () => { window.removeEventListener('focus', handleWindowAttention); document.removeEventListener('visibilitychange', handleWindowAttention); }; }, [hasAccess, isSharedManageMode, refreshServerCommandState]); useEffect(() => { if (!workServerDeployment || workServerDeployment.status === 'idle' || workServerDeployment.status === 'running') { return; } const noticeKey = `${workServerDeployment.status}:${workServerDeployment.completedAt ?? workServerDeployment.updatedAt ?? ''}`; if (workServerDeploymentNoticeKeyRef.current === noticeKey) { return; } workServerDeploymentNoticeKeyRef.current = noticeKey; if (runningActionKey === 'work-server') { setRunningActionKey(null); } if (workServerDeployment.status === 'completed') { void refreshServerCommandState({ silent: true }); void messageApi.success('WORK 서버 무중단 배포가 완료되었습니다.'); return; } void messageApi.error('WORK 서버 배포가 실패했습니다.'); }, [messageApi, runningActionKey, workServerDeployment]); useEffect(() => { if (!testDeployment || testDeployment.status === 'idle' || testDeployment.status === 'running') { return; } const noticeKey = `${testDeployment.status}:${testDeployment.completedAt ?? testDeployment.updatedAt ?? ''}`; if (testDeploymentNoticeKeyRef.current === noticeKey) { return; } testDeploymentNoticeKeyRef.current = noticeKey; if (runningActionKey === 'test-deploy') { setRunningActionKey(null); } if (testDeployment.status === 'completed') { setTestDeployment(null); void Promise.all([loadItems({ silent: true }), loadReservation({ silent: true })]); void messageApi.success('TEST 배포가 완료되었습니다.'); return; } void messageApi.error('TEST 배포가 실패했습니다.'); }, [messageApi, runningActionKey, testDeployment]); const visibleItems = useMemo(() => { const baseItems = isSharedManageMode ? [...items].sort((left, right) => { const leftIndex = SHARED_SERVER_KEY_ORDER.indexOf(left.key); const rightIndex = SHARED_SERVER_KEY_ORDER.indexOf(right.key); const normalizedLeft = leftIndex === -1 ? SHARED_SERVER_KEY_ORDER.length : leftIndex; const normalizedRight = rightIndex === -1 ? SHARED_SERVER_KEY_ORDER.length : rightIndex; return normalizedLeft - normalizedRight; }) : items; return baseItems; }, [isSharedManageMode, items]); const manageableItems = useMemo( () => visibleItems.filter((item) => item.key === 'work-server' || item.key === 'test'), [visibleItems], ); const workServerItem = manageableItems.find((item) => item.key === 'work-server') ?? null; const previewItem = manageableItems.find((item) => item.key === 'test') ?? null; const summary = useMemo(() => { return manageableItems.reduce( (result, item) => { result.total += 1; result[item.availability] += 1; return result; }, { total: 0, online: 0, degraded: 0, offline: 0 }, ); }, [manageableItems]); const sharedReservationCard = useMemo( () => (isSharedManageMode ? buildSharedReservationCard(reservation) : null), [isSharedManageMode, reservation], ); const hasDeploymentStatusPanel = Boolean( (testDeployment && (testDeployment.status === 'running' || testDeployment.status === 'failed')) || (workServerDeployment && (workServerDeployment.status === 'running' || workServerDeployment.status === 'failed')) ); const handleRestart = async (key: ServerCommandKey) => { setRunningActionKey(key); setRestartErrorInfo(null); try { const result = await restartServerCommand(key, isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined); setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item))); void loadReservation({ silent: true }); setLastActionByKey((previous) => ({ ...previous, [result.item.key]: { output: result.commandOutput, executedAt: new Date().toISOString(), restartState: result.restartState, }, })); void messageApi.open({ type: result.restartState === 'accepted' ? 'info' : 'success', content: result.restartState === 'accepted' ? `${result.item.label} 재기동 요청이 접수되었습니다. 새 런타임 확인 전까지는 완료가 아닙니다.` : `${result.item.label} 재기동이 완료되었습니다.`, }); } 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 { setRunningActionKey(null); } }; const handleDeployWorkServer = async () => { setRunningActionKey('work-server'); setRestartErrorInfo(null); try { const result = await deployWorkServerCommand(isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined); setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item))); setWorkServerDeployment(result.deployment ?? result.item.deployment ?? null); void loadReservation({ silent: true }); setLastActionByKey((previous) => ({ ...previous, 'work-server': { output: result.commandOutput, executedAt: new Date().toISOString(), restartState: result.restartState, }, })); void messageApi.open({ type: 'info', content: 'WORK 서버 무중단 배포를 시작했습니다. 빌드, 슬롯 전환, 이전 요청 이관 상태를 계속 확인합니다.', }); } catch (error) { const detail = error instanceof Error ? error.message : 'WORK 서버 배포에 실패했습니다.'; setRestartErrorInfo(buildRestartErrorInfo('WORK 서버 배포', detail)); setRunningActionKey(null); } }; const handleDeployTest = async () => { setRunningActionKey('test-deploy'); setRestartErrorInfo(null); try { const result = await deployTestServerCommand( isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined, ); setTestDeployment(result.testDeployment ?? null); await Promise.all([loadItems(), loadReservation({ silent: true })]); setLastActionByKey((previous) => ({ ...previous, test: { output: result.commandOutput, executedAt: new Date().toISOString(), restartState: result.restartState, }, })); void messageApi.open({ type: 'info', content: 'TEST 배포를 시작했습니다. origin/main 푸시, 테스트 빌드, 테스트 서버 배포 과정을 계속 확인합니다.', }); } catch (error) { setRestartErrorInfo( buildRestartErrorInfo('TEST 반영', error instanceof Error ? error.message : 'TEST 반영에 실패했습니다.'), ); } finally { setRunningActionKey(null); } }; const handleCopyRestartError = async () => { if (!restartErrorInfo || copyingRestartError) { return; } setCopyingRestartError(true); try { await copyText(`${restartErrorInfo.title}\n${restartErrorInfo.detail}`); messageApi.success('에러 메시지를 복사했습니다.'); } catch { messageApi.error('에러 메시지 복사에 실패했습니다.'); } finally { setCopyingRestartError(false); } }; const handleCopyDeploymentLog = async (title: string, detail: string | null | undefined) => { if (!detail?.trim()) { messageApi.error('복사할 배포 로그가 없습니다.'); return; } try { await copyText(`${title}\n${detail}`); messageApi.success('배포 로그를 복사했습니다.'); } catch { messageApi.error('배포 로그 복사에 실패했습니다.'); } }; const handleScheduleReservation = async () => { if (schedulingReservation) { return; } setSchedulingReservation(true); try { await scheduleServerRestartReservation( isSharedManageMode ? { target: 'work-server', shareToken: sharedAccess?.shareToken } : undefined, ); setRestartErrorInfo(null); await loadReservation({ silent: true }); messageApi.success( isSharedManageMode ? 'WORK 서버 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.' : '전체 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.', ); } catch (error) { const detail = error instanceof Error ? error.message : '재기동 예약 등록에 실패했습니다.'; setRestartErrorInfo(buildRestartErrorInfo('전체 서버', detail)); } finally { setSchedulingReservation(false); } }; const resolveControlCardProgressLabel = (key: ServerActionKey) => { if (key === 'test-deploy' && runningActionKey === 'test-deploy') { return 'TEST 배포 요청 중'; } if (key === 'test-deploy' && testDeployment) { if (testDeployment.status === 'running') { return testDeployment.summary?.trim() || resolveTestServerDeploymentPhaseLabel(testDeployment.phase); } if (testDeployment.status === 'failed') { return '최근 배포 실패'; } } if (key === 'test' && runningActionKey === 'test') { return 'TEST 재기동 요청 중'; } if (key === 'work-server' && runningActionKey === 'work-server') { return 'WORK 배포 요청 중'; } if (key === 'work-server' && workServerDeployment) { if (workServerDeployment.status === 'running') { return workServerDeployment.summary?.trim() || resolveWorkServerDeploymentPhaseLabel(workServerDeployment.phase); } if (workServerDeployment.status === 'failed') { return '최근 배포 실패'; } } const matchesReservation = key === 'work-server' ? reservation && (reservation.target === 'work-server' || reservation.target === 'all') : (key === 'test' || key === 'test-deploy') ? reservation && (reservation.target === 'test' || reservation.target === 'all') : false; if (!matchesReservation) { return null; } if (reservation.status === 'recovering') { return reservation.autoFix.summary?.trim() || 'Codex 자동 개선 중'; } if (reservation.status === 'executing') { if (reservation.executionPhase === 'commit-main-worktree') { return '재기동 실행 준비 중'; } if (reservation.executionPhase === 'verify-runtime') { return '정상 기동 확인 중'; } if (reservation.executionPhase === 'restart-work-server') { return 'WORK 배포 진행 중'; } if (reservation.executionPhase === 'restart-test') { return 'TEST 재기동 진행 중'; } return '재기동 진행 중'; } if (reservation.status === 'ready') { return '자동 실행 대기 중'; } if (reservation.status === 'waiting') { return '재기동 대기 중'; } if (reservation.status === 'completed') { return '최근 재기동 완료'; } if (reservation.status === 'failed') { return '최근 재기동 실패'; } return null; }; const controlCards = useMemo(() => { const cards: ServerControlCard[] = []; const workServerControlStatus = resolveWorkServerControlStatus( workServerItem, workServerDeployment, runningActionKey === 'work-server', ); const testDeployControlStatus = resolveTestDeployControlStatus( previewItem, testDeployment, runningActionKey === 'test-deploy', ); if (workServerItem) { cards.push({ key: 'work-server', label: 'Work서버 배포', statusTone: workServerControlStatus.statusTone, statusLabel: workServerControlStatus.statusLabel, executedAt: resolveCardExecutedAt(workServerItem, lastActionByKey['work-server']), updatedAt: resolveCardUpdatedAt(workServerItem), actionLabel: resolveWorkServerDeploymentActionLabel(), actionLoading: runningActionKey === 'work-server', progressLabel: resolveControlCardProgressLabel('work-server'), actionType: 'primary', onAction: () => { void handleDeployWorkServer(); }, }); } if (previewItem) { cards.push({ key: 'test', label: 'Preview재기동', statusTone: resolveVersionTone(previewItem), statusLabel: resolveVersionStatusLabel(previewItem), executedAt: resolveCardExecutedAt(previewItem, lastActionByKey.test), updatedAt: resolveCardUpdatedAt(previewItem), actionLabel: '재기동', actionLoading: runningActionKey === 'test', progressLabel: resolveControlCardProgressLabel('test'), actionType: 'default', onAction: () => { void handleRestart('test'); }, }); cards.push({ key: 'test-deploy', label: 'TEST배포', statusTone: testDeployControlStatus.statusTone, statusLabel: testDeployControlStatus.statusLabel, executedAt: lastActionByKey.test.executedAt || previewItem.latestBuiltAt || previewItem.checkedAt, updatedAt: resolveCardUpdatedAt(previewItem), actionLabel: '실행', actionLoading: runningActionKey === 'test-deploy', progressLabel: resolveControlCardProgressLabel('test-deploy'), actionType: 'default', onAction: () => { void handleDeployTest(); }, }); } return cards; }, [isSharedManageMode, lastActionByKey, previewItem, reservation, runningActionKey, testDeployment, workServerDeployment, workServerItem]); if (!hasAccess && !isSharedManageMode) { return (
토큰 등록 사용자 또는 허용된 공유채팅 참여자만 Server Command 메뉴를 사용할 수 있습니다.
); } return ( {contextHolder}
서버관리 표로 확인하고 재기동
{summary.total} 전체 {summary.online} {summary.degraded} {summary.offline} {sharedReservationCard ? ( {sharedReservationCard.icon} {sharedReservationCard.value} ) : null}
{restartErrorInfo && !hasDeploymentStatusPanel ? ( {restartErrorInfo.title} {restartErrorInfo.missingScriptPath ? ( {restartErrorInfo.missingScriptPath} ) : null} {restartErrorInfo.detail}
} action={ {restartErrorInfo.canScheduleReservation ? ( ) : null} {testDeployment.logExcerpt} ) : null} ) : null} {workServerDeployment && (workServerDeployment.status === 'running' || workServerDeployment.status === 'failed') ? (
WORK 배포 진행 {resolveWorkServerDeploymentStatusTag(workServerDeployment)} {workServerDeployment.summary?.trim() || resolveWorkServerDeploymentPhaseLabel(workServerDeployment.phase)}
활성 슬롯 {resolveWorkServerSlotLabel(workServerDeployment.activeSlot)}
대상 슬롯 {resolveWorkServerSlotLabel(workServerDeployment.targetSlot)}
이전 슬롯 {resolveWorkServerSlotLabel(workServerDeployment.previousSlot)}
마지막 갱신 {formatDateTime(workServerDeployment.updatedAt)}
이관 대기 active {workServerDeployment.previousSlotActiveChatRequestCount ?? '-'} · queued {workServerDeployment.previousSlotQueuedChatRequestCount ?? '-'}
복구 확인 session {workServerDeployment.recoveredSessionCount ?? '-'} · restarted {workServerDeployment.recoveredRestartedCount ?? '-'}
실시간 배포 단계 {workServerDeployment.steps.map((step) => (
{step.status === 'running' ? '진행 중' : step.status === 'completed' ? '완료' : step.status === 'failed' ? '실패' : '대기'} {resolveWorkServerDeploymentStepLabel(step)} {step.detail ? ( {step.detail} ) : null} {step.updatedAt ? ( 갱신 {formatDateTime(step.updatedAt)} ) : null}
))}
{workServerDeployment.logExcerpt ? (
배포 로그
{workServerDeployment.logExcerpt}
) : null}
) : null} {reservation && !isSharedManageMode && (reservation.enabled || reservation.status !== 'idle' || reservation.autoFix.enabled) ? (
재기동 예약 상태 {resolveReservationStatusTag(reservation)} {reservation.waitingReason?.trim() || (reservation.status === 'completed' ? `예약된 ${getReservationTargetLabel(reservation.target)} 재기동이 완료되었습니다.` : '예약 상태를 확인했습니다.')}
대상 {getReservationTargetLabel(reservation.target)}
요청 {formatDateTime(reservation.requestedAt)}
자동실행 {formatDateTime(reservation.autoExecuteAt)}
갱신 {formatDateTime(reservation.updatedAt)}
{reservation.status === 'executing' ? ( 예약 실행 단계 {buildReservationExecutionSteps(reservation.executionPhase, reservation.target).map((step) => (
{step.active ? '진행 중' : step.done ? '완료' : '대기'} {step.label}
))}
) : null} {reservation.workItems.length > 0 ? ( 현재 진행 작업 {reservation.workItems.map((item, index) => (
{formatReservationWorkItemTag(item)} {item.title} {item.detail ? ( {item.detail} ) : null}
))}
) : null} {reservation.autoFix.enabled ? ( {reservation.autoFix.summary?.trim() || '빌드 오류 자동 개선 상태를 추적 중입니다.'} {reservation.autoFix.detail ? {reservation.autoFix.detail} : null} 상태: {formatAutoFixStatusLabel(reservation.autoFix.status)} {reservation.autoFix.targetKey ? ` · 대상 ${reservation.autoFix.targetKey.toUpperCase()}` : ''}
} /> ) : null} {reservation.lastError ? ( {reservation.lastError} ) : null}
) : null} {loading ? ( ) : errorMessage ? ( void Promise.all([loadItems(), loadReservation({ silent: true })])}> 다시 시도 } /> ) : manageableItems.length === 0 ? (
) : (
{controlCards.map((card) => (
{card.label} {card.statusLabel}
실행일 {formatDateTime(card.executedAt)}
최근수정 {formatDateTime(card.updatedAt)}
{card.progressLabel ? (
진행상태 {card.progressLabel}
) : null}
))}
)} ); }