1699 lines
58 KiB
TypeScript
1699 lines
58 KiB
TypeScript
import {
|
|
CheckCircleFilled,
|
|
ClockCircleFilled,
|
|
CloseCircleFilled,
|
|
CopyOutlined,
|
|
ExclamationCircleFilled,
|
|
ReloadOutlined,
|
|
SyncOutlined,
|
|
} from '@ant-design/icons';
|
|
import { Alert, Button, Empty, Space, Tag, Typography, message } from 'antd';
|
|
import { useEffect, 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 <CheckCircleFilled />;
|
|
}
|
|
|
|
if (item.availability === 'degraded') {
|
|
return <ExclamationCircleFilled />;
|
|
}
|
|
|
|
return <CloseCircleFilled />;
|
|
}
|
|
|
|
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<ServerControlCard, 'statusTone' | 'statusLabel'> {
|
|
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 <ClockCircleFilled />;
|
|
case 'executing':
|
|
case 'recovering':
|
|
return <SyncOutlined spin />;
|
|
case 'waiting':
|
|
return <ClockCircleFilled />;
|
|
case 'completed':
|
|
return <CheckCircleFilled />;
|
|
case 'failed':
|
|
return <CloseCircleFilled />;
|
|
case 'cancelled':
|
|
return <ExclamationCircleFilled />;
|
|
default:
|
|
return <ClockCircleFilled />;
|
|
}
|
|
}
|
|
|
|
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: <ClockCircleFilled /> };
|
|
}
|
|
}
|
|
|
|
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 <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 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 <Tag color="processing">배포 진행 중</Tag>;
|
|
case 'completed':
|
|
return <Tag color="success">배포 완료</Tag>;
|
|
case 'failed':
|
|
return <Tag color="error">배포 실패</Tag>;
|
|
default:
|
|
return <Tag>대기</Tag>;
|
|
}
|
|
}
|
|
|
|
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 <Tag color="processing">배포 진행 중</Tag>;
|
|
case 'completed':
|
|
return <Tag color="success">배포 완료</Tag>;
|
|
case 'failed':
|
|
return <Tag color="error">배포 실패</Tag>;
|
|
default:
|
|
return <Tag>대기</Tag>;
|
|
}
|
|
}
|
|
|
|
function resolveTestServerDeploymentStepLabel(step: TestServerDeploymentStep) {
|
|
return resolveTestServerDeploymentPhaseLabel(step.key);
|
|
}
|
|
|
|
function resolveTestDeployControlStatus(
|
|
item: ServerCommandItem | null,
|
|
deployment: TestServerDeploymentState | null,
|
|
isSubmitting: boolean,
|
|
): Pick<ServerControlCard, 'statusTone' | 'statusLabel'> {
|
|
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 <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 '대기 없음';
|
|
}
|
|
}
|
|
|
|
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<ServerCommandItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [reservation, setReservation] = useState<ServerRestartReservation | null>(null);
|
|
const [workServerDeployment, setWorkServerDeployment] = useState<WorkServerDeploymentState | null>(null);
|
|
const [testDeployment, setTestDeployment] = useState<TestServerDeploymentState | null>(null);
|
|
const [runningActionKey, setRunningActionKey] = useState<ServerActionKey | 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' },
|
|
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;
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!hasAccess && !isSharedManageMode) {
|
|
setItems([]);
|
|
setLoading(false);
|
|
setErrorMessage(null);
|
|
setReservation(null);
|
|
setWorkServerDeployment(null);
|
|
setTestDeployment(null);
|
|
return;
|
|
}
|
|
|
|
void Promise.all([loadItems(), loadReservation({ silent: true }), loadWorkServerDeployment({ silent: true }), loadTestDeployment({ silent: true })]);
|
|
}, [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 Promise.all([
|
|
loadItems({ silent: true }),
|
|
loadReservation({ silent: true }),
|
|
loadWorkServerDeployment({ silent: true }),
|
|
loadTestDeployment({ 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 (!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 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<ServerControlCard[]>(() => {
|
|
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 (
|
|
<div className="server-command-page__surface">
|
|
<Paragraph className="app-main-copy">
|
|
토큰 등록 사용자 또는 허용된 공유채팅 참여자만 Server Command 메뉴를 사용할 수 있습니다.
|
|
</Paragraph>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Space direction="vertical" size={16} className="server-command-page">
|
|
{contextHolder}
|
|
|
|
<div className="server-command-page__surface server-command-page__surface--toolbar">
|
|
<div className="server-command-page__toolbar">
|
|
<div className="server-command-page__toolbar-copy">
|
|
<Title level={4} className="server-command-page__title">
|
|
서버관리
|
|
</Title>
|
|
<Paragraph className="server-command-page__copy">표로 확인하고 재기동</Paragraph>
|
|
</div>
|
|
<div className="server-command-page__toolbar-side">
|
|
<div className="server-command-page__status-summary" aria-label="서버 상태 요약">
|
|
<span className="server-command-page__status-summary-item" title={`전체 ${summary.total}대`}>
|
|
<strong>{summary.total}</strong>
|
|
<span>전체</span>
|
|
</span>
|
|
<span className="server-command-page__status-summary-item server-command-page__status-summary-item--online" title={`정상 ${summary.online}대`}>
|
|
<CheckCircleFilled />
|
|
<strong>{summary.online}</strong>
|
|
</span>
|
|
<span className="server-command-page__status-summary-item server-command-page__status-summary-item--degraded" title={`주의 ${summary.degraded}대`}>
|
|
<ExclamationCircleFilled />
|
|
<strong>{summary.degraded}</strong>
|
|
</span>
|
|
<span className="server-command-page__status-summary-item server-command-page__status-summary-item--offline" title={`장애 ${summary.offline}대`}>
|
|
<CloseCircleFilled />
|
|
<strong>{summary.offline}</strong>
|
|
</span>
|
|
{sharedReservationCard ? (
|
|
<span
|
|
className={`server-command-page__status-summary-item server-command-page__status-summary-item--${sharedReservationCard.tone}`}
|
|
title={`재기동 예약 ${sharedReservationCard.value}`}
|
|
>
|
|
{sharedReservationCard.icon}
|
|
<strong>{sharedReservationCard.value}</strong>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<Button
|
|
icon={<ReloadOutlined />}
|
|
onClick={() => {
|
|
void Promise.all([
|
|
loadItems(),
|
|
loadReservation({ silent: true }),
|
|
loadWorkServerDeployment({ silent: true }),
|
|
loadTestDeployment({ silent: true }),
|
|
]);
|
|
}}
|
|
loading={loading}
|
|
>
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{restartErrorInfo && !hasDeploymentStatusPanel ? (
|
|
<Alert
|
|
showIcon
|
|
type={restartErrorInfo.tone}
|
|
message={restartErrorInfo.tone === 'warning' ? '재기동 예약 필요' : '재기동 에러'}
|
|
description={
|
|
<Space direction="vertical" size={8} className="server-command-page__alert-body">
|
|
<Text strong>{restartErrorInfo.title}</Text>
|
|
{restartErrorInfo.missingScriptPath ? (
|
|
<Text code className="server-command-page__alert-code">
|
|
{restartErrorInfo.missingScriptPath}
|
|
</Text>
|
|
) : null}
|
|
<span className="server-command-page__alert-text">{restartErrorInfo.detail}</span>
|
|
</Space>
|
|
}
|
|
action={
|
|
<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}
|
|
|
|
|
|
{testDeployment && (testDeployment.status === 'running' || testDeployment.status === 'failed') ? (
|
|
<div className="server-command-page__surface server-command-page__reservation-panel">
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
<Space size={8} wrap>
|
|
<Title level={5} className="server-command-page__server-title">
|
|
TEST 배포 진행
|
|
</Title>
|
|
{resolveTestServerDeploymentStatusTag(testDeployment)}
|
|
</Space>
|
|
|
|
<Paragraph className="server-command-page__summary">
|
|
{testDeployment.summary?.trim() || resolveTestServerDeploymentPhaseLabel(testDeployment.phase)}
|
|
</Paragraph>
|
|
|
|
<div className="server-command-page__compact-grid">
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">시작</Text>
|
|
<Text strong>{formatDateTime(testDeployment.startedAt)}</Text>
|
|
</div>
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">마지막 갱신</Text>
|
|
<Text strong>{formatDateTime(testDeployment.updatedAt)}</Text>
|
|
</div>
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">완료</Text>
|
|
<Text strong>{formatDateTime(testDeployment.completedAt)}</Text>
|
|
</div>
|
|
</div>
|
|
|
|
<Space direction="vertical" size={8} className="server-command-page__work-list">
|
|
<Text strong>실시간 배포 단계</Text>
|
|
{testDeployment.steps.map((step) => (
|
|
<div key={step.key} className="server-command-page__work-item">
|
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
|
<Space size={8} wrap>
|
|
<Tag color={step.status === 'running' ? 'processing' : step.status === 'completed' ? 'success' : step.status === 'failed' ? 'error' : 'default'}>
|
|
{step.status === 'running' ? '진행 중' : step.status === 'completed' ? '완료' : step.status === 'failed' ? '실패' : '대기'}
|
|
</Tag>
|
|
<Text strong={step.status === 'running' || step.status === 'completed'}>{resolveTestServerDeploymentStepLabel(step)}</Text>
|
|
</Space>
|
|
{step.detail ? (
|
|
<Text type="secondary" className="server-command-page__work-detail">
|
|
{step.detail}
|
|
</Text>
|
|
) : null}
|
|
{step.updatedAt ? (
|
|
<Text type="secondary" className="server-command-page__work-detail">
|
|
갱신 {formatDateTime(step.updatedAt)}
|
|
</Text>
|
|
) : null}
|
|
</Space>
|
|
</div>
|
|
))}
|
|
</Space>
|
|
|
|
{testDeployment.logExcerpt ? (
|
|
<div className="server-command-page__preview-block">
|
|
<div className="server-command-page__preview-header">
|
|
<Text strong>배포 로그</Text>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
aria-label="TEST 배포 로그 복사"
|
|
onClick={() => void handleCopyDeploymentLog('TEST 배포 로그', testDeployment.logExcerpt)}
|
|
>
|
|
복사
|
|
</Button>
|
|
</div>
|
|
<Text type={testDeployment.status === 'failed' ? 'danger' : undefined} className="server-command-page__preview">
|
|
{testDeployment.logExcerpt}
|
|
</Text>
|
|
</div>
|
|
) : null}
|
|
</Space>
|
|
</div>
|
|
) : null}
|
|
{workServerDeployment && (workServerDeployment.status === 'running' || workServerDeployment.status === 'failed') ? (
|
|
<div className="server-command-page__surface server-command-page__reservation-panel">
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
<Space size={8} wrap>
|
|
<Title level={5} className="server-command-page__server-title">
|
|
WORK 배포 진행
|
|
</Title>
|
|
{resolveWorkServerDeploymentStatusTag(workServerDeployment)}
|
|
</Space>
|
|
|
|
<Paragraph className="server-command-page__summary">
|
|
{workServerDeployment.summary?.trim() || resolveWorkServerDeploymentPhaseLabel(workServerDeployment.phase)}
|
|
</Paragraph>
|
|
|
|
<div className="server-command-page__compact-grid">
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">활성 슬롯</Text>
|
|
<Text strong>{resolveWorkServerSlotLabel(workServerDeployment.activeSlot)}</Text>
|
|
</div>
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">대상 슬롯</Text>
|
|
<Text strong>{resolveWorkServerSlotLabel(workServerDeployment.targetSlot)}</Text>
|
|
</div>
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">이전 슬롯</Text>
|
|
<Text strong>{resolveWorkServerSlotLabel(workServerDeployment.previousSlot)}</Text>
|
|
</div>
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">마지막 갱신</Text>
|
|
<Text strong>{formatDateTime(workServerDeployment.updatedAt)}</Text>
|
|
</div>
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">이관 대기</Text>
|
|
<Text strong>
|
|
active {workServerDeployment.previousSlotActiveChatRequestCount ?? '-'} · queued {workServerDeployment.previousSlotQueuedChatRequestCount ?? '-'}
|
|
</Text>
|
|
</div>
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">복구 확인</Text>
|
|
<Text strong>
|
|
session {workServerDeployment.recoveredSessionCount ?? '-'} · restarted {workServerDeployment.recoveredRestartedCount ?? '-'}
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
|
|
<Space direction="vertical" size={8} className="server-command-page__work-list">
|
|
<Text strong>실시간 배포 단계</Text>
|
|
{workServerDeployment.steps.map((step) => (
|
|
<div key={step.key} className="server-command-page__work-item">
|
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
|
<Space size={8} wrap>
|
|
<Tag color={step.status === 'running' ? 'processing' : step.status === 'completed' ? 'success' : step.status === 'failed' ? 'error' : 'default'}>
|
|
{step.status === 'running' ? '진행 중' : step.status === 'completed' ? '완료' : step.status === 'failed' ? '실패' : '대기'}
|
|
</Tag>
|
|
<Text strong={step.status === 'running' || step.status === 'completed'}>{resolveWorkServerDeploymentStepLabel(step)}</Text>
|
|
</Space>
|
|
{step.detail ? (
|
|
<Text type="secondary" className="server-command-page__work-detail">
|
|
{step.detail}
|
|
</Text>
|
|
) : null}
|
|
{step.updatedAt ? (
|
|
<Text type="secondary" className="server-command-page__work-detail">
|
|
갱신 {formatDateTime(step.updatedAt)}
|
|
</Text>
|
|
) : null}
|
|
</Space>
|
|
</div>
|
|
))}
|
|
</Space>
|
|
|
|
{workServerDeployment.logExcerpt ? (
|
|
<div className="server-command-page__preview-block">
|
|
<div className="server-command-page__preview-header">
|
|
<Text strong>배포 로그</Text>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
aria-label="WORK 배포 로그 복사"
|
|
onClick={() => void handleCopyDeploymentLog('WORK 배포 로그', workServerDeployment.logExcerpt)}
|
|
>
|
|
복사
|
|
</Button>
|
|
</div>
|
|
<Text type={workServerDeployment.status === 'failed' ? 'danger' : undefined} className="server-command-page__preview">
|
|
{workServerDeployment.logExcerpt}
|
|
</Text>
|
|
</div>
|
|
) : null}
|
|
</Space>
|
|
</div>
|
|
) : null}
|
|
|
|
{reservation && !isSharedManageMode && (reservation.enabled || reservation.status !== 'idle' || reservation.autoFix.enabled) ? (
|
|
<div className="server-command-page__surface server-command-page__reservation-panel">
|
|
<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'
|
|
? `예약된 ${getReservationTargetLabel(reservation.target)} 재기동이 완료되었습니다.`
|
|
: '예약 상태를 확인했습니다.')}
|
|
</Paragraph>
|
|
|
|
<div className="server-command-page__compact-grid">
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">대상</Text>
|
|
<Text strong>{getReservationTargetLabel(reservation.target)}</Text>
|
|
</div>
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">요청</Text>
|
|
<Text strong>{formatDateTime(reservation.requestedAt)}</Text>
|
|
</div>
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">자동실행</Text>
|
|
<Text strong>{formatDateTime(reservation.autoExecuteAt)}</Text>
|
|
</div>
|
|
<div className="server-command-page__compact-item">
|
|
<Text type="secondary">갱신</Text>
|
|
<Text strong>{formatDateTime(reservation.updatedAt)}</Text>
|
|
</div>
|
|
</div>
|
|
|
|
{reservation.status === 'executing' ? (
|
|
<Space direction="vertical" size={8} className="server-command-page__work-list">
|
|
<Text strong>예약 실행 단계</Text>
|
|
{buildReservationExecutionSteps(reservation.executionPhase, reservation.target).map((step) => (
|
|
<div key={step.label} className="server-command-page__work-item">
|
|
<Space size={8} wrap>
|
|
<Tag color={step.active ? 'processing' : step.done ? 'success' : 'default'}>
|
|
{step.active ? '진행 중' : step.done ? '완료' : '대기'}
|
|
</Tag>
|
|
<Text strong={step.active || step.done}>{step.label}</Text>
|
|
</Space>
|
|
</div>
|
|
))}
|
|
</Space>
|
|
) : null}
|
|
|
|
{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>
|
|
</div>
|
|
) : null}
|
|
|
|
{loading ? (
|
|
<DataStatePanel state="loading" title="서버 상태를 확인하는 중입니다." />
|
|
) : errorMessage ? (
|
|
<DataStatePanel
|
|
state="error"
|
|
title="서버 명령 메뉴를 불러오지 못했습니다."
|
|
description={errorMessage}
|
|
actions={
|
|
<Button type="primary" onClick={() => void Promise.all([loadItems(), loadReservation({ silent: true })])}>
|
|
다시 시도
|
|
</Button>
|
|
}
|
|
/>
|
|
) : manageableItems.length === 0 ? (
|
|
<div className="server-command-page__surface">
|
|
<Empty description="표시할 서버가 없습니다." />
|
|
</div>
|
|
) : (
|
|
<div className="server-command-page__control-list" aria-label="서버 제어 목록">
|
|
{controlCards.map((card) => (
|
|
<div key={card.key} className="server-command-page__surface server-command-page__control-card">
|
|
<div className="server-command-page__control-main">
|
|
<div className="server-command-page__control-header">
|
|
<Text strong className="server-command-page__control-title">
|
|
{card.label}
|
|
</Text>
|
|
<span
|
|
className={`server-command-page__status-badge server-command-page__status-badge--${card.statusTone}`}
|
|
aria-label={`${card.label} 최신 상태 ${card.statusLabel}`}
|
|
title={`${card.label} 최신 상태 ${card.statusLabel}`}
|
|
>
|
|
{card.statusLabel}
|
|
</span>
|
|
</div>
|
|
<div className="server-command-page__control-meta">
|
|
<Text type="secondary">실행일</Text>
|
|
<Text>{formatDateTime(card.executedAt)}</Text>
|
|
</div>
|
|
<div className="server-command-page__control-meta">
|
|
<Text type="secondary">최근수정</Text>
|
|
<Text>{formatDateTime(card.updatedAt)}</Text>
|
|
</div>
|
|
{card.progressLabel ? (
|
|
<div className="server-command-page__control-meta">
|
|
<Text type="secondary">진행상태</Text>
|
|
<Text>{card.progressLabel}</Text>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<Button
|
|
className="server-command-page__restart-button server-command-page__control-button"
|
|
type={card.actionType}
|
|
icon={card.key === 'test-deploy' ? <SyncOutlined /> : <ReloadOutlined />}
|
|
loading={card.actionLoading}
|
|
onClick={card.onAction}
|
|
>
|
|
{card.actionLabel}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Space>
|
|
);
|
|
}
|