3320 lines
121 KiB
TypeScript
Executable File
3320 lines
121 KiB
TypeScript
Executable File
import {
|
|
ApiOutlined,
|
|
BellOutlined,
|
|
ClockCircleOutlined,
|
|
CopyOutlined,
|
|
FileMarkdownOutlined,
|
|
LoadingOutlined,
|
|
MenuFoldOutlined,
|
|
MenuUnfoldOutlined,
|
|
ProfileOutlined,
|
|
ReloadOutlined,
|
|
SettingOutlined,
|
|
} from '@ant-design/icons';
|
|
import {
|
|
Alert,
|
|
Button,
|
|
Checkbox,
|
|
Drawer,
|
|
Dropdown,
|
|
Grid,
|
|
Input,
|
|
InputNumber,
|
|
Layout,
|
|
Modal,
|
|
Select,
|
|
Segmented,
|
|
Space,
|
|
Typography,
|
|
} from 'antd';
|
|
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';
|
|
import { fetchServerCommands, restartServerCommand } from '../../features/serverCommand/api';
|
|
import type { ServerCommandItem } from '../../features/serverCommand/types';
|
|
import {
|
|
DEFAULT_APP_CONFIG,
|
|
saveAutomationNotificationPreferenceToServer,
|
|
saveAppConfigToServer,
|
|
setStoredAppConfig,
|
|
syncAppConfigFromServer,
|
|
useAppConfig,
|
|
type AppConfig,
|
|
type PlanCostTimeUnit,
|
|
} from './appConfig';
|
|
import {
|
|
fetchWebPushConfig,
|
|
registerPwaNotificationToken,
|
|
registerWebPushSubscription,
|
|
unregisterPwaNotificationToken,
|
|
unregisterWebPushSubscription,
|
|
type WebPushSubscriptionPayload,
|
|
} from './notificationApi';
|
|
import {
|
|
clearNotificationIdentity,
|
|
getSavedNotificationDeviceId,
|
|
getSavedPwaNotificationToken,
|
|
setSavedPwaNotificationToken,
|
|
} from './notificationIdentity';
|
|
import { resetNonAuthClientState } from './appMaintenance';
|
|
import {
|
|
ALLOWED_REGISTRATION_TOKEN,
|
|
setRegisteredAccessToken,
|
|
useTokenAccess,
|
|
} from './tokenAccess';
|
|
import { chatConnectionGateway, chatGateway } from './chatV2';
|
|
import { HeaderMessageCenter } from './HeaderMessageCenter';
|
|
import { fetchChatRuntimeJobDetail } from './mainChatPanel';
|
|
import {
|
|
getSharedActiveConversationSnapshot,
|
|
subscribeSharedActiveConversation,
|
|
} from './mainChatPanel/sharedActiveConversation';
|
|
import { buildChatPath } from './routes';
|
|
import type { MainHeaderProps } from './types';
|
|
import type { ChatRuntimeJobDetail, ChatRuntimeSnapshot } from './mainChatPanel/types';
|
|
|
|
const { Header } = Layout;
|
|
const { Paragraph, Text } = Typography;
|
|
const { useBreakpoint } = Grid;
|
|
const APP_SETTINGS_CATEGORIES = [
|
|
{ value: 'automation', label: '작업' },
|
|
{ value: 'workspace', label: '작업 환경' },
|
|
] as const;
|
|
|
|
const APP_SETTINGS_SECTIONS: Array<{
|
|
value: 'chatSettings' | 'planDefaults' | 'planCost' | 'worklogAutomation' | 'automationNotifications' | 'gestureShortcuts';
|
|
label: string;
|
|
category: (typeof APP_SETTINGS_CATEGORIES)[number]['value'];
|
|
}> = [
|
|
{ value: 'chatSettings', label: '채팅 문맥 설정', category: 'workspace' },
|
|
{ value: 'planDefaults', label: '자동화 기본값', category: 'automation' },
|
|
{ value: 'planCost', label: '비용 표시', category: 'automation' },
|
|
{ value: 'worklogAutomation', label: '업무일지 자동화 설정', category: 'automation' },
|
|
{ value: 'automationNotifications', label: '자동화 알림', category: 'automation' },
|
|
{ value: 'gestureShortcuts', label: '제스처 / 단축키', category: 'workspace' },
|
|
];
|
|
|
|
const PLAN_COST_TIME_UNIT_LABELS: Record<PlanCostTimeUnit, string> = {
|
|
hour: '시간',
|
|
minute: '분',
|
|
second: '초',
|
|
};
|
|
|
|
type ClientNotificationPermissionState = 'unsupported' | 'default' | 'granted' | 'denied';
|
|
type SettingsModalKey = 'appSettings' | 'notification' | 'token' | 'update';
|
|
type AppSettingsCategoryKey = (typeof APP_SETTINGS_CATEGORIES)[number]['value'];
|
|
type AppSettingsSectionKey = (typeof APP_SETTINGS_SECTIONS)[number]['value'];
|
|
|
|
let lastPushSwRegisterAttempts: Array<{ url: string; mode: string; error: string }> = [];
|
|
type FeedbackTone = 'success' | 'info' | 'warning' | 'error';
|
|
type InlineFeedback = {
|
|
tone: FeedbackTone;
|
|
message: string;
|
|
};
|
|
|
|
function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat']) {
|
|
return left.maxContextMessages === right.maxContextMessages && left.maxContextChars === right.maxContextChars;
|
|
}
|
|
|
|
function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['chat']) {
|
|
const changedLabels: string[] = [];
|
|
|
|
if (saved.maxContextMessages !== draft.maxContextMessages) {
|
|
changedLabels.push('최근 문맥 메시지 수');
|
|
}
|
|
|
|
if (saved.maxContextChars !== draft.maxContextChars) {
|
|
changedLabels.push('최근 문맥 글자 수');
|
|
}
|
|
|
|
return changedLabels;
|
|
}
|
|
|
|
function areWorklogAutomationSettingsEqual(left: AppConfig['worklogAutomation'], right: AppConfig['worklogAutomation']) {
|
|
return (
|
|
left.autoCreateDailyWorklog === right.autoCreateDailyWorklog &&
|
|
left.dailyCreateTime === right.dailyCreateTime &&
|
|
left.includeScreenshots === right.includeScreenshots &&
|
|
left.includeChangedFiles === right.includeChangedFiles &&
|
|
left.includeCommandLogs === right.includeCommandLogs &&
|
|
left.template === right.template
|
|
);
|
|
}
|
|
|
|
function getWorklogAutomationDiffLabels(saved: AppConfig['worklogAutomation'], draft: AppConfig['worklogAutomation']) {
|
|
const changedLabels: string[] = [];
|
|
|
|
if (saved.autoCreateDailyWorklog !== draft.autoCreateDailyWorklog) {
|
|
changedLabels.push('일일 작업일지 자동 생성');
|
|
}
|
|
|
|
if (saved.dailyCreateTime !== draft.dailyCreateTime) {
|
|
changedLabels.push('자동 생성 시각');
|
|
}
|
|
|
|
if (saved.includeScreenshots !== draft.includeScreenshots) {
|
|
changedLabels.push('스크린샷 포함');
|
|
}
|
|
|
|
if (saved.includeChangedFiles !== draft.includeChangedFiles) {
|
|
changedLabels.push('변경 파일 목록 포함');
|
|
}
|
|
|
|
if (saved.includeCommandLogs !== draft.includeCommandLogs) {
|
|
changedLabels.push('실행 커맨드 포함');
|
|
}
|
|
|
|
if (saved.template !== draft.template) {
|
|
changedLabels.push('기본 템플릿');
|
|
}
|
|
|
|
return changedLabels;
|
|
}
|
|
|
|
function arePlanDefaultSettingsEqual(left: AppConfig['planDefaults'], right: AppConfig['planDefaults']) {
|
|
return (
|
|
left.jangsingProcessingRequired === right.jangsingProcessingRequired &&
|
|
left.autoDeployToMain === right.autoDeployToMain &&
|
|
left.openEditorAfterCreate === right.openEditorAfterCreate
|
|
);
|
|
}
|
|
|
|
function getPlanDefaultDiffLabels(saved: AppConfig['planDefaults'], draft: AppConfig['planDefaults']) {
|
|
const changedLabels: string[] = [];
|
|
|
|
if (saved.jangsingProcessingRequired !== draft.jangsingProcessingRequired) {
|
|
changedLabels.push('기능동작확인 기본값');
|
|
}
|
|
|
|
if (saved.autoDeployToMain !== draft.autoDeployToMain) {
|
|
changedLabels.push('메인까지 자동등록');
|
|
}
|
|
|
|
if (saved.openEditorAfterCreate !== draft.openEditorAfterCreate) {
|
|
changedLabels.push('등록 후 편집기 열기');
|
|
}
|
|
|
|
return changedLabels;
|
|
}
|
|
|
|
function arePlanCostSettingsEqual(left: AppConfig['planCost'], right: AppConfig['planCost']) {
|
|
return (
|
|
left.baseCostPerMillionTokens === right.baseCostPerMillionTokens &&
|
|
left.retryCostMultiplierPercent === right.retryCostMultiplierPercent &&
|
|
left.hourlyCostMultiplierPercent === right.hourlyCostMultiplierPercent &&
|
|
left.timeCostUnit === right.timeCostUnit &&
|
|
left.attentionCostThresholdMultiplier === right.attentionCostThresholdMultiplier &&
|
|
left.warningCostThresholdMultiplier === right.warningCostThresholdMultiplier &&
|
|
left.highCostThresholdMultiplier === right.highCostThresholdMultiplier
|
|
);
|
|
}
|
|
|
|
function getPlanCostDiffLabels(saved: AppConfig['planCost'], draft: AppConfig['planCost']) {
|
|
const changedLabels: string[] = [];
|
|
|
|
if (saved.baseCostPerMillionTokens !== draft.baseCostPerMillionTokens) {
|
|
changedLabels.push('백만 토큰당 기준 비용');
|
|
}
|
|
|
|
if (saved.retryCostMultiplierPercent !== draft.retryCostMultiplierPercent) {
|
|
changedLabels.push('재처리 가산 비율');
|
|
}
|
|
|
|
if (saved.hourlyCostMultiplierPercent !== draft.hourlyCostMultiplierPercent) {
|
|
changedLabels.push('시간 가산 비율');
|
|
}
|
|
|
|
if (saved.timeCostUnit !== draft.timeCostUnit) {
|
|
changedLabels.push('시간 가산 기준 단위');
|
|
}
|
|
|
|
if (saved.attentionCostThresholdMultiplier !== draft.attentionCostThresholdMultiplier) {
|
|
changedLabels.push('관심 구간 기준');
|
|
}
|
|
|
|
if (saved.warningCostThresholdMultiplier !== draft.warningCostThresholdMultiplier) {
|
|
changedLabels.push('주의 구간 기준');
|
|
}
|
|
|
|
if (saved.highCostThresholdMultiplier !== draft.highCostThresholdMultiplier) {
|
|
changedLabels.push('높음 구간 기준');
|
|
}
|
|
|
|
return changedLabels;
|
|
}
|
|
|
|
function formatCostAmount(value: number) {
|
|
return `${new Intl.NumberFormat('ko-KR').format(value)}원`;
|
|
}
|
|
|
|
function formatPlanCostTimeMultiplierLabel(planCost: AppConfig['planCost']) {
|
|
return `${PLAN_COST_TIME_UNIT_LABELS[planCost.timeCostUnit]}당 ${planCost.hourlyCostMultiplierPercent}%`;
|
|
}
|
|
|
|
function getPlanCostThresholdPreview(planCost: AppConfig['planCost']) {
|
|
const attentionThreshold = planCost.baseCostPerMillionTokens * Math.max(0.1, planCost.attentionCostThresholdMultiplier);
|
|
const warningThreshold =
|
|
planCost.baseCostPerMillionTokens *
|
|
Math.max(planCost.attentionCostThresholdMultiplier, planCost.warningCostThresholdMultiplier);
|
|
const highThreshold =
|
|
planCost.baseCostPerMillionTokens * Math.max(planCost.warningCostThresholdMultiplier, planCost.highCostThresholdMultiplier);
|
|
|
|
return `안정 ${formatCostAmount(attentionThreshold)} 이하 · 관심 ${formatCostAmount(
|
|
warningThreshold,
|
|
)} 이하 · 주의 ${formatCostAmount(highThreshold)} 이하 · 높음 ${formatCostAmount(highThreshold)} 초과`;
|
|
}
|
|
|
|
function getAppSettingsSectionCategory(section: AppSettingsSectionKey): AppSettingsCategoryKey {
|
|
return APP_SETTINGS_SECTIONS.find((entry) => entry.value === section)?.category ?? 'automation';
|
|
}
|
|
|
|
function getAppSettingsSectionOptions(category: AppSettingsCategoryKey) {
|
|
return APP_SETTINGS_SECTIONS.filter((entry) => entry.category === category).map((entry) => ({
|
|
label: entry.label,
|
|
value: entry.value,
|
|
}));
|
|
}
|
|
|
|
function formatRuntimeTimestamp(value: string | null | undefined) {
|
|
if (!value) {
|
|
return '-';
|
|
}
|
|
|
|
const date = new Date(value);
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
return value;
|
|
}
|
|
|
|
return date.toLocaleTimeString('ko-KR', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
}
|
|
|
|
function formatRuntimeRelativeLabel(value: string | null | undefined) {
|
|
if (!value) {
|
|
return '-';
|
|
}
|
|
|
|
const date = new Date(value);
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
return formatRuntimeTimestamp(value);
|
|
}
|
|
|
|
const diffMs = Date.now() - date.getTime();
|
|
|
|
if (diffMs <= 15_000) {
|
|
return '방금';
|
|
}
|
|
|
|
const diffSeconds = Math.floor(diffMs / 1000);
|
|
|
|
if (diffSeconds < 60) {
|
|
return `${diffSeconds}초 전`;
|
|
}
|
|
|
|
const diffMinutes = Math.floor(diffSeconds / 60);
|
|
|
|
if (diffMinutes < 60) {
|
|
return `${diffMinutes}분 전`;
|
|
}
|
|
|
|
const diffHours = Math.floor(diffMinutes / 60);
|
|
|
|
if (diffHours < 24) {
|
|
return `${diffHours}시간 전`;
|
|
}
|
|
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
return `${diffDays}일 전`;
|
|
}
|
|
|
|
function buildRuntimeTerminalLabel(terminalStatus: ChatRuntimeJobDetail['terminalStatus']) {
|
|
if (!terminalStatus) {
|
|
return '-';
|
|
}
|
|
|
|
if (terminalStatus === 'cancelled') {
|
|
return '취소됨';
|
|
}
|
|
|
|
if (terminalStatus === 'removed') {
|
|
return '대기열 제거됨';
|
|
}
|
|
|
|
if (terminalStatus === 'failed') {
|
|
return '실패';
|
|
}
|
|
|
|
return '완료';
|
|
}
|
|
|
|
const AUTOMATION_NOTIFICATION_OPTIONS: Array<{
|
|
key: keyof Pick<
|
|
AppConfig['automation'],
|
|
| 'notifyOnAutomationStart'
|
|
| 'notifyOnAutomationProgress'
|
|
| 'notifyOnAutomationCompletion'
|
|
| 'notifyOnAutomationRelease'
|
|
| 'notifyOnAutomationMain'
|
|
| 'notifyOnAutomationFailure'
|
|
| 'notifyOnAutomationRestart'
|
|
| 'notifyOnAutomationIssueResolved'
|
|
>;
|
|
label: string;
|
|
description: string;
|
|
}> = [
|
|
{
|
|
key: 'notifyOnAutomationStart',
|
|
label: '자동화 시작',
|
|
description: '작업 자동화가 시작될 때 알림을 받습니다.',
|
|
},
|
|
{
|
|
key: 'notifyOnAutomationProgress',
|
|
label: '자동화 진행',
|
|
description: '자동 작업이 오래 걸릴 때 진행 알림을 받습니다.',
|
|
},
|
|
{
|
|
key: 'notifyOnAutomationCompletion',
|
|
label: '자동 작업 완료',
|
|
description: '작업완료, 변경 없음 완료, 수동 완료 처리 알림을 받습니다.',
|
|
},
|
|
{
|
|
key: 'notifyOnAutomationRelease',
|
|
label: 'release 반영 완료',
|
|
description: 'release 반영 완료 알림을 받습니다.',
|
|
},
|
|
{
|
|
key: 'notifyOnAutomationMain',
|
|
label: 'main 반영 완료',
|
|
description: 'main 반영과 메인 프로젝트 동기화 완료 알림을 받습니다.',
|
|
},
|
|
{
|
|
key: 'notifyOnAutomationFailure',
|
|
label: '자동화 실패',
|
|
description: '브랜치, 자동 작업, release, main 반영 실패 알림을 받습니다.',
|
|
},
|
|
{
|
|
key: 'notifyOnAutomationRestart',
|
|
label: '작업 재시작',
|
|
description: '재시도나 이슈 조치로 작업이 다시 큐에 들어갈 때 알림을 받습니다.',
|
|
},
|
|
{
|
|
key: 'notifyOnAutomationIssueResolved',
|
|
label: '이슈 해결 처리',
|
|
description: '최신 이슈가 해결 처리될 때 알림을 받습니다.',
|
|
},
|
|
];
|
|
|
|
function areAutomationNotificationSettingsEqual(left: AppConfig['automation'], right: AppConfig['automation']) {
|
|
return AUTOMATION_NOTIFICATION_OPTIONS.every((option) => left[option.key] === right[option.key]);
|
|
}
|
|
|
|
function getAutomationNotificationDiffLabels(saved: AppConfig['automation'], draft: AppConfig['automation']) {
|
|
return AUTOMATION_NOTIFICATION_OPTIONS.filter((option) => saved[option.key] !== draft[option.key]).map(
|
|
(option) => option.label,
|
|
);
|
|
}
|
|
|
|
function areGestureShortcutSettingsEqual(
|
|
left: AppConfig['gestureShortcuts'],
|
|
right: AppConfig['gestureShortcuts'],
|
|
) {
|
|
return left.openSearch === right.openSearch && left.openWindowSearch === right.openWindowSearch;
|
|
}
|
|
|
|
function getGestureShortcutDiffLabels(saved: AppConfig['gestureShortcuts'], draft: AppConfig['gestureShortcuts']) {
|
|
const changedLabels: string[] = [];
|
|
|
|
if (saved.openSearch !== draft.openSearch) {
|
|
changedLabels.push('통합 검색 열기');
|
|
}
|
|
|
|
if (saved.openWindowSearch !== draft.openWindowSearch) {
|
|
changedLabels.push('Window UI 검색 열기');
|
|
}
|
|
|
|
return changedLabels;
|
|
}
|
|
|
|
function isSettingsModalAllowed(modal: SettingsModalKey, hasAccess: boolean) {
|
|
if (hasAccess) {
|
|
return true;
|
|
}
|
|
|
|
return modal === 'token';
|
|
}
|
|
|
|
function getSettingsModalTitle(modal: SettingsModalKey) {
|
|
switch (modal) {
|
|
case 'notification':
|
|
return '알림';
|
|
case 'token':
|
|
return '권한';
|
|
case 'update':
|
|
return '업데이트';
|
|
default:
|
|
return '앱 설정';
|
|
}
|
|
}
|
|
|
|
function formatDateTimeLabel(value: string | null) {
|
|
if (!value) {
|
|
return '-';
|
|
}
|
|
|
|
const parsed = new Date(value);
|
|
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return value;
|
|
}
|
|
|
|
return new Intl.DateTimeFormat('ko-KR', {
|
|
timeZone: 'Asia/Seoul',
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
}).format(parsed);
|
|
}
|
|
|
|
function getServerVersionStatusClassName(item: ServerCommandItem | null) {
|
|
if (!item) {
|
|
return 'app-header__server-version-indicator--unknown';
|
|
}
|
|
|
|
if (item.buildRequired) {
|
|
return 'app-header__server-version-indicator--build-required';
|
|
}
|
|
|
|
if (item.updateAvailable) {
|
|
return 'app-header__server-version-indicator--update-available';
|
|
}
|
|
|
|
return '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.buildRequired) {
|
|
return `${label} 커밋 미반영 상태`;
|
|
}
|
|
|
|
if (item.updateAvailable) {
|
|
return `${label} 운영 반영 대기 상태`;
|
|
}
|
|
|
|
return `${label} 최신 버전`;
|
|
}
|
|
|
|
function getClientNotificationPermission(): ClientNotificationPermissionState {
|
|
if (
|
|
typeof window === 'undefined' ||
|
|
typeof Notification === 'undefined' ||
|
|
typeof navigator === 'undefined' ||
|
|
!('serviceWorker' in navigator) ||
|
|
!('PushManager' in window)
|
|
) {
|
|
return 'unsupported';
|
|
}
|
|
|
|
if (Notification.permission === 'granted') {
|
|
return 'granted';
|
|
}
|
|
|
|
if (Notification.permission === 'denied') {
|
|
return 'denied';
|
|
}
|
|
|
|
return 'default';
|
|
}
|
|
|
|
function getClientNotificationPermissionLabel(permission: ClientNotificationPermissionState) {
|
|
switch (permission) {
|
|
case 'granted':
|
|
return '허용됨';
|
|
case 'denied':
|
|
return '차단됨';
|
|
case 'unsupported':
|
|
return '미지원';
|
|
default:
|
|
return '미확인';
|
|
}
|
|
}
|
|
|
|
function isStandaloneDisplayMode() {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
window.matchMedia?.('(display-mode: standalone)').matches === true ||
|
|
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
|
);
|
|
}
|
|
|
|
function isAppleMobileDevice() {
|
|
if (typeof navigator === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
return /iPhone|iPad|iPod/i.test(navigator.userAgent);
|
|
}
|
|
|
|
function hasSecureOrigin() {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
return window.isSecureContext || window.location.hostname === 'localhost';
|
|
}
|
|
|
|
function urlBase64ToUint8Array(base64String: string) {
|
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
const rawData = window.atob(base64);
|
|
const outputArray = new Uint8Array(rawData.length);
|
|
|
|
for (let index = 0; index < rawData.length; index += 1) {
|
|
outputArray[index] = rawData.charCodeAt(index);
|
|
}
|
|
|
|
return outputArray;
|
|
}
|
|
|
|
function isSamePushApplicationServerKey(
|
|
leftKey: ArrayBuffer | null | undefined,
|
|
rightKey: Uint8Array,
|
|
) {
|
|
if (!leftKey) {
|
|
return false;
|
|
}
|
|
|
|
const leftBytes = new Uint8Array(leftKey);
|
|
|
|
if (leftBytes.byteLength !== rightKey.byteLength) {
|
|
return false;
|
|
}
|
|
|
|
for (let index = 0; index < leftBytes.byteLength; index += 1) {
|
|
if (leftBytes[index] !== rightKey[index]) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function serializePushSubscription(subscription: PushSubscription): WebPushSubscriptionPayload {
|
|
const json = subscription.toJSON();
|
|
|
|
return {
|
|
endpoint: subscription.endpoint,
|
|
expirationTime: subscription.expirationTime,
|
|
keys: {
|
|
p256dh: json.keys?.p256dh ?? '',
|
|
auth: json.keys?.auth ?? '',
|
|
},
|
|
};
|
|
}
|
|
|
|
async function getPushServiceWorkerRegistration() {
|
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
|
return null;
|
|
}
|
|
|
|
const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js';
|
|
const resolvedServiceWorkerUrl =
|
|
typeof window !== 'undefined' ? new URL(serviceWorkerUrl, window.location.href).toString() : serviceWorkerUrl;
|
|
const timeoutMs = 30000;
|
|
const startedAt = Date.now();
|
|
let registeredRegistration: ServiceWorkerRegistration | null = null;
|
|
|
|
const waitForServiceWorkerReady = async (remainingTimeoutMs: number) => {
|
|
const readyStartedAt = Date.now();
|
|
|
|
while (Date.now() - readyStartedAt < remainingTimeoutMs) {
|
|
try {
|
|
const readyRegistration = await Promise.race([
|
|
navigator.serviceWorker.ready,
|
|
new Promise<null>((resolve) => {
|
|
window.setTimeout(() => resolve(null), 1000);
|
|
}),
|
|
]);
|
|
|
|
if (readyRegistration) {
|
|
return readyRegistration;
|
|
}
|
|
} catch {
|
|
// keep polling until timeout
|
|
}
|
|
|
|
await new Promise((resolve) => {
|
|
window.setTimeout(resolve, 300);
|
|
});
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const resolveUsableRegistration = async (registration: ServiceWorkerRegistration | null | undefined) => {
|
|
if (!registration) {
|
|
return null;
|
|
}
|
|
|
|
if (registration.active || registration.waiting) {
|
|
return registration;
|
|
}
|
|
|
|
const installingWorker = registration.installing;
|
|
|
|
if (!installingWorker) {
|
|
return registration;
|
|
}
|
|
|
|
const remainingTimeoutMs = Math.max(1500, timeoutMs - (Date.now() - startedAt));
|
|
|
|
await new Promise<void>((resolve) => {
|
|
let completed = false;
|
|
|
|
const finish = () => {
|
|
if (completed) {
|
|
return;
|
|
}
|
|
|
|
completed = true;
|
|
window.clearTimeout(timeoutId);
|
|
installingWorker.removeEventListener('statechange', onStateChange);
|
|
resolve();
|
|
};
|
|
|
|
const onStateChange = () => {
|
|
if (
|
|
installingWorker.state === 'activated' ||
|
|
installingWorker.state === 'installed' ||
|
|
Boolean(registration.active) ||
|
|
Boolean(registration.waiting)
|
|
) {
|
|
finish();
|
|
}
|
|
};
|
|
|
|
const timeoutId = window.setTimeout(finish, remainingTimeoutMs);
|
|
installingWorker.addEventListener('statechange', onStateChange);
|
|
onStateChange();
|
|
});
|
|
|
|
if (registration.active || registration.waiting) {
|
|
return registration;
|
|
}
|
|
|
|
const readyRegistration = await waitForServiceWorkerReady(remainingTimeoutMs);
|
|
return readyRegistration ?? registration;
|
|
};
|
|
|
|
const tryRegister = async (url: string, mode: string, options?: RegistrationOptions) => {
|
|
try {
|
|
registeredRegistration = await navigator.serviceWorker.register(url, options);
|
|
return true;
|
|
} catch (error) {
|
|
lastPushSwRegisterAttempts = [
|
|
...lastPushSwRegisterAttempts,
|
|
{
|
|
url,
|
|
mode,
|
|
error: error instanceof Error ? error.message : 'unknown error',
|
|
},
|
|
];
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const devOptionsModule = { type: 'module', scope: '/', updateViaCache: 'none' } as const;
|
|
const devOptionsClassic = { scope: '/', updateViaCache: 'none' } as const;
|
|
|
|
if (import.meta.env.DEV) {
|
|
lastPushSwRegisterAttempts = [];
|
|
const registered =
|
|
(await tryRegister(resolvedServiceWorkerUrl, 'module', devOptionsModule)) ||
|
|
(await tryRegister(resolvedServiceWorkerUrl, 'classic', devOptionsClassic));
|
|
|
|
if (!registered) {
|
|
// registerSW helper in main.tsx may already be handling this path; if manual registration fails,
|
|
// continue and try to resolve an existing active registration below.
|
|
}
|
|
} else {
|
|
await tryRegister(resolvedServiceWorkerUrl, 'default');
|
|
}
|
|
|
|
const usableRegisteredRegistration = await resolveUsableRegistration(registeredRegistration);
|
|
|
|
if (usableRegisteredRegistration) {
|
|
return usableRegisteredRegistration;
|
|
}
|
|
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
const existingRegistration = await navigator.serviceWorker.getRegistration();
|
|
const usableExistingRegistration = await resolveUsableRegistration(existingRegistration);
|
|
|
|
if (usableExistingRegistration) {
|
|
return usableExistingRegistration;
|
|
}
|
|
|
|
const scopedRegistration = await navigator.serviceWorker.getRegistration('/');
|
|
const usableScopedRegistration = await resolveUsableRegistration(scopedRegistration);
|
|
|
|
if (usableScopedRegistration) {
|
|
return usableScopedRegistration;
|
|
}
|
|
|
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
|
|
for (const registration of registrations) {
|
|
const usableRegistration = await resolveUsableRegistration(registration);
|
|
|
|
if (usableRegistration) {
|
|
return usableRegistration;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const remainingTimeoutMs = Math.max(1500, timeoutMs - (Date.now() - startedAt));
|
|
const readyRegistration = await waitForServiceWorkerReady(remainingTimeoutMs);
|
|
const usableReadyRegistration = await resolveUsableRegistration(readyRegistration);
|
|
|
|
if (usableReadyRegistration) {
|
|
return usableReadyRegistration;
|
|
}
|
|
} catch {
|
|
// keep polling until timeout
|
|
}
|
|
|
|
await new Promise((resolve) => {
|
|
window.setTimeout(resolve, 250);
|
|
});
|
|
}
|
|
|
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
|
|
for (const registration of registrations) {
|
|
const usableRegistration = await resolveUsableRegistration(registration);
|
|
|
|
if (usableRegistration) {
|
|
return usableRegistration;
|
|
}
|
|
}
|
|
|
|
if (import.meta.env.DEV) {
|
|
try {
|
|
await Promise.all(registrations.map((registration) => registration.unregister()));
|
|
await waitForDuration(300);
|
|
|
|
await tryRegister(resolvedServiceWorkerUrl, 'module', devOptionsModule);
|
|
if (!registeredRegistration) {
|
|
await tryRegister(resolvedServiceWorkerUrl, 'classic', devOptionsClassic);
|
|
}
|
|
|
|
const remainingTimeoutMs = Math.max(1500, timeoutMs - (Date.now() - startedAt));
|
|
const readyRegistration = await waitForServiceWorkerReady(remainingTimeoutMs);
|
|
const usableReadyRegistration = await resolveUsableRegistration(readyRegistration ?? registeredRegistration);
|
|
|
|
if (usableReadyRegistration) {
|
|
return usableReadyRegistration;
|
|
}
|
|
} catch {
|
|
// ignore and fallthrough to null
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function waitForDuration(durationMs: number) {
|
|
return new Promise<void>((resolve) => {
|
|
window.setTimeout(resolve, durationMs);
|
|
});
|
|
}
|
|
|
|
async function copyText(text: string) {
|
|
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
}
|
|
|
|
if (typeof document === 'undefined') {
|
|
throw new Error('클립보드 API를 사용할 수 없습니다.');
|
|
}
|
|
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
textarea.setAttribute('readonly', 'true');
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.opacity = '0';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(textarea);
|
|
}
|
|
|
|
export function MainHeader({
|
|
activeTopMenu,
|
|
sidebarCollapsed,
|
|
contentExpanded,
|
|
isMobileViewport,
|
|
onToggleSidebar,
|
|
onToggleContentExpanded,
|
|
onChangeTopMenu,
|
|
onOpenPlanQuickFilter,
|
|
}: MainHeaderProps) {
|
|
void contentExpanded;
|
|
void onToggleContentExpanded;
|
|
const screens = useBreakpoint();
|
|
const [modalApi, modalContextHolder] = Modal.useModal();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
|
const [activeSettingsModal, setActiveSettingsModal] = useState<SettingsModalKey>('appSettings');
|
|
const [activeAppSettingsCategory, setActiveAppSettingsCategory] = useState<AppSettingsCategoryKey>('automation');
|
|
const [activeAppSettingsSection, setActiveAppSettingsSection] = useState<AppSettingsSectionKey>('planDefaults');
|
|
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
|
const [notificationLoading, setNotificationLoading] = useState(false);
|
|
const [notificationFeedback, setNotificationFeedback] = useState<InlineFeedback | null>(null);
|
|
const [notificationCopyFeedback, setNotificationCopyFeedback] = useState<InlineFeedback | null>(null);
|
|
const [registeredPwaNotificationToken, setRegisteredPwaNotificationToken] = useState(() =>
|
|
getSavedPwaNotificationToken(),
|
|
);
|
|
const [pwaNotificationTokenInput, setPwaNotificationTokenInput] = useState(() => getSavedPwaNotificationToken());
|
|
const [pwaNotificationTokenSaving, setPwaNotificationTokenSaving] = useState(false);
|
|
const [pwaNotificationTokenFeedback, setPwaNotificationTokenFeedback] = useState<InlineFeedback | null>(null);
|
|
const [pwaNotificationTokenCopyFeedback, setPwaNotificationTokenCopyFeedback] = useState<InlineFeedback | null>(null);
|
|
const [clientNotificationPermission, setClientNotificationPermission] = useState<ClientNotificationPermissionState>(
|
|
() => getClientNotificationPermission(),
|
|
);
|
|
const [webPushConfigured, setWebPushConfigured] = useState(false);
|
|
const [isStandaloneMode, setIsStandaloneMode] = useState(false);
|
|
const [chatConnection, setChatConnection] = useState(() => chatConnectionGateway.getSnapshot());
|
|
const [chatRuntimeSnapshot, setChatRuntimeSnapshot] = useState<ChatRuntimeSnapshot | null>(() =>
|
|
chatConnectionGateway.getSharedRuntimeSnapshot(),
|
|
);
|
|
const [activeConversationSnapshot, setActiveConversationSnapshot] = useState(() =>
|
|
getSharedActiveConversationSnapshot(),
|
|
);
|
|
const [isRuntimeModalOpen, setIsRuntimeModalOpen] = useState(false);
|
|
const [runtimeConversationTitles, setRuntimeConversationTitles] = useState<Record<string, string>>({});
|
|
const [isRuntimeLogDrawerOpen, setIsRuntimeLogDrawerOpen] = useState(false);
|
|
const [runtimeLogLoading, setRuntimeLogLoading] = useState(false);
|
|
const [runtimeLogError, setRuntimeLogError] = useState('');
|
|
const [runtimeLogDetail, setRuntimeLogDetail] = useState<ChatRuntimeJobDetail | null>(null);
|
|
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 [prodServerStatus, setProdServerStatus] = useState<ServerCommandItem | null>(null);
|
|
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
|
|
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
|
|
const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'prod' | '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);
|
|
const [appConfigFeedback, setAppConfigFeedback] = useState<InlineFeedback | null>(null);
|
|
const [appConfigSaving, setAppConfigSaving] = useState(false);
|
|
const [appConfigCopyFeedback, setAppConfigCopyFeedback] = useState<InlineFeedback | null>(null);
|
|
const [tokenInput, setTokenInput] = useState('');
|
|
const [tokenFeedback, setTokenFeedback] = useState<InlineFeedback | null>(null);
|
|
const [planShortcutCounts, setPlanShortcutCounts] = useState({
|
|
working: 0,
|
|
releasePendingMain: 0,
|
|
automationFailed: 0,
|
|
});
|
|
const headerTopMenu = !hasAccess ? 'docs' : activeTopMenu === 'apis' ? 'docs' : activeTopMenu === 'chat' ? 'plans' : activeTopMenu;
|
|
const notificationStatusClassName = notificationEnabled
|
|
? 'app-header__status-dot--active'
|
|
: 'app-header__status-dot--inactive';
|
|
const chatConnectionStatusClassName =
|
|
chatConnection.connectionState === 'connected'
|
|
? 'app-header__status-dot--active'
|
|
: chatConnection.connectionState === 'connecting'
|
|
? 'app-header__status-dot--progress'
|
|
: 'app-header__status-dot--inactive';
|
|
const testServerPendingUpdateCount =
|
|
testServerStatus && (testServerStatus.updateAvailable || testServerStatus.buildRequired) ? 1 : 0;
|
|
const prodServerPendingUpdateCount =
|
|
prodServerStatus && (prodServerStatus.updateAvailable || prodServerStatus.buildRequired) ? 1 : 0;
|
|
const workServerPendingUpdateCount =
|
|
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
|
|
const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount;
|
|
const settingsStatusClassName =
|
|
totalPendingUpdateCount >= 2
|
|
? 'app-header__status-dot--inactive'
|
|
: totalPendingUpdateCount === 1
|
|
? 'app-header__status-dot--warning'
|
|
: 'app-header__status-dot--active';
|
|
const settingsStatusLabel =
|
|
totalPendingUpdateCount >= 2 ? '모든 업데이트 존재' : totalPendingUpdateCount === 1 ? '업데이트 1건 존재' : '최신 상태';
|
|
const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0;
|
|
const queuedRuntimeCount = chatRuntimeSnapshot?.queuedCount ?? 0;
|
|
const runtimeSessionCount = chatRuntimeSnapshot?.sessionCount ?? 0;
|
|
const runningRuntimeBadgeLabel = runningRuntimeCount > 99 ? '99+' : String(runningRuntimeCount);
|
|
const hasPendingRuntimeWork = runningRuntimeCount > 0 || queuedRuntimeCount > 0;
|
|
const chatConnectionLabelParts = [
|
|
chatConnection.connectionState === 'connected'
|
|
? 'Codex 채팅 실시간 연결됨'
|
|
: chatConnection.connectionState === 'connecting'
|
|
? 'Codex 채팅 연결 중'
|
|
: 'Codex 채팅 오프라인',
|
|
];
|
|
|
|
if (runningRuntimeCount > 0) {
|
|
chatConnectionLabelParts.push(`실행 ${runningRuntimeCount}건`);
|
|
}
|
|
|
|
if (queuedRuntimeCount > 0) {
|
|
chatConnectionLabelParts.push(`대기 ${queuedRuntimeCount}건`);
|
|
}
|
|
|
|
const chatConnectionLabel = chatConnectionLabelParts.join(' · ');
|
|
const connectionIndicatorClassName = `app-header__connection-indicator app-header__connection-indicator--${chatConnection.connectionState}${
|
|
hasPendingRuntimeWork ? ' app-header__connection-indicator--busy' : ''
|
|
}`;
|
|
const connectionCountBadgeClassName = `app-header__connection-count-badge app-header__connection-count-badge--${chatConnection.connectionState}`;
|
|
const getConversationLabel = (sessionId: string) => {
|
|
if (sessionId === activeConversationSnapshot.sessionId && activeConversationSnapshot.title) {
|
|
return activeConversationSnapshot.title;
|
|
}
|
|
|
|
return runtimeConversationTitles[sessionId] || sessionId;
|
|
};
|
|
const navigateToConversation = (sessionId: string) => {
|
|
const searchParams = new URLSearchParams(location.search);
|
|
searchParams.set('topMenu', 'chat');
|
|
searchParams.set('sessionId', sessionId);
|
|
searchParams.delete('chatView');
|
|
searchParams.delete('runtimeRequestId');
|
|
navigate({
|
|
pathname: buildChatPath('live'),
|
|
search: `?${searchParams.toString()}`,
|
|
});
|
|
setIsRuntimeModalOpen(false);
|
|
};
|
|
const openRuntimeLog = async (requestId: string) => {
|
|
setIsRuntimeModalOpen(false);
|
|
setIsRuntimeLogDrawerOpen(true);
|
|
setRuntimeLogLoading(true);
|
|
setRuntimeLogError('');
|
|
|
|
try {
|
|
const detail = await fetchChatRuntimeJobDetail(requestId);
|
|
setRuntimeLogDetail(detail);
|
|
} catch (error) {
|
|
setRuntimeLogDetail(null);
|
|
setRuntimeLogError(error instanceof Error ? error.message : '실행 로그를 불러오지 못했습니다.');
|
|
} finally {
|
|
setRuntimeLogLoading(false);
|
|
}
|
|
};
|
|
const canRefreshWorkServerStatus = hasAccess && !workServerStatusLoading && !serverRestartingKey;
|
|
const canResetClientState = !clientResetting;
|
|
const canRestartServers =
|
|
hasAccess &&
|
|
!workServerStatusLoading &&
|
|
!serverRestartingKey;
|
|
const chatSettingsDirty = !areChatSettingsEqual(appConfig.chat, appConfigDraft.chat);
|
|
const worklogAutomationSettingsDirty = !areWorklogAutomationSettingsEqual(
|
|
appConfig.worklogAutomation,
|
|
appConfigDraft.worklogAutomation,
|
|
);
|
|
const chatSettingsDiffLabels = getChatSettingsDiffLabels(appConfig.chat, appConfigDraft.chat);
|
|
const planDefaultSettingsDirty = !arePlanDefaultSettingsEqual(appConfig.planDefaults, appConfigDraft.planDefaults);
|
|
const planDefaultDiffLabels = getPlanDefaultDiffLabels(appConfig.planDefaults, appConfigDraft.planDefaults);
|
|
const planCostSettingsDirty = !arePlanCostSettingsEqual(appConfig.planCost, appConfigDraft.planCost);
|
|
const planCostDiffLabels = getPlanCostDiffLabels(appConfig.planCost, appConfigDraft.planCost);
|
|
const worklogAutomationDiffLabels = getWorklogAutomationDiffLabels(
|
|
appConfig.worklogAutomation,
|
|
appConfigDraft.worklogAutomation,
|
|
);
|
|
const automationNotificationSettingsDirty = !areAutomationNotificationSettingsEqual(
|
|
appConfig.automation,
|
|
appConfigDraft.automation,
|
|
);
|
|
const automationNotificationDiffLabels = getAutomationNotificationDiffLabels(
|
|
appConfig.automation,
|
|
appConfigDraft.automation,
|
|
);
|
|
const gestureShortcutSettingsDirty = !areGestureShortcutSettingsEqual(
|
|
appConfig.gestureShortcuts,
|
|
appConfigDraft.gestureShortcuts,
|
|
);
|
|
const gestureShortcutDiffLabels = getGestureShortcutDiffLabels(
|
|
appConfig.gestureShortcuts,
|
|
appConfigDraft.gestureShortcuts,
|
|
);
|
|
const activeAppSettingsSectionOptions = getAppSettingsSectionOptions(activeAppSettingsCategory);
|
|
const syncRegisteredWebPushStatus = async () => {
|
|
const permission = getClientNotificationPermission();
|
|
setClientNotificationPermission(permission);
|
|
setIsStandaloneMode(isStandaloneDisplayMode());
|
|
|
|
if (permission === 'unsupported') {
|
|
setNotificationEnabled(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const config = await fetchWebPushConfig();
|
|
setWebPushConfigured(Boolean(config.enabled && config.publicKey));
|
|
|
|
if (!config.enabled || !config.publicKey) {
|
|
setNotificationEnabled(false);
|
|
return;
|
|
}
|
|
|
|
if (Notification.permission !== 'granted') {
|
|
setNotificationEnabled(false);
|
|
return;
|
|
}
|
|
|
|
const registration = await getPushServiceWorkerRegistration();
|
|
|
|
if (!registration) {
|
|
setNotificationEnabled(false);
|
|
return;
|
|
}
|
|
|
|
let subscription = await registration.pushManager.getSubscription();
|
|
|
|
if (!subscription) {
|
|
setNotificationEnabled(false);
|
|
return;
|
|
}
|
|
|
|
const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey);
|
|
|
|
if (!isSamePushApplicationServerKey(subscription.options.applicationServerKey, expectedApplicationServerKey)) {
|
|
await unregisterWebPushSubscription(subscription.endpoint).catch(() => undefined);
|
|
await subscription.unsubscribe().catch(() => undefined);
|
|
|
|
subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: expectedApplicationServerKey,
|
|
});
|
|
}
|
|
|
|
await registerWebPushSubscription(serializePushSubscription(subscription), getSavedNotificationDeviceId());
|
|
setNotificationEnabled(true);
|
|
setNotificationFeedback(null);
|
|
} catch {
|
|
setNotificationEnabled(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void syncRegisteredWebPushStatus();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleSync = () => {
|
|
void syncRegisteredWebPushStatus();
|
|
};
|
|
|
|
window.addEventListener('focus', handleSync);
|
|
window.addEventListener('pageshow', handleSync);
|
|
document.addEventListener('visibilitychange', handleSync);
|
|
if ('serviceWorker' in navigator) {
|
|
void navigator.serviceWorker.ready
|
|
.then(() => {
|
|
void syncRegisteredWebPushStatus();
|
|
})
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('focus', handleSync);
|
|
window.removeEventListener('pageshow', handleSync);
|
|
document.removeEventListener('visibilitychange', handleSync);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return chatConnectionGateway.subscribe(() => {
|
|
setChatConnection(chatConnectionGateway.getSnapshot());
|
|
setChatRuntimeSnapshot(chatConnectionGateway.getSharedRuntimeSnapshot());
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return subscribeSharedActiveConversation(() => {
|
|
setActiveConversationSnapshot(getSharedActiveConversationSnapshot());
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isRuntimeModalOpen) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
void chatGateway.listConversations()
|
|
.then((items) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setRuntimeConversationTitles(
|
|
items.reduce<Record<string, string>>((acc, item) => {
|
|
acc[item.sessionId] = item.title?.trim() || item.sessionId;
|
|
return acc;
|
|
}, {}),
|
|
);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setRuntimeConversationTitles({});
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isRuntimeModalOpen]);
|
|
|
|
useEffect(() => {
|
|
setTokenInput(registeredToken);
|
|
}, [registeredToken]);
|
|
|
|
useEffect(() => {
|
|
setAppConfigDraft(appConfig);
|
|
}, [appConfig]);
|
|
|
|
useEffect(() => {
|
|
if (getAppSettingsSectionCategory(activeAppSettingsSection) === activeAppSettingsCategory) {
|
|
return;
|
|
}
|
|
|
|
const firstSection = APP_SETTINGS_SECTIONS.find((entry) => entry.category === activeAppSettingsCategory);
|
|
|
|
if (firstSection) {
|
|
setActiveAppSettingsSection(firstSection.value);
|
|
}
|
|
}, [activeAppSettingsCategory, activeAppSettingsSection]);
|
|
|
|
useEffect(() => {
|
|
if (!isSettingsModalAllowed(activeSettingsModal, hasAccess)) {
|
|
setActiveSettingsModal('token');
|
|
}
|
|
}, [activeSettingsModal, hasAccess]);
|
|
|
|
useEffect(() => {
|
|
if (!hasAccess) {
|
|
setTestServerStatus(null);
|
|
setWorkServerStatus(null);
|
|
return;
|
|
}
|
|
|
|
void refreshUpdateTargets(true);
|
|
}, [hasAccess]);
|
|
|
|
useEffect(() => {
|
|
if (!settingsModalOpen || activeSettingsModal !== 'update' || !hasAccess) {
|
|
return;
|
|
}
|
|
|
|
void refreshUpdateTargets(true);
|
|
}, [activeSettingsModal, hasAccess, settingsModalOpen]);
|
|
|
|
const ensureClientNotificationPermission = async () => {
|
|
const currentPermission = getClientNotificationPermission();
|
|
setClientNotificationPermission(currentPermission);
|
|
|
|
if (currentPermission === 'unsupported') {
|
|
setNotificationFeedback({ tone: 'error', message: '현재 브라우저에서는 Web Push를 지원하지 않습니다.' });
|
|
return false;
|
|
}
|
|
|
|
if (!hasSecureOrigin()) {
|
|
setNotificationFeedback({ tone: 'error', message: '알림은 HTTPS 또는 localhost 환경에서만 사용할 수 있습니다.' });
|
|
return false;
|
|
}
|
|
|
|
if (isAppleMobileDevice() && !isStandaloneDisplayMode()) {
|
|
setNotificationFeedback({
|
|
tone: 'warning',
|
|
message: '아이폰에서는 홈 화면에 추가한 뒤 실행한 PWA에서만 웹 푸시를 사용할 수 있습니다.',
|
|
});
|
|
return false;
|
|
}
|
|
|
|
if (currentPermission === 'denied') {
|
|
setNotificationFeedback({
|
|
tone: 'warning',
|
|
message: '브라우저 또는 기기 설정에서 알림 권한을 먼저 허용해 주세요.',
|
|
});
|
|
return false;
|
|
}
|
|
|
|
if (currentPermission === 'granted') {
|
|
setNotificationFeedback(null);
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const permission = await Notification.requestPermission();
|
|
const nextPermission =
|
|
permission === 'granted' ? 'granted' : permission === 'denied' ? 'denied' : 'default';
|
|
setClientNotificationPermission(nextPermission);
|
|
|
|
if (nextPermission !== 'granted') {
|
|
setNotificationFeedback({ tone: 'warning', message: '알림 권한이 허용되지 않아 알림을 켤 수 없습니다.' });
|
|
return false;
|
|
}
|
|
|
|
setNotificationFeedback(null);
|
|
return true;
|
|
} catch {
|
|
setNotificationFeedback({ tone: 'error', message: '알림 권한 요청 중 오류가 발생했습니다.' });
|
|
return false;
|
|
}
|
|
};
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
try {
|
|
let registration = await getPushServiceWorkerRegistration();
|
|
|
|
if (!registration) {
|
|
await waitForDuration(1500);
|
|
registration = await getPushServiceWorkerRegistration();
|
|
}
|
|
|
|
if (!registration) {
|
|
if (nextEnabled) {
|
|
if (import.meta.env.DEV) {
|
|
const serviceWorkerUrl = '/dev-sw.js?dev-sw';
|
|
let swFetchStatus = 'unknown';
|
|
|
|
try {
|
|
const response = await fetch(serviceWorkerUrl, { cache: 'no-store' });
|
|
swFetchStatus = `${response.status} ${response.statusText}`;
|
|
} catch {
|
|
swFetchStatus = 'fetch failed';
|
|
}
|
|
|
|
let registrationSummary = 'none';
|
|
|
|
try {
|
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
registrationSummary =
|
|
registrations.length === 0
|
|
? 'none'
|
|
: registrations
|
|
.map((item) => {
|
|
const states = [
|
|
item.active ? `active:${item.active.state}` : 'active:none',
|
|
item.waiting ? `waiting:${item.waiting.state}` : 'waiting:none',
|
|
item.installing ? `installing:${item.installing.state}` : 'installing:none',
|
|
];
|
|
return `${item.scope} [${states.join(', ')}]`;
|
|
})
|
|
.join(' | ');
|
|
} catch {
|
|
registrationSummary = 'read failed';
|
|
}
|
|
|
|
throw new Error(
|
|
[
|
|
'알림 서비스워커 준비가 아직 끝나지 않았습니다.',
|
|
`dev-sw 응답: ${swFetchStatus}`,
|
|
`등록 상태: ${registrationSummary}`,
|
|
`등록 시도: ${
|
|
lastPushSwRegisterAttempts.length === 0
|
|
? 'none'
|
|
: lastPushSwRegisterAttempts
|
|
.map((attempt) => `${attempt.mode} ${attempt.url} -> ${attempt.error}`)
|
|
.join(' | ')
|
|
}`,
|
|
`secureContext: ${window.isSecureContext ? 'yes' : 'no'}`,
|
|
`permission: ${Notification.permission}`,
|
|
].join(' '),
|
|
);
|
|
}
|
|
|
|
throw new Error('알림 서비스워커 준비가 아직 끝나지 않았습니다. 잠시 후 다시 시도해 주세요.');
|
|
}
|
|
|
|
setNotificationEnabled(false);
|
|
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 해제했습니다.' });
|
|
return;
|
|
}
|
|
|
|
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);
|
|
|
|
if (
|
|
subscription &&
|
|
!isSamePushApplicationServerKey(subscription.options.applicationServerKey, expectedApplicationServerKey)
|
|
) {
|
|
await unregisterWebPushSubscription(subscription.endpoint).catch(() => undefined);
|
|
await subscription.unsubscribe().catch(() => undefined);
|
|
subscription = null;
|
|
}
|
|
|
|
if (!subscription) {
|
|
subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: expectedApplicationServerKey,
|
|
});
|
|
}
|
|
|
|
await registerWebPushSubscription(serializePushSubscription(subscription), getSavedNotificationDeviceId());
|
|
setNotificationEnabled(true);
|
|
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 서버에 등록했습니다.' });
|
|
return;
|
|
}
|
|
|
|
const subscription = await registration.pushManager.getSubscription();
|
|
|
|
if (subscription) {
|
|
await unregisterWebPushSubscription(subscription.endpoint);
|
|
await subscription.unsubscribe();
|
|
}
|
|
|
|
setNotificationEnabled(false);
|
|
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 해제했습니다.' });
|
|
} catch (error) {
|
|
setNotificationEnabled(previousEnabled);
|
|
setNotificationFeedback({
|
|
tone: 'error',
|
|
message: error instanceof Error ? error.message : '알림 설정 저장에 실패했습니다.',
|
|
});
|
|
} finally {
|
|
setNotificationLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRegisterPwaNotificationToken = async () => {
|
|
const trimmedToken = pwaNotificationTokenInput.trim();
|
|
setPwaNotificationTokenCopyFeedback(null);
|
|
|
|
if (!trimmedToken) {
|
|
setPwaNotificationTokenFeedback({ tone: 'warning', message: '등록할 PWA 알림 토큰을 입력해 주세요.' });
|
|
return;
|
|
}
|
|
|
|
setPwaNotificationTokenSaving(true);
|
|
|
|
try {
|
|
await registerPwaNotificationToken({
|
|
token: trimmedToken,
|
|
deviceId: getSavedNotificationDeviceId(),
|
|
});
|
|
|
|
if (registeredPwaNotificationToken && registeredPwaNotificationToken !== trimmedToken) {
|
|
await unregisterPwaNotificationToken(registeredPwaNotificationToken);
|
|
}
|
|
|
|
setSavedPwaNotificationToken(trimmedToken);
|
|
setRegisteredPwaNotificationToken(trimmedToken);
|
|
void syncAppConfigFromServer();
|
|
setPwaNotificationTokenFeedback({ tone: 'success', message: 'PWA 알림 토큰을 서버에 등록했습니다.' });
|
|
} catch (error) {
|
|
setPwaNotificationTokenFeedback({
|
|
tone: 'error',
|
|
message: error instanceof Error ? error.message : 'PWA 알림 토큰 등록에 실패했습니다.',
|
|
});
|
|
} finally {
|
|
setPwaNotificationTokenSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleClearPwaNotificationToken = async () => {
|
|
const tokenToRemove = registeredPwaNotificationToken || pwaNotificationTokenInput.trim();
|
|
setPwaNotificationTokenCopyFeedback(null);
|
|
|
|
if (!tokenToRemove) {
|
|
setPwaNotificationTokenFeedback({ tone: 'info', message: '제거할 PWA 알림 토큰이 없습니다.' });
|
|
return;
|
|
}
|
|
|
|
setPwaNotificationTokenSaving(true);
|
|
|
|
try {
|
|
await unregisterPwaNotificationToken(tokenToRemove);
|
|
setSavedPwaNotificationToken('');
|
|
setRegisteredPwaNotificationToken('');
|
|
setPwaNotificationTokenInput('');
|
|
void syncAppConfigFromServer();
|
|
setPwaNotificationTokenFeedback({ tone: 'success', message: 'PWA 알림 토큰을 제거했습니다.' });
|
|
} catch (error) {
|
|
setPwaNotificationTokenFeedback({
|
|
tone: 'error',
|
|
message: error instanceof Error ? error.message : 'PWA 알림 토큰 제거에 실패했습니다.',
|
|
});
|
|
} finally {
|
|
setPwaNotificationTokenSaving(false);
|
|
}
|
|
};
|
|
|
|
const refreshServerStatuses = async () => {
|
|
const items = await fetchServerCommands();
|
|
const nextTestServerStatus = items.find((item) => item.key === 'test') ?? null;
|
|
const nextProdServerStatus = items.find((item) => item.key === 'prod') ?? null;
|
|
const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null;
|
|
setTestServerStatus(nextTestServerStatus);
|
|
setProdServerStatus(nextProdServerStatus);
|
|
setWorkServerStatus(nextWorkServerStatus);
|
|
return {
|
|
test: nextTestServerStatus,
|
|
prod: nextProdServerStatus,
|
|
'work-server': nextWorkServerStatus,
|
|
} satisfies Record<'test' | 'prod' | 'work-server', ServerCommandItem | null>;
|
|
};
|
|
|
|
const refreshUpdateTargets = async (silent = false) => {
|
|
if (!hasAccess) {
|
|
setTestServerStatus(null);
|
|
setProdServerStatus(null);
|
|
setWorkServerStatus(null);
|
|
if (!silent) {
|
|
setUpdateCheckFeedback({ tone: 'warning', message: '업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' });
|
|
}
|
|
return null;
|
|
}
|
|
|
|
setWorkServerStatusLoading(true);
|
|
|
|
try {
|
|
const nextStatuses = await refreshServerStatuses();
|
|
|
|
if (!silent) {
|
|
setUpdateCheckFeedback(null);
|
|
}
|
|
|
|
return nextStatuses;
|
|
} catch (error) {
|
|
if (!silent) {
|
|
setUpdateCheckFeedback({
|
|
tone: 'error',
|
|
message: error instanceof Error ? error.message : 'TEST/PROD/WORK 서버 업데이트 상태를 불러오지 못했습니다.',
|
|
});
|
|
}
|
|
return null;
|
|
} finally {
|
|
setWorkServerStatusLoading(false);
|
|
}
|
|
};
|
|
|
|
const waitForServerRestart = async (key: 'test' | 'prod' | 'work-server', baseline: ServerCommandItem | null) => {
|
|
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
await waitForDuration(2500);
|
|
|
|
try {
|
|
const nextStatuses = await refreshServerStatuses();
|
|
const nextStatus = nextStatuses[key];
|
|
|
|
if (!nextStatus) {
|
|
continue;
|
|
}
|
|
|
|
const restarted =
|
|
baseline == null ||
|
|
nextStatus.startedAt !== baseline.startedAt ||
|
|
nextStatus.checkedAt !== baseline.checkedAt;
|
|
|
|
if (nextStatus.availability === 'online' && restarted) {
|
|
return { ok: true, item: nextStatus };
|
|
}
|
|
} catch {
|
|
// 서버 재기동 중에는 일시적으로 조회가 실패할 수 있습니다.
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
item: key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus,
|
|
};
|
|
};
|
|
|
|
const handleResetClientState = async () => {
|
|
if (clientResetting) {
|
|
return;
|
|
}
|
|
|
|
setClientResetCopyFeedback(null);
|
|
setClientResetFeedback(null);
|
|
setClientResetting(true);
|
|
|
|
try {
|
|
const result = await resetNonAuthClientState();
|
|
const changedCount =
|
|
result.removedLocalStorageKeys.length +
|
|
result.removedSessionStorageKeys.length +
|
|
result.removedCacheKeys.length +
|
|
result.unregisteredServiceWorkerCount;
|
|
|
|
setClientResetFeedback({
|
|
tone: 'success',
|
|
message:
|
|
changedCount > 0
|
|
? `토큰/권한 정보는 유지하고 캐시·스토리지를 초기화했습니다. 변경 ${changedCount}건을 정리한 뒤 화면을 새로고침합니다.`
|
|
: '토큰/권한 정보는 유지하고 초기화 대상 캐시·스토리지를 확인했으며, 화면을 새로고침합니다.',
|
|
});
|
|
window.setTimeout(() => {
|
|
window.location.replace(window.location.href);
|
|
}, 700);
|
|
} catch (error) {
|
|
setClientResetFeedback({
|
|
tone: 'error',
|
|
message: error instanceof Error ? error.message : '캐시·스토리지 초기화에 실패했습니다.',
|
|
});
|
|
} finally {
|
|
setClientResetting(false);
|
|
}
|
|
};
|
|
|
|
const restartServerWithVerification = async (
|
|
key: 'test' | 'prod' | 'work-server',
|
|
busyKey: 'test' | 'prod' | 'work-server' | 'all',
|
|
) => {
|
|
const baseline = key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus;
|
|
const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버';
|
|
|
|
const result = await restartServerCommand(key);
|
|
|
|
if (key === 'test') {
|
|
setTestServerStatus(result.item);
|
|
} else if (key === 'prod') {
|
|
setProdServerStatus(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' | 'prod' | '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 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버';
|
|
setServerRestartFeedback({
|
|
tone: 'error',
|
|
message: error instanceof Error ? error.message : `${targetLabel} 재기동에 실패했습니다.`,
|
|
});
|
|
return false;
|
|
} finally {
|
|
setServerRestartingKey(null);
|
|
}
|
|
};
|
|
|
|
const handleConfirmRestartProdServer = () => {
|
|
if (!hasAccess || serverRestartingKey) {
|
|
return;
|
|
}
|
|
|
|
modalApi.confirm({
|
|
title: 'PROD 빌드 반영',
|
|
content: 'PROD 컨테이너를 빌드 후 재기동합니다. 진행할까요?',
|
|
okText: '빌드 및 재기동',
|
|
cancelText: '취소',
|
|
okButtonProps: { danger: true },
|
|
onOk: async () => {
|
|
await handleRestartSingleServer('prod');
|
|
},
|
|
});
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const openSettingsModal = (
|
|
modal: SettingsModalKey,
|
|
nextAppSettingsSection: AppSettingsSectionKey = 'planDefaults',
|
|
) => {
|
|
setSettingsOpen(false);
|
|
setActiveSettingsModal(isSettingsModalAllowed(modal, hasAccess) ? modal : 'token');
|
|
setActiveAppSettingsCategory(getAppSettingsSectionCategory(nextAppSettingsSection));
|
|
setActiveAppSettingsSection(nextAppSettingsSection);
|
|
setAppConfigDraft(appConfig);
|
|
setAppConfigFeedback(null);
|
|
setSettingsModalOpen(true);
|
|
};
|
|
|
|
const updateAppConfigDraft = (updater: (current: AppConfig) => AppConfig) => {
|
|
setAppConfigDraft((current) => updater(current));
|
|
};
|
|
|
|
const handleSaveAppConfig = async () => {
|
|
if (appConfigSaving) {
|
|
return;
|
|
}
|
|
|
|
setAppConfigSaving(true);
|
|
setAppConfigFeedback(null);
|
|
setAppConfigCopyFeedback(null);
|
|
|
|
try {
|
|
const savedConfig =
|
|
activeAppSettingsSection === 'automationNotifications'
|
|
? await saveAutomationNotificationPreferenceToServer(appConfigDraft)
|
|
: await saveAppConfigToServer(appConfigDraft);
|
|
setStoredAppConfig(savedConfig);
|
|
setAppConfigFeedback({
|
|
tone: 'success',
|
|
message:
|
|
activeAppSettingsSection === 'automationNotifications'
|
|
? '자동화 알림 설정을 알림 토큰과 클라이언트별로 저장했습니다.'
|
|
: '앱 설정을 DB에 저장했습니다.',
|
|
});
|
|
} catch (error) {
|
|
setAppConfigDraft(appConfig);
|
|
setAppConfigFeedback({
|
|
tone: 'error',
|
|
message: error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.',
|
|
});
|
|
} finally {
|
|
setAppConfigSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleResetNotificationIdentity = () => {
|
|
modalApi.confirm({
|
|
title: '알림 클라이언트 초기화',
|
|
content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.',
|
|
okText: '초기화',
|
|
cancelText: '취소',
|
|
onOk: () => {
|
|
clearNotificationIdentity();
|
|
window.location.reload();
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleResetAppConfig = () => {
|
|
setAppConfigDraft(DEFAULT_APP_CONFIG);
|
|
setAppConfigFeedback({ tone: 'info', message: '추천 기본값으로 되돌렸습니다. 저장 후 반영됩니다.' });
|
|
setAppConfigCopyFeedback(null);
|
|
};
|
|
|
|
useEffect(() => {
|
|
void syncAppConfigFromServer().then((synced) => {
|
|
if (!synced) {
|
|
setAppConfigFeedback({ tone: 'warning', message: '서버 설정을 불러오지 못해 로컬 설정을 사용합니다.' });
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!hasAccess || !settingsOpen) {
|
|
setPlanShortcutCounts({
|
|
working: 0,
|
|
releasePendingMain: 0,
|
|
automationFailed: 0,
|
|
});
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
void fetchPlanItems('all')
|
|
.then((items) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setPlanShortcutCounts({
|
|
working: items.filter(isWorkingPlanItem).length,
|
|
releasePendingMain: items.filter(isReleasePendingMainItem).length,
|
|
automationFailed: items.filter(isAutomationFailedItem).length,
|
|
});
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setPlanShortcutCounts({
|
|
working: 0,
|
|
releasePendingMain: 0,
|
|
automationFailed: 0,
|
|
});
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [hasAccess, settingsOpen]);
|
|
|
|
const renderFeedback = (
|
|
feedback: InlineFeedback | null,
|
|
copyFeedback: InlineFeedback | null,
|
|
setCopyFeedback: (feedback: InlineFeedback | null) => void,
|
|
) => {
|
|
if (!feedback) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
|
<Alert
|
|
showIcon
|
|
type={feedback.tone}
|
|
message={feedback.message}
|
|
action={
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
aria-label="메시지 복사"
|
|
icon={<CopyOutlined />}
|
|
onClick={() => {
|
|
void copyText(feedback.message)
|
|
.then(() => {
|
|
setCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
|
})
|
|
.catch(() => {
|
|
setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
|
});
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
{copyFeedback ? <Alert showIcon type={copyFeedback.tone} message={copyFeedback.message} /> : null}
|
|
</Space>
|
|
);
|
|
};
|
|
|
|
const handleRegisterToken = () => {
|
|
const trimmedToken = tokenInput.trim();
|
|
|
|
if (!trimmedToken) {
|
|
setTokenFeedback({ tone: 'warning', message: '등록할 토큰을 입력해 주세요.' });
|
|
return;
|
|
}
|
|
|
|
if (trimmedToken !== ALLOWED_REGISTRATION_TOKEN) {
|
|
setTokenFeedback({ tone: 'error', message: '허용되지 않은 토큰입니다.' });
|
|
return;
|
|
}
|
|
|
|
setRegisteredAccessToken(trimmedToken);
|
|
setTokenFeedback({ tone: 'success', message: '권한 토큰을 등록했습니다.' });
|
|
};
|
|
|
|
const handleClearToken = () => {
|
|
setRegisteredAccessToken('');
|
|
setTokenInput('');
|
|
setTokenFeedback({ tone: 'info', message: '권한 토큰을 제거했습니다.' });
|
|
};
|
|
|
|
const settingsTriggerButton = (
|
|
<Button
|
|
type="text"
|
|
aria-label={`설정 · ${settingsStatusLabel}`}
|
|
icon={
|
|
<span className="app-header__settings-icon" aria-hidden="true">
|
|
<SettingOutlined />
|
|
<span className={`app-header__status-dot ${settingsStatusClassName}`} />
|
|
</span>
|
|
}
|
|
/>
|
|
);
|
|
|
|
const tokenTriggerButton = (
|
|
<Button
|
|
type="text"
|
|
aria-label={`권한 · ${hasAccess ? '허용됨' : '차단됨'}`}
|
|
icon={
|
|
<span className="app-header__settings-icon" aria-hidden="true">
|
|
<ProfileOutlined />
|
|
<span
|
|
className={`app-header__status-dot ${
|
|
hasAccess ? 'app-header__status-dot--active' : 'app-header__status-dot--inactive'
|
|
}`}
|
|
/>
|
|
</span>
|
|
}
|
|
onClick={() => {
|
|
openSettingsModal('token');
|
|
}}
|
|
/>
|
|
);
|
|
|
|
const worklogAutomationPanel = (
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
<Alert
|
|
showIcon
|
|
type="info"
|
|
message="업무일지 자동화 기본값을 설정합니다."
|
|
description="현재는 앱 설정의 기준값을 저장하는 단계이며, 자동 생성/캡처 흐름에서 공통 기본값으로 사용하기 위한 설정입니다."
|
|
/>
|
|
<Alert
|
|
showIcon
|
|
type={worklogAutomationSettingsDirty ? 'warning' : 'success'}
|
|
message={
|
|
worklogAutomationSettingsDirty
|
|
? 'DB 저장값과 편집 중인 업무일지 자동화 설정이 다릅니다.'
|
|
: '업무일지 자동화 설정이 현재 DB 저장값과 같습니다.'
|
|
}
|
|
description={
|
|
worklogAutomationSettingsDirty
|
|
? `변경 항목: ${worklogAutomationDiffLabels.join(', ')} / DB 저장값 기준: ${
|
|
appConfig.worklogAutomation.autoCreateDailyWorklog ? '자동 생성 On' : '자동 생성 Off'
|
|
}, 실행 시각 ${appConfig.worklogAutomation.dailyCreateTime}, 템플릿 ${
|
|
appConfig.worklogAutomation.template === 'detailed' ? '상세형' : '간단형'
|
|
} / 편집 중: ${
|
|
appConfigDraft.worklogAutomation.autoCreateDailyWorklog ? '자동 생성 On' : '자동 생성 Off'
|
|
}, 실행 시각 ${appConfigDraft.worklogAutomation.dailyCreateTime}, 템플릿 ${
|
|
appConfigDraft.worklogAutomation.template === 'detailed' ? '상세형' : '간단형'
|
|
}`
|
|
: `DB 저장값 기준: ${
|
|
appConfig.worklogAutomation.autoCreateDailyWorklog ? '자동 생성 On' : '자동 생성 Off'
|
|
}, 실행 시각 ${appConfig.worklogAutomation.dailyCreateTime}, 템플릿 ${
|
|
appConfig.worklogAutomation.template === 'detailed' ? '상세형' : '간단형'
|
|
}`
|
|
}
|
|
/>
|
|
{appConfigFeedback ? (
|
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
|
<Alert
|
|
showIcon
|
|
type={appConfigFeedback.tone}
|
|
message={appConfigFeedback.message}
|
|
action={
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
aria-label="메시지 복사"
|
|
icon={<CopyOutlined />}
|
|
onClick={() => {
|
|
void copyText(appConfigFeedback.message)
|
|
.then(() => {
|
|
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
|
})
|
|
.catch(() => {
|
|
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
|
});
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
{appConfigCopyFeedback ? (
|
|
<Alert showIcon type={appConfigCopyFeedback.tone} message={appConfigCopyFeedback.message} />
|
|
) : null}
|
|
</Space>
|
|
) : null}
|
|
<Checkbox
|
|
checked={appConfigDraft.worklogAutomation.autoCreateDailyWorklog}
|
|
onChange={(event) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
worklogAutomation: {
|
|
...current.worklogAutomation,
|
|
autoCreateDailyWorklog: event.target.checked,
|
|
},
|
|
}));
|
|
}}
|
|
>
|
|
일일 업무일지 파일 자동 생성
|
|
</Checkbox>
|
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
<Text strong>자동 생성 시각</Text>
|
|
<Input
|
|
type="time"
|
|
value={appConfigDraft.worklogAutomation.dailyCreateTime}
|
|
onChange={(event) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
worklogAutomation: {
|
|
...current.worklogAutomation,
|
|
dailyCreateTime: event.target.value || DEFAULT_APP_CONFIG.worklogAutomation.dailyCreateTime,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
<Text type="secondary">설정한 시각 이후 서버 워커가 오늘자 업무일지 요청을 자동 등록합니다.</Text>
|
|
</Space>
|
|
<Text type="secondary">반복 등록은 별도 서버 작업으로 관리합니다.</Text>
|
|
<Checkbox
|
|
checked={appConfigDraft.worklogAutomation.includeScreenshots}
|
|
onChange={(event) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
worklogAutomation: {
|
|
...current.worklogAutomation,
|
|
includeScreenshots: event.target.checked,
|
|
},
|
|
}));
|
|
}}
|
|
>
|
|
자동 캡처 스크린샷 포함
|
|
</Checkbox>
|
|
<Checkbox
|
|
checked={appConfigDraft.worklogAutomation.includeChangedFiles}
|
|
onChange={(event) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
worklogAutomation: {
|
|
...current.worklogAutomation,
|
|
includeChangedFiles: event.target.checked,
|
|
},
|
|
}));
|
|
}}
|
|
>
|
|
변경 파일 목록 자동 포함
|
|
</Checkbox>
|
|
<Checkbox
|
|
checked={appConfigDraft.worklogAutomation.includeCommandLogs}
|
|
onChange={(event) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
worklogAutomation: {
|
|
...current.worklogAutomation,
|
|
includeCommandLogs: event.target.checked,
|
|
},
|
|
}));
|
|
}}
|
|
>
|
|
실행 커맨드 로그 자동 포함
|
|
</Checkbox>
|
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
<Text strong>기본 템플릿</Text>
|
|
<Segmented
|
|
block
|
|
value={appConfigDraft.worklogAutomation.template}
|
|
options={[
|
|
{ label: '상세형', value: 'detailed' },
|
|
{ label: '간단형', value: 'simple' },
|
|
]}
|
|
onChange={(value) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
worklogAutomation: {
|
|
...current.worklogAutomation,
|
|
template: value as AppConfig['worklogAutomation']['template'],
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
<Text type="secondary">
|
|
상세형은 스크린샷, 소스, 실행 커맨드, 변경 파일 중심으로 남기고 간단형은 핵심 작업 요약 위주로 정리합니다.
|
|
</Text>
|
|
</Space>
|
|
<Space wrap>
|
|
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
|
|
설정 저장
|
|
</Button>
|
|
<Button onClick={handleResetAppConfig}>추천값 불러오기</Button>
|
|
<Button onClick={handleResetNotificationIdentity}>클라이언트 초기화</Button>
|
|
</Space>
|
|
</Space>
|
|
);
|
|
|
|
const chatSettingsPanel = (
|
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
|
<Alert
|
|
type={chatSettingsDirty ? 'warning' : 'success'}
|
|
showIcon
|
|
message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'}
|
|
description={
|
|
chatSettingsDirty
|
|
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자`
|
|
: `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조`
|
|
}
|
|
/>
|
|
|
|
<div>
|
|
<Text strong>최근 문맥 메시지 수</Text>
|
|
<Paragraph type="secondary">최근 대화 중 몇 개의 메시지를 채팅 문맥으로 참조할지 설정합니다.</Paragraph>
|
|
<InputNumber
|
|
min={1}
|
|
max={50}
|
|
value={appConfigDraft.chat.maxContextMessages}
|
|
onChange={(value) => {
|
|
setAppConfigDraft((current) => ({
|
|
...current,
|
|
chat: {
|
|
...current.chat,
|
|
maxContextMessages:
|
|
typeof value === 'number' && Number.isFinite(value)
|
|
? Math.min(50, Math.max(1, Math.round(value)))
|
|
: DEFAULT_APP_CONFIG.chat.maxContextMessages,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Text strong>최근 문맥 글자 수</Text>
|
|
<Paragraph type="secondary">최근 대화 전체에서 참조할 최대 글자 수입니다. 초과하면 전송 전에 확인 모달이 표시됩니다.</Paragraph>
|
|
<InputNumber
|
|
min={500}
|
|
max={20000}
|
|
step={100}
|
|
value={appConfigDraft.chat.maxContextChars}
|
|
onChange={(value) => {
|
|
setAppConfigDraft((current) => ({
|
|
...current,
|
|
chat: {
|
|
...current.chat,
|
|
maxContextChars:
|
|
typeof value === 'number' && Number.isFinite(value)
|
|
? Math.min(20_000, Math.max(500, Math.round(value)))
|
|
: DEFAULT_APP_CONFIG.chat.maxContextChars,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
</div>
|
|
</Space>
|
|
);
|
|
|
|
const planDefaultsPanel = (
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
<Alert
|
|
showIcon
|
|
type="info"
|
|
message="작업 자동화 기본값을 설정합니다."
|
|
description="새 작업 요청을 등록할 때 기본으로 채워지는 자동화 옵션입니다."
|
|
/>
|
|
<Alert
|
|
showIcon
|
|
type={planDefaultSettingsDirty ? 'warning' : 'success'}
|
|
message={
|
|
planDefaultSettingsDirty
|
|
? 'DB 저장값과 편집 중인 자동화 기본값이 다릅니다.'
|
|
: '자동화 기본값이 현재 DB 저장값과 같습니다.'
|
|
}
|
|
description={
|
|
planDefaultSettingsDirty
|
|
? `변경 항목: ${planDefaultDiffLabels.join(', ')} / DB 저장값 기준: 기능동작확인 ${
|
|
appConfig.planDefaults.jangsingProcessingRequired ? '완료' : '오동작'
|
|
}, ${appConfig.planDefaults.autoDeployToMain ? '메인까지 자동등록' : 'release만 반영'}, 등록 후 편집기 ${
|
|
appConfig.planDefaults.openEditorAfterCreate ? '열기' : '닫기'
|
|
} / 편집 중: 기능동작확인 ${
|
|
appConfigDraft.planDefaults.jangsingProcessingRequired ? '완료' : '오동작'
|
|
}, ${
|
|
appConfigDraft.planDefaults.autoDeployToMain ? '메인까지 자동등록' : 'release만 반영'
|
|
}, 등록 후 편집기 ${appConfigDraft.planDefaults.openEditorAfterCreate ? '열기' : '닫기'}`
|
|
: `DB 저장값 기준: 기능동작확인 ${
|
|
appConfig.planDefaults.jangsingProcessingRequired ? '완료' : '오동작'
|
|
}, ${appConfig.planDefaults.autoDeployToMain ? '메인까지 자동등록' : 'release만 반영'}, 등록 후 편집기 ${
|
|
appConfig.planDefaults.openEditorAfterCreate ? '열기' : '닫기'
|
|
}`
|
|
}
|
|
/>
|
|
{renderFeedback(appConfigFeedback, appConfigCopyFeedback, setAppConfigCopyFeedback)}
|
|
<Checkbox
|
|
checked={appConfigDraft.planDefaults.jangsingProcessingRequired}
|
|
onChange={(event) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
planDefaults: {
|
|
...current.planDefaults,
|
|
jangsingProcessingRequired: event.target.checked,
|
|
},
|
|
}));
|
|
}}
|
|
>
|
|
기능동작확인 기본값을 완료로 설정
|
|
</Checkbox>
|
|
<Checkbox
|
|
checked={appConfigDraft.planDefaults.autoDeployToMain}
|
|
onChange={(event) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
planDefaults: {
|
|
...current.planDefaults,
|
|
autoDeployToMain: event.target.checked,
|
|
},
|
|
}));
|
|
}}
|
|
>
|
|
메인까지 자동등록
|
|
</Checkbox>
|
|
<Checkbox
|
|
checked={appConfigDraft.planDefaults.openEditorAfterCreate}
|
|
onChange={(event) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
planDefaults: {
|
|
...current.planDefaults,
|
|
openEditorAfterCreate: event.target.checked,
|
|
},
|
|
}));
|
|
}}
|
|
>
|
|
새 항목 등록 후 편집기 자동 열기
|
|
</Checkbox>
|
|
<Space wrap>
|
|
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
|
|
설정 저장
|
|
</Button>
|
|
<Button onClick={handleResetAppConfig}>추천값 불러오기</Button>
|
|
</Space>
|
|
</Space>
|
|
);
|
|
|
|
const planCostPanel = (
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
<Alert
|
|
showIcon
|
|
type="info"
|
|
message="자동화 비용 표시 기준을 설정합니다."
|
|
description="진행 중 작업과 완료된 작업 모두 누적 토큰 사용량, 재처리 횟수, 종료 시각 기준 처리 시간을 반영해 예상 비용을 계산합니다. 재처리/시간 가산 비율과 상태 구간 기준값은 아래에서 직접 조정할 수 있습니다."
|
|
/>
|
|
<Alert
|
|
showIcon
|
|
type={planCostSettingsDirty ? 'warning' : 'success'}
|
|
message={
|
|
planCostSettingsDirty ? 'DB 저장값과 편집 중인 비용 설정이 다릅니다.' : '비용 설정이 현재 DB 저장값과 같습니다.'
|
|
}
|
|
description={
|
|
planCostSettingsDirty
|
|
? `변경 항목: ${planCostDiffLabels.join(', ')} / DB 저장값 기준: 백만 토큰당 ${formatCostAmount(
|
|
appConfig.planCost.baseCostPerMillionTokens,
|
|
)}, 재처리 ${appConfig.planCost.retryCostMultiplierPercent}%, ${formatPlanCostTimeMultiplierLabel(appConfig.planCost)}, 관심 ${appConfig.planCost.attentionCostThresholdMultiplier}배, 주의 ${appConfig.planCost.warningCostThresholdMultiplier}배, 높음 ${appConfig.planCost.highCostThresholdMultiplier}배 / 편집 중: 백만 토큰당 ${formatCostAmount(appConfigDraft.planCost.baseCostPerMillionTokens)}, 재처리 ${appConfigDraft.planCost.retryCostMultiplierPercent}%, ${formatPlanCostTimeMultiplierLabel(appConfigDraft.planCost)}, 관심 ${appConfigDraft.planCost.attentionCostThresholdMultiplier}배, 주의 ${appConfigDraft.planCost.warningCostThresholdMultiplier}배, 높음 ${appConfigDraft.planCost.highCostThresholdMultiplier}배`
|
|
: `DB 저장값 기준: 백만 토큰당 ${formatCostAmount(appConfig.planCost.baseCostPerMillionTokens)}, 재처리 ${appConfig.planCost.retryCostMultiplierPercent}%, ${formatPlanCostTimeMultiplierLabel(appConfig.planCost)}, 관심 ${appConfig.planCost.attentionCostThresholdMultiplier}배, 주의 ${appConfig.planCost.warningCostThresholdMultiplier}배, 높음 ${appConfig.planCost.highCostThresholdMultiplier}배`
|
|
}
|
|
/>
|
|
{renderFeedback(appConfigFeedback, appConfigCopyFeedback, setAppConfigCopyFeedback)}
|
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
<Text strong>백만 토큰당 기준 비용</Text>
|
|
<InputNumber
|
|
min={100}
|
|
max={1000000}
|
|
step={100}
|
|
style={{ width: '100%' }}
|
|
addonAfter="원"
|
|
value={appConfigDraft.planCost.baseCostPerMillionTokens}
|
|
onChange={(value) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
planCost: {
|
|
...current.planCost,
|
|
baseCostPerMillionTokens: typeof value === 'number' ? value : current.planCost.baseCostPerMillionTokens,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
<Text type="secondary">
|
|
예: {formatCostAmount(appConfigDraft.planCost.baseCostPerMillionTokens)} 기준이면 누적 토큰 100만개에서 재처리
|
|
가산 전 기본 비용이 동일하게 계산됩니다.
|
|
</Text>
|
|
</Space>
|
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
<Text strong>재처리 가산 비율</Text>
|
|
<InputNumber
|
|
min={0}
|
|
max={500}
|
|
step={1}
|
|
style={{ width: '100%' }}
|
|
addonAfter="%"
|
|
value={appConfigDraft.planCost.retryCostMultiplierPercent}
|
|
onChange={(value) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
planCost: {
|
|
...current.planCost,
|
|
retryCostMultiplierPercent:
|
|
typeof value === 'number' ? value : current.planCost.retryCostMultiplierPercent,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
<Text type="secondary">재처리 1회마다 기본 비용에 가산할 비율입니다.</Text>
|
|
</Space>
|
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
<Text strong>시간 가산 비율</Text>
|
|
<Select
|
|
style={{ width: '100%' }}
|
|
value={appConfigDraft.planCost.timeCostUnit}
|
|
options={[
|
|
{ value: 'hour', label: '시간 기준' },
|
|
{ value: 'minute', label: '분 기준' },
|
|
{ value: 'second', label: '초 기준' },
|
|
]}
|
|
onChange={(value) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
planCost: {
|
|
...current.planCost,
|
|
timeCostUnit: value,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
<InputNumber
|
|
min={0}
|
|
max={500}
|
|
step={1}
|
|
style={{ width: '100%' }}
|
|
addonAfter="%"
|
|
value={appConfigDraft.planCost.hourlyCostMultiplierPercent}
|
|
onChange={(value) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
planCost: {
|
|
...current.planCost,
|
|
hourlyCostMultiplierPercent:
|
|
typeof value === 'number' ? value : current.planCost.hourlyCostMultiplierPercent,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
<Text type="secondary">
|
|
종료 시각 기준 처리시간 {PLAN_COST_TIME_UNIT_LABELS[appConfigDraft.planCost.timeCostUnit]}마다 기본 비용에 가산할
|
|
비율입니다.
|
|
</Text>
|
|
</Space>
|
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
<Text strong>상태 구간 기준 배수</Text>
|
|
<Space direction={screens.sm ? 'horizontal' : 'vertical'} size={8} style={{ width: '100%' }}>
|
|
<InputNumber
|
|
min={0.1}
|
|
max={100}
|
|
step={0.1}
|
|
style={{ width: screens.sm ? '33.33%' : '100%' }}
|
|
addonBefore="관심"
|
|
addonAfter="배"
|
|
value={appConfigDraft.planCost.attentionCostThresholdMultiplier}
|
|
onChange={(value) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
planCost: {
|
|
...current.planCost,
|
|
attentionCostThresholdMultiplier:
|
|
typeof value === 'number' ? value : current.planCost.attentionCostThresholdMultiplier,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
<InputNumber
|
|
min={0.1}
|
|
max={100}
|
|
step={0.1}
|
|
style={{ width: screens.sm ? '33.33%' : '100%' }}
|
|
addonBefore="주의"
|
|
addonAfter="배"
|
|
value={appConfigDraft.planCost.warningCostThresholdMultiplier}
|
|
onChange={(value) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
planCost: {
|
|
...current.planCost,
|
|
warningCostThresholdMultiplier:
|
|
typeof value === 'number' ? value : current.planCost.warningCostThresholdMultiplier,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
<InputNumber
|
|
min={0.1}
|
|
max={100}
|
|
step={0.1}
|
|
style={{ width: screens.sm ? '33.33%' : '100%' }}
|
|
addonBefore="높음"
|
|
addonAfter="배"
|
|
value={appConfigDraft.planCost.highCostThresholdMultiplier}
|
|
onChange={(value) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
planCost: {
|
|
...current.planCost,
|
|
highCostThresholdMultiplier:
|
|
typeof value === 'number' ? value : current.planCost.highCostThresholdMultiplier,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
</Space>
|
|
<Text type="secondary">기준 비용 대비 몇 배부터 관심, 주의, 높음 상태로 표시할지 설정합니다.</Text>
|
|
<Text type="secondary">현재 편집값 기준: {getPlanCostThresholdPreview(appConfigDraft.planCost)}</Text>
|
|
</Space>
|
|
<Space wrap>
|
|
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
|
|
설정 저장
|
|
</Button>
|
|
<Button onClick={handleResetAppConfig}>추천값 불러오기</Button>
|
|
</Space>
|
|
</Space>
|
|
);
|
|
|
|
const automationNotificationPanel = (
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
<Alert
|
|
showIcon
|
|
type="info"
|
|
message="자동화 알림을 항목별로 설정합니다."
|
|
description="알림 사용이 켜진 기기에서만 Web Push를 받을 수 있습니다."
|
|
/>
|
|
<Alert
|
|
showIcon
|
|
type={automationNotificationSettingsDirty ? 'warning' : 'success'}
|
|
message={
|
|
automationNotificationSettingsDirty
|
|
? 'DB 저장값과 편집 중인 자동화 알림 설정이 다릅니다.'
|
|
: '자동화 알림 설정이 현재 DB 저장값과 같습니다.'
|
|
}
|
|
description={
|
|
automationNotificationSettingsDirty
|
|
? `변경 항목: ${automationNotificationDiffLabels.join(', ')}`
|
|
: `알림 항목: ${AUTOMATION_NOTIFICATION_OPTIONS.map((option) => option.label).join(', ')}`
|
|
}
|
|
/>
|
|
{appConfigFeedback ? (
|
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
|
<Alert
|
|
showIcon
|
|
type={appConfigFeedback.tone}
|
|
message={appConfigFeedback.message}
|
|
action={
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
aria-label="메시지 복사"
|
|
icon={<CopyOutlined />}
|
|
onClick={() => {
|
|
void copyText(appConfigFeedback.message)
|
|
.then(() => {
|
|
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
|
})
|
|
.catch(() => {
|
|
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
|
});
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
{appConfigCopyFeedback ? (
|
|
<Alert showIcon type={appConfigCopyFeedback.tone} message={appConfigCopyFeedback.message} />
|
|
) : null}
|
|
</Space>
|
|
) : null}
|
|
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
|
{AUTOMATION_NOTIFICATION_OPTIONS.map((option) => (
|
|
<Checkbox
|
|
key={option.key}
|
|
checked={appConfigDraft.automation[option.key]}
|
|
onChange={(event) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
automation: {
|
|
...current.automation,
|
|
[option.key]: event.target.checked,
|
|
},
|
|
}));
|
|
}}
|
|
>
|
|
<Space direction="vertical" size={0}>
|
|
<Text>{option.label}</Text>
|
|
<Text type="secondary">{option.description}</Text>
|
|
</Space>
|
|
</Checkbox>
|
|
))}
|
|
</Space>
|
|
<Space wrap>
|
|
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
|
|
설정 저장
|
|
</Button>
|
|
<Button onClick={handleResetAppConfig}>추천값 불러오기</Button>
|
|
</Space>
|
|
</Space>
|
|
);
|
|
|
|
const gestureShortcutsPanel = (
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
<Alert
|
|
showIcon
|
|
type="info"
|
|
message="모바일 제스처와 같은 액션을 키보드 단축키로도 실행합니다."
|
|
description="저장 후 즉시 반영됩니다. `Mod`는 macOS에서는 Command, Windows/Linux에서는 Ctrl로 처리합니다."
|
|
/>
|
|
<Alert
|
|
showIcon
|
|
type={gestureShortcutSettingsDirty ? 'warning' : 'success'}
|
|
message={
|
|
gestureShortcutSettingsDirty
|
|
? 'DB 저장값과 편집 중인 제스처 단축키 설정이 다릅니다.'
|
|
: '제스처 단축키 설정이 현재 DB 저장값과 같습니다.'
|
|
}
|
|
description={
|
|
gestureShortcutSettingsDirty
|
|
? `변경 항목: ${gestureShortcutDiffLabels.join(', ')} / DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, Window UI 검색 ${appConfig.gestureShortcuts.openWindowSearch} / 편집 중: 통합 검색 ${appConfigDraft.gestureShortcuts.openSearch}, Window UI 검색 ${appConfigDraft.gestureShortcuts.openWindowSearch}`
|
|
: `DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, Window UI 검색 ${appConfig.gestureShortcuts.openWindowSearch}`
|
|
}
|
|
/>
|
|
{appConfigFeedback ? (
|
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
|
<Alert
|
|
showIcon
|
|
type={appConfigFeedback.tone}
|
|
message={appConfigFeedback.message}
|
|
action={
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
aria-label="메시지 복사"
|
|
icon={<CopyOutlined />}
|
|
onClick={() => {
|
|
void copyText(appConfigFeedback.message)
|
|
.then(() => {
|
|
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
|
})
|
|
.catch(() => {
|
|
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
|
});
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
{appConfigCopyFeedback ? (
|
|
<Alert showIcon type={appConfigCopyFeedback.tone} message={appConfigCopyFeedback.message} />
|
|
) : null}
|
|
</Space>
|
|
) : null}
|
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
<Text strong>상단 우측 당기기 액션</Text>
|
|
<Input
|
|
value={appConfigDraft.gestureShortcuts.openSearch}
|
|
placeholder={DEFAULT_APP_CONFIG.gestureShortcuts.openSearch}
|
|
onChange={(event) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
gestureShortcuts: {
|
|
...current.gestureShortcuts,
|
|
openSearch: event.target.value,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
<Text type="secondary">통합 검색을 엽니다. 예: `Mod+K`, `Alt+/`</Text>
|
|
</Space>
|
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
<Text strong>오른쪽 가운데 왼쪽 당기기 액션</Text>
|
|
<Input
|
|
value={appConfigDraft.gestureShortcuts.openWindowSearch}
|
|
placeholder={DEFAULT_APP_CONFIG.gestureShortcuts.openWindowSearch}
|
|
onChange={(event) => {
|
|
updateAppConfigDraft((current) => ({
|
|
...current,
|
|
gestureShortcuts: {
|
|
...current.gestureShortcuts,
|
|
openWindowSearch: event.target.value,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
<Text type="secondary">선택한 항목을 `Window UI`로 여는 검색을 엽니다. 예: `Mod+Shift+K`</Text>
|
|
</Space>
|
|
<Space wrap>
|
|
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
|
|
설정 저장
|
|
</Button>
|
|
<Button onClick={handleResetAppConfig}>추천값 불러오기</Button>
|
|
</Space>
|
|
</Space>
|
|
);
|
|
|
|
const settingsMenu = (
|
|
<div className="app-header__settings-menu">
|
|
{hasAccess ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="app-header__settings-item"
|
|
onClick={() => {
|
|
setSettingsOpen(false);
|
|
onOpenPlanQuickFilter('working');
|
|
}}
|
|
>
|
|
<span className="app-header__settings-icon">
|
|
<ProfileOutlined />
|
|
<span
|
|
className={`app-header__status-dot ${
|
|
planShortcutCounts.working > 0
|
|
? 'app-header__status-dot--active'
|
|
: 'app-header__status-dot--inactive'
|
|
}`}
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
<span className="app-header__settings-label">
|
|
작업중 항목
|
|
<Text type="secondary"> {planShortcutCounts.working}건</Text>
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="app-header__settings-item"
|
|
onClick={() => {
|
|
setSettingsOpen(false);
|
|
onOpenPlanQuickFilter('release-pending-main');
|
|
}}
|
|
>
|
|
<span className="app-header__settings-icon">
|
|
<ProfileOutlined />
|
|
<span
|
|
className={`app-header__status-dot ${
|
|
planShortcutCounts.releasePendingMain > 0
|
|
? 'app-header__status-dot--warning'
|
|
: 'app-header__status-dot--inactive'
|
|
}`}
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
<span className="app-header__settings-label">
|
|
release 상태 작업
|
|
<Text type="secondary"> {planShortcutCounts.releasePendingMain}건</Text>
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="app-header__settings-item"
|
|
onClick={() => {
|
|
setSettingsOpen(false);
|
|
onOpenPlanQuickFilter('automation-failed');
|
|
}}
|
|
>
|
|
<span className="app-header__settings-icon">
|
|
<ReloadOutlined />
|
|
<span
|
|
className={`app-header__status-dot ${
|
|
planShortcutCounts.automationFailed > 0
|
|
? 'app-header__status-dot--warning'
|
|
: 'app-header__status-dot--inactive'
|
|
}`}
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
<span className="app-header__settings-label">
|
|
자동화 실패
|
|
<Text type="secondary"> {planShortcutCounts.automationFailed}건</Text>
|
|
</span>
|
|
</button>
|
|
</>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="app-header__settings-item"
|
|
disabled={!hasAccess}
|
|
onClick={() => {
|
|
openSettingsModal('appSettings', 'planDefaults');
|
|
}}
|
|
>
|
|
<span className="app-header__settings-icon">
|
|
<SettingOutlined />
|
|
<span className={`app-header__status-dot ${settingsStatusClassName}`} aria-hidden="true" />
|
|
</span>
|
|
<span className="app-header__settings-label">앱 설정</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="app-header__settings-item"
|
|
disabled={!hasAccess}
|
|
onClick={() => {
|
|
openSettingsModal('notification');
|
|
}}
|
|
>
|
|
<span className="app-header__settings-icon">
|
|
<BellOutlined />
|
|
<span
|
|
className={`app-header__status-dot ${notificationStatusClassName}`}
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
<span className="app-header__settings-label">알림</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="app-header__settings-item"
|
|
onClick={() => {
|
|
openSettingsModal('token');
|
|
}}
|
|
>
|
|
<span className="app-header__settings-icon">
|
|
<ProfileOutlined />
|
|
<span
|
|
className={`app-header__status-dot ${
|
|
hasAccess ? 'app-header__status-dot--active' : 'app-header__status-dot--inactive'
|
|
}`}
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
<span className="app-header__settings-label">권한</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="app-header__settings-item"
|
|
onClick={() => {
|
|
openSettingsModal('update');
|
|
}}
|
|
>
|
|
<span className="app-header__settings-icon">
|
|
<ReloadOutlined />
|
|
<span className={`app-header__status-dot ${settingsStatusClassName}`} aria-hidden="true" />
|
|
</span>
|
|
<span className="app-header__settings-label">업데이트</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{modalContextHolder}
|
|
<Header className="app-header">
|
|
<Space size={12} className="app-header__row">
|
|
<Space size={12} className="app-header__menu-side">
|
|
<Button
|
|
type="text"
|
|
icon={sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
|
onClick={onToggleSidebar}
|
|
/>
|
|
<Segmented
|
|
value={headerTopMenu}
|
|
options={
|
|
hasAccess
|
|
? [
|
|
{ label: 'Docs', value: 'docs', icon: <FileMarkdownOutlined /> },
|
|
{ label: '작업', value: 'plans', icon: <ProfileOutlined /> },
|
|
]
|
|
: [{ label: 'Docs', value: 'docs', icon: <FileMarkdownOutlined /> }]
|
|
}
|
|
onChange={(value) => {
|
|
onChangeTopMenu(value as 'docs' | 'plans');
|
|
}}
|
|
/>
|
|
</Space>
|
|
|
|
<Space size={4} className="app-header__actions">
|
|
{hasAccess ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className={connectionIndicatorClassName}
|
|
aria-label={chatConnectionLabel}
|
|
title={chatConnectionLabel}
|
|
onClick={() => {
|
|
setIsRuntimeModalOpen(true);
|
|
}}
|
|
>
|
|
<span className="app-header__settings-icon" aria-hidden="true">
|
|
<ApiOutlined />
|
|
<span className={`app-header__status-dot ${chatConnectionStatusClassName}`} />
|
|
</span>
|
|
{runningRuntimeCount > 0 ? (
|
|
<span
|
|
className={connectionCountBadgeClassName}
|
|
aria-label={`실행 중 요청 ${runningRuntimeCount}건`}
|
|
title={`실행 중 요청 ${runningRuntimeCount}건`}
|
|
>
|
|
{runningRuntimeBadgeLabel}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
<HeaderMessageCenter isMobileViewport={isMobileViewport} />
|
|
<Dropdown
|
|
trigger={['click']}
|
|
open={settingsOpen}
|
|
onOpenChange={setSettingsOpen}
|
|
placement="bottomRight"
|
|
popupRender={() => settingsMenu}
|
|
>
|
|
{settingsTriggerButton}
|
|
</Dropdown>
|
|
</>
|
|
) : (
|
|
tokenTriggerButton
|
|
)}
|
|
</Space>
|
|
</Space>
|
|
</Header>
|
|
|
|
<Modal
|
|
title="Codex 런타임"
|
|
open={isRuntimeModalOpen}
|
|
footer={null}
|
|
onCancel={() => {
|
|
setIsRuntimeModalOpen(false);
|
|
}}
|
|
>
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
<div className="app-header__runtime-summary">
|
|
<div className="app-header__runtime-summary-card" title="실행 중 요청">
|
|
<LoadingOutlined />
|
|
<Text type="secondary">실행</Text>
|
|
<Text strong>{runningRuntimeCount}</Text>
|
|
</div>
|
|
<div className="app-header__runtime-summary-card" title="대기 요청">
|
|
<ClockCircleOutlined />
|
|
<Text type="secondary">대기</Text>
|
|
<Text strong>{queuedRuntimeCount}</Text>
|
|
</div>
|
|
<div className="app-header__runtime-summary-card" title="활성 세션">
|
|
<ProfileOutlined />
|
|
<Text type="secondary">활성</Text>
|
|
<Text strong>{runtimeSessionCount}</Text>
|
|
</div>
|
|
</div>
|
|
<Alert
|
|
type={
|
|
chatConnection.connectionState === 'connected'
|
|
? 'success'
|
|
: chatConnection.connectionState === 'connecting'
|
|
? 'info'
|
|
: 'warning'
|
|
}
|
|
showIcon
|
|
message={
|
|
chatConnection.connectionState === 'connected'
|
|
? '웹소켓으로 실시간 반영 중입니다.'
|
|
: chatConnection.connectionState === 'connecting'
|
|
? '웹소켓을 다시 연결하는 중입니다.'
|
|
: '웹소켓 연결이 끊겨 최신 상태가 지연될 수 있습니다.'
|
|
}
|
|
description={
|
|
chatRuntimeSnapshot?.generatedAt
|
|
? `마지막 반영 ${formatRuntimeRelativeLabel(chatRuntimeSnapshot.generatedAt)}`
|
|
: '아직 수신된 런타임 정보가 없습니다.'
|
|
}
|
|
/>
|
|
<div className="app-header__runtime-list">
|
|
<Text strong>실행 중 요청</Text>
|
|
{chatRuntimeSnapshot && chatRuntimeSnapshot.running.length > 0 ? (
|
|
chatRuntimeSnapshot.running.map((item) => (
|
|
<div key={item.requestId} className="app-header__runtime-list-item">
|
|
<div className="app-header__runtime-list-row">
|
|
<div className="app-header__runtime-list-copy">
|
|
<Text strong>{getConversationLabel(item.sessionId)}</Text>
|
|
<Text className="app-header__runtime-summary-text">{item.summary || '요약 없음'}</Text>
|
|
<div className="app-header__runtime-meta">
|
|
<span className="app-header__runtime-badge">
|
|
<LoadingOutlined />
|
|
실행
|
|
</span>
|
|
<span className="app-header__runtime-badge">{formatRuntimeRelativeLabel(item.startedAt)}</span>
|
|
</div>
|
|
</div>
|
|
<Space size={8} wrap>
|
|
<Button
|
|
size="small"
|
|
type="default"
|
|
onClick={() => {
|
|
void openRuntimeLog(item.requestId);
|
|
}}
|
|
>
|
|
로그 보기
|
|
</Button>
|
|
<Button size="small" type="default" onClick={() => navigateToConversation(item.sessionId)}>
|
|
이동
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<Text type="secondary">현재 실행 중인 요청이 없습니다.</Text>
|
|
)}
|
|
</div>
|
|
<div className="app-header__runtime-list">
|
|
<Text strong>대기 요청</Text>
|
|
{chatRuntimeSnapshot && chatRuntimeSnapshot.queued.length > 0 ? (
|
|
chatRuntimeSnapshot.queued.map((item) => (
|
|
<div key={item.requestId} className="app-header__runtime-list-item">
|
|
<div className="app-header__runtime-list-row">
|
|
<div className="app-header__runtime-list-copy">
|
|
<Text strong>{getConversationLabel(item.sessionId)}</Text>
|
|
<Text className="app-header__runtime-summary-text">{item.summary || '요약 없음'}</Text>
|
|
<div className="app-header__runtime-meta">
|
|
<span className="app-header__runtime-badge">
|
|
<ClockCircleOutlined />
|
|
대기
|
|
</span>
|
|
<span className="app-header__runtime-badge">{formatRuntimeRelativeLabel(item.enqueuedAt)}</span>
|
|
</div>
|
|
</div>
|
|
<Button size="small" type="default" onClick={() => navigateToConversation(item.sessionId)}>
|
|
이동
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<Text type="secondary">현재 대기 중인 요청이 없습니다.</Text>
|
|
)}
|
|
</div>
|
|
<div className="app-header__runtime-list">
|
|
<Text strong>최근 작업</Text>
|
|
{chatRuntimeSnapshot && chatRuntimeSnapshot.recent.length > 0 ? (
|
|
chatRuntimeSnapshot.recent.map((item) => (
|
|
<div key={`recent-${item.requestId}-${item.lastUpdatedAt}`} className="app-header__runtime-list-item">
|
|
<div className="app-header__runtime-list-row">
|
|
<div className="app-header__runtime-list-copy">
|
|
<Text strong>{getConversationLabel(item.sessionId)}</Text>
|
|
<Text className="app-header__runtime-summary-text">{item.summary || '요약 없음'}</Text>
|
|
<div className="app-header__runtime-meta">
|
|
<span className="app-header__runtime-badge">{buildRuntimeTerminalLabel(item.terminalStatus)}</span>
|
|
<span className="app-header__runtime-badge">
|
|
{formatRuntimeRelativeLabel(item.lastUpdatedAt)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Space size={8} wrap>
|
|
<Button
|
|
size="small"
|
|
type="default"
|
|
onClick={() => {
|
|
void openRuntimeLog(item.requestId);
|
|
}}
|
|
>
|
|
로그 보기
|
|
</Button>
|
|
<Button size="small" type="default" onClick={() => navigateToConversation(item.sessionId)}>
|
|
이동
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<Text type="secondary">최근 종료된 작업이 없습니다.</Text>
|
|
)}
|
|
</div>
|
|
</Space>
|
|
</Modal>
|
|
|
|
<Drawer
|
|
open={isRuntimeLogDrawerOpen}
|
|
title="실행 로그"
|
|
placement="right"
|
|
width="100vw"
|
|
onClose={() => {
|
|
setIsRuntimeLogDrawerOpen(false);
|
|
setRuntimeLogDetail(null);
|
|
setRuntimeLogError('');
|
|
}}
|
|
styles={{
|
|
body: {
|
|
padding: 24,
|
|
},
|
|
}}
|
|
>
|
|
{runtimeLogLoading ? (
|
|
<Text type="secondary">로그를 불러오는 중입니다.</Text>
|
|
) : runtimeLogError ? (
|
|
<Text type="danger">{runtimeLogError}</Text>
|
|
) : runtimeLogDetail ? (
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
{runtimeLogDetail.item?.sessionId ? (
|
|
<Button
|
|
size="small"
|
|
type="default"
|
|
style={{ alignSelf: 'flex-start' }}
|
|
onClick={() => {
|
|
navigateToConversation(runtimeLogDetail.item?.sessionId ?? '');
|
|
setIsRuntimeLogDrawerOpen(false);
|
|
}}
|
|
>
|
|
채팅방 이동
|
|
</Button>
|
|
) : null}
|
|
<div className="app-chat-runtime__job-meta">
|
|
<Text type="secondary">요청: {runtimeLogDetail.item?.requestId ?? '-'}</Text>
|
|
<Text type="secondary">세션: {runtimeLogDetail.item?.sessionId ?? '-'}</Text>
|
|
<Text type="secondary">마지막 갱신: {formatRuntimeTimestamp(runtimeLogDetail.lastUpdatedAt)}</Text>
|
|
<Text type="secondary">
|
|
종료 상태: {buildRuntimeTerminalLabel(runtimeLogDetail.terminalStatus)}
|
|
</Text>
|
|
</div>
|
|
<Paragraph className="app-chat-runtime__job-summary">
|
|
{runtimeLogDetail.item?.summary ?? '요약 없음'}
|
|
</Paragraph>
|
|
<pre className="app-chat-runtime__log-viewer">
|
|
{runtimeLogDetail.logs.length > 0 ? runtimeLogDetail.logs.join('\n') : '아직 기록된 로그가 없습니다.'}
|
|
</pre>
|
|
</Space>
|
|
) : (
|
|
<Text type="secondary">표시할 로그가 없습니다.</Text>
|
|
)}
|
|
</Drawer>
|
|
|
|
<Modal
|
|
title={getSettingsModalTitle(activeSettingsModal)}
|
|
open={settingsModalOpen}
|
|
footer={null}
|
|
confirmLoading={notificationLoading}
|
|
onCancel={() => {
|
|
setSettingsModalOpen(false);
|
|
}}
|
|
>
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
{activeSettingsModal === 'appSettings' ? (
|
|
<>
|
|
<Segmented
|
|
block
|
|
value={activeAppSettingsCategory}
|
|
options={[...APP_SETTINGS_CATEGORIES]}
|
|
onChange={(value) => {
|
|
setActiveAppSettingsCategory(value as AppSettingsCategoryKey);
|
|
}}
|
|
/>
|
|
<Select
|
|
value={activeAppSettingsSection}
|
|
options={activeAppSettingsSectionOptions}
|
|
onChange={(value) => {
|
|
setActiveAppSettingsSection(value);
|
|
}}
|
|
/>
|
|
{activeAppSettingsSection === 'planDefaults' ? planDefaultsPanel : null}
|
|
{activeAppSettingsSection === 'planCost' ? planCostPanel : null}
|
|
{activeAppSettingsSection === 'chatSettings' ? chatSettingsPanel : null}
|
|
{activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null}
|
|
{activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null}
|
|
{activeAppSettingsSection === 'gestureShortcuts' ? gestureShortcutsPanel : null}
|
|
</>
|
|
) : null}
|
|
{activeSettingsModal === 'notification' ? (
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
|
알림을 `On`으로 바꾸면 현재 기기의 브라우저 권한을 확인한 뒤 Web Push 구독을 생성하고 서버에 자동 등록합니다.
|
|
</Paragraph>
|
|
<Text type="secondary">클라이언트 알림 권한 상태: {getClientNotificationPermissionLabel(clientNotificationPermission)}</Text>
|
|
<Text type="secondary">PWA 실행 상태: {isStandaloneMode ? '홈 화면 앱' : '브라우저 탭'}</Text>
|
|
<Text type="secondary">서버 Web Push 설정: {webPushConfigured ? '준비됨' : '미설정'}</Text>
|
|
{isAppleMobileDevice() && !isStandaloneMode ? (
|
|
<Text type="warning">아이폰에서는 홈 화면에 추가한 뒤 연 PWA에서만 웹 푸시를 사용할 수 있습니다.</Text>
|
|
) : null}
|
|
{clientNotificationPermission === 'denied' ? (
|
|
<Text type="warning">브라우저 또는 기기 설정에서 이 사이트의 알림 권한을 허용한 뒤 다시 시도해 주세요.</Text>
|
|
) : null}
|
|
{clientNotificationPermission === 'unsupported' ? (
|
|
<Text type="warning">현재 환경에서는 Web Push 알림 등록을 지원하지 않습니다.</Text>
|
|
) : null}
|
|
{!hasSecureOrigin() ? (
|
|
<Text type="warning">알림은 HTTPS 또는 localhost 환경에서만 사용할 수 있습니다.</Text>
|
|
) : null}
|
|
{renderFeedback(notificationFeedback, notificationCopyFeedback, setNotificationCopyFeedback)}
|
|
<Checkbox
|
|
checked={notificationEnabled}
|
|
disabled={notificationLoading}
|
|
onChange={(event) => {
|
|
void syncNotificationEnabled(event.target.checked);
|
|
}}
|
|
>
|
|
알림 사용
|
|
</Checkbox>
|
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
<Text strong>PWA 토큰 등록</Text>
|
|
<Text type="secondary">
|
|
PWA에서 전달받은 알림 토큰은 권한 토큰과 별도로 등록합니다.
|
|
</Text>
|
|
<Text type="secondary">PWA 토큰 상태: {registeredPwaNotificationToken ? '등록됨' : '미등록'}</Text>
|
|
<Input.TextArea
|
|
value={pwaNotificationTokenInput}
|
|
placeholder="PWA에서 받은 알림 토큰 입력"
|
|
autoSize={{ minRows: 2, maxRows: 4 }}
|
|
onChange={(event) => {
|
|
setPwaNotificationTokenInput(event.target.value);
|
|
}}
|
|
/>
|
|
{renderFeedback(
|
|
pwaNotificationTokenFeedback,
|
|
pwaNotificationTokenCopyFeedback,
|
|
setPwaNotificationTokenCopyFeedback,
|
|
)}
|
|
<Space wrap>
|
|
<Button
|
|
type="primary"
|
|
loading={pwaNotificationTokenSaving}
|
|
onClick={() => {
|
|
void handleRegisterPwaNotificationToken();
|
|
}}
|
|
>
|
|
PWA 토큰 등록
|
|
</Button>
|
|
<Button
|
|
disabled={pwaNotificationTokenSaving || !registeredPwaNotificationToken}
|
|
onClick={() => {
|
|
void handleClearPwaNotificationToken();
|
|
}}
|
|
>
|
|
PWA 토큰 제거
|
|
</Button>
|
|
</Space>
|
|
</Space>
|
|
</Space>
|
|
) : null}
|
|
{activeSettingsModal === 'token' ? (
|
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
<Text strong>토큰 관리</Text>
|
|
<Text type="secondary">
|
|
관리자에게 전달받은 권한 토큰을 등록한 사용자만 Codex 채팅과 새 메모 등록을 사용할 수 있습니다.
|
|
</Text>
|
|
<Text type="secondary">권한 상태: {hasAccess ? '허용됨' : '차단됨'}</Text>
|
|
<Input.Password
|
|
value={tokenInput}
|
|
placeholder="권한 토큰 입력"
|
|
onChange={(event) => {
|
|
setTokenInput(event.target.value);
|
|
}}
|
|
/>
|
|
{tokenFeedback ? <Alert showIcon type={tokenFeedback.tone} message={tokenFeedback.message} /> : null}
|
|
<Space wrap>
|
|
<Button type="primary" onClick={handleRegisterToken}>
|
|
토큰 등록
|
|
</Button>
|
|
<Button onClick={handleClearToken} disabled={!registeredToken}>
|
|
토큰 제거
|
|
</Button>
|
|
</Space>
|
|
</Space>
|
|
) : null}
|
|
{activeSettingsModal === 'update' ? (
|
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
<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>
|
|
<Text type="secondary">
|
|
운영
|
|
<span
|
|
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(prodServerStatus)}`}
|
|
aria-label={getServerVersionStatusTitle(prodServerStatus, '운영')}
|
|
title={getServerVersionStatusTitle(prodServerStatus, '운영')}
|
|
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
|
|
aria-hidden="true"
|
|
/>
|
|
</Text>
|
|
<Text type="secondary">{formatDateTimeLabel(prodServerStatus?.runningBuiltAt ?? null)}</Text>
|
|
{renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)}
|
|
<Button
|
|
block
|
|
icon={workServerStatusLoading ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
|
loading={workServerStatusLoading}
|
|
disabled={!canRefreshWorkServerStatus}
|
|
onClick={() => {
|
|
void refreshUpdateTargets();
|
|
}}
|
|
>
|
|
업데이트 확인
|
|
</Button>
|
|
<Text strong style={{ marginTop: 8 }}>
|
|
캐시 / 스토리지 초기화
|
|
</Text>
|
|
<Text type="secondary">
|
|
권한, 앱 설정, 푸시/서비스워커 등록은 유지하고 화면 상태와 앱 캐시만 초기화합니다.
|
|
</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">전체 재기동은 TEST와 WORK 서버만 순서대로 진행합니다.</Text>
|
|
<Text type="secondary">
|
|
테스트 마지막 확인: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
|
|
</Text>
|
|
<Text type="secondary">
|
|
워크 마지막 확인: {formatDateTimeLabel(workServerStatus?.checkedAt ?? null)}
|
|
</Text>
|
|
{!hasAccess ? (
|
|
<Text type="warning">서버 재기동은 권한 토큰 등록 후 사용할 수 있습니다.</Text>
|
|
) : null}
|
|
{renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)}
|
|
<Space direction={screens.xs ? 'vertical' : 'horizontal'} style={{ width: '100%' }}>
|
|
<Button
|
|
block={screens.xs}
|
|
icon={serverRestartingKey === 'test' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
|
loading={serverRestartingKey === 'test'}
|
|
disabled={!canRestartServers}
|
|
onClick={() => {
|
|
void handleRestartSingleServer('test');
|
|
}}
|
|
>
|
|
테스트 재기동
|
|
</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={serverRestartingKey === 'all' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
|
loading={serverRestartingKey === 'all'}
|
|
disabled={!canRestartServers}
|
|
onClick={() => {
|
|
void handleRestartBothServers();
|
|
}}
|
|
>
|
|
전체 재기동
|
|
</Button>
|
|
</Space>
|
|
<Text strong style={{ marginTop: 8 }}>
|
|
PROD 빌드 반영
|
|
</Text>
|
|
<Text type="secondary">운영 마지막 확인: {formatDateTimeLabel(prodServerStatus?.checkedAt ?? null)}</Text>
|
|
<Text type="secondary">
|
|
PROD 컨테이너는 전체 재기동에 포함하지 않고, 별도 확인 후 빌드와 재기동을 진행합니다.
|
|
</Text>
|
|
<Button
|
|
type="primary"
|
|
danger
|
|
block
|
|
icon={serverRestartingKey === 'prod' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
|
loading={serverRestartingKey === 'prod'}
|
|
disabled={!canRestartServers}
|
|
onClick={handleConfirmRestartProdServer}
|
|
>
|
|
PROD 빌드 반영
|
|
</Button>
|
|
</Space>
|
|
) : null}
|
|
</Space>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|