feat: update codex live chat workflow

This commit is contained in:
2026-04-22 20:00:38 +09:00
parent 9e4b70f1f1
commit b0b9980a6c
70 changed files with 5178 additions and 2401 deletions

View File

@@ -3,7 +3,6 @@ import {
BellOutlined,
ClockCircleOutlined,
CopyOutlined,
DownloadOutlined,
FileMarkdownOutlined,
LoadingOutlined,
MenuFoldOutlined,
@@ -23,13 +22,12 @@ import {
InputNumber,
Layout,
Modal,
Progress,
Select,
Segmented,
Space,
Typography,
} from 'antd';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { fetchPlanItems } from '../../features/planBoard/api';
import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters';
@@ -45,7 +43,6 @@ import {
type AppConfig,
type PlanCostTimeUnit,
} from './appConfig';
import { applyAppUpdate, getAppUpdateSnapshot, subscribeAppUpdate, type AppUpdateStatus } from './appUpdate';
import {
fetchWebPushConfig,
registerPwaNotificationToken,
@@ -60,6 +57,7 @@ import {
getSavedPwaNotificationToken,
setSavedPwaNotificationToken,
} from './notificationIdentity';
import { resetNonAuthClientState } from './appMaintenance';
import {
ALLOWED_REGISTRATION_TOKEN,
setRegisteredAccessToken,
@@ -465,19 +463,6 @@ function getSettingsModalTitle(modal: SettingsModalKey) {
}
}
function getAppUpdateStatusLabel(status: AppUpdateStatus) {
switch (status) {
case 'available':
return '업데이트 가능';
case 'updating':
return '업데이트 중';
case 'ready':
return '최신 상태';
default:
return '확인 중';
}
}
function formatDateTimeLabel(value: string | null) {
if (!value) {
return '-';
@@ -490,29 +475,36 @@ function formatDateTimeLabel(value: string | null) {
}
return new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
dateStyle: 'medium',
timeStyle: 'short',
}).format(parsed);
}
function getWorkServerUpdateStatusLabel(item: ServerCommandItem | null) {
function getServerVersionStatusClassName(item: ServerCommandItem | null) {
if (!item) {
return '확인 전';
return 'app-header__server-version-indicator--stale';
}
if (item.buildRequired) {
return '소스 변경 감지됨';
return item.buildRequired || item.updateAvailable
? 'app-header__server-version-indicator--stale'
: 'app-header__server-version-indicator--latest';
}
function getServerLastSourceChangedDateLabel(item: ServerCommandItem | null) {
return formatDateTimeLabel(item?.latestSourceChangeAt ?? null);
}
function getServerVersionStatusTitle(item: ServerCommandItem | null, label: string) {
if (!item) {
return `${label} 최신 버전 확인 전`;
}
if (item.updateAvailable) {
return '새 빌드 대기 중';
if (item.buildRequired || item.updateAvailable) {
return `${label} 최신 버전 아님`;
}
if (item.availability === 'online') {
return '최신 빌드 실행 중';
}
return '상태 확인 필요';
return `${label} 최신 버전`;
}
function getClientNotificationPermission(): ClientNotificationPermissionState {
@@ -900,14 +892,6 @@ export function MainHeader({
);
const [webPushConfigured, setWebPushConfigured] = useState(false);
const [isStandaloneMode, setIsStandaloneMode] = useState(false);
const [appUpdateStatus, setAppUpdateStatus] = useState<AppUpdateStatus>(() => getAppUpdateSnapshot().status);
const [appUpdateSupported, setAppUpdateSupported] = useState<boolean>(() => getAppUpdateSnapshot().supported);
const [appUpdateProgressPercent, setAppUpdateProgressPercent] = useState<number | null>(
() => getAppUpdateSnapshot().progressPercent,
);
const [appUpdateCurrentTaskLabel, setAppUpdateCurrentTaskLabel] = useState<string | null>(
() => getAppUpdateSnapshot().currentTaskLabel,
);
const [chatConnection, setChatConnection] = useState(() => chatConnectionGateway.getSnapshot());
const [chatRuntimeSnapshot, setChatRuntimeSnapshot] = useState<ChatRuntimeSnapshot | null>(() =>
chatConnectionGateway.getSharedRuntimeSnapshot(),
@@ -921,14 +905,17 @@ export function MainHeader({
const [runtimeLogLoading, setRuntimeLogLoading] = useState(false);
const [runtimeLogError, setRuntimeLogError] = useState('');
const [runtimeLogDetail, setRuntimeLogDetail] = useState<ChatRuntimeJobDetail | null>(null);
const [appUpdateFeedback, setAppUpdateFeedback] = useState<InlineFeedback | null>(null);
const [appUpdateCopyFeedback, setAppUpdateCopyFeedback] = useState<InlineFeedback | null>(null);
const previousAppUpdateStatusRef = useRef<AppUpdateStatus>(getAppUpdateSnapshot().status);
const [updateCheckFeedback, setUpdateCheckFeedback] = useState<InlineFeedback | null>(null);
const [updateCheckCopyFeedback, setUpdateCheckCopyFeedback] = useState<InlineFeedback | null>(null);
const [clientResetting, setClientResetting] = useState(false);
const [clientResetFeedback, setClientResetFeedback] = useState<InlineFeedback | null>(null);
const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState<InlineFeedback | null>(null);
const [testServerStatus, setTestServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
const [workServerRestarting, setWorkServerRestarting] = useState(false);
const [workServerUpdateFeedback, setWorkServerUpdateFeedback] = useState<InlineFeedback | null>(null);
const [workServerUpdateCopyFeedback, setWorkServerUpdateCopyFeedback] = useState<InlineFeedback | null>(null);
const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'work-server' | 'all' | null>(null);
const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null);
const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null);
const { registeredToken, hasAccess } = useTokenAccess();
const appConfig = useAppConfig();
const [appConfigDraft, setAppConfigDraft] = useState<AppConfig>(appConfig);
@@ -946,22 +933,17 @@ export function MainHeader({
const notificationStatusClassName = notificationEnabled
? 'app-header__status-dot--active'
: 'app-header__status-dot--inactive';
const appUpdateStatusClassName =
appUpdateStatus === 'available'
? 'app-header__status-dot--warning'
: appUpdateStatus === 'updating'
? 'app-header__status-dot--progress'
: 'app-header__status-dot--active';
const chatConnectionStatusClassName =
chatConnection.connectionState === 'connected'
? 'app-header__status-dot--active'
: chatConnection.connectionState === 'connecting'
? 'app-header__status-dot--progress'
: 'app-header__status-dot--inactive';
const appPendingUpdateCount = appUpdateStatus === 'available' ? 1 : 0;
const testServerPendingUpdateCount =
testServerStatus && (testServerStatus.updateAvailable || testServerStatus.buildRequired) ? 1 : 0;
const workServerPendingUpdateCount =
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
const totalPendingUpdateCount = appPendingUpdateCount + workServerPendingUpdateCount;
const totalPendingUpdateCount = testServerPendingUpdateCount + workServerPendingUpdateCount;
const settingsStatusClassName =
totalPendingUpdateCount >= 2
? 'app-header__status-dot--inactive'
@@ -1029,12 +1011,12 @@ export function MainHeader({
setRuntimeLogLoading(false);
}
};
const canApplyAppUpdate = appUpdateSupported && appUpdateStatus === 'available';
const canRefreshWorkServerStatus = hasAccess && !workServerRestarting && !workServerStatusLoading;
const canApplyWorkServerUpdate =
const canRefreshWorkServerStatus = hasAccess && !workServerStatusLoading && !serverRestartingKey;
const canResetClientState = !clientResetting;
const canRestartServers =
hasAccess &&
!workServerRestarting &&
!workServerStatusLoading;
!workServerStatusLoading &&
!serverRestartingKey;
const chatSettingsDirty = !areChatSettingsEqual(appConfig.chat, appConfigDraft.chat);
const worklogAutomationSettingsDirty = !areWorklogAutomationSettingsEqual(
appConfig.worklogAutomation,
@@ -1194,32 +1176,6 @@ export function MainHeader({
};
}, [isRuntimeModalOpen]);
useEffect(() => {
return subscribeAppUpdate((nextSnapshot) => {
setAppUpdateSupported(nextSnapshot.supported);
setAppUpdateStatus(nextSnapshot.status);
setAppUpdateProgressPercent(nextSnapshot.progressPercent);
setAppUpdateCurrentTaskLabel(nextSnapshot.currentTaskLabel);
});
}, []);
useEffect(() => {
const previousStatus = previousAppUpdateStatusRef.current;
if (appUpdateStatus === 'available' && previousStatus !== 'available') {
setAppUpdateFeedback({
tone: 'info',
message: import.meta.env.DEV
? '개발 서버 변경 사항이 준비되었습니다. 설정 > 업데이트에서 앱 업데이트 적용을 누르면 반영됩니다.'
: '새 앱 버전이 준비되었습니다. 설정 > 업데이트에서 앱 업데이트 적용을 누르세요.',
});
} else if (appUpdateStatus !== 'available' && previousStatus === 'available') {
setAppUpdateFeedback(null);
}
previousAppUpdateStatusRef.current = appUpdateStatus;
}, [appUpdateStatus]);
useEffect(() => {
setTokenInput(registeredToken);
}, [registeredToken]);
@@ -1248,11 +1204,12 @@ export function MainHeader({
useEffect(() => {
if (!hasAccess) {
setTestServerStatus(null);
setWorkServerStatus(null);
return;
}
void refreshWorkServerStatus(true);
void refreshUpdateTargets(true);
}, [hasAccess]);
useEffect(() => {
@@ -1260,7 +1217,7 @@ export function MainHeader({
return;
}
void refreshWorkServerStatus(true);
void refreshUpdateTargets(true);
}, [activeSettingsModal, hasAccess, settingsModalOpen]);
const ensureClientNotificationPermission = async () => {
@@ -1318,27 +1275,27 @@ export function MainHeader({
};
const syncNotificationEnabled = async (nextEnabled: boolean) => {
if (notificationLoading) {
return;
}
const previousEnabled = notificationEnabled;
setNotificationCopyFeedback(null);
setNotificationLoading(true);
setNotificationEnabled(nextEnabled);
if (nextEnabled) {
setNotificationFeedback({ tone: 'info', message: '알림 권한과 Web Push 등록 상태를 확인하는 중입니다.' });
const permissionGranted = await ensureClientNotificationPermission();
if (!permissionGranted) {
setNotificationEnabled(false);
setNotificationLoading(false);
return;
}
}
setNotificationLoading(true);
try {
const config = await fetchWebPushConfig();
setWebPushConfigured(Boolean(config.enabled && config.publicKey));
if (!config.enabled || !config.publicKey) {
throw new Error('서버 Web Push 설정이 비어 있습니다. VAPID 설정을 먼저 확인해 주세요.');
}
let registration = await getPushServiceWorkerRegistration();
if (!registration) {
@@ -1407,6 +1364,13 @@ export function MainHeader({
}
if (nextEnabled) {
const config = await fetchWebPushConfig();
setWebPushConfigured(Boolean(config.enabled && config.publicKey));
if (!config.enabled || !config.publicKey) {
throw new Error('서버 Web Push 설정이 비어 있습니다. VAPID 설정을 먼저 확인해 주세요.');
}
let subscription = await registration.pushManager.getSubscription();
const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey);
@@ -1442,7 +1406,7 @@ export function MainHeader({
setNotificationEnabled(false);
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 해제했습니다.' });
} catch (error) {
setNotificationEnabled(!nextEnabled);
setNotificationEnabled(previousEnabled);
setNotificationFeedback({
tone: 'error',
message: error instanceof Error ? error.message : '알림 설정 저장에 실패했습니다.',
@@ -1515,40 +1479,24 @@ export function MainHeader({
}
};
const handleApplyAppUpdate = async () => {
setAppUpdateCopyFeedback(null);
if (!appUpdateSupported) {
setAppUpdateFeedback({ tone: 'warning', message: '현재 환경에서는 앱 업데이트를 지원하지 않습니다.' });
return;
}
if (appUpdateStatus === 'updating') {
return;
}
try {
const applied = await applyAppUpdate();
if (!applied) {
setAppUpdateFeedback({ tone: 'info', message: '현재 적용할 앱 업데이트가 없습니다.' });
return;
}
setAppUpdateFeedback({ tone: 'success', message: '앱 업데이트를 적용합니다.' });
} catch (error) {
setAppUpdateFeedback({
tone: 'error',
message: error instanceof Error ? error.message : '앱 업데이트 적용에 실패했습니다.',
});
}
const refreshServerStatuses = async () => {
const items = await fetchServerCommands();
const nextTestServerStatus = items.find((item) => item.key === 'test') ?? null;
const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null;
setTestServerStatus(nextTestServerStatus);
setWorkServerStatus(nextWorkServerStatus);
return {
test: nextTestServerStatus,
'work-server': nextWorkServerStatus,
} satisfies Record<'test' | 'work-server', ServerCommandItem | null>;
};
const refreshWorkServerStatus = async (silent = false) => {
const refreshUpdateTargets = async (silent = false) => {
if (!hasAccess) {
setTestServerStatus(null);
setWorkServerStatus(null);
if (!silent) {
setWorkServerUpdateFeedback({ tone: 'warning', message: '워크서버 업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' });
setUpdateCheckFeedback({ tone: 'warning', message: '업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' });
}
return null;
}
@@ -1556,20 +1504,18 @@ export function MainHeader({
setWorkServerStatusLoading(true);
try {
const items = await fetchServerCommands();
const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null;
setWorkServerStatus(nextWorkServerStatus);
const nextStatuses = await refreshServerStatuses();
if (!silent) {
setWorkServerUpdateFeedback(null);
setUpdateCheckFeedback(null);
}
return nextWorkServerStatus;
return nextStatuses;
} catch (error) {
if (!silent) {
setWorkServerUpdateFeedback({
setUpdateCheckFeedback({
tone: 'error',
message: error instanceof Error ? error.message : '워크서버 업데이트 상태를 불러오지 못했습니다.',
message: error instanceof Error ? error.message : 'TEST/WORK 서버 업데이트 상태를 불러오지 못했습니다.',
});
}
return null;
@@ -1578,94 +1524,165 @@ export function MainHeader({
}
};
const waitForWorkServerRestart = async () => {
for (let attempt = 0; attempt < 12; attempt += 1) {
const waitForServerRestart = async (key: 'test' | 'work-server', baseline: ServerCommandItem | null) => {
for (let attempt = 0; attempt < 16; attempt += 1) {
await waitForDuration(2500);
try {
const nextStatus = await refreshWorkServerStatus(true);
const nextStatuses = await refreshServerStatuses();
const nextStatus = nextStatuses[key];
if (!nextStatus) {
continue;
}
if (nextStatus.availability === 'online' && !nextStatus.updateAvailable && !nextStatus.buildRequired) {
setWorkServerUpdateFeedback({
tone: 'success',
message: '워크서버가 재시작되었고 최신 빌드가 적용되었습니다.',
});
return;
}
const restarted =
baseline == null ||
nextStatus.startedAt !== baseline.startedAt ||
nextStatus.checkedAt !== baseline.checkedAt;
if (nextStatus.availability === 'online' && nextStatus.buildRequired) {
setWorkServerUpdateFeedback({
tone: 'info',
message:
nextStatus.updateSummary ?? '워크서버는 재시작되었지만 최신 소스 기준으로 다시 빌드가 필요합니다.',
});
return;
if (nextStatus.availability === 'online' && restarted) {
return { ok: true, item: nextStatus };
}
} catch {
// 서버시작 중이면 일시적으로 실패할 수 있어 다음 주기까지 기다립니다.
// 서버 재기동 중에는 일시적으로 조회가 실패할 수 있니다.
}
}
setWorkServerUpdateFeedback({
tone: 'info',
message: '워크서버 재시작 요청은 접수했습니다. 잠시 후 업데이트 상태를 다시 확인해 주세요.',
});
return { ok: false, item: key === 'test' ? testServerStatus : workServerStatus };
};
const handleApplyWorkServerUpdate = async () => {
setWorkServerUpdateCopyFeedback(null);
if (!hasAccess) {
setWorkServerUpdateFeedback({ tone: 'warning', message: '워크서버 업데이트 적용은 권한 토큰 등록 후 사용할 수 있습니다.' });
const handleResetClientState = async () => {
if (clientResetting) {
return;
}
if (workServerRestarting || workServerStatusLoading) {
return;
}
let nextStatus = workServerStatus;
if (!nextStatus || (!nextStatus.updateAvailable && !nextStatus.buildRequired)) {
nextStatus = await refreshWorkServerStatus(true);
setWorkServerStatus(nextStatus);
}
if (!nextStatus) {
setWorkServerUpdateFeedback({ tone: 'warning', message: '워크서버 상태를 먼저 확인해 주세요.' });
return;
}
if (!nextStatus.updateAvailable && !nextStatus.buildRequired) {
setWorkServerUpdateFeedback({ tone: 'info', message: '현재 적용할 Work 서버 업데이트가 없습니다.' });
return;
}
setWorkServerUpdateFeedback(null);
setWorkServerRestarting(true);
setClientResetCopyFeedback(null);
setClientResetFeedback(null);
setClientResetting(true);
try {
const result = await restartServerCommand('work-server');
setWorkServerStatus(result.item);
setWorkServerUpdateFeedback({
const result = await resetNonAuthClientState();
const changedCount =
result.removedLocalStorageKeys.length +
result.removedSessionStorageKeys.length +
result.removedCacheKeys.length +
result.unregisteredServiceWorkerCount;
setClientResetFeedback({
tone: 'success',
message:
result.restartState === 'accepted'
? '워크서버 재시작 요청을 접수했습니다. 최신 빌드 적용 여부를 확인하는 중입니다.'
: '워크서버를 다시 시작했습니다. 최신 빌드 적용 여부를 확인하는 중입니다.',
changedCount > 0
? `토큰/권한 정보는 유지하고 캐시·스토리지를 초기화했습니다. 변경 ${changedCount}건을 정리한 뒤 화면을 새로고침합니다.`
: '토큰/권한 정보는 유지하고 초기화 대상 캐시·스토리지를 확인했으며, 화면을 새로고침합니다.',
});
await waitForWorkServerRestart();
window.setTimeout(() => {
window.location.replace(window.location.href);
}, 700);
} catch (error) {
setWorkServerUpdateFeedback({
setClientResetFeedback({
tone: 'error',
message: error instanceof Error ? error.message : '워크서버 재시작에 실패했습니다.',
message: error instanceof Error ? error.message : '캐시·스토리지 초기화에 실패했습니다.',
});
} finally {
setWorkServerRestarting(false);
setClientResetting(false);
}
};
const restartServerWithVerification = async (key: 'test' | 'work-server', busyKey: 'test' | 'work-server' | 'all') => {
const baseline = key === 'test' ? testServerStatus : workServerStatus;
const targetLabel = key === 'test' ? 'TEST 서버' : 'WORK 서버';
const result = await restartServerCommand(key);
if (key === 'test') {
setTestServerStatus(result.item);
} else {
setWorkServerStatus(result.item);
}
setServerRestartFeedback({
tone: 'info',
message:
result.restartState === 'accepted'
? `${targetLabel} 재기동 요청을 접수했습니다. 실제 정상 응답 여부를 확인하는 중입니다.`
: `${targetLabel} 재기동을 실행했습니다. 실제 정상 응답 여부를 확인하는 중입니다.`,
});
setServerRestartingKey(busyKey);
const verified = await waitForServerRestart(key, baseline);
if (!verified.ok || !verified.item || verified.item.availability !== 'online') {
setServerRestartFeedback({
tone: 'error',
message: `${targetLabel} 재기동 후 정상 응답을 확인하지 못했습니다. 상태를 다시 확인해 주세요.`,
});
return false;
}
setServerRestartFeedback({
tone: 'success',
message: `${targetLabel} 재기동 성공을 확인했습니다. 확인 시각 ${formatDateTimeLabel(verified.item.checkedAt)}`,
});
return true;
};
const handleRestartSingleServer = async (key: 'test' | 'work-server') => {
if (!hasAccess || serverRestartingKey) {
return false;
}
setServerRestartCopyFeedback(null);
setServerRestartFeedback(null);
setServerRestartingKey(key);
try {
return await restartServerWithVerification(key, key);
} catch (error) {
const targetLabel = key === 'test' ? 'TEST 서버' : 'WORK 서버';
setServerRestartFeedback({
tone: 'error',
message: error instanceof Error ? error.message : `${targetLabel} 재기동에 실패했습니다.`,
});
return false;
} finally {
setServerRestartingKey(null);
}
};
const handleRestartBothServers = async () => {
if (!hasAccess || serverRestartingKey) {
return;
}
setServerRestartCopyFeedback(null);
setServerRestartFeedback(null);
setServerRestartingKey('all');
try {
const testOk = await restartServerWithVerification('test', 'all');
if (!testOk) {
return;
}
const workServerOk = await restartServerWithVerification('work-server', 'all');
if (!workServerOk) {
return;
}
setServerRestartFeedback({
tone: 'success',
message: 'TEST 서버와 WORK 서버 모두 재기동 성공을 확인했습니다.',
});
} catch (error) {
setServerRestartFeedback({
tone: 'error',
message: error instanceof Error ? error.message : '서버 재기동에 실패했습니다.',
});
} finally {
setServerRestartingKey(null);
}
};
@@ -2691,11 +2708,8 @@ export function MainHeader({
}}
>
<span className="app-header__settings-icon">
{appUpdateStatus === 'updating' ? <ReloadOutlined spin /> : <DownloadOutlined />}
<span
className={`app-header__status-dot ${appUpdateStatusClassName}`}
aria-hidden="true"
/>
<ReloadOutlined />
<span className={`app-header__status-dot ${settingsStatusClassName}`} aria-hidden="true" />
</span>
<span className="app-header__settings-label"></span>
</button>
@@ -2724,9 +2738,6 @@ export function MainHeader({
}
onChange={(value) => {
onChangeTopMenu(value as 'docs' | 'plans');
if (isMobileViewport && sidebarCollapsed) {
onToggleSidebar();
}
}}
/>
</Space>
@@ -3116,110 +3127,111 @@ export function MainHeader({
) : null}
{activeSettingsModal === 'update' ? (
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong> </Text>
<Text type="secondary"> : {appUpdateSupported ? getAppUpdateStatusLabel(appUpdateStatus) : '미지원'}</Text>
{import.meta.env.DEV ? (
<Text type="secondary">
.
</Text>
) : null}
{renderFeedback(appUpdateFeedback, appUpdateCopyFeedback, setAppUpdateCopyFeedback)}
{appUpdateStatus === 'updating' ? (
<div className="app-header__update-progress" role="status" aria-live="polite">
<div className="app-header__update-progress-copy">
<Text strong> </Text>
<Text type="secondary">
{appUpdateCurrentTaskLabel ?? '새 버전을 내려받고 적용하는 중입니다. 잠시만 기다려 주세요.'}
</Text>
</div>
<div className="app-header__update-progress-task">
<Text type="secondary"> </Text>
<Text>{appUpdateCurrentTaskLabel ?? '새 버전 반영 준비'}</Text>
</div>
<Progress
percent={appUpdateProgressPercent ?? 0}
size="small"
showInfo={false}
status="active"
strokeColor="#2563eb"
trailColor="rgba(37, 99, 235, 0.12)"
/>
</div>
) : null}
<Text strong> </Text>
<Text type="secondary">
<span
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(testServerStatus)}`}
aria-label={getServerVersionStatusTitle(testServerStatus, '테스트')}
title={getServerVersionStatusTitle(testServerStatus, '테스트')}
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
aria-hidden="true"
/>
</Text>
<Text type="secondary">
: {getServerLastSourceChangedDateLabel(testServerStatus)}
</Text>
<Text type="secondary">
<span
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(workServerStatus)}`}
aria-label={getServerVersionStatusTitle(workServerStatus, '워크')}
title={getServerVersionStatusTitle(workServerStatus, '워크')}
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
aria-hidden="true"
/>
</Text>
<Text type="secondary">
: {getServerLastSourceChangedDateLabel(workServerStatus)}
</Text>
{renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)}
<Button
block
icon={appUpdateStatus === 'updating' ? <ReloadOutlined spin /> : <DownloadOutlined />}
disabled={!canApplyAppUpdate && appUpdateStatus !== 'updating'}
icon={workServerStatusLoading ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={workServerStatusLoading}
disabled={!canRefreshWorkServerStatus}
onClick={() => {
void handleApplyAppUpdate();
void refreshUpdateTargets();
}}
>
{appUpdateStatus === 'updating'
? '업데이트 진행 중'
: appUpdateStatus === 'ready'
? '적용할 앱 업데이트 없음'
: import.meta.env.DEV
? '개발 변경 반영'
: '앱 업데이트 적용'}
</Button>
<Text strong style={{ marginTop: 8 }}>
Work
/
</Text>
<Text type="secondary">
: {getWorkServerUpdateStatusLabel(workServerStatus)}
, , / .
</Text>
{renderFeedback(clientResetFeedback, clientResetCopyFeedback, setClientResetCopyFeedback)}
<Button
block
danger
icon={clientResetting ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={clientResetting}
disabled={!canResetClientState}
onClick={() => {
void handleResetClientState();
}}
>
{clientResetting ? '초기화 진행 중' : '초기화'}
</Button>
<Text strong style={{ marginTop: 8 }}>
</Text>
<Text type="secondary">
: {formatDateTimeLabel(workServerStatus?.runningBuiltAt ?? null)}
: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
</Text>
<Text type="secondary">
: {formatDateTimeLabel(workServerStatus?.latestBuiltAt ?? null)}
: {formatDateTimeLabel(workServerStatus?.checkedAt ?? null)}
</Text>
<Text type="secondary">
: {formatDateTimeLabel(workServerStatus?.latestSourceChangeAt ?? null)}
</Text>
{workServerStatus?.latestSourceChangePath ? (
<Text type="secondary"> : {workServerStatus.latestSourceChangePath}</Text>
) : null}
{!hasAccess ? (
<Text type="warning"> .</Text>
<Text type="warning"> .</Text>
) : null}
{workServerStatus?.updateSummary ? <Text type="secondary">{workServerStatus.updateSummary}</Text> : null}
{renderFeedback(
workServerUpdateFeedback,
workServerUpdateCopyFeedback,
setWorkServerUpdateCopyFeedback,
)}
{renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)}
<Space direction={screens.xs ? 'vertical' : 'horizontal'} style={{ width: '100%' }}>
<Button
block={screens.xs}
icon={workServerStatusLoading ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={workServerStatusLoading}
icon={serverRestartingKey === 'test' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'test'}
disabled={!canRestartServers}
onClick={() => {
void refreshWorkServerStatus();
void handleRestartSingleServer('test');
}}
disabled={!canRefreshWorkServerStatus}
>
</Button>
<Button
block={screens.xs}
icon={serverRestartingKey === 'work-server' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'work-server'}
disabled={!canRestartServers}
onClick={() => {
void handleRestartSingleServer('work-server');
}}
>
</Button>
<Button
type="primary"
block={screens.xs}
icon={workServerRestarting ? <ReloadOutlined spin /> : <DownloadOutlined />}
loading={workServerRestarting}
icon={serverRestartingKey === 'all' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'all'}
disabled={!canRestartServers}
onClick={() => {
void handleApplyWorkServerUpdate();
void handleRestartBothServers();
}}
disabled={!canApplyWorkServerUpdate}
>
{workServerRestarting
? '워크서버 재시작 중'
: workServerStatusLoading
? '상태 확인 중'
: workServerStatus == null
? 'Work 서버 상태 확인 후 적용'
: workServerStatus.updateAvailable || workServerStatus.buildRequired
? 'Work 서버 업데이트 적용'
: '적용할 Work 서버 업데이트 없음'}
</Button>
</Space>
</Space>