feat: refresh shared chat and server workflows
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
ClockCircleFilled,
|
||||
CloseCircleFilled,
|
||||
CopyOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ReloadOutlined,
|
||||
SyncOutlined,
|
||||
} 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 { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import { DataStatePanel } from '../../components/dataStatePanel';
|
||||
import { copyText } from '../../app/main/mainChatPanel';
|
||||
@@ -24,6 +32,13 @@ import './serverCommand.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
type ServerCommandPageProps = {
|
||||
sharedAccess?: {
|
||||
shareToken: string;
|
||||
allowedKeys?: ServerCommandKey[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
type RestartErrorInfo = {
|
||||
tone: 'error' | 'warning';
|
||||
title: string;
|
||||
@@ -38,6 +53,26 @@ type LastActionInfo = {
|
||||
restartState: 'completed' | 'accepted';
|
||||
};
|
||||
|
||||
type SharedStatusTone =
|
||||
| 'online'
|
||||
| 'degraded'
|
||||
| 'offline'
|
||||
| 'latest'
|
||||
| 'update-available'
|
||||
| 'build-required'
|
||||
| 'unknown'
|
||||
| 'info';
|
||||
|
||||
type SharedStatusCard = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
tone: SharedStatusTone;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
const SHARED_SERVER_KEY_ORDER: ServerCommandKey[] = ['work-server', 'test', 'rel', 'prod', 'command-runner'];
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
@@ -92,6 +127,185 @@ function resolveAvailabilityTag(item: ServerCommandItem) {
|
||||
return <Tag color="error">OFFLINE</Tag>;
|
||||
}
|
||||
|
||||
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 resolveVersionLabel(item: ServerCommandItem | null) {
|
||||
const latestVersion = item?.latestVersion?.trim();
|
||||
const runningVersion = item?.runningVersion?.trim();
|
||||
|
||||
return latestVersion || runningVersion || '확인 필요';
|
||||
}
|
||||
|
||||
function resolveRestartButtonLabel(item: ServerCommandItem) {
|
||||
return item.key === 'work-server' ? '예약' : '재기동';
|
||||
}
|
||||
|
||||
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 resolveAvailabilityStatusIcon(tone: ReturnType<typeof resolveAvailabilityTone>) {
|
||||
if (tone === 'online') {
|
||||
return <CheckCircleFilled />;
|
||||
}
|
||||
|
||||
if (tone === 'degraded') {
|
||||
return <ExclamationCircleFilled />;
|
||||
}
|
||||
|
||||
return <CloseCircleFilled />;
|
||||
}
|
||||
|
||||
function resolveVersionStatusIcon(tone: ReturnType<typeof resolveVersionTone>) {
|
||||
if (tone === 'latest') {
|
||||
return <CheckCircleFilled />;
|
||||
}
|
||||
|
||||
if (tone === 'update-available') {
|
||||
return <ClockCircleFilled />;
|
||||
}
|
||||
|
||||
if (tone === 'build-required') {
|
||||
return <ExclamationCircleFilled />;
|
||||
}
|
||||
|
||||
return <ExclamationCircleFilled />;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -155,6 +369,18 @@ function resolveReservationStatusTag(reservation: ServerRestartReservation) {
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
@@ -202,32 +428,42 @@ function formatAutoFixStatusLabel(status: ServerRestartReservationAutoFix['statu
|
||||
}
|
||||
}
|
||||
|
||||
function buildReservationExecutionSteps(phase: ServerRestartReservationExecutionPhase) {
|
||||
const activeIndex =
|
||||
phase === 'commit-main-worktree'
|
||||
? 0
|
||||
: phase === 'restart-test'
|
||||
? 1
|
||||
: phase === 'restart-work-server'
|
||||
? 2
|
||||
: phase === 'verify-runtime'
|
||||
? 3
|
||||
: -1;
|
||||
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;
|
||||
|
||||
return [
|
||||
'main 작업트리 커밋',
|
||||
'TEST 재기동',
|
||||
'WORK 재기동',
|
||||
'정상 기동 확인',
|
||||
].map((label, index) => ({
|
||||
label,
|
||||
const filteredSteps = steps.filter((label) => {
|
||||
if (target === 'work-server' && label.label === 'TEST 재기동') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target === 'test' && label.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,
|
||||
}));
|
||||
}
|
||||
|
||||
export function ServerCommandPage() {
|
||||
export function ServerCommandPage({ sharedAccess = null }: ServerCommandPageProps) {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const isSharedManageMode = Boolean(sharedAccess?.shareToken);
|
||||
const allowedKeysSet = useMemo(() => new Set(sharedAccess?.allowedKeys ?? []), [sharedAccess?.allowedKeys]);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<ServerCommandItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -250,8 +486,12 @@ export function ServerCommandPage() {
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const nextItems = await fetchServerCommands();
|
||||
setItems(nextItems);
|
||||
const nextItems = await fetchServerCommands(isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined);
|
||||
setItems(
|
||||
isSharedManageMode && allowedKeysSet.size > 0
|
||||
? nextItems.filter((item) => allowedKeysSet.has(item.key))
|
||||
: nextItems,
|
||||
);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '서버 정보를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
@@ -261,7 +501,7 @@ export function ServerCommandPage() {
|
||||
|
||||
const loadReservation = async (options?: { silent?: boolean }) => {
|
||||
try {
|
||||
const nextReservation = await fetchServerRestartReservation();
|
||||
const nextReservation = await fetchServerRestartReservation(isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined);
|
||||
setReservation(nextReservation);
|
||||
return nextReservation;
|
||||
} catch (error) {
|
||||
@@ -273,7 +513,7 @@ export function ServerCommandPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
if (!hasAccess && !isSharedManageMode) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
setErrorMessage(null);
|
||||
@@ -285,10 +525,10 @@ export function ServerCommandPage() {
|
||||
loadItems(),
|
||||
loadReservation({ silent: true }),
|
||||
]);
|
||||
}, [hasAccess]);
|
||||
}, [allowedKeysSet, hasAccess, isSharedManageMode, sharedAccess?.shareToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
if (!hasAccess && !isSharedManageMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -310,7 +550,7 @@ export function ServerCommandPage() {
|
||||
return () => {
|
||||
window.clearInterval(timerId);
|
||||
};
|
||||
}, [hasAccess, reservation, restartingKey]);
|
||||
}, [hasAccess, isSharedManageMode, reservation, restartingKey]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return items.reduce(
|
||||
@@ -322,13 +562,40 @@ export function ServerCommandPage() {
|
||||
{ total: 0, online: 0, degraded: 0, offline: 0 },
|
||||
);
|
||||
}, [items]);
|
||||
const sharedReservationCard = useMemo(
|
||||
() => (isSharedManageMode ? buildSharedReservationCard(reservation) : null),
|
||||
[isSharedManageMode, reservation],
|
||||
);
|
||||
const sharedItems = useMemo(() => {
|
||||
if (!isSharedManageMode) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...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;
|
||||
});
|
||||
}, [isSharedManageMode, items]);
|
||||
|
||||
const handleRestart = async (key: ServerCommandKey) => {
|
||||
setRestartingKey(key);
|
||||
setRestartErrorInfo(null);
|
||||
|
||||
try {
|
||||
const result = await restartServerCommand(key);
|
||||
if (key === 'work-server') {
|
||||
await scheduleServerRestartReservation({
|
||||
target: 'work-server',
|
||||
...(isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : {}),
|
||||
});
|
||||
await loadReservation({ silent: true });
|
||||
messageApi.success('WORK-SERVER 무중단 재기동 예약을 등록했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
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) => ({
|
||||
@@ -381,10 +648,18 @@ export function ServerCommandPage() {
|
||||
setSchedulingReservation(true);
|
||||
|
||||
try {
|
||||
await scheduleServerRestartReservation();
|
||||
await scheduleServerRestartReservation(
|
||||
isSharedManageMode
|
||||
? { target: 'work-server', shareToken: sharedAccess?.shareToken }
|
||||
: undefined,
|
||||
);
|
||||
setRestartErrorInfo(null);
|
||||
await loadReservation({ silent: true });
|
||||
messageApi.success('전체 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.');
|
||||
messageApi.success(
|
||||
isSharedManageMode
|
||||
? 'WORK 서버 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.'
|
||||
: '전체 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.',
|
||||
);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : '재기동 예약 등록에 실패했습니다.';
|
||||
setRestartErrorInfo(buildRestartErrorInfo('전체 서버', detail));
|
||||
@@ -393,11 +668,11 @@ export function ServerCommandPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
if (!hasAccess && !isSharedManageMode) {
|
||||
return (
|
||||
<Card className="server-command-page__card" bordered={false}>
|
||||
<Paragraph className="app-main-copy">
|
||||
토큰 등록 사용자만 Server Command 메뉴를 사용할 수 있습니다.
|
||||
토큰 등록 사용자 또는 허용된 공유채팅 참여자만 Server Command 메뉴를 사용할 수 있습니다.
|
||||
</Paragraph>
|
||||
</Card>
|
||||
);
|
||||
@@ -406,28 +681,64 @@ export function ServerCommandPage() {
|
||||
return (
|
||||
<Space direction="vertical" size={16} className="server-command-page">
|
||||
{contextHolder}
|
||||
<Card className="server-command-page__card" bordered={false}>
|
||||
<Card
|
||||
className={`server-command-page__card${isSharedManageMode ? ' server-command-page__card--shared' : ''}`}
|
||||
bordered={false}
|
||||
>
|
||||
<Space direction="vertical" size={8}>
|
||||
<Title level={4} className="server-command-page__title">
|
||||
Server Command
|
||||
{isSharedManageMode ? '서버관리' : 'Server Command'}
|
||||
</Title>
|
||||
<Paragraph className="server-command-page__copy">
|
||||
TEST, REL, PROD, 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>
|
||||
{isSharedManageMode ? (
|
||||
<div className="server-command-page__shared-toolbar">
|
||||
<Space wrap size={[8, 8]} className="server-command-page__shared-toolbar-chips">
|
||||
<span className="server-command-page__toolbar-chip">
|
||||
<span className="server-command-page__toolbar-chip-value">{summary.total}</span>
|
||||
<span className="server-command-page__toolbar-chip-label">ALL</span>
|
||||
</span>
|
||||
<span className="server-command-page__toolbar-chip server-command-page__toolbar-chip--online">
|
||||
<CheckCircleFilled />
|
||||
<span className="server-command-page__toolbar-chip-value">{summary.online}</span>
|
||||
</span>
|
||||
<span className="server-command-page__toolbar-chip server-command-page__toolbar-chip--degraded">
|
||||
<ExclamationCircleFilled />
|
||||
<span className="server-command-page__toolbar-chip-value">{summary.degraded}</span>
|
||||
</span>
|
||||
<span className="server-command-page__toolbar-chip server-command-page__toolbar-chip--offline">
|
||||
<CloseCircleFilled />
|
||||
<span className="server-command-page__toolbar-chip-value">{summary.offline}</span>
|
||||
</span>
|
||||
{sharedReservationCard ? (
|
||||
<span
|
||||
className={`server-command-page__toolbar-chip server-command-page__toolbar-chip--${sharedReservationCard.tone}`}
|
||||
>
|
||||
{sharedReservationCard.icon}
|
||||
<span className="server-command-page__toolbar-chip-value">{sharedReservationCard.value}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Paragraph className="server-command-page__copy">
|
||||
TEST, REL, PROD, 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 />}
|
||||
@@ -490,7 +801,7 @@ export function ServerCommandPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{reservation && (reservation.enabled || reservation.status !== 'idle' || reservation.autoFix.enabled) ? (
|
||||
{reservation && !isSharedManageMode && (reservation.enabled || reservation.status !== 'idle' || reservation.autoFix.enabled) ? (
|
||||
<Card className="server-command-page__card server-command-page__reservation-card" bordered={false}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Space size={8} wrap>
|
||||
@@ -503,37 +814,44 @@ export function ServerCommandPage() {
|
||||
<Paragraph className="server-command-page__summary">
|
||||
{reservation.waitingReason?.trim()
|
||||
|| (reservation.status === 'completed'
|
||||
? '예약된 TEST / WORK 서버 재기동이 완료되었습니다.'
|
||||
? `예약된 ${getReservationTargetLabel(reservation.target)} 재기동이 완료되었습니다.`
|
||||
: '예약 상태를 확인했습니다.')}
|
||||
</Paragraph>
|
||||
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={1}
|
||||
className="server-command-page__meta"
|
||||
items={[
|
||||
{
|
||||
key: 'requested-at',
|
||||
label: '요청시각',
|
||||
children: formatDateTime(reservation.requestedAt),
|
||||
},
|
||||
{
|
||||
key: 'auto-execute-at',
|
||||
label: '자동실행',
|
||||
children: formatDateTime(reservation.autoExecuteAt),
|
||||
},
|
||||
{
|
||||
key: 'updated-at',
|
||||
label: '마지막 갱신',
|
||||
children: formatDateTime(reservation.updatedAt),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{(
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={1}
|
||||
className="server-command-page__meta"
|
||||
items={[
|
||||
{
|
||||
key: 'target',
|
||||
label: '대상',
|
||||
children: getReservationTargetLabel(reservation.target),
|
||||
},
|
||||
{
|
||||
key: 'requested-at',
|
||||
label: '요청시각',
|
||||
children: formatDateTime(reservation.requestedAt),
|
||||
},
|
||||
{
|
||||
key: 'auto-execute-at',
|
||||
label: '자동실행',
|
||||
children: formatDateTime(reservation.autoExecuteAt),
|
||||
},
|
||||
{
|
||||
key: 'updated-at',
|
||||
label: '마지막 갱신',
|
||||
children: formatDateTime(reservation.updatedAt),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{reservation.status === 'executing' ? (
|
||||
<Space direction="vertical" size={8} className="server-command-page__work-list">
|
||||
<Text strong>예약 실행 단계</Text>
|
||||
{buildReservationExecutionSteps(reservation.executionPhase).map((step) => (
|
||||
{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'}>
|
||||
@@ -626,6 +944,96 @@ export function ServerCommandPage() {
|
||||
<Card className="server-command-page__card" bordered={false}>
|
||||
<Empty description="표시할 서버가 없습니다." />
|
||||
</Card>
|
||||
) : isSharedManageMode ? (
|
||||
<div className="server-command-page__shared-server-grid">
|
||||
{sharedItems.map((item) => {
|
||||
const availabilityTone = resolveAvailabilityTone(item);
|
||||
const versionTone = resolveVersionTone(item);
|
||||
const lastAction = lastActionByKey[item.key];
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.key}
|
||||
className={`server-command-page__server-card server-command-page__server-card--shared server-command-page__server-card--shared-compact server-command-page__server-card--${availabilityTone}`}
|
||||
bordered={false}
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<div className="server-command-page__shared-server-head">
|
||||
<Space size={8} wrap className="server-command-page__title-row">
|
||||
<span
|
||||
className={`server-command-page__status-dot server-command-page__status-dot--${availabilityTone}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Title level={5} className="server-command-page__server-title">
|
||||
{item.label}
|
||||
</Title>
|
||||
</Space>
|
||||
<Button
|
||||
className="server-command-page__restart-button server-command-page__restart-button--shared-compact"
|
||||
type={item.key === 'work-server' ? 'primary' : 'default'}
|
||||
icon={<ReloadOutlined />}
|
||||
loading={restartingKey === item.key}
|
||||
onClick={() => {
|
||||
void handleRestart(item.key);
|
||||
}}
|
||||
>
|
||||
{resolveRestartButtonLabel(item)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="server-command-page__shared-server-meta">
|
||||
<span
|
||||
className={`server-command-page__shared-pill server-command-page__shared-pill--${availabilityTone}`}
|
||||
title={`상태 ${item.availability}`}
|
||||
>
|
||||
{resolveAvailabilityStatusIcon(availabilityTone)}
|
||||
</span>
|
||||
<span
|
||||
className={`server-command-page__shared-pill server-command-page__shared-pill--${versionTone}`}
|
||||
title={`버전 ${resolveVersionLabel(item)}`}
|
||||
>
|
||||
{resolveVersionStatusIcon(versionTone)}
|
||||
</span>
|
||||
{item.key === 'work-server' && sharedReservationCard ? (
|
||||
<span
|
||||
className={`server-command-page__shared-pill server-command-page__shared-pill--${sharedReservationCard.tone}`}
|
||||
title={`예약 ${sharedReservationCard.value}`}
|
||||
>
|
||||
{sharedReservationCard.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{item.composeStatus ? <span className="server-command-page__shared-pill">{item.composeStatus}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="server-command-page__shared-server-stats">
|
||||
<div className="server-command-page__shared-stat">
|
||||
<Text type="secondary">MS</Text>
|
||||
<Text strong>{formatResponseTime(item.responseTimeMs)}</Text>
|
||||
</div>
|
||||
<div className="server-command-page__shared-stat">
|
||||
<Text type="secondary">HTTP</Text>
|
||||
<Text strong>{formatStatusCode(item.httpStatus)}</Text>
|
||||
</div>
|
||||
<div className="server-command-page__shared-stat">
|
||||
<Text type="secondary">확인</Text>
|
||||
<Text strong>{formatDateTime(item.checkedAt)}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text type="secondary" className="server-command-page__shared-server-summary">
|
||||
{resolveHostLabel(item.publicUrl ?? item.checkUrl)}
|
||||
</Text>
|
||||
|
||||
{lastAction?.executedAt ? (
|
||||
<Text type="secondary" className="server-command-page__shared-server-footer">
|
||||
{lastAction.restartState === 'accepted' ? '요청' : '완료'} {formatDateTime(lastAction.executedAt)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="server-command-page__grid">
|
||||
{items.map((item) => (
|
||||
@@ -652,7 +1060,7 @@ export function ServerCommandPage() {
|
||||
void handleRestart(item.key);
|
||||
}}
|
||||
>
|
||||
{item.label} 재기동
|
||||
{item.key === 'work-server' ? `${item.label} 무중단 예약` : `${item.label} 재기동`}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -67,7 +67,11 @@ const SERVER_COMMAND_API_FALLBACK_BASE_URL =
|
||||
? resolveServerCommandFallbackBaseUrl()
|
||||
: null;
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit) {
|
||||
type ServerCommandRequestOptions = {
|
||||
shareToken?: string | null;
|
||||
};
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit, options?: ServerCommandRequestOptions) {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init?.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
@@ -100,14 +104,20 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
|
||||
}
|
||||
|
||||
const token = getRegisteredAccessToken();
|
||||
if (!isAllowedRegistrationToken(token)) {
|
||||
throw new ServerCommandApiError('권한 토큰 등록 후에만 Work Server API를 호출할 수 있습니다.', 403);
|
||||
const shareToken = options?.shareToken?.trim() ?? '';
|
||||
|
||||
if (!shareToken && !isAllowedRegistrationToken(token)) {
|
||||
throw new ServerCommandApiError('권한 토큰 등록 또는 허용된 공유채팅 링크에서만 Work Server API를 호출할 수 있습니다.', 403);
|
||||
}
|
||||
|
||||
if (token && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', token);
|
||||
}
|
||||
|
||||
if (shareToken && !headers.has('X-Chat-Share-Token')) {
|
||||
headers.set('X-Chat-Share-Token', shareToken);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
@@ -182,9 +192,9 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
async function request<T>(path: string, init?: RequestInit, options?: ServerCommandRequestOptions): Promise<T> {
|
||||
try {
|
||||
return await requestOnce<T>(SERVER_COMMAND_API_BASE_URL, path, init);
|
||||
return await requestOnce<T>(SERVER_COMMAND_API_BASE_URL, path, init, options);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
SERVER_COMMAND_API_FALLBACK_BASE_URL &&
|
||||
@@ -197,7 +207,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestOnce<T>(SERVER_COMMAND_API_FALLBACK_BASE_URL, path, init);
|
||||
return requestOnce<T>(SERVER_COMMAND_API_FALLBACK_BASE_URL, path, init, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,56 +522,59 @@ function extractServerRestartReservation(response: unknown): ServerRestartReserv
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchServerCommands() {
|
||||
const response = await request<unknown>('/server-commands');
|
||||
export async function fetchServerCommands(options?: ServerCommandRequestOptions) {
|
||||
const response = await request<unknown>('/server-commands', undefined, options);
|
||||
return extractServerCommandItems(response);
|
||||
}
|
||||
|
||||
export async function restartServerCommand(key: ServerCommandKey, options?: { signal?: AbortSignal }) {
|
||||
export async function restartServerCommand(key: ServerCommandKey, options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||
const response = await request<unknown>(`/server-commands/${key}/actions/restart`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
signal: options?.signal,
|
||||
timeoutMs: key === 'test' || key === 'rel' ? 30000 : 12000,
|
||||
});
|
||||
}, { shareToken: options?.shareToken });
|
||||
|
||||
return extractServerCommandActionResult(response);
|
||||
}
|
||||
|
||||
export async function fetchServerRestartReservation(options?: { signal?: AbortSignal }) {
|
||||
export async function fetchServerRestartReservation(options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||
const response = await request<unknown>('/server-commands/restart-reservation', {
|
||||
signal: options?.signal,
|
||||
});
|
||||
}, { shareToken: options?.shareToken });
|
||||
return extractServerRestartReservation(response);
|
||||
}
|
||||
|
||||
export async function scheduleServerRestartReservation(options?: {
|
||||
signal?: AbortSignal;
|
||||
autoExecuteDelaySeconds?: number;
|
||||
target?: 'all' | 'test' | 'work-server';
|
||||
shareToken?: string | null;
|
||||
}) {
|
||||
const response = await request<unknown>('/server-commands/restart-reservation', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
target: options?.target,
|
||||
autoExecuteDelaySeconds: options?.autoExecuteDelaySeconds,
|
||||
}),
|
||||
signal: options?.signal,
|
||||
});
|
||||
}, { shareToken: options?.shareToken });
|
||||
return extractServerRestartReservation(response);
|
||||
}
|
||||
|
||||
export async function cancelServerRestartReservation(options?: { signal?: AbortSignal }) {
|
||||
export async function cancelServerRestartReservation(options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||
const response = await request<unknown>('/server-commands/restart-reservation', {
|
||||
method: 'DELETE',
|
||||
signal: options?.signal,
|
||||
});
|
||||
}, { shareToken: options?.shareToken });
|
||||
return extractServerRestartReservation(response);
|
||||
}
|
||||
|
||||
export async function confirmServerRestartReservation(options?: { signal?: AbortSignal }) {
|
||||
export async function confirmServerRestartReservation(options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||
const response = await request<unknown>('/server-commands/restart-reservation/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
signal: options?.signal,
|
||||
});
|
||||
}, { shareToken: options?.shareToken });
|
||||
return extractServerRestartReservation(response);
|
||||
}
|
||||
|
||||
@@ -48,10 +48,53 @@
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.server-command-page__card--shared {
|
||||
background:
|
||||
linear-gradient(135deg, rgba(15, 23, 42, 0.96) 0%, rgba(30, 41, 59, 0.94) 52%, rgba(37, 99, 235, 0.88) 100%);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.server-command-page__card--shared .server-command-page__title.ant-typography,
|
||||
.server-command-page__card--shared .server-command-page__copy.ant-typography {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.server-command-page__server-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-command-page__server-card--shared {
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.96) 100%);
|
||||
box-shadow:
|
||||
0 20px 44px rgba(15, 23, 42, 0.12),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.server-command-page__server-card--shared-compact {
|
||||
border-radius: 20px;
|
||||
box-shadow:
|
||||
0 12px 28px rgba(15, 23, 42, 0.08),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.server-command-page__server-card--online {
|
||||
border-color: rgba(147, 197, 253, 0.72);
|
||||
}
|
||||
|
||||
.server-command-page__server-card--degraded {
|
||||
border-color: rgba(253, 230, 138, 0.92);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 251, 235, 0.95) 0%, rgba(255, 255, 255, 0.98) 100%);
|
||||
}
|
||||
|
||||
.server-command-page__server-card--offline {
|
||||
border-color: rgba(254, 202, 202, 0.9);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(254, 242, 242, 0.96) 0%, rgba(255, 255, 255, 0.98) 100%);
|
||||
}
|
||||
|
||||
.server-command-page__title.ant-typography,
|
||||
.server-command-page__server-title.ant-typography {
|
||||
margin-bottom: 0;
|
||||
@@ -71,10 +114,177 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-command-page__shared-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.server-command-page__shared-toolbar-chips {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip-label {
|
||||
color: rgba(226, 232, 240, 0.72);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip-value {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip--online,
|
||||
.server-command-page__toolbar-chip--latest {
|
||||
background: rgba(37, 99, 235, 0.2);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip--degraded,
|
||||
.server-command-page__toolbar-chip--update-available {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip--offline,
|
||||
.server-command-page__toolbar-chip--build-required {
|
||||
background: rgba(220, 38, 38, 0.2);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip--unknown,
|
||||
.server-command-page__toolbar-chip--info {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.server-command-page__status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.server-command-page__status-dot--online,
|
||||
.server-command-page__status-dot--latest {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.server-command-page__status-dot--degraded,
|
||||
.server-command-page__status-dot--update-available {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.server-command-page__status-dot--offline,
|
||||
.server-command-page__status-dot--build-required {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.server-command-page__status-dot--unknown {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.server-command-page__summary-grid {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.server-command-page__shared-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(226, 232, 240, 0.7);
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.server-command-page__shared-pill--online,
|
||||
.server-command-page__shared-pill--latest {
|
||||
background: rgba(219, 234, 254, 0.95);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.server-command-page__shared-pill--degraded,
|
||||
.server-command-page__shared-pill--update-available {
|
||||
background: rgba(254, 240, 138, 0.55);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.server-command-page__shared-pill--offline,
|
||||
.server-command-page__shared-pill--build-required {
|
||||
background: rgba(254, 226, 226, 0.92);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.server-command-page__shared-stat {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-summary.ant-typography,
|
||||
.server-command-page__shared-server-footer.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-summary.ant-typography {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.server-command-page__restart-button--shared-compact {
|
||||
min-width: 88px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.server-command-page__summary-grid .ant-statistic {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
@@ -131,7 +341,36 @@
|
||||
width: 104px;
|
||||
}
|
||||
|
||||
.server-command-page__restart-button--shared {
|
||||
min-width: 180px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.server-command-page__shared-server-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.server-command-page__shared-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.server-command-page__restart-button--shared {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__restart-button--shared-compact {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__server-card .ant-card-head {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user