feat: refine codex live chat context flows
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
useAutomationTypeRegistry,
|
||||
} from '../../app/main/automationTypeAccess';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import {
|
||||
createBoardPost,
|
||||
deleteBoardPost,
|
||||
@@ -123,23 +124,6 @@ function resolveBoardAttachmentSessionId(
|
||||
return draftAttachmentSessionIdRef.current;
|
||||
}
|
||||
|
||||
async function copyText(value: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = value;
|
||||
textArea.setAttribute('readonly', '');
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function hasBoardPostAutomation(item: BoardPost | null | undefined) {
|
||||
if (!item) {
|
||||
return false;
|
||||
@@ -562,7 +546,7 @@ export function BoardPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
await copyText(draft.content);
|
||||
await copyTextToClipboard(draft.content);
|
||||
messageApi.success('공통 메모를 복사했습니다.');
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '공통 메모 복사에 실패했습니다.');
|
||||
|
||||
@@ -1,50 +1,16 @@
|
||||
# Layout Feature
|
||||
|
||||
프로젝트 종속적인 레이아웃은 `src/features/layout` 아래에서 관리합니다.
|
||||
`src/features/layout`은 현재 프로젝트 전용 레이아웃 기능을 둡니다.
|
||||
|
||||
## 포함 항목
|
||||
## 포함 범위
|
||||
|
||||
- 컴포넌트 샘플 레이아웃
|
||||
- 위젯 샘플 레이아웃
|
||||
- Markdown preview 리스트 레이아웃
|
||||
- `Layout Editor`와 저장 레이아웃 흐름
|
||||
- 문서 미리보기 레이아웃
|
||||
- `Layout Editor`
|
||||
|
||||
## 규칙
|
||||
## 기준
|
||||
|
||||
- 공통 레이아웃 시스템이 아니라 현재 프로젝트 화면에 종속되면 `features/layout`에 배치
|
||||
- 공통 재사용 가능성이 높으면 `components` 또는 `widgets`로 승격 검토
|
||||
|
||||
## Layout Editor 기준
|
||||
|
||||
`Layout Editor`는 위젯 자체의 기본 기능을 정의하는 화면이 아닙니다. 이 화면은 레이아웃 안에 배치된 컴포넌트가 현재 화면에서 어떤 역할을 해야 하는지 기술하는 편집기입니다.
|
||||
|
||||
용어 기준:
|
||||
|
||||
- `컴포넌트 기능 명세`: 현재 레이아웃에 배치된 컴포넌트의 화면 동작, 상호작용, 구현 포인트를 적는 항목
|
||||
- `저장 레이아웃`: 구조, 배치, 기능 명세를 함께 저장한 편집 결과
|
||||
- `Codex 요청`: 저장된 레이아웃과 그 안의 기능 명세를 기준으로 구현 요청을 만드는 액션
|
||||
|
||||
허용 범위:
|
||||
|
||||
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
|
||||
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
|
||||
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
|
||||
- 가능하면 공통 컴포넌트나 위젯 본체를 직접 수정하기보다, 현재 레이아웃에서 필요한 `props`를 내려 동작과 표시를 조정하는 방식으로 구현한다
|
||||
- `Layout Editor 실행` 요청은 기본적으로 "현재 화면 조합을 props/배치/상호작용으로 맞춘다"는 의미로 해석하고, 공통 패키지 내부 구현 변경은 최후 수단으로만 검토한다
|
||||
|
||||
구현 우선순위:
|
||||
|
||||
- 1순위는 기존 컴포넌트/위젯 조합과 `props` 조정만으로 요구사항을 만족시키는 것이다
|
||||
- 2순위는 현재 프로젝트 전용 래퍼, feature 레이어, 어댑터를 추가해 공통 패키지 수정 없이 화면 요구를 흡수하는 것이다
|
||||
- 공통 컴포넌트/위젯 수정이 정말 필요할 때만 기존 사용처를 모두 확인한 뒤 제한적으로 수정한다
|
||||
- 공통 컴포넌트/위젯에 새 동작을 추가할 때는 기본값 `props`를 기존 동작과 동일하게 유지해, 명시적으로 opt-in한 화면만 달라지게 만든다
|
||||
|
||||
금지 해석:
|
||||
|
||||
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
|
||||
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
|
||||
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
|
||||
- 현재 레이아웃 요구를 맞추기 위해 공통 위젯 내부 코드를 바로 덧대고, 그 부작용을 기존 화면이 함께 떠안게 만드는 방식은 지양한다
|
||||
- 기존 화면 영향도 검토 없이 공통 컴포넌트/위젯의 기본 동작, 기본 스타일, 기본 데이터 흐름을 바꾸지 않는다
|
||||
|
||||
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.
|
||||
- 현재 프로젝트 화면에만 의미가 있으면 여기 둡니다.
|
||||
- 공통 재사용 가치가 높아지면 `src/components` 또는 `src/widgets`로 승격합니다.
|
||||
- `Layout Editor`의 기능 명세는 위젯 스펙 문서가 아니라 현재 레이아웃 안에서의 역할 설명으로 취급합니다.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Features Overview
|
||||
|
||||
이 영역은 현재 프로젝트에 종속된 기능과 화면 구성을 관리합니다.
|
||||
`src/features`는 프로젝트 전용 기능 영역입니다.
|
||||
|
||||
## 목적
|
||||
## 구조 기준
|
||||
|
||||
- 공통 `components`, `widgets`와 분리된 프로젝트 전용 기능 관리
|
||||
- 기능별 문서, 화면 조합, 레이아웃을 한 곳에서 정리
|
||||
- 향후 `dashboard`, `sampleBoard`, `docsViewer` 같은 프로젝트 전용 기능 확장
|
||||
- 공통 UI로 분리하기 어려운 화면 로직은 `src/features`에 둡니다.
|
||||
- 재사용 가능한 UI는 `src/components`, 카드형 조합은 `src/widgets`로 분리합니다.
|
||||
- 레이아웃 전용 기능은 `src/features/layout`에서 관리합니다.
|
||||
|
||||
@@ -1160,7 +1160,7 @@ export function PlanBoardPage({
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '작업 목록을 불러오지 못했습니다.');
|
||||
setErrorMessage(error instanceof Error ? error.message : '자동화 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -1703,60 +1703,66 @@ export function PlanBoardPage({
|
||||
? currentReleaseUsageSummaryByHistoryId.get(selectedSourceWork.id) ?? null
|
||||
: null;
|
||||
const memoRows = screens.md ? 18 : 9;
|
||||
const isMobileAutomationLayout = !screens.md;
|
||||
const overviewActionContent = (
|
||||
<Space wrap>
|
||||
<Space size={8} className="plan-board-page__auto-refresh-control">
|
||||
<LongPressButton
|
||||
onClick={() => void loadItems(statusFilter)}
|
||||
onLongPress={() => {
|
||||
if (!hasAccess) {
|
||||
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
handleAutoRefreshToggle();
|
||||
}}
|
||||
longPressMs={AUTO_REFRESH_LONG_PRESS_MS}
|
||||
loading={loading}
|
||||
title="길게 눌러 자동조회 On/Off"
|
||||
className={`plan-board-page__auto-refresh-button${
|
||||
isAutoRefreshRunning ? ' plan-board-page__auto-refresh-button--active' : ''
|
||||
}`}
|
||||
>
|
||||
조회
|
||||
</LongPressButton>
|
||||
{isAutoRefreshRunning ? (
|
||||
<Text className="plan-board-page__auto-refresh-countdown">
|
||||
자동 조회까지 {autoRefreshCountdownSeconds}초
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{listRequestMeta ? (
|
||||
<Text type="secondary">
|
||||
최근 조회 {formatResponseBytes(listRequestMeta.responseBytes)} · {listRequestMeta.durationMs}ms
|
||||
</Text>
|
||||
) : null}
|
||||
<Button onClick={handleCreateNew} disabled={isRestrictedClient}>
|
||||
새 메모
|
||||
</Button>
|
||||
<Button onClick={() => void handleSetup()} disabled={isRestrictedClient}>
|
||||
테이블 생성
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
return (
|
||||
<div className="plan-board-page">
|
||||
{contextHolder}
|
||||
|
||||
<Card className="plan-board-page__overview" bordered={false}>
|
||||
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||
<div>
|
||||
<Title level={4}>자동화</Title>
|
||||
<Paragraph className="plan-board-page__intro">
|
||||
작업 메모와 이력, 증적을 확인하고 필요한 내용을 수동으로 정리합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
{isMobileAutomationLayout ? null : (
|
||||
<Card className="plan-board-page__overview" bordered={false}>
|
||||
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||
<div>
|
||||
<Title level={4}>자동화</Title>
|
||||
<Paragraph className="plan-board-page__intro">
|
||||
작업 메모와 이력, 증적을 확인하고 필요한 내용을 수동으로 정리합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Space wrap>
|
||||
<Space size={8} className="plan-board-page__auto-refresh-control">
|
||||
<LongPressButton
|
||||
onClick={() => void loadItems(statusFilter)}
|
||||
onLongPress={() => {
|
||||
if (!hasAccess) {
|
||||
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
handleAutoRefreshToggle();
|
||||
}}
|
||||
longPressMs={AUTO_REFRESH_LONG_PRESS_MS}
|
||||
loading={loading}
|
||||
title="길게 눌러 자동조회 On/Off"
|
||||
className={`plan-board-page__auto-refresh-button${
|
||||
isAutoRefreshRunning ? ' plan-board-page__auto-refresh-button--active' : ''
|
||||
}`}
|
||||
>
|
||||
조회
|
||||
</LongPressButton>
|
||||
{isAutoRefreshRunning ? (
|
||||
<Text className="plan-board-page__auto-refresh-countdown">
|
||||
자동 조회까지 {autoRefreshCountdownSeconds}초
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{listRequestMeta ? (
|
||||
<Text type="secondary">
|
||||
최근 조회 {formatResponseBytes(listRequestMeta.responseBytes)} · {listRequestMeta.durationMs}ms
|
||||
</Text>
|
||||
) : null}
|
||||
<Button onClick={handleCreateNew} disabled={isRestrictedClient}>
|
||||
새 메모
|
||||
</Button>
|
||||
<Button onClick={() => void handleSetup()} disabled={isRestrictedClient}>
|
||||
테이블 생성
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
{overviewActionContent}
|
||||
</Flex>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isRestrictedClient ? (
|
||||
<Alert
|
||||
@@ -1788,7 +1794,7 @@ export function PlanBoardPage({
|
||||
showIcon
|
||||
type="warning"
|
||||
className="plan-board-page__alert"
|
||||
message="작업 요청 메뉴를 아직 사용할 수 없습니다."
|
||||
message="자동화 현황 메뉴를 아직 사용할 수 없습니다."
|
||||
description={<ExpandableDetailText text={errorMessage} />}
|
||||
action={
|
||||
<Button
|
||||
@@ -1804,107 +1810,128 @@ export function PlanBoardPage({
|
||||
) : null}
|
||||
|
||||
<PlanListDetailLayout
|
||||
listTitle={`작업 목록 / ${getPlanQuickFilterLabel(quickFilter) ?? getPlanFilterLabel(statusFilter)}`}
|
||||
listTitle={`자동화 목록 / ${getPlanQuickFilterLabel(quickFilter) ?? getPlanFilterLabel(statusFilter)}`}
|
||||
listExtra={<Text code>{filteredItems.length} items</Text>}
|
||||
listContent={
|
||||
<>
|
||||
{quickFilter ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type={quickFilter === 'automation-failed' ? 'error' : 'info'}
|
||||
className="plan-board-page__alert"
|
||||
message={`${getPlanQuickFilterLabel(quickFilter)} 목록을 표시합니다.`}
|
||||
description={
|
||||
quickFilter === 'automation-failed'
|
||||
? '브랜치 생성, 작업, release/main 반영 단계에서 실패한 항목만 추렸습니다.'
|
||||
: quickFilter === 'working'
|
||||
? '현재 상태가 작업중인 항목만 추렸습니다.'
|
||||
: 'release 반영은 끝났지만 main 반영이 남아 있거나 실패한 항목만 추렸습니다.'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{selectedItem?.lastError ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
className="plan-board-page__alert"
|
||||
message="현재 선택된 작업에 오류가 있습니다."
|
||||
description={<ExpandableDetailText text={resolveProtectedText(selectedItem.lastError, !hasAccess)} type="danger" />}
|
||||
action={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label="오류 메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!hasAccess}
|
||||
onClick={() => void handleCopyText(selectedItem.lastError ?? '')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<div className="plan-board-page__list-panel">
|
||||
<div className="plan-board-page__list-controls">
|
||||
{isMobileAutomationLayout ? (
|
||||
<div className="plan-board-page__mobile-overview">
|
||||
<Flex vertical gap={10}>
|
||||
<div>
|
||||
<Text strong className="plan-board-page__mobile-overview-title">
|
||||
자동화 목록
|
||||
</Text>
|
||||
<Paragraph className="plan-board-page__mobile-overview-description">
|
||||
작업 메모와 이력, 증적을 한 화면 흐름에서 바로 확인합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
{overviewActionContent}
|
||||
</Flex>
|
||||
</div>
|
||||
) : null}
|
||||
{quickFilter ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type={quickFilter === 'automation-failed' ? 'error' : 'info'}
|
||||
className="plan-board-page__alert"
|
||||
message={`${getPlanQuickFilterLabel(quickFilter)} 목록을 표시합니다.`}
|
||||
description={
|
||||
quickFilter === 'automation-failed'
|
||||
? '브랜치 생성, 작업, release/main 반영 단계에서 실패한 항목만 추렸습니다.'
|
||||
: quickFilter === 'working'
|
||||
? '현재 상태가 작업중인 항목만 추렸습니다.'
|
||||
: 'release 반영은 끝났지만 main 반영이 남아 있거나 실패한 항목만 추렸습니다.'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{selectedItem?.lastError ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
className="plan-board-page__alert"
|
||||
message="현재 선택된 자동화 항목에 오류가 있습니다."
|
||||
description={
|
||||
<ExpandableDetailText text={resolveProtectedText(selectedItem.lastError, !hasAccess)} type="danger" />
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label="오류 메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!hasAccess}
|
||||
onClick={() => void handleCopyText(selectedItem.lastError ?? '')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
placeholder="작업 ID, 메모 내용, 브랜치, 이슈 태그 검색"
|
||||
disabled={isRestrictedClient}
|
||||
onChange={(event) => {
|
||||
setSearchKeyword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<Flex gap={8} wrap className="plan-board-page__list-filter-bar">
|
||||
<Select
|
||||
size="small"
|
||||
value={workerStateFilter}
|
||||
options={WORKER_STATE_FILTER_OPTIONS}
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
placeholder="작업 ID, 메모 내용, 브랜치, 이슈 태그 검색"
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setWorkerStateFilter}
|
||||
onChange={(event) => {
|
||||
setSearchKeyword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={releaseStateFilter}
|
||||
options={RELEASE_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setReleaseStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={mainStateFilter}
|
||||
options={MAIN_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setMainStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={issueStateFilter}
|
||||
options={ISSUE_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setIssueStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={costStateFilter}
|
||||
options={COST_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setCostStateFilter}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex gap={8} wrap className="plan-board-page__list-filter-bar">
|
||||
<Select
|
||||
size="small"
|
||||
value={workerStateFilter}
|
||||
options={WORKER_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setWorkerStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={releaseStateFilter}
|
||||
options={RELEASE_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setReleaseStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={mainStateFilter}
|
||||
options={MAIN_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setMainStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={issueStateFilter}
|
||||
options={ISSUE_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setIssueStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={costStateFilter}
|
||||
options={COST_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setCostStateFilter}
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<PlanItemList
|
||||
activeDraftId={draft.id}
|
||||
currentPage={currentListPage}
|
||||
editorOpen={editorOpen}
|
||||
hasAccess={hasAccess}
|
||||
items={filteredItems}
|
||||
jangsingProcessingSavingId={jangsingProcessingSavingId}
|
||||
reviewIndicatorsByPlanId={reviewIndicatorsByPlanId}
|
||||
searchKeyword={searchKeyword}
|
||||
usageSummaryByPlanId={usageSummaryByPlanId}
|
||||
onChangePage={setCurrentListPage}
|
||||
onChangeJangsingProcessing={handleJangsingProcessingChange}
|
||||
onSelectItem={handleSelectItem}
|
||||
/>
|
||||
</>
|
||||
<div className="plan-board-page__list-scroller">
|
||||
<PlanItemList
|
||||
activeDraftId={draft.id}
|
||||
currentPage={currentListPage}
|
||||
editorOpen={editorOpen}
|
||||
hasAccess={hasAccess}
|
||||
items={filteredItems}
|
||||
jangsingProcessingSavingId={jangsingProcessingSavingId}
|
||||
reviewIndicatorsByPlanId={reviewIndicatorsByPlanId}
|
||||
searchKeyword={searchKeyword}
|
||||
usageSummaryByPlanId={usageSummaryByPlanId}
|
||||
onChangePage={setCurrentListPage}
|
||||
onChangeJangsingProcessing={handleJangsingProcessingChange}
|
||||
onSelectItem={handleSelectItem}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
desktopDetailOpen={editorOpen}
|
||||
mobileDetailOpen={editorOpen}
|
||||
@@ -1934,7 +1961,7 @@ export function PlanBoardPage({
|
||||
emptyDetailTitle="상세 보기"
|
||||
detailContent={
|
||||
!sourceViewerOpen ? (
|
||||
<>
|
||||
<div className="plan-board-page__detail-panel">
|
||||
{selectedItem ? (
|
||||
<Alert
|
||||
type={selectedItem.hasOpenIssues ? 'warning' : 'info'}
|
||||
@@ -2488,7 +2515,7 @@ export function PlanBoardPage({
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="plan-board-page__overlay-body">
|
||||
<Flex justify="space-between" align="start" gap={12} wrap>
|
||||
|
||||
@@ -103,6 +103,7 @@ export function PlanListDetailLayout({
|
||||
const showMobileDetail = mobileOverlayEnabled && mobileDetailOpen;
|
||||
const showMobileOverlay = showMobileDetail && mobileLayoutMode === 'overlay';
|
||||
const showMobileDetailOnly = showMobileDetail && mobileLayoutMode === 'detail-only';
|
||||
const hideInlineDetailCardOnMobile = mobileOverlayEnabled && (!showMobileDetail || showMobileOverlay);
|
||||
|
||||
useBodyScrollLock(showMobileOverlay || showMobileDetailOnly);
|
||||
|
||||
@@ -118,7 +119,7 @@ export function PlanListDetailLayout({
|
||||
showMobileDetailOnly ? ` ${classNamePrefix}__list-card--mobile-hidden` : ''
|
||||
}`;
|
||||
const detailCardClassName = `${classNamePrefix}__editor-card ${classNamePrefix}__detail-card${
|
||||
showMobileOverlay ? ` ${classNamePrefix}__detail-card--mobile-hidden` : ''
|
||||
hideInlineDetailCardOnMobile ? ` ${classNamePrefix}__detail-card--mobile-hidden` : ''
|
||||
}${showMobileDetailOnly ? ` ${classNamePrefix}__detail-card--mobile-only` : ''}`;
|
||||
const detailActionsClassName = `${classNamePrefix}__detail-actions`;
|
||||
const detailEmptyClassName = `${classNamePrefix}__detail-empty`;
|
||||
|
||||
@@ -136,6 +136,25 @@
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.plan-board-page__mobile-overview {
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(22, 93, 255, 0.06) 0%, rgba(22, 93, 255, 0.02) 100%),
|
||||
#ffffff;
|
||||
border: 1px solid rgba(22, 93, 255, 0.08);
|
||||
}
|
||||
|
||||
.plan-board-page__mobile-overview-title.ant-typography {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.plan-board-page__mobile-overview-description.ant-typography {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.plan-board-page__list {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
@@ -148,6 +167,40 @@
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-panel,
|
||||
.plan-board-page__detail-panel {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__list-controls {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-scroller {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-panel {
|
||||
gap: 14px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-filter-bar {
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
@@ -592,6 +645,12 @@
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.plan-board-page {
|
||||
overflow: auto;
|
||||
overscroll-behavior: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.plan-board-page__split--mobile-detail-only {
|
||||
gap: 0;
|
||||
}
|
||||
@@ -600,6 +659,65 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.plan-board-page__list-card.ant-card {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.plan-board-page__list-card .ant-card-body,
|
||||
.plan-board-page__editor-card .ant-card-body,
|
||||
.plan-board-page__detail-card .ant-card-body {
|
||||
padding-top: 14px;
|
||||
padding-bottom: max(14px, env(safe-area-inset-bottom, 0px));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.plan-board-page__list-controls {
|
||||
position: static;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.plan-board-page__mobile-overview {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.plan-board-page__mobile-overview .ant-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__mobile-overview .ant-space-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__list-filter-bar {
|
||||
margin: 0;
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__list-filter-bar .ant-select {
|
||||
min-width: 136px;
|
||||
flex: 1 1 136px;
|
||||
}
|
||||
|
||||
.plan-board-page__list,
|
||||
.plan-board-page__list-scroller {
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__list-scroller {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-panel {
|
||||
gap: 12px;
|
||||
padding-right: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.plan-board-page__list-card--mobile-hidden,
|
||||
.plan-board-page__detail-card--mobile-hidden {
|
||||
display: none;
|
||||
@@ -911,6 +1029,10 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-card .ant-card-head {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.plan-board-page__form > div {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
@@ -941,6 +1063,10 @@
|
||||
padding: 14px 14px max(18px, env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.plan-board-page__list-filter-bar .ant-select {
|
||||
min-width: 128px;
|
||||
}
|
||||
|
||||
.plan-board-page__readonly-field {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -4,16 +4,31 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import { DataStatePanel } from '../../components/dataStatePanel';
|
||||
import { copyText } from '../../app/main/mainChatPanel';
|
||||
import { fetchServerCommands, restartServerCommand } from './api';
|
||||
import type { ServerCommandItem, ServerCommandKey } from './types';
|
||||
import {
|
||||
ServerCommandApiError,
|
||||
fetchServerCommands,
|
||||
fetchServerRestartReservation,
|
||||
restartServerCommand,
|
||||
scheduleServerRestartReservation,
|
||||
} from './api';
|
||||
import type {
|
||||
RestartReservationWorkloadSummary,
|
||||
ServerCommandItem,
|
||||
ServerCommandKey,
|
||||
ServerRestartReservation,
|
||||
ServerRestartReservationAutoFix,
|
||||
ServerRestartReservationWorkItem,
|
||||
} from './types';
|
||||
import './serverCommand.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
type RestartErrorInfo = {
|
||||
tone: 'error' | 'warning';
|
||||
title: string;
|
||||
detail: string;
|
||||
missingScriptPath: string | null;
|
||||
canScheduleReservation: boolean;
|
||||
};
|
||||
|
||||
type LastActionInfo = {
|
||||
@@ -83,28 +98,120 @@ function buildRestartErrorInfo(targetLabel: string, detail: string): RestartErro
|
||||
const missingScriptPath = missingScriptMatch[1].trim();
|
||||
|
||||
return {
|
||||
tone: 'error',
|
||||
title: `${targetLabel} 재기동 실패`,
|
||||
detail: `재기동 스크립트 파일이 서버에 없습니다.\n배치 경로: ${missingScriptPath}\n\n원본 오류:\n${detail}`,
|
||||
missingScriptPath,
|
||||
canScheduleReservation: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: 'error',
|
||||
title: `${targetLabel} 재기동 실패`,
|
||||
detail,
|
||||
missingScriptPath: null,
|
||||
canScheduleReservation: false,
|
||||
};
|
||||
}
|
||||
|
||||
function formatWorkloadSummary(summary: RestartReservationWorkloadSummary | null) {
|
||||
if (!summary) {
|
||||
return '진행 중 작업이 있어 즉시 재기동할 수 없습니다.';
|
||||
}
|
||||
|
||||
return `Codex 실행 ${summary.codexRunningCount}건, Codex 대기 ${summary.codexQueuedCount}건, 자동화 실행 ${summary.automationRunningCount}건, 자동화 대기 ${summary.automationQueuedCount}건이 감지되었습니다.`;
|
||||
}
|
||||
|
||||
function buildRestartReservationInfo(targetLabel: string, summary: RestartReservationWorkloadSummary | null, detail: string) {
|
||||
return {
|
||||
tone: 'warning' as const,
|
||||
title: `${targetLabel} 즉시 재기동 보류`,
|
||||
detail: `${detail}\n\n${formatWorkloadSummary(summary)}\n현재 화면에서는 전체 재기동 예약으로 이어서 처리할 수 있습니다.`,
|
||||
missingScriptPath: null,
|
||||
canScheduleReservation: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReservationStatusTag(reservation: ServerRestartReservation) {
|
||||
switch (reservation.status) {
|
||||
case 'waiting':
|
||||
return <Tag color="gold">대기 중</Tag>;
|
||||
case 'ready':
|
||||
return <Tag color="blue">자동 실행 예정</Tag>;
|
||||
case 'executing':
|
||||
return <Tag color="processing">재기동 실행 중</Tag>;
|
||||
case 'recovering':
|
||||
return <Tag color="purple">Codex 자동 개선 중</Tag>;
|
||||
case 'completed':
|
||||
return <Tag color="success">완료</Tag>;
|
||||
case 'failed':
|
||||
return <Tag color="error">실패</Tag>;
|
||||
case 'cancelled':
|
||||
return <Tag>취소됨</Tag>;
|
||||
default:
|
||||
return <Tag>대기 없음</Tag>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatReservationWorkItemTag(item: ServerRestartReservationWorkItem) {
|
||||
if (item.kind === 'automation') {
|
||||
if (item.status === 'running') {
|
||||
return <Tag color="processing">자동화 실행</Tag>;
|
||||
}
|
||||
if (item.status === 'queued') {
|
||||
return <Tag color="blue">자동화 대기열</Tag>;
|
||||
}
|
||||
return <Tag color="gold">자동화 선행대기</Tag>;
|
||||
}
|
||||
|
||||
if (item.status === 'running') {
|
||||
return <Tag color="processing">Codex 실행</Tag>;
|
||||
}
|
||||
if (item.status === 'queued') {
|
||||
return <Tag color="blue">Codex 대기열</Tag>;
|
||||
}
|
||||
return <Tag color="gold">Codex 대기</Tag>;
|
||||
}
|
||||
|
||||
function resolveAutoFixTone(autoFix: ServerRestartReservationAutoFix) {
|
||||
if (autoFix.status === 'failed') {
|
||||
return 'error' as const;
|
||||
}
|
||||
|
||||
if (autoFix.status === 'completed') {
|
||||
return 'success' as const;
|
||||
}
|
||||
|
||||
return 'info' as const;
|
||||
}
|
||||
|
||||
function formatAutoFixStatusLabel(status: ServerRestartReservationAutoFix['status']) {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return '요청 대기';
|
||||
case 'running':
|
||||
return '개선 실행 중';
|
||||
case 'completed':
|
||||
return '개선 완료';
|
||||
case 'failed':
|
||||
return '개선 실패';
|
||||
default:
|
||||
return '대기 없음';
|
||||
}
|
||||
}
|
||||
|
||||
export function ServerCommandPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<ServerCommandItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [reservation, setReservation] = useState<ServerRestartReservation | null>(null);
|
||||
const [restartingKey, setRestartingKey] = useState<ServerCommandKey | null>(null);
|
||||
const [restartErrorInfo, setRestartErrorInfo] = useState<RestartErrorInfo | null>(null);
|
||||
const [copyingRestartError, setCopyingRestartError] = useState(false);
|
||||
const [schedulingReservation, setSchedulingReservation] = useState(false);
|
||||
const [lastActionByKey, setLastActionByKey] = useState<Record<ServerCommandKey, LastActionInfo>>({
|
||||
test: { output: null, executedAt: '', restartState: 'completed' },
|
||||
rel: { output: null, executedAt: '', restartState: 'completed' },
|
||||
@@ -127,17 +234,59 @@ export function ServerCommandPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadReservation = async (options?: { silent?: boolean }) => {
|
||||
try {
|
||||
const nextReservation = await fetchServerRestartReservation();
|
||||
setReservation(nextReservation);
|
||||
return nextReservation;
|
||||
} catch (error) {
|
||||
if (!options?.silent) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '재기동 예약 상태를 불러오지 못했습니다.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
setErrorMessage(null);
|
||||
setReservation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadItems();
|
||||
void Promise.all([
|
||||
loadItems(),
|
||||
loadReservation({ silent: true }),
|
||||
]);
|
||||
}, [hasAccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldPoll =
|
||||
reservation?.enabled
|
||||
|| reservation?.status === 'recovering'
|
||||
|| reservation?.autoFix.enabled
|
||||
|| restartingKey === 'test'
|
||||
|| restartingKey === 'work-server';
|
||||
|
||||
if (!shouldPoll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = window.setInterval(() => {
|
||||
void loadReservation({ silent: true });
|
||||
}, 4000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timerId);
|
||||
};
|
||||
}, [hasAccess, reservation, restartingKey]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return items.reduce(
|
||||
(result, item) => {
|
||||
@@ -156,6 +305,7 @@ export function ServerCommandPage() {
|
||||
try {
|
||||
const result = await restartServerCommand(key);
|
||||
setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item)));
|
||||
void loadReservation({ silent: true });
|
||||
setLastActionByKey((previous) => ({
|
||||
...previous,
|
||||
[result.item.key]: {
|
||||
@@ -169,6 +319,11 @@ export function ServerCommandPage() {
|
||||
);
|
||||
} catch (error) {
|
||||
const targetLabel = items.find((item) => item.key === key)?.label ?? key.toUpperCase();
|
||||
if (error instanceof ServerCommandApiError && error.status === 409 && (key === 'test' || key === 'work-server')) {
|
||||
setRestartErrorInfo(buildRestartReservationInfo(targetLabel, error.workloadSummary, error.message));
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
|
||||
setRestartErrorInfo(buildRestartErrorInfo(targetLabel, detail));
|
||||
} finally {
|
||||
@@ -193,6 +348,26 @@ export function ServerCommandPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleReservation = async () => {
|
||||
if (schedulingReservation) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSchedulingReservation(true);
|
||||
|
||||
try {
|
||||
await scheduleServerRestartReservation();
|
||||
setRestartErrorInfo(null);
|
||||
await loadReservation({ silent: true });
|
||||
messageApi.success('전체 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.');
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : '재기동 예약 등록에 실패했습니다.';
|
||||
setRestartErrorInfo(buildRestartErrorInfo('전체 서버', detail));
|
||||
} finally {
|
||||
setSchedulingReservation(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card className="server-command-page__card" bordered={false}>
|
||||
@@ -229,7 +404,16 @@ export function ServerCommandPage() {
|
||||
</Col>
|
||||
</Row>
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadItems()} loading={loading}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
void Promise.all([
|
||||
loadItems(),
|
||||
loadReservation({ silent: true }),
|
||||
]);
|
||||
}}
|
||||
loading={loading}
|
||||
>
|
||||
새로고침
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -239,8 +423,8 @@ export function ServerCommandPage() {
|
||||
{restartErrorInfo ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="재기동 에러"
|
||||
type={restartErrorInfo.tone}
|
||||
message={restartErrorInfo.tone === 'warning' ? '재기동 예약 필요' : '재기동 에러'}
|
||||
description={
|
||||
<Space direction="vertical" size={8} className="server-command-page__alert-body">
|
||||
<Text strong>{restartErrorInfo.title}</Text>
|
||||
@@ -253,20 +437,129 @@ export function ServerCommandPage() {
|
||||
</Space>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingRestartError}
|
||||
aria-label="에러 메시지 복사"
|
||||
onClick={() => {
|
||||
void handleCopyRestartError();
|
||||
}}
|
||||
/>
|
||||
<Space size={4}>
|
||||
{restartErrorInfo.canScheduleReservation ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
loading={schedulingReservation}
|
||||
onClick={() => {
|
||||
void handleScheduleReservation();
|
||||
}}
|
||||
>
|
||||
재기동 예약
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingRestartError}
|
||||
aria-label="에러 메시지 복사"
|
||||
onClick={() => {
|
||||
void handleCopyRestartError();
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{reservation && (reservation.enabled || reservation.status !== 'idle' || reservation.autoFix.enabled) ? (
|
||||
<Card className="server-command-page__card server-command-page__reservation-card" bordered={false}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Space size={8} wrap>
|
||||
<Title level={5} className="server-command-page__server-title">
|
||||
재기동 예약 상태
|
||||
</Title>
|
||||
{resolveReservationStatusTag(reservation)}
|
||||
</Space>
|
||||
|
||||
<Paragraph className="server-command-page__summary">
|
||||
{reservation.waitingReason?.trim()
|
||||
|| (reservation.status === 'completed'
|
||||
? '예약된 TEST / WORK 서버 재기동이 완료되었습니다.'
|
||||
: '예약 상태를 확인했습니다.')}
|
||||
</Paragraph>
|
||||
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={1}
|
||||
className="server-command-page__meta"
|
||||
items={[
|
||||
{
|
||||
key: 'requested-at',
|
||||
label: '요청시각',
|
||||
children: formatDateTime(reservation.requestedAt),
|
||||
},
|
||||
{
|
||||
key: 'auto-execute-at',
|
||||
label: '자동실행',
|
||||
children: formatDateTime(reservation.autoExecuteAt),
|
||||
},
|
||||
{
|
||||
key: 'updated-at',
|
||||
label: '마지막 갱신',
|
||||
children: formatDateTime(reservation.updatedAt),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{reservation.workItems.length > 0 ? (
|
||||
<Space direction="vertical" size={8} className="server-command-page__work-list">
|
||||
<Text strong>현재 진행 작업</Text>
|
||||
{reservation.workItems.map((item, index) => (
|
||||
<div
|
||||
key={`${item.kind}-${item.requestId ?? item.title}-${index}`}
|
||||
className="server-command-page__work-item"
|
||||
>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Space size={8} wrap>
|
||||
{formatReservationWorkItemTag(item)}
|
||||
<Text strong>{item.title}</Text>
|
||||
</Space>
|
||||
{item.detail ? (
|
||||
<Text type="secondary" className="server-command-page__work-detail">
|
||||
{item.detail}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
) : null}
|
||||
|
||||
{reservation.autoFix.enabled ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type={resolveAutoFixTone(reservation.autoFix)}
|
||||
message="Codex 자동 개선"
|
||||
description={
|
||||
<Space direction="vertical" size={4} className="server-command-page__alert-body">
|
||||
<Text strong>
|
||||
{reservation.autoFix.summary?.trim() || '빌드 오류 자동 개선 상태를 추적 중입니다.'}
|
||||
</Text>
|
||||
{reservation.autoFix.detail ? (
|
||||
<span className="server-command-page__alert-text">{reservation.autoFix.detail}</span>
|
||||
) : null}
|
||||
<Text type="secondary">
|
||||
상태: {formatAutoFixStatusLabel(reservation.autoFix.status)}
|
||||
{reservation.autoFix.targetKey ? ` · 대상 ${reservation.autoFix.targetKey.toUpperCase()}` : ''}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{reservation.lastError ? (
|
||||
<Text type="danger" className="server-command-page__preview">
|
||||
{reservation.lastError}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<DataStatePanel state="loading" title="서버 상태를 확인하는 중입니다." />
|
||||
) : errorMessage ? (
|
||||
@@ -275,7 +568,15 @@ export function ServerCommandPage() {
|
||||
title="서버 명령 메뉴를 불러오지 못했습니다."
|
||||
description={errorMessage}
|
||||
actions={
|
||||
<Button type="primary" onClick={() => void loadItems()}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
void Promise.all([
|
||||
loadItems(),
|
||||
loadReservation({ silent: true }),
|
||||
]);
|
||||
}}
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { appendClientIdHeader } from '../../app/main/clientIdentity';
|
||||
import { getRegisteredAccessToken, isAllowedRegistrationToken } from '../../app/main/tokenAccess';
|
||||
import type {
|
||||
RestartReservationWorkloadSummary,
|
||||
ServerCommandActionResult,
|
||||
ServerCommandItem,
|
||||
ServerCommandKey,
|
||||
ServerRestartReservationAutoFix,
|
||||
ServerRestartReservation,
|
||||
ServerRestartReservationStatus,
|
||||
ServerRestartReservationWorkItem,
|
||||
} from './types';
|
||||
|
||||
class ServerCommandApiError extends Error {
|
||||
export class ServerCommandApiError extends Error {
|
||||
status: number;
|
||||
workloadSummary: RestartReservationWorkloadSummary | null;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
constructor(message: string, status: number, workloadSummary: RestartReservationWorkloadSummary | null = null) {
|
||||
super(message);
|
||||
this.name = 'ServerCommandApiError';
|
||||
this.status = status;
|
||||
this.workloadSummary = workloadSummary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,13 +136,30 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
let payload: { message?: string; workloadSummary?: Partial<RestartReservationWorkloadSummary> } | null = null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new ServerCommandApiError(payload.message || '서버 명령 요청에 실패했습니다.', response.status);
|
||||
} catch {
|
||||
throw new ServerCommandApiError(text || '서버 명령 요청에 실패했습니다.', response.status);
|
||||
payload = JSON.parse(text) as { message?: string; workloadSummary?: Partial<RestartReservationWorkloadSummary> };
|
||||
} catch {}
|
||||
|
||||
if (payload) {
|
||||
const workloadSummary =
|
||||
payload.workloadSummary && typeof payload.workloadSummary === 'object'
|
||||
? {
|
||||
codexRunningCount: Number(payload.workloadSummary.codexRunningCount ?? 0),
|
||||
codexQueuedCount: Number(payload.workloadSummary.codexQueuedCount ?? 0),
|
||||
automationRunningCount: Number(payload.workloadSummary.automationRunningCount ?? 0),
|
||||
automationQueuedCount: Number(payload.workloadSummary.automationQueuedCount ?? 0),
|
||||
}
|
||||
: null;
|
||||
throw new ServerCommandApiError(
|
||||
payload.message || '서버 명령 요청에 실패했습니다.',
|
||||
response.status,
|
||||
workloadSummary,
|
||||
);
|
||||
}
|
||||
|
||||
throw new ServerCommandApiError(text || '서버 명령 요청에 실패했습니다.', response.status);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
@@ -281,6 +303,7 @@ function normalizeServerRestartReservationStatus(value: unknown): ServerRestartR
|
||||
return value === 'waiting'
|
||||
|| value === 'ready'
|
||||
|| value === 'executing'
|
||||
|| value === 'recovering'
|
||||
|| value === 'completed'
|
||||
|| value === 'cancelled'
|
||||
|| value === 'failed'
|
||||
@@ -288,6 +311,83 @@ function normalizeServerRestartReservationStatus(value: unknown): ServerRestartR
|
||||
: 'idle';
|
||||
}
|
||||
|
||||
function normalizeServerRestartReservationTarget(value: unknown): ServerRestartReservation['target'] {
|
||||
return value === 'test' || value === 'work-server' ? value : 'all';
|
||||
}
|
||||
|
||||
function normalizeServerRestartReservationWorkItems(value: unknown): ServerRestartReservationWorkItem[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.flatMap((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidate = item as Partial<ServerRestartReservationWorkItem>;
|
||||
const kind = candidate.kind === 'automation' ? 'automation' : candidate.kind === 'codex' ? 'codex' : null;
|
||||
const status =
|
||||
candidate.status === 'running' || candidate.status === 'queued' || candidate.status === 'waiting'
|
||||
? candidate.status
|
||||
: null;
|
||||
const title = typeof candidate.title === 'string' ? candidate.title.trim() : '';
|
||||
|
||||
if (!kind || !status || !title) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
kind,
|
||||
status,
|
||||
title,
|
||||
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
||||
requestId: typeof candidate.requestId === 'string' ? candidate.requestId : null,
|
||||
sessionId: typeof candidate.sessionId === 'string' ? candidate.sessionId : null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeServerRestartReservationAutoFix(value: unknown): ServerRestartReservationAutoFix {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
enabled: false,
|
||||
targetKey: null,
|
||||
requestId: null,
|
||||
sessionId: null,
|
||||
status: 'idle',
|
||||
summary: null,
|
||||
detail: null,
|
||||
requestedAt: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
failedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const candidate = value as Partial<ServerRestartReservationAutoFix>;
|
||||
|
||||
return {
|
||||
enabled: candidate.enabled === true,
|
||||
targetKey: candidate.targetKey === 'test' || candidate.targetKey === 'work-server' ? candidate.targetKey : null,
|
||||
requestId: typeof candidate.requestId === 'string' ? candidate.requestId : null,
|
||||
sessionId: typeof candidate.sessionId === 'string' ? candidate.sessionId : null,
|
||||
status:
|
||||
candidate.status === 'queued'
|
||||
|| candidate.status === 'running'
|
||||
|| candidate.status === 'completed'
|
||||
|| candidate.status === 'failed'
|
||||
? candidate.status
|
||||
: 'idle',
|
||||
summary: typeof candidate.summary === 'string' ? candidate.summary : null,
|
||||
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
||||
requestedAt: typeof candidate.requestedAt === 'string' ? candidate.requestedAt : null,
|
||||
startedAt: typeof candidate.startedAt === 'string' ? candidate.startedAt : null,
|
||||
completedAt: typeof candidate.completedAt === 'string' ? candidate.completedAt : null,
|
||||
failedAt: typeof candidate.failedAt === 'string' ? candidate.failedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractServerRestartReservation(response: unknown): ServerRestartReservation {
|
||||
if (!response || typeof response !== 'object') {
|
||||
throw new Error('재기동 예약 응답 형식이 올바르지 않습니다.');
|
||||
@@ -309,12 +409,12 @@ function extractServerRestartReservation(response: unknown): ServerRestartReserv
|
||||
const reservation = item as Partial<ServerRestartReservation>;
|
||||
const workloadSummary =
|
||||
reservation.workloadSummary && typeof reservation.workloadSummary === 'object'
|
||||
? reservation.workloadSummary
|
||||
? (reservation.workloadSummary as Partial<RestartReservationWorkloadSummary>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
enabled: reservation.enabled === true,
|
||||
target: reservation.target === 'all' ? 'all' : 'all',
|
||||
target: normalizeServerRestartReservationTarget(reservation.target),
|
||||
status: normalizeServerRestartReservationStatus(reservation.status),
|
||||
requestedAt: typeof reservation.requestedAt === 'string' ? reservation.requestedAt : null,
|
||||
requestedByClientId: typeof reservation.requestedByClientId === 'string' ? reservation.requestedByClientId : null,
|
||||
@@ -338,6 +438,8 @@ function extractServerRestartReservation(response: unknown): ServerRestartReserv
|
||||
autoExecuteAt: typeof reservation.autoExecuteAt === 'string' ? reservation.autoExecuteAt : null,
|
||||
autoExecuteDelaySeconds: Number(reservation.autoExecuteDelaySeconds ?? 10),
|
||||
updatedAt: typeof reservation.updatedAt === 'string' ? reservation.updatedAt : null,
|
||||
workItems: normalizeServerRestartReservationWorkItems(reservation.workItems),
|
||||
autoFix: normalizeServerRestartReservationAutoFix(reservation.autoFix),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.server-command-page__reservation-card {
|
||||
border: 1px solid #d6e4ff;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.server-command-page__server-card {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -107,6 +112,21 @@
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.server-command-page__work-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__work-item {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: #f7faff;
|
||||
}
|
||||
|
||||
.server-command-page__work-detail.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-command-page__meta .ant-descriptions-item-label {
|
||||
width: 104px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export type ServerCommandKey = 'test' | 'rel' | 'prod' | 'work-server' | 'command-runner';
|
||||
|
||||
export type RestartReservationWorkloadSummary = {
|
||||
codexRunningCount: number;
|
||||
codexQueuedCount: number;
|
||||
automationRunningCount: number;
|
||||
automationQueuedCount: number;
|
||||
};
|
||||
|
||||
export type ServerCommandItem = {
|
||||
key: ServerCommandKey;
|
||||
label: string;
|
||||
@@ -44,25 +51,44 @@ export type ServerRestartReservationStatus =
|
||||
| 'waiting'
|
||||
| 'ready'
|
||||
| 'executing'
|
||||
| 'recovering'
|
||||
| 'completed'
|
||||
| 'cancelled'
|
||||
| 'failed';
|
||||
|
||||
export type ServerRestartReservationWorkItem = {
|
||||
kind: 'codex' | 'automation';
|
||||
status: 'running' | 'queued' | 'waiting';
|
||||
title: string;
|
||||
detail: string | null;
|
||||
requestId: string | null;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export type ServerRestartReservationAutoFix = {
|
||||
enabled: boolean;
|
||||
targetKey: 'test' | 'work-server' | null;
|
||||
requestId: string | null;
|
||||
sessionId: string | null;
|
||||
status: 'idle' | 'queued' | 'running' | 'completed' | 'failed';
|
||||
summary: string | null;
|
||||
detail: string | null;
|
||||
requestedAt: string | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
failedAt: string | null;
|
||||
};
|
||||
|
||||
export type ServerRestartReservation = {
|
||||
enabled: boolean;
|
||||
target: 'all';
|
||||
target: 'all' | 'test' | 'work-server';
|
||||
status: ServerRestartReservationStatus;
|
||||
requestedAt: string | null;
|
||||
requestedByClientId: string | null;
|
||||
lastCheckedAt: string | null;
|
||||
nextCheckAt: string | null;
|
||||
waitingReason: string | null;
|
||||
workloadSummary: {
|
||||
codexRunningCount: number;
|
||||
codexQueuedCount: number;
|
||||
automationRunningCount: number;
|
||||
automationQueuedCount: number;
|
||||
};
|
||||
workloadSummary: RestartReservationWorkloadSummary;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
@@ -73,4 +99,6 @@ export type ServerRestartReservation = {
|
||||
autoExecuteAt: string | null;
|
||||
autoExecuteDelaySeconds: number;
|
||||
updatedAt: string | null;
|
||||
workItems: ServerRestartReservationWorkItem[];
|
||||
autoFix: ServerRestartReservationAutoFix;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user