From fe678b295bda0b44dc162347e05f30e0eef3647c Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 9 Aug 2026 12:43:46 +0000 Subject: [PATCH] fix(gateway): restore current scenario selection --- app/gateway-api/src/adminRouter.ts | 23 +++++--- app/gateway-api/test/adminOperations.test.ts | 18 ++++++ .../e2e/server-operations.spec.ts | 59 ++++++++++++++++++- .../src/views/ServerOperationsView.vue | 49 +++++++++++---- docs/admin-console.md | 4 ++ 5 files changed, 134 insertions(+), 19 deletions(-) diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index f6a6a8e7..acc9ed3b 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -1549,6 +1549,7 @@ export const adminRouter = router({ const adminAuth = requireAdminAuth(ctx); const sourceMode = input?.sourceMode ?? 'CURRENT'; let gitRef = input?.gitRef?.trim(); + let currentScenarioId: number | null = null; if (sourceMode === 'CURRENT') { if (!input?.profileName) { if (!adminAuth.isSuperuser) { @@ -1562,6 +1563,8 @@ export const adminRouter = router({ ); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); + const parsedScenarioId = Number(profile.scenario); + currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null; gitRef = profile.buildCommitSha?.trim(); if (!gitRef) { throw new TRPCError({ @@ -1575,14 +1578,18 @@ export const adminRouter = router({ } else { assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]); } - if (!gitRef) { - return listScenarioPreviews(); - } - const resolved = - sourceMode === 'BRANCH' - ? await resolveGitBranchCommitSha(gitRef) - : await resolveGitCommitSha(gitRef); - return listScenarioPreviews({ gitRef: resolved }); + const scenarios = !gitRef + ? await listScenarioPreviews() + : await listScenarioPreviews({ + gitRef: + sourceMode === 'BRANCH' + ? await resolveGitBranchCommitSha(gitRef) + : await resolveGitCommitSha(gitRef), + }); + return scenarios.map((scenario) => ({ + ...scenario, + isCurrent: currentScenarioId === scenario.id, + })); }), upsert: profileAdminProcedure .input( diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 9626a2d1..47259749 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -326,6 +326,24 @@ describe('admin profile navigation API', () => { }); }); +describe('admin scenario catalog API', () => { + it('marks scenario zero as the current selectable scenario', async () => { + const harness = await buildCaller( + async () => { + throw new Error('not used'); + }, + { profileScenario: '0' } + ); + + const scenarios = await harness.caller.admin.profiles.listScenarios({ + profileName: 'che:2', + sourceMode: 'CURRENT', + }); + + expect(scenarios.find((scenario) => scenario.id === 0)?.isCurrent).toBe(true); + }); +}); + describe('gateway notice API', () => { const dirtyNotice = '점검
' + diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 1379b744..ad8dd4c7 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -39,6 +39,7 @@ type FixtureState = { profileNavigationDelayMs?: number; profileNavigationRequests?: number; profileNavigationResolved?: boolean; + scenarioFailuresRemaining?: number; }; const profile = (runtimeRunning: boolean) => ({ @@ -65,6 +66,16 @@ const profile = (runtimeRunning: boolean) => ({ }); const scenarios = [ + { + id: 0, + title: '【테스트】공백지', + year: null, + npcCount: 0, + npcExCount: 0, + npcNeutralCount: 0, + nations: [], + isCurrent: false, + }, { id: 2, title: '【테스트】황건의 난', @@ -73,6 +84,7 @@ const scenarios = [ npcExCount: 0, npcNeutralCount: 0, nations: [], + isCurrent: true, }, { id: 5, @@ -82,6 +94,7 @@ const scenarios = [ npcExCount: 0, npcNeutralCount: 0, nations: [], + isCurrent: false, }, ]; @@ -109,6 +122,11 @@ const installFixture = async (page: Page, state: FixtureState) => { if (names.includes('admin.profiles.list') && state.profileListDelayMs) { await new Promise((resolve) => setTimeout(resolve, state.profileListDelayMs)); } + if (names.includes('admin.profiles.listScenarios') && (state.scenarioFailuresRemaining ?? 0) > 0) { + state.scenarioFailuresRemaining = (state.scenarioFailuresRemaining ?? 0) - 1; + await route.abort('failed'); + return; + } const results = names.map((name) => { if (route.request().method() === 'POST') { state.requestBodies.push({ operation: name, body }); @@ -185,6 +203,7 @@ const installFixture = async (page: Page, state: FixtureState) => { }); } if (name === 'admin.profiles.listScenarios') { + expect(names).toEqual(['admin.profiles.listScenarios']); return response(scenarios); } if (name === 'admin.operations.requestReset') { @@ -295,6 +314,26 @@ test('separates branch and commit semantics and submits a reset from the dedicat await expect(page.getByTestId('source-current')).toBeChecked(); await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋'); await expect(page.getByTestId('scenario-select')).toHaveValue('2'); + await expect(page.getByTestId('request-reset')).toBeEnabled(); + await expect(page.getByTestId('scenario-select').locator('option:checked')).toContainText('현재 시나리오'); + const catalogGeometry = await page.getByTestId('scenario-select').evaluate((select) => { + const scenarioSelect = select as HTMLSelectElement; + const rect = select.getBoundingClientRect(); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + optionCount: scenarioSelect.options.length, + value: scenarioSelect.value, + }; + }); + expect(catalogGeometry.optionCount).toBe(3); + expect(catalogGeometry.value).toBe('2'); + expect(catalogGeometry.width).toBeGreaterThan(300); + await page.screenshot({ path: testInfo.outputPath('current-scenario-catalog.png'), fullPage: true }); + await page.getByTestId('scenario-select').selectOption('0'); + await expect(page.getByTestId('request-reset')).toBeEnabled(); await expect(page.getByTestId('server-profile-tabs')).toBeVisible(); await expect(page.getByRole('link', { name: '시나리오 초기화', exact: true })).toHaveAttribute( 'aria-current', @@ -331,7 +370,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat }); await writeFile( testInfo.outputPath('layout-metrics.json'), - JSON.stringify({ desktopGeometry, focusedInputStyle }, null, 2) + JSON.stringify({ catalogGeometry, desktopGeometry, focusedInputStyle }, null, 2) ); await page.screenshot({ path: testInfo.outputPath('desktop-operations.png'), fullPage: true }); @@ -415,6 +454,24 @@ test('renders the fixed-profile version form without waiting for the server list expect(state.profileNavigationRequests).toBe(1); }); +test('recovers the current-version scenario catalog after the initial request fails', async ({ page }) => { + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + scenarioFailuresRemaining: 1, + }; + await installFixture(page, state); + + await page.goto('admin/servers/che%3A2/scenario'); + await expect(page.getByTestId('scenario-select')).toContainText('선택할 수 있는 시나리오가 없습니다.'); + await expect(page.getByTestId('request-reset')).toBeDisabled(); + await page.getByTestId('load-scenarios').click(); + await expect(page.getByTestId('scenario-select')).toHaveValue('2'); + await expect(page.getByTestId('request-reset')).toBeEnabled(); +}); + test('renders the server navigation before the detailed runtime profile request resolves', 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 66bf97e5..98282ccc 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } import ServerProfileTabs from '../components/ServerProfileTabs.vue'; import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; -import { trpc } from '../utils/trpc'; +import { directTrpc, trpc } from '../utils/trpc'; type OperationPageMode = 'version' | 'scenario' | 'gateway'; @@ -13,12 +13,14 @@ const props = defineProps<{ }>(); const adminClient = trpc.admin; +const scenarioClient = directTrpc.admin; type Scenario = { id: number; title: string; year: number | null; npcCount: number; + isCurrent: boolean; }; type Operation = { @@ -84,6 +86,7 @@ const selectedProfileName = computed(() => props.profileName ?? ''); const capabilities = ref>([]); const loading = ref(false); const catalogLoading = ref(false); +const catalogAttempted = ref(false); const submitting = ref(false); const message = ref(''); const errorMessage = ref(''); @@ -95,7 +98,7 @@ let componentMounted = false; const form = reactive({ sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT', sourceRef: 'main', - scenarioId: 0, + scenarioId: null as number | null, turnTermMinutes: 60, sync: true, fiction: 1, @@ -372,20 +375,22 @@ const loadScenarios = async () => { } catalogLoading.value = true; try { - const result = await adminClient.profiles.listScenarios.query({ + const result = await scenarioClient.profiles.listScenarios.query({ profileName: selectedProfileName.value, gitRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(), sourceMode: form.sourceMode, }); scenarios.value = result as Scenario[]; if (!scenarios.value.some((scenario) => scenario.id === form.scenarioId)) { - form.scenarioId = scenarios.value[0]?.id ?? 0; + form.scenarioId = + scenarios.value.find((scenario) => scenario.isCurrent)?.id ?? scenarios.value[0]?.id ?? null; } message.value = `${scenarios.value.length}개 시나리오를 확인했습니다.`; } catch (error) { scenarios.value = []; errorMessage.value = error instanceof Error ? error.message : '소스에서 시나리오를 읽지 못했습니다.'; } finally { + catalogAttempted.value = true; catalogLoading.value = false; } }; @@ -405,15 +410,16 @@ const requestReset = async () => { if (!selectedProfileName.value || activeOperation.value) { return; } - if ((form.sourceMode !== 'CURRENT' && !form.sourceRef.trim()) || !form.scenarioId) { + if ((form.sourceMode !== 'CURRENT' && !form.sourceRef.trim()) || form.scenarioId === null) { errorMessage.value = '초기화 소스와 시나리오를 먼저 선택해주세요.'; return; } + const scenarioId = form.scenarioId; const sourceLabel = form.sourceMode === 'CURRENT' ? '현재 배포 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋'; if ( !window.confirm( - `${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${form.scenarioId}` + `${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}` ) ) { return; @@ -427,7 +433,7 @@ const requestReset = async () => { scheduledAt: toIso(form.scheduledAt), reason: form.reason.trim() || undefined, install: { - scenarioId: form.scenarioId, + scenarioId, turnTermMinutes: form.turnTermMinutes, sync: form.sync, fiction: form.fiction, @@ -493,6 +499,18 @@ watch(selectedGatewayOperationId, (operationId) => { if (operationId && componentMounted) void pollGatewayReleaseLogs(operationId, releaseLogLoopGeneration); }); +watch( + () => form.sourceMode, + (sourceMode) => { + scenarios.value = []; + form.scenarioId = null; + catalogAttempted.value = false; + if (sourceMode === 'CURRENT' && componentMounted) { + void loadScenarios(); + } + } +); + onMounted(async () => { componentMounted = true; await Promise.all([ @@ -601,8 +619,12 @@ onBeforeUnmount(() => {

{{ sourceHelp }}

-
+
- 시나리오 확인 + {{ catalogLoading ? '확인 중…' : catalogAttempted ? '시나리오 재확인' : '시나리오 확인' }}
@@ -632,8 +654,15 @@ onBeforeUnmount(() => { class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white" data-testid="scenario-select" > + + @@ -784,7 +813,7 @@ onBeforeUnmount(() => { v-else type="submit" class="w-full rounded bg-amber-500 px-4 py-3 font-bold text-black hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40" - :disabled="submitting || Boolean(activeOperation) || !form.scenarioId" + :disabled="submitting || Boolean(activeOperation) || form.scenarioId === null" data-testid="request-reset" > {{ form.scheduledAt ? '시나리오 초기화 예약' : '시나리오 초기화' }} diff --git a/docs/admin-console.md b/docs/admin-console.md index 584f2d84..a60cca7b 100644 --- a/docs/admin-console.md +++ b/docs/admin-console.md @@ -44,6 +44,10 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 - 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git 업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화 권한과 버전 배포 권한이 모두 필요합니다. +- 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와 + 분리된 요청으로 읽습니다. API가 profile의 현재 scenario를 표시하며 화면은 + 그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이 + 실패하면 현재 버전 모드에서 다시 확인할 수 있습니다. - Gateway 릴리스는 profile 작업과 다른 전역 `admin.releases.manage` 권한을 사용하며 외부 release-controller가 실행합니다. 선택한 릴리스 작업의 단계와 명령 출력을 관리자 화면이 long polling으로 이어 받아 표시하며, 완료된 이력의