Initial import
This commit is contained in:
438
src/features/serverCommand/ServerCommandPage.tsx
Executable file
438
src/features/serverCommand/ServerCommandPage.tsx
Executable file
@@ -0,0 +1,438 @@
|
||||
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Col, Descriptions, Empty, Row, Space, Statistic, Tag, Typography, message } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import { DataStatePanel } from '../../components/dataStatePanel';
|
||||
import { copyText } from '../../app/main/mainChatPanel';
|
||||
import { fetchServerCommands, restartServerCommand } from './api';
|
||||
import type { ServerCommandItem, ServerCommandKey } from './types';
|
||||
import './serverCommand.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
type RestartErrorInfo = {
|
||||
title: string;
|
||||
detail: string;
|
||||
missingScriptPath: string | null;
|
||||
};
|
||||
|
||||
type LastActionInfo = {
|
||||
output: string | null;
|
||||
executedAt: string;
|
||||
restartState: 'completed' | 'accepted';
|
||||
};
|
||||
|
||||
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 formatContentType(value: string | null | undefined) {
|
||||
return value?.trim() || '-';
|
||||
}
|
||||
|
||||
function resolveHostLabel(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '내부 전용';
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value).host;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAvailabilityTag(item: ServerCommandItem) {
|
||||
if (item.availability === 'online') {
|
||||
return <Tag color="success">ONLINE</Tag>;
|
||||
}
|
||||
|
||||
if (item.availability === 'degraded') {
|
||||
return <Tag color="warning">DEGRADED</Tag>;
|
||||
}
|
||||
|
||||
return <Tag color="error">OFFLINE</Tag>;
|
||||
}
|
||||
|
||||
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 {
|
||||
title: `${targetLabel} 재기동 실패`,
|
||||
detail: `재기동 스크립트 파일이 서버에 없습니다.\n배치 경로: ${missingScriptPath}\n\n원본 오류:\n${detail}`,
|
||||
missingScriptPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${targetLabel} 재기동 실패`,
|
||||
detail,
|
||||
missingScriptPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function ServerCommandPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<ServerCommandItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [restartingKey, setRestartingKey] = useState<ServerCommandKey | null>(null);
|
||||
const [restartErrorInfo, setRestartErrorInfo] = useState<RestartErrorInfo | null>(null);
|
||||
const [copyingRestartError, setCopyingRestartError] = useState(false);
|
||||
const [lastActionByKey, setLastActionByKey] = useState<Record<ServerCommandKey, LastActionInfo>>({
|
||||
test: { output: null, executedAt: '', restartState: 'completed' },
|
||||
rel: { output: null, executedAt: '', restartState: 'completed' },
|
||||
'work-server': { output: null, executedAt: '', restartState: 'completed' },
|
||||
'command-runner': { output: null, executedAt: '', restartState: 'completed' },
|
||||
});
|
||||
|
||||
const loadItems = async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const nextItems = await fetchServerCommands();
|
||||
setItems(nextItems);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '서버 정보를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
setErrorMessage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadItems();
|
||||
}, [hasAccess]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return items.reduce(
|
||||
(result, item) => {
|
||||
result.total += 1;
|
||||
result[item.availability] += 1;
|
||||
return result;
|
||||
},
|
||||
{ total: 0, online: 0, degraded: 0, offline: 0 },
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
const handleRestart = async (key: ServerCommandKey) => {
|
||||
setRestartingKey(key);
|
||||
setRestartErrorInfo(null);
|
||||
|
||||
try {
|
||||
const result = await restartServerCommand(key);
|
||||
setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item)));
|
||||
setLastActionByKey((previous) => ({
|
||||
...previous,
|
||||
[result.item.key]: {
|
||||
output: result.commandOutput,
|
||||
executedAt: new Date().toISOString(),
|
||||
restartState: result.restartState,
|
||||
},
|
||||
}));
|
||||
messageApi.success(
|
||||
result.restartState === 'accepted' ? `${result.item.label} 재기동 요청 완료` : `${result.item.label} 재기동 완료`,
|
||||
);
|
||||
} catch (error) {
|
||||
const targetLabel = items.find((item) => item.key === key)?.label ?? key.toUpperCase();
|
||||
const detail = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
|
||||
setRestartErrorInfo(buildRestartErrorInfo(targetLabel, detail));
|
||||
} finally {
|
||||
setRestartingKey(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);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card className="server-command-page__card" bordered={false}>
|
||||
<Paragraph className="app-main-copy">
|
||||
토큰 등록 사용자만 Server Command 메뉴를 사용할 수 있습니다.
|
||||
</Paragraph>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} className="server-command-page">
|
||||
{contextHolder}
|
||||
<Card className="server-command-page__card" bordered={false}>
|
||||
<Space direction="vertical" size={8}>
|
||||
<Title level={4} className="server-command-page__title">
|
||||
Server Command
|
||||
</Title>
|
||||
<Paragraph className="server-command-page__copy">
|
||||
TEST, REL, WORK-SERVER, COMMAND-RUNNER 상태를 확인하고 허용된 재기동 명령만 실행합니다.
|
||||
</Paragraph>
|
||||
<Row gutter={[12, 12]} className="server-command-page__summary-grid">
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic title="전체" value={summary.total} />
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic title="ONLINE" value={summary.online} valueStyle={{ color: '#389e0d' }} />
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic title="DEGRADED" value={summary.degraded} valueStyle={{ color: '#d48806' }} />
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic title="OFFLINE" value={summary.offline} valueStyle={{ color: '#cf1322' }} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadItems()} loading={loading}>
|
||||
새로고침
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{restartErrorInfo ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="재기동 에러"
|
||||
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={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingRestartError}
|
||||
aria-label="에러 메시지 복사"
|
||||
onClick={() => {
|
||||
void handleCopyRestartError();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<DataStatePanel state="loading" title="서버 상태를 확인하는 중입니다." />
|
||||
) : errorMessage ? (
|
||||
<DataStatePanel
|
||||
state="error"
|
||||
title="서버 명령 메뉴를 불러오지 못했습니다."
|
||||
description={errorMessage}
|
||||
actions={
|
||||
<Button type="primary" onClick={() => void loadItems()}>
|
||||
다시 시도
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<Card className="server-command-page__card" bordered={false}>
|
||||
<Empty description="표시할 서버가 없습니다." />
|
||||
</Card>
|
||||
) : (
|
||||
<div className="server-command-page__grid">
|
||||
{items.map((item) => (
|
||||
<Card
|
||||
key={item.key}
|
||||
className="server-command-page__server-card"
|
||||
bordered={false}
|
||||
title={
|
||||
<Space size={8} wrap className="server-command-page__title-row">
|
||||
<Title level={5} className="server-command-page__server-title">
|
||||
{item.label}
|
||||
</Title>
|
||||
{resolveAvailabilityTag(item)}
|
||||
{item.composeStatus ? <Tag color="blue">{item.composeStatus}</Tag> : null}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
className="server-command-page__restart-button"
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={restartingKey === item.key}
|
||||
onClick={() => {
|
||||
void handleRestart(item.key);
|
||||
}}
|
||||
>
|
||||
{item.label} 재기동
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Paragraph className="server-command-page__summary">{item.summary}</Paragraph>
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={1}
|
||||
className="server-command-page__meta"
|
||||
items={[
|
||||
{
|
||||
key: 'environment',
|
||||
label: '환경',
|
||||
children: item.environment,
|
||||
},
|
||||
{
|
||||
key: 'started-at',
|
||||
label: '시작일시',
|
||||
children: formatDateTime(item.startedAt),
|
||||
},
|
||||
{
|
||||
key: 'response-time',
|
||||
label: '응답시간',
|
||||
children: formatResponseTime(item.responseTimeMs),
|
||||
},
|
||||
{
|
||||
key: 'http-status',
|
||||
label: 'HTTP',
|
||||
children: formatStatusCode(item.httpStatus),
|
||||
},
|
||||
{
|
||||
key: 'checked-at',
|
||||
label: '확인시각',
|
||||
children: formatDateTime(item.checkedAt),
|
||||
},
|
||||
{
|
||||
key: 'content-type',
|
||||
label: 'Content-Type',
|
||||
children: formatContentType(item.contentType),
|
||||
},
|
||||
{
|
||||
key: 'service',
|
||||
label: '서비스',
|
||||
children: item.serviceName,
|
||||
},
|
||||
{
|
||||
key: 'compose-file',
|
||||
label: 'Compose',
|
||||
children: item.composeFile,
|
||||
},
|
||||
{
|
||||
key: 'command-script',
|
||||
label: 'Script',
|
||||
children: item.commandScript,
|
||||
},
|
||||
{
|
||||
key: 'working-directory',
|
||||
label: '작업경로',
|
||||
children: item.commandWorkingDirectory,
|
||||
},
|
||||
{
|
||||
key: 'host',
|
||||
label: '호스트',
|
||||
children: resolveHostLabel(item.publicUrl ?? item.checkUrl),
|
||||
},
|
||||
{
|
||||
key: 'public-url',
|
||||
label: 'URL',
|
||||
children: item.publicUrl ? (
|
||||
<Typography.Link href={item.publicUrl} target="_blank" rel="noreferrer">
|
||||
{item.publicUrl}
|
||||
</Typography.Link>
|
||||
) : (
|
||||
<Text type="secondary">내부 전용</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'check-url',
|
||||
label: '체크',
|
||||
children: item.checkUrl,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{item.composeDetails ? (
|
||||
<Text type="secondary" className="server-command-page__preview">
|
||||
{item.composeDetails}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{item.responsePreview ? (
|
||||
<Text type="secondary" className="server-command-page__preview">
|
||||
{item.responsePreview}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{item.errorMessage ? (
|
||||
<Text type="danger" className="server-command-page__preview">
|
||||
{item.errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Text code className="server-command-page__command">
|
||||
{item.lastCommand}
|
||||
</Text>
|
||||
|
||||
{lastActionByKey[item.key]?.executedAt ? (
|
||||
<Text type="secondary" className="server-command-page__preview">
|
||||
{lastActionByKey[item.key].restartState === 'accepted' ? '최근 재기동 요청' : '최근 재기동 완료'}:{' '}
|
||||
{formatDateTime(lastActionByKey[item.key].executedAt)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{lastActionByKey[item.key]?.output ? (
|
||||
<Text className="server-command-page__command">
|
||||
{lastActionByKey[item.key].output}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user