feat: refresh shared chat and server workflows

This commit is contained in:
2026-05-26 12:26:33 +09:00
parent 51e0099bea
commit c1d0f4c1db
82 changed files with 18604 additions and 12461 deletions

View File

@@ -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>
}
>