From 78844700c00393aad3567ee97de9c93751c1c71b Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 13:19:32 +0000 Subject: [PATCH] feat: expose profile reset operation progress --- .../e2e/admin-runtime-actions.spec.ts | 93 ++++++++++++++++++- .../e2e/server-operations.spec.ts | 83 ++++++++++++++++- app/gateway-frontend/src/views/AdminView.vue | 54 +++++++++-- .../src/views/ServerOperationsView.vue | 14 ++- 4 files changed, 228 insertions(+), 16 deletions(-) diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index dfe39af..ded240f 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -33,12 +33,15 @@ const installFixture = async ( page: Page, options: { deferRequest?: boolean; + deferInstall?: boolean; initialActions?: RuntimeAction[]; afterRequestActions?: RuntimeAction[]; pendingProfileReads?: number; } = {} ) => { let requested = false; + let installRequested = false; + let installActive = false; let postRequestProfileReads = 0; const requestBodies: unknown[] = []; let releaseRequest = (): void => {}; @@ -47,6 +50,12 @@ const installFixture = async ( releaseRequest = resolve; }) : Promise.resolve(); + let releaseInstall = (): void => {}; + const installGate = options.deferInstall + ? new Promise((resolve) => { + releaseInstall = resolve; + }) + : Promise.resolve(); await page.addInitScript(() => { window.localStorage.setItem('sammo-session-token', 'playwright-admin-session'); }); @@ -58,6 +67,12 @@ const installFixture = async ( requestBodies.push(body); await requestGate; } + if (operations.includes('admin.profiles.install')) { + installRequested = true; + requestBodies.push(body); + await installGate; + installActive = true; + } const results = operations.map((operation) => { if (operation === 'me') { return response({ @@ -75,7 +90,17 @@ const installFixture = async ( return response({ enabled: true }); } if (operation === 'admin.profiles.listScenarios') { - return response([]); + return response([ + { + id: 1010, + title: '【테스트】황건의 난', + year: 184, + npcCount: 42, + npcExCount: 0, + npcNeutralCount: 0, + nations: [], + }, + ]); } if (operation === 'admin.profiles.list') { const keepPending = requested && postRequestProfileReads++ < (options.pendingProfileReads ?? 0); @@ -83,11 +108,17 @@ const installFixture = async ( { profileName: 'hwe:default', profile: 'hwe', - scenario: 'default', + scenario: '1010', apiPort: 15015, status: 'RUNNING', buildStatus: 'SUCCEEDED', meta: {}, + activeOperation: installActive + ? { + id: '77777777-7777-4777-8777-777777777777', + status: 'QUEUED', + } + : null, runtime: { profileName: 'hwe:default', apiRunning: true, @@ -123,6 +154,32 @@ const installFixture = async ( }, }); } + if (operation === 'admin.profiles.install') { + return response({ + ok: true, + operationId: '77777777-7777-4777-8777-777777777777', + }); + } + if (operation === 'admin.operations.list') { + return response( + installRequested + ? [ + { + id: '77777777-7777-4777-8777-777777777777', + profileName: 'hwe:default', + type: 'RESET', + status: installActive ? 'QUEUED' : 'RUNNING', + sourceMode: 'COMMIT', + sourceRef: '0123456789abcdef0123456789abcdef01234567', + payload: {}, + requestedBy: 'admin-user', + createdAt: '2026-07-30T02:00:00.000Z', + updatedAt: '2026-07-30T02:00:00.000Z', + }, + ] + : [] + ); + } throw new Error(`Unhandled tRPC operation: ${operation}`); }); await route.fulfill({ @@ -131,7 +188,7 @@ const installFixture = async ( body: JSON.stringify(results), }); }); - return { releaseRequest, requestBodies }; + return { releaseRequest, releaseInstall, requestBodies }; }; test('reports clock-shift acceptance separately from actual application', async ({ page }) => { @@ -215,3 +272,33 @@ test('renders an ignored terminal outcome without calling it applied', async ({ await expect(page.getByText('지원하지 않는 요청')).toBeVisible(); await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0); }); + +test('keeps profile installation disabled while its queued operation is active', async ({ page }, testInfo) => { + const fixture = await installFixture(page, { deferInstall: true }); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('admin'); + + const installButton = page.getByRole('button', { name: '설치 적용' }); + const click = installButton.click(); + await expect.poll(() => fixture.requestBodies.length).toBe(1); + await expect(page.getByRole('button', { name: '등록 중…' })).toBeDisabled(); + fixture.releaseInstall(); + await click; + + const operationLink = page.getByRole('link', { name: /77777777-7777-4777-8777-777777777777 상태 보기/ }); + await expect(operationLink).toBeVisible(); + await expect(page.getByRole('button', { name: '설치 작업 진행 중' })).toBeDisabled(); + + await page.setViewportSize({ width: 390, height: 844 }); + const linkGeometry = await operationLink.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right, viewportWidth: window.innerWidth }; + }); + expect(linkGeometry.left).toBeGreaterThanOrEqual(0); + expect(linkGeometry.right).toBeLessThanOrEqual(linkGeometry.viewportWidth); + await page.screenshot({ path: testInfo.outputPath('admin-install-active-mobile.png'), fullPage: true }); + + await operationLink.click(); + await expect(page).toHaveURL(/\/gateway\/admin\/server-operations\?operationId=77777777/); + await expect(page.getByTestId('operations-table')).toContainText('77777777-7777-4777-8777-777777777777'); +}); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 3f23cd4..a06ff7d 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -10,6 +10,8 @@ type Operation = { sourceMode?: 'BRANCH' | 'COMMIT'; sourceRef?: string; resolvedCommitSha?: string; + completedAt?: string; + error?: string; payload: Record; requestedBy: string; createdAt: string; @@ -127,6 +129,22 @@ const installFixture = async (page: Page, state: FixtureState) => { state.operations = [operation]; return response(operation); } + if (name === 'admin.operations.retry') { + const operation: Operation = { + id: '44444444-4444-4444-8444-444444444444', + profileName: 'che:2', + type: 'RESET', + status: 'QUEUED', + sourceMode: 'COMMIT', + sourceRef: 'fedcba9876543210fedcba9876543210fedcba98', + payload: { installOperationId: 'failed-generation' }, + requestedBy: 'admin', + createdAt: '2026-07-25T04:00:00.000Z', + updatedAt: '2026-07-25T04:00:00.000Z', + }; + state.operations = [operation, ...state.operations]; + return response(operation); + } throw new Error(`Unhandled tRPC operation: ${name}`); }); await route.fulfill({ @@ -190,7 +208,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat await page.getByTestId('request-reset').hover(); await page.getByTestId('request-reset').click(); - await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible(); + await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible(); await expect(page.getByTestId('operations-table')).toContainText('RESET'); const resetRequest = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset'); expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"'); @@ -232,3 +250,66 @@ test('starts and stops all runtime roles through the operation controls', async expect(serializedRequests).toContain('"action":"START"'); expect(serializedRequests).toContain('"action":"STOP"'); }); + +test('renders a failed reset, retries it as a new operation, and reaches success', async ({ page }, testInfo) => { + const longError = + '선택한 커밋의 프로필 프로세스를 시작하지 못했습니다. 실패 원인을 확인한 뒤 동일 generation으로 재시도해 주세요.'; + const state: FixtureState = { + operations: [ + { + id: '55555555-5555-4555-8555-555555555555', + profileName: 'che:2', + type: 'RESET', + status: 'FAILED', + sourceMode: 'COMMIT', + sourceRef: 'fedcba9876543210fedcba9876543210fedcba98', + resolvedCommitSha: 'fedcba9876543210fedcba9876543210fedcba98', + payload: { installOperationId: 'failed-generation' }, + requestedBy: 'admin', + completedAt: '2026-07-25T03:30:00.000Z', + error: longError, + createdAt: '2026-07-25T03:00:00.000Z', + updatedAt: '2026-07-25T03:30:00.000Z', + }, + ], + runtimeRunning: false, + requestBodies: [], + }; + await installFixture(page, state); + page.on('dialog', (dialog) => dialog.accept()); + + await page.goto('admin/server-operations'); + await expect(page.getByText('FAILED', { exact: true })).toBeVisible(); + await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible(); + const failure = page.getByText(longError); + await expect(failure).toBeVisible(); + expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)'); + + await page.getByRole('button', { name: '재시도' }).click(); + await expect(page.getByText('재시도 작업을 등록했습니다.')).toBeVisible(); + await expect(page.getByText('FAILED', { exact: true })).toBeVisible(); + await expect(page.getByText('QUEUED', { exact: true })).toBeVisible(); + await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2); + + state.operations[0] = { + ...state.operations[0]!, + status: 'SUCCEEDED', + resolvedCommitSha: 'fedcba9876543210fedcba9876543210fedcba98', + completedAt: '2026-07-25T04:05:00.000Z', + updatedAt: '2026-07-25T04:05:00.000Z', + }; + state.runtimeRunning = true; + await page.getByTestId('refresh-operations').click(); + await expect(page.getByText('SUCCEEDED', { exact: true })).toBeVisible(); + await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible(); + + await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); + 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 }; + }); + expect(tableGeometry.tableWidth).toBeGreaterThan(tableGeometry.scrollerWidth); + await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-mobile.png'), fullPage: true }); +}); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 8d380c9..cfe1391 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -74,6 +74,10 @@ type AdminProfile = { tournamentRunning: boolean; }; buildCommitSha?: string; + activeOperation?: { + id: string; + status: 'QUEUED' | 'RUNNING'; + } | null; meta: Record; runtimeActions: Array<{ id: string; @@ -238,7 +242,7 @@ type AdminClient = { gitRef?: string; }; reason?: string; - }) => Promise<{ ok: boolean; action?: unknown }>; + }) => Promise<{ ok: boolean; operationId: string; action?: unknown }>; }; requestAction: { mutate: (input: { @@ -306,6 +310,8 @@ const profileActionSubmitting = ref>({}); const scenarioCatalogs = ref>({}); const profileInstalls = ref>({}); const profileInstallStatus = ref>({}); +const profileInstallSubmitting = ref>({}); +const profileInstallOperationId = ref>({}); const runtimeActionPending = (profile: AdminProfile): boolean => { return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL'); @@ -685,6 +691,9 @@ const loadScenariosForProfile = async (profileName: string) => { if (!install) { return; } + if (profileInstallSubmitting.value[profileName]) { + return; + } await loadScenarioCatalog(install.gitRef); }; @@ -817,8 +826,9 @@ const requestInstall = async (profileName: string) => { return; } try { + profileInstallSubmitting.value = { ...profileInstallSubmitting.value, [profileName]: true }; const gitRef = normalizeGitRefInput(install.gitRef); - await adminClient.profiles.install.mutate({ + const result = await adminClient.profiles.install.mutate({ profileName, install: { scenarioId: install.scenarioId, @@ -840,14 +850,20 @@ const requestInstall = async (profileName: string) => { }); profileInstallStatus.value = { ...profileInstallStatus.value, - [profileName]: openAt ? '설치 예약 완료' : '설치 요청 완료', + [profileName]: openAt ? '설치 작업을 예약했습니다.' : '설치 작업을 등록했습니다.', + }; + profileInstallOperationId.value = { + ...profileInstallOperationId.value, + [profileName]: result.operationId, }; await loadProfiles(); } catch (error) { profileInstallStatus.value = { ...profileInstallStatus.value, - [profileName]: '설치 요청 실패', + [profileName]: error instanceof Error ? `설치 요청 실패: ${error.message}` : '설치 요청 실패', }; + } finally { + profileInstallSubmitting.value = { ...profileInstallSubmitting.value, [profileName]: false }; } }; @@ -1664,9 +1680,19 @@ onMounted(() => { >

설치/리셋

- {{ - profileInstallStatus[profile.profileName] - }} +
+
{{ profileInstallStatus[profile.profileName] }}
+ + 작업 {{ profileInstallOperationId[profile.profileName] }} 상태 보기 + +
@@ -2064,10 +2090,20 @@ onMounted(() => {
{ preopenAt: toIso(form.preopenAt), }, }); - message.value = form.scheduledAt ? '예약 초기화 작업을 등록했습니다.' : '초기화 작업을 시작했습니다.'; + message.value = form.scheduledAt ? '예약 초기화 작업을 등록했습니다.' : '초기화 작업을 등록했습니다.'; await loadState(true); } catch (error) { errorMessage.value = error instanceof Error ? error.message : '초기화 요청에 실패했습니다.'; @@ -668,6 +668,7 @@ onBeforeUnmount(() => { 요청/예약 + 작업 ID 프로필 작업 상태 @@ -690,6 +691,7 @@ onBeforeUnmount(() => { 예약 {{ formatTime(operation.scheduledAt) }}
+ {{ operation.id }} {{ operation.profileName }} {{ operation.type }} {{ operation.status }} @@ -703,7 +705,13 @@ onBeforeUnmount(() => { {{ formatTime(operation.completedAt) }} -
{{ operation.error }}
+
+ {{ operation.error }} +