Files
ai-code-app/src/features/serverCommand/ServerCommandPage.tsx
2026-05-28 12:45:36 +09:00

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>
);
}