diff --git a/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts b/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts index 461fd81a..6bf08071 100644 --- a/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts +++ b/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts @@ -34,15 +34,20 @@ const resetScenario = async (page: Page, scenarioId: string, sourceCommit: strin await expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible(); await page.getByTestId('scenario-select').selectOption(scenarioId); - const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first(); + const latestOperation = page.getByTestId('operation-summary-row').first(); const previousLatestOperation = await latestOperation.textContent(); await page.getByTestId('request-reset').click(); await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible(); await expect.poll(() => latestOperation.textContent(), { timeout: 15_000 }).not.toBe(previousLatestOperation); - await expect(latestOperation).toContainText(sourceCommit, { timeout: 15_000 }); - await expect(latestOperation.locator('td').nth(3)).toHaveText('SUCCEEDED', { - timeout: 300_000, - }); + await latestOperation.getByTestId('operation-details-toggle').click(); + await expect(page.getByTestId('operation-detail').first()).toContainText(sourceCommit, { timeout: 15_000 }); + await expect(latestOperation.locator('[data-operation-status]')).toHaveAttribute( + 'data-operation-status', + 'SUCCEEDED', + { + timeout: 300_000, + } + ); const profileStatus = page.getByTestId('selected-profile-status'); await expect(profileStatus.locator(':scope > div').nth(0)).toContainText('RUNNING', { timeout: 30_000, diff --git a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts index 48cb8287..069e3149 100644 --- a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts +++ b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts @@ -95,7 +95,7 @@ test('admin resets and opens hwe, then two users create generals and reach main' await page.getByTestId('load-scenarios').click(); await expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible(); await page.getByTestId('scenario-select').selectOption(scenarioId); - const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first(); + const latestOperation = page.getByTestId('operation-summary-row').first(); const previousLatestOperation = await latestOperation.textContent(); await page.getByTestId('request-reset').click(); await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); @@ -105,12 +105,17 @@ test('admin resets and opens hwe, then two users create generals and reach main' timeout: 15_000, }) .not.toBe(previousLatestOperation); - await expect(latestOperation).toContainText(sourceCommit, { + await latestOperation.getByTestId('operation-details-toggle').click(); + await expect(page.getByTestId('operation-detail').first()).toContainText(sourceCommit, { timeout: 15_000, }); - await expect(latestOperation.locator('td').nth(4)).toHaveText('SUCCEEDED', { - timeout: 300_000, - }); + await expect(latestOperation.locator('[data-operation-status]')).toHaveAttribute( + 'data-operation-status', + 'SUCCEEDED', + { + timeout: 300_000, + } + ); } await expect(profileStatus).toContainText('RUNNING', { timeout: 30_000 }); await expect(profileStatus).toContainText('SUCCEEDED'); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index b70f1aac..a5b2eccb 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -637,47 +637,59 @@ test('separates branch and commit semantics and submits a reset from the dedicat await page.getByTestId('request-reset').click(); await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); - await expect(page.getByTestId('operations-table')).toContainText('RESET'); + await expect(page.getByTestId('operations-table')).toContainText('시나리오 초기화'); await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); + const operationTable = page.getByTestId('operations-table'); + await expect(operationTable.getByRole('columnheader')).toHaveText(['요청 · 작업', '상태', '보기']); + await expect(operationTable.getByText('시나리오 초기화', { exact: true })).toBeVisible(); + await expect(operationTable.getByText('완료', { exact: true })).toBeVisible(); + await expect(operationTable.getByText('che:default', { exact: true })).toBeHidden(); + const detailsToggle = operationTable.getByTestId('operation-details-toggle'); + await expect(detailsToggle).toHaveAttribute('aria-expanded', 'false'); + await detailsToggle.click(); + await expect(detailsToggle).toHaveAttribute('aria-expanded', 'true'); + const operationDetail = operationTable.getByTestId('operation-detail'); + await expect(operationDetail).toContainText('che:default'); + await expect(operationDetail).toContainText('0123456789abcdef0123456789abcdef01234567'); + await expect(operationDetail).toContainText('admin'); const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => { const columnWidths = Array.from(table.querySelectorAll('thead th')).map( (heading) => heading.getBoundingClientRect().width ); - const rowHeight = table.querySelector('tbody tr')?.getBoundingClientRect().height ?? 0; + const summaryRowHeight = table + .querySelector('[data-testid="operation-summary-row"]') + ?.getBoundingClientRect().height; return { columnWidths, - rowHeight, + summaryRowHeight, tableWidth: table.getBoundingClientRect().width, scrollerWidth: table.parentElement?.getBoundingClientRect().width ?? 0, + scrollerScrollWidth: table.parentElement?.scrollWidth ?? 0, tableLayout: getComputedStyle(table).tableLayout, }; }); const sourceRefGeometry = await page.getByTestId('operation-source-ref').evaluate((element) => { const style = getComputedStyle(element); return { - title: element.getAttribute('title'), + text: element.textContent?.trim(), overflow: style.overflow, - textOverflow: style.textOverflow, whiteSpace: style.whiteSpace, }; }); expect(operationTableGeometry.tableLayout).toBe('fixed'); - expect(operationTableGeometry.tableWidth).toBeGreaterThanOrEqual(1_300); - expect(operationTableGeometry.tableWidth).toBeGreaterThan(operationTableGeometry.scrollerWidth); - expect(operationTableGeometry.columnWidths[0]).toBeGreaterThanOrEqual(159); - expect(operationTableGeometry.columnWidths[1]).toBeGreaterThanOrEqual(263); - expect(operationTableGeometry.columnWidths[5]).toBeLessThanOrEqual(113); - expect(operationTableGeometry.columnWidths[7]).toBeGreaterThanOrEqual(175); - expect(operationTableGeometry.columnWidths[1]).toBeGreaterThan(operationTableGeometry.columnWidths[5]! * 2); - expect(operationTableGeometry.rowHeight).toBeLessThanOrEqual(50); + expect(operationTableGeometry.columnWidths).toHaveLength(3); + expect(operationTableGeometry.tableWidth).toBeLessThanOrEqual(operationTableGeometry.scrollerWidth + 1); + expect(operationTableGeometry.scrollerScrollWidth).toBeLessThanOrEqual(operationTableGeometry.scrollerWidth + 1); + expect(operationTableGeometry.columnWidths[0]).toBeGreaterThan(operationTableGeometry.columnWidths[1]!); + expect(operationTableGeometry.columnWidths[2]).toBeGreaterThan(operationTableGeometry.columnWidths[1]!); + expect(operationTableGeometry.summaryRowHeight).toBeLessThanOrEqual(90); expect(sourceRefGeometry).toEqual({ - title: '0123456789abcdef0123456789abcdef01234567', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', + text: 'COMMIT 0123456789abcdef0123456789abcdef01234567', + overflow: 'visible', + whiteSpace: 'normal', }); await writeFile( testInfo.outputPath('operation-table-metrics.json'), @@ -693,7 +705,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat expect(JSON.stringify(resetRequest?.body)).toContain('"openAt":"2030-08-13T02:00:00.000Z"'); await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true }); - await page.setViewportSize({ width: 390, height: 844 }); + await page.setViewportSize({ width: 500, height: 844 }); const mobileGeometry = await page .getByTestId('server-operations-page') .locator('section') @@ -705,7 +717,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat }); return children; }); - expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390); + expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(500); const mobilePublishGeometry = await publishSchedule.evaluate((element) => { const label = element.closest('label'); if (!label) throw new Error('expected publish schedule label'); @@ -735,18 +747,21 @@ test('separates branch and commit semantics and submits a reset from the dedicat const mobileOperationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => { const scroller = table.parentElement!; const scrollerRect = scroller.getBoundingClientRect(); + const detailRect = table.querySelector('[data-testid="operation-detail"]')?.getBoundingClientRect(); return { tableWidth: table.getBoundingClientRect().width, scrollerX: scrollerRect.x, scrollerWidth: scrollerRect.width, scrollerScrollWidth: scroller.scrollWidth, + detailX: detailRect?.x, + detailRight: detailRect?.right, viewportWidth: document.documentElement.clientWidth, documentScrollWidth: document.documentElement.scrollWidth, }; }); - expect(mobileOperationTableGeometry.tableWidth).toBeGreaterThanOrEqual(1_300); - expect(mobileOperationTableGeometry.scrollerScrollWidth).toBeGreaterThan( - mobileOperationTableGeometry.scrollerWidth + expect(mobileOperationTableGeometry.tableWidth).toBeLessThanOrEqual(mobileOperationTableGeometry.scrollerWidth + 1); + expect(mobileOperationTableGeometry.scrollerScrollWidth).toBeLessThanOrEqual( + mobileOperationTableGeometry.scrollerWidth + 1 ); expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0); expect(mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth).toBeLessThanOrEqual( @@ -755,6 +770,13 @@ test('separates branch and commit semantics and submits a reset from the dedicat expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual( mobileOperationTableGeometry.viewportWidth ); + expect(mobileOperationTableGeometry.detailX).toBeGreaterThanOrEqual(mobileOperationTableGeometry.scrollerX); + expect(mobileOperationTableGeometry.detailRight).toBeLessThanOrEqual( + mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth + ); + await page.screenshot({ path: testInfo.outputPath('mobile-operation-detail.png'), fullPage: true }); + await detailsToggle.click(); + await expect(operationDetail).toBeHidden(); await writeFile( testInfo.outputPath('operation-table-mobile-metrics.json'), JSON.stringify(mobileOperationTableGeometry, null, 2) @@ -783,7 +805,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } await page.getByTestId('request-deploy').click(); await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible(); - await expect(page.getByTestId('operations-table')).toContainText('DEPLOY'); + await expect(page.getByTestId('operations-table')).toContainText('버전 업데이트'); await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete'); @@ -848,7 +870,7 @@ test('submits a separately authorized destructive game cancellation on desktop a await cancelButton.click(); await expect(page.getByText('게임 취소 작업을 등록했습니다.').first()).toBeVisible(); - await expect(page.getByTestId('operations-table')).toContainText('CANCEL_GAME'); + await expect(page.getByTestId('operations-table')).toContainText('게임 취소'); expect(confirmations).toHaveLength(1); expect(confirmations[0]).toContain('기수 행 물리 삭제'); expect(confirmations[0]).toContain('장수 기록 보존'); @@ -1416,7 +1438,7 @@ test('stops a running profile build while keeping the existing runtime available await page.getByRole('button', { name: '빌드 중단' }).click(); await expect(page.getByText('프로필 빌드를 중단했습니다.').first()).toBeVisible(); - await expect(page.getByTestId('operations-table').getByText('CANCELLED', { exact: true })).toBeVisible(); + await expect(page.getByTestId('operations-table').getByText('중단됨', { exact: true })).toBeVisible(); expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.cancel')).toBe(true); }); @@ -1688,17 +1710,20 @@ test('renders a failed reset, retries it as a new operation, and reaches success page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/servers/che%3Adefault/scenario'); - await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible(); - await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible(); + const operationTable = page.getByTestId('operations-table'); + await expect(operationTable.getByText('실패', { exact: true })).toBeVisible(); + await expect(operationTable.getByText(longError)).toBeHidden(); + await operationTable.getByRole('button', { name: '오류 상세' }).click(); + await expect(operationTable.getByText('fedcba9876543210fedcba9876543210fedcba98', { exact: true })).toBeVisible(); const failure = page.getByTestId('operations-table').getByText(longError); await expect(failure).toBeVisible(); - expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)'); + expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.808 0.114 19.571)'); await page.getByRole('button', { name: '재시도' }).click(); await expect(page.getByText('재시도 작업을 등록했습니다.').first()).toBeVisible(); - await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible(); - await expect(page.getByTestId('operations-table').getByText('QUEUED', { exact: true })).toBeVisible(); - await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2); + await expect(page.getByTestId('operations-table').getByText('실패', { exact: true })).toBeVisible(); + await expect(page.getByTestId('operations-table').getByText('대기 중', { exact: true })).toBeVisible(); + await expect(page.getByTestId('operation-summary-row')).toHaveCount(2); state.operations[0] = { ...state.operations[0]!, @@ -1709,7 +1734,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success }; state.runtimeRunning = true; await page.getByTestId('refresh-operations').click(); - await expect(page.getByTestId('operations-table').getByText('SUCCEEDED', { exact: true })).toBeVisible(); + await expect(page.getByTestId('operations-table').getByText('완료', { exact: true })).toBeVisible(); await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true }); @@ -1717,8 +1742,14 @@ test('renders a failed reset, retries it as a new operation, and reaches success const tableGeometry = await page.getByTestId('operations-table').evaluate((table) => { const tableRect = table.getBoundingClientRect(); const scrollerRect = table.parentElement!.getBoundingClientRect(); - return { tableWidth: tableRect.width, scrollerWidth: scrollerRect.width }; + return { + tableWidth: tableRect.width, + scrollerWidth: scrollerRect.width, + documentScrollWidth: document.documentElement.scrollWidth, + viewportWidth: document.documentElement.clientWidth, + }; }); - expect(tableGeometry.tableWidth).toBeGreaterThan(tableGeometry.scrollerWidth); + expect(tableGeometry.tableWidth).toBeLessThanOrEqual(tableGeometry.scrollerWidth + 1); + expect(tableGeometry.documentScrollWidth).toBeLessThanOrEqual(tableGeometry.viewportWidth); await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-mobile.png'), fullPage: true }); }); diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 4f0742a7..371bb2df 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -86,6 +86,7 @@ type GatewayReleaseLog = { const scenarios = ref([]); const operations = ref([]); const selectedProfileOperationId = ref(''); +const expandedProfileOperationId = ref(''); const profileOperationLogs = ref([]); const profileOperationLogCursor = ref(); const profileOperationLogStatus = ref(''); @@ -269,6 +270,40 @@ const formatTime = (value?: string): string => formatServerDateTime(value, { fal const formatLogTime = (value: string): string => formatServerDateTime(value, { format: 'timeSeconds' }); const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-'); +const operationTypeLabel = (type: Operation['type']): string => { + const labels: Record = { + DEPLOY: '버전 업데이트', + RESET: '시나리오 초기화', + CANCEL_GAME: '게임 취소', + START: '서버 시작', + STOP: '서버 중지', + }; + return labels[type]; +}; + +const operationStatusLabel = (status: Operation['status']): string => { + const labels: Record = { + QUEUED: '대기 중', + RUNNING: '진행 중', + SUCCEEDED: '완료', + FAILED: '실패', + CANCELLED: '중단됨', + }; + return labels[status]; +}; + +const operationStatusClass = (status: Operation['status']): string => { + if (status === 'RUNNING') return 'border-emerald-700 bg-emerald-950/70 text-emerald-200'; + if (status === 'QUEUED') return 'border-amber-700 bg-amber-950/70 text-amber-200'; + if (status === 'SUCCEEDED') return 'border-cyan-800 bg-cyan-950/60 text-cyan-200'; + if (status === 'FAILED') return 'border-red-800 bg-red-950/70 text-red-200'; + return 'border-zinc-700 bg-zinc-800 text-zinc-300'; +}; + +const toggleProfileOperationDetails = (operationId: string) => { + expandedProfileOperationId.value = expandedProfileOperationId.value === operationId ? '' : operationId; +}; + const clearStatus = () => { message.value = ''; errorMessage.value = ''; @@ -1753,7 +1788,7 @@ onBeforeUnmount(() => { -
+
{

작업 이력

- 3초마다 상태 갱신 + 진행 상태를 3초마다 갱신
-
- +
+
- - - - - - - - - - + + + - - - - + - - - - - + - - - - - - - - - - - + + + + + + - +
+
+
서버 ID
+
+ {{ operation.profileName }} +
+
+
+
작업 ID
+
+ {{ operation.id }} +
+
+
+
소스
+
+ {{ operation.sourceMode ?? '-' }} + {{ operation.sourceRef ?? '' }} +
+
+
+
해석 커밋
+
+ {{ operation.resolvedCommitSha ?? '-' }} +
+
+
+
요청자
+
+ {{ operation.requestedBy }} +
+
+
+
완료 시각
+
+ {{ formatTime(operation.completedAt) }} +
+
+
+
사유
+
+ {{ operation.reason }} +
+
+
+
오류
+
+ {{ operation.error }} +
+
+
+ + + + - +
요청/예약작업 ID프로필작업요청 · 작업 상태소스해석 커밋요청자/사유완료/오류동작보기
- {{ formatTime(operation.createdAt) }} -
- 예약 {{ formatTime(operation.scheduledAt) }} -
-
{{ operation.id }}{{ operation.profileName }}{{ operation.type }}{{ operation.status }} -
{{ operation.sourceMode ?? '-' }}
-
- {{ operation.sourceRef }} -
-
{{ shortSha(operation.resolvedCommitSha) }} -
{{ operation.requestedBy }}
-
{{ operation.reason }}
-
- {{ formatTime(operation.completedAt) }} -
- {{ operation.error }} -
-
-
-
+
+ {{ operationTypeLabel(operation.type) }} +
+
+ {{ formatTime(operation.createdAt) }} +
+
- 로그 - -
+ - {{ operation.status === 'RUNNING' ? '빌드 중단' : '취소' }} - - +
+ + + + +
+
+
- 재시도 - -
-
작업 이력이 없습니다.작업 이력이 없습니다.
diff --git a/docs/admin-console.md b/docs/admin-console.md index bc8536d6..e3283860 100644 --- a/docs/admin-console.md +++ b/docs/admin-console.md @@ -40,6 +40,11 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를 기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세 runtime·빌드 상태는 상태 설정 탭에서 확인합니다. +- 버전 업데이트·시나리오 초기화·게임 취소 화면의 작업 이력은 요청·작업, 상태, + 보기의 3열 요약으로 표시합니다. `진행 중`, `완료`, `실패` 등 현재 상태와 로그를 + 먼저 확인하고, 서버 ID·작업 ID·소스·커밋·요청자·사유·완료 시각·오류 원문은 + 각 작업의 `상세` 행에서 확인합니다. 상세 버튼은 `aria-expanded`와 + `aria-controls`로 연결되며 500px 화면에서도 페이지 가로 스크롤 없이 열립니다. - 공통 좌측 메뉴는 `admin.profiles.listNavigation`으로 접근 가능한 profile의 이름과 표시명만 읽습니다. 이 요청은 PM2 runtime 상태를 포함하는 본문용 `admin.profiles.list`와 별도의 non-batch 요청으로 전송하므로, 상태 조회가 늦거나