Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

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