feat: update codex live chat workflow
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user