diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 90b50763..d369b41e 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -26,6 +26,9 @@ type FixtureState = { status: OperationStatus; sourceMode?: 'BRANCH' | 'COMMIT'; sourceRef?: string; + resolvedCommitSha?: string; + completedAt?: string; + error?: string; payload: Record; requestedBy: string; createdAt: string; @@ -38,6 +41,9 @@ type FixtureState = { profileLogsEmpty?: boolean; gatewayLogPollCount?: number; gatewayLogsEmpty?: boolean; + gatewayStateFailuresAfterRequest?: number; + gatewayStateFailuresRemaining?: number; + gatewayStateFailureCount?: number; capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>; profileListDelayMs?: number; profileNavigationDelayMs?: number; @@ -139,6 +145,19 @@ const installFixture = async (page: Page, state: FixtureState) => { await route.abort('failed'); return; } + if ( + names.includes('admin.releases.gatewayState') && + (state.gatewayStateFailuresRemaining ?? 0) > 0 + ) { + state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1; + state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1; + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: '', + }); + return; + } if (names.includes('admin.operations.logs') && !state.profileLogProgress) { await new Promise((resolve) => setTimeout(resolve, 50)); } @@ -337,6 +356,7 @@ const installFixture = async (page: Page, state: FixtureState) => { updatedAt: '2026-08-01T02:00:00.000Z', }; state.gatewayOperations = [releaseOperation]; + state.gatewayStateFailuresRemaining = state.gatewayStateFailuresAfterRequest; return response(releaseOperation); } if (name === 'admin.operations.requestRuntime') { @@ -815,7 +835,13 @@ test('scenario-only operator resets the current version without Git or Gateway c }); test('controls gateway deployment and rollback through the external controller queue', async ({ page }, testInfo) => { - const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] }; + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + gatewayStateFailuresAfterRequest: 1, + }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); @@ -828,6 +854,9 @@ test('controls gateway deployment and rollback through the external controller q await page.getByTestId('request-gateway-deploy').click(); await expect(page.getByText(/Gateway 배포 작업을 등록했습니다/).first()).toBeVisible(); + expect(state.gatewayStateFailureCount).toBe(1); + await expect(page.getByTestId('server-operations-page')).not.toContainText('Unexpected end of JSON input'); + await expect(page.getByTestId('action-toast').filter({ hasText: 'Unexpected end of JSON input' })).toHaveCount(0); await expect(page.getByTestId('gateway-release-table')).toContainText('DEPLOY'); await expect(page.getByTestId('gateway-release-log-panel')).toBeVisible(); await expect(page.getByTestId('gateway-release-log')).toContainText('Gateway 구성 요소를 빌드합니다.'); @@ -853,6 +882,131 @@ test('controls gateway deployment and rollback through the external controller q expect(state.requestBodies.some((entry) => entry.operation === 'admin.releases.requestGatewayRollback')).toBe(true); }); +test('moves long Gateway release errors out of the table column into an expandable detail row', async ({ + page, +}, testInfo) => { + const longError = [ + 'Gateway release did not become ready before the timeout.', + 'Error: gateway-frontend readiness check failed after 30 attempts', + ' at waitForGatewayReadiness (/srv/core/release-controller/dist/releaseController.js:842:19)', + 'controller-output-without-breaks-'.repeat(12), + ].join('\n'); + const operationId = '88888888-8888-4888-8888-888888888888'; + const state: FixtureState = { + operations: [], + gatewayOperations: [ + { + id: operationId, + type: 'DEPLOY', + status: 'FAILED', + sourceMode: 'COMMIT', + sourceRef: 'cccccccccccccccccccccccccccccccccccccccc', + resolvedCommitSha: 'cccccccccccccccccccccccccccccccccccccccc', + completedAt: '2026-08-01T02:03:00.000Z', + error: longError, + payload: {}, + requestedBy: 'admin', + createdAt: '2026-08-01T02:00:00.000Z', + updatedAt: '2026-08-01T02:03:00.000Z', + }, + ], + gatewayLogsEmpty: true, + runtimeRunning: true, + requestBodies: [], + }; + await installFixture(page, state); + + await page.goto('admin/releases'); + const table = page.getByTestId('gateway-release-table'); + await expect(table.getByRole('columnheader')).toHaveCount(6); + await expect(table.getByRole('columnheader', { name: '오류', exact: true })).toHaveCount(0); + await expect(table.getByRole('columnheader', { name: '상세', exact: true })).toBeVisible(); + + const errorToggle = page.getByTestId('gateway-release-error-toggle'); + await expect(errorToggle).toHaveText('오류 보기'); + await expect(errorToggle).toHaveAttribute('aria-expanded', 'false'); + await expect(page.getByTestId('gateway-release-error-detail')).toBeHidden(); + + await errorToggle.focus(); + const focusedToggleStyle = await errorToggle.evaluate((element) => { + const style = getComputedStyle(element); + return { + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + color: style.color, + }; + }); + expect(focusedToggleStyle.outlineStyle).not.toBe('none'); + expect(parseFloat(focusedToggleStyle.outlineWidth)).toBeGreaterThanOrEqual(2); + + await errorToggle.hover(); + await errorToggle.click(); + await expect(errorToggle).toHaveText('오류 닫기'); + await expect(errorToggle).toHaveAttribute('aria-expanded', 'true'); + const errorDetail = page.getByTestId('gateway-release-error-detail'); + await expect(errorDetail).toContainText('Gateway release did not become ready'); + await expect(errorDetail).toContainText('controller-output-without-breaks'); + const desktopGeometry = await table.evaluate((element) => { + const headings = Array.from(element.querySelectorAll('thead th')); + const detail = element.querySelector('[data-testid="gateway-release-error-detail"]'); + const detailCell = detail?.querySelector('td'); + const errorText = detail?.querySelector('pre'); + return { + tableWidth: element.getBoundingClientRect().width, + scrollerWidth: element.parentElement?.getBoundingClientRect().width ?? 0, + tableLayout: getComputedStyle(element).tableLayout, + columnCount: headings.length, + detailColSpan: detailCell?.getAttribute('colspan'), + detailWidth: detailCell?.getBoundingClientRect().width ?? 0, + errorWhiteSpace: errorText ? getComputedStyle(errorText).whiteSpace : '', + errorOverflowWrap: errorText ? getComputedStyle(errorText).overflowWrap : '', + }; + }); + expect(desktopGeometry).toMatchObject({ + tableLayout: 'fixed', + columnCount: 6, + detailColSpan: '6', + errorWhiteSpace: 'pre-wrap', + }); + expect(desktopGeometry.tableWidth).toBeGreaterThanOrEqual(680); + expect(desktopGeometry.detailWidth).toBeGreaterThanOrEqual(desktopGeometry.tableWidth - 1); + await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-desktop.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(table.getByRole('columnheader')).toHaveCount(4); + await expect(table.getByRole('columnheader', { name: '소스', exact: true })).toHaveCount(0); + await expect(table.getByRole('columnheader', { name: '해석 커밋', exact: true })).toHaveCount(0); + const mobileGeometry = await table.evaluate((element) => { + const scroller = element.parentElement!; + const scrollerRect = scroller.getBoundingClientRect(); + const detailRect = element + .querySelector('[data-testid="gateway-release-error-detail"]')! + .getBoundingClientRect(); + return { + tableWidth: element.getBoundingClientRect().width, + scrollerX: scrollerRect.x, + scrollerWidth: scrollerRect.width, + scrollerScrollWidth: scroller.scrollWidth, + detailWidth: detailRect.width, + viewportWidth: document.documentElement.clientWidth, + documentScrollWidth: document.documentElement.scrollWidth, + }; + }); + expect(mobileGeometry.tableWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1); + expect(mobileGeometry.detailWidth).toBeGreaterThanOrEqual(mobileGeometry.tableWidth - 1); + expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1); + expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0); + expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual( + mobileGeometry.viewportWidth + ); + expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth); + await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-mobile.png'), fullPage: true }); + + await errorToggle.click(); + await expect(errorToggle).toHaveAttribute('aria-expanded', 'false'); + await expect(page.getByTestId('gateway-release-error-detail')).toBeHidden(); +}); + test('explains terminal releases created before controller progress logging', async ({ page }) => { const state: FixtureState = { operations: [], diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 664fa097..7031f4d3 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -89,6 +89,7 @@ const profileOperationLogViewport = ref(); const gatewayReleaseState = ref(null); const gatewayReleaseOperations = ref([]); const selectedGatewayOperationId = ref(''); +const expandedGatewayErrorOperationId = ref(''); const gatewayReleaseLogs = ref([]); const gatewayReleaseLogCursor = ref(); const gatewayReleaseLogStatus = ref(''); @@ -113,6 +114,7 @@ let stateRequestInFlight = false; let releaseLogLoopGeneration = 0; let profileLogLoopGeneration = 0; let componentMounted = false; +let gatewayReleaseTransitionActive = false; const form = reactive({ sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT', @@ -169,7 +171,7 @@ const gatewayReleaseLogEmptyMessage = computed(() => { return 'controller 로그를 기다리고 있습니다…'; } if (operation.error) { - return `이 작업에는 controller 로그가 기록되지 않았습니다. 작업 오류: ${operation.error}`; + return '이 작업에는 controller 로그가 기록되지 않았습니다. 작업 이력의 오류 상세를 확인하세요.'; } return '이 작업에는 controller 로그가 기록되지 않았습니다. 로그 지원 controller 적용 전 작업일 수 있습니다.'; }); @@ -286,6 +288,7 @@ const loadState = async (quiet = false) => { const active = gatewayReleaseOperations.value.find((operation) => ['QUEUED', 'RUNNING'].includes(operation.status) ); + gatewayReleaseTransitionActive = Boolean(active); if (active && selectedGatewayOperationId.value !== active.id) { selectedGatewayOperationId.value = active.id; } else if ( @@ -312,7 +315,11 @@ const loadState = async (quiet = false) => { } } } catch (error) { - errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.'; + // Gateway release가 process를 교체하는 동안에는 background poll의 HTTP 연결이 + // 일시적으로 끊길 수 있다. 이 경우는 기존 상태를 유지하고 다음 poll에서 복구한다. + if (!quiet || props.mode !== 'gateway' || !gatewayReleaseTransitionActive) { + errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.'; + } } finally { loading.value = false; stateRequestInFlight = false; @@ -429,6 +436,11 @@ const selectGatewayReleaseOperation = (operationId: string) => { selectedGatewayOperationId.value = operationId; }; +const toggleGatewayReleaseError = (operationId: string) => { + expandedGatewayErrorOperationId.value = + expandedGatewayErrorOperationId.value === operationId ? '' : operationId; +}; + const requestDeploy = async () => { clearStatus(); if ( @@ -475,6 +487,7 @@ const requestGatewayDeploy = async () => { sourceRef: gatewayForm.sourceRef.trim(), reason: gatewayForm.reason.trim() || undefined, }); + gatewayReleaseTransitionActive = true; selectedGatewayOperationId.value = operation.id; message.value = 'Gateway 배포 작업을 등록했습니다. 외부 release-controller가 처리합니다.'; await loadState(true); @@ -499,6 +512,7 @@ const requestGatewayRollback = async () => { const operation = await adminClient.releases.requestGatewayRollback.mutate({ reason: gatewayForm.reason.trim() || undefined, }); + gatewayReleaseTransitionActive = true; selectedGatewayOperationId.value = operation.id; message.value = 'Gateway rollback 작업을 등록했습니다.'; await loadState(true); @@ -1119,45 +1133,97 @@ onBeforeUnmount(() => {
- +
+ + + + + + + + - - - - + + + - - - - - - - - - +
시각 작업 상태소스해석 커밋오류로그상세
{{ formatTime(operation.createdAt) }}{{ operation.type }}{{ operation.status }}{{ operation.sourceRef }}{{ shortSha(operation.resolvedCommitSha) }}{{ operation.error }} - -