From 81c599569f99064a695c430aaf1de083a17ca285 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 9 Aug 2026 02:23:29 +0000 Subject: [PATCH 1/3] fix(gateway): streamline server management tabs --- README.md | 3 + .../e2e/admin-runtime-actions.spec.ts | 22 +- .../e2e/server-operations.spec.ts | 62 +++++- .../src/components/ServerProfileTabs.vue | 108 +++++++++ .../src/layouts/AdminConsoleLayout.vue | 16 +- app/gateway-frontend/src/views/AdminView.vue | 66 +----- .../src/views/ServerOperationsView.vue | 207 ++++-------------- docs/admin-console.md | 7 +- 8 files changed, 251 insertions(+), 240 deletions(-) create mode 100644 app/gateway-frontend/src/components/ServerProfileTabs.vue diff --git a/README.md b/README.md index 63280eba..3bfa7389 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,9 @@ commit-worktree build 경로에서 구성합니다. 각 서버의 `버전 업데이트`는 profile의 game migration만 적용하고 현재 게임 DB를 seed하지 않습니다. 별도 `시나리오 초기화`는 Git 업데이트 없이 현재 게시 commit을 기본으로 사용하며, 필요할 때만 새 버전 배포와 결합합니다. +상태 설정·버전 업데이트·시나리오 초기화는 서버별 상단 탭으로 이동하며, +버전/초기화 화면은 URL에 고정된 profile을 다시 선택하거나 전체 profile 상태를 +기다리지 않습니다. 초기화는 현재 시즌 테이블을 새 시나리오로 교체하지만 `hall`, `ng_games`, 연감, 과거 장수·국가와 상속 자료는 보존합니다. Gateway API·frontend·orchestrator는 외부 release-controller가 함께 전환합니다. 설치와 CLI self-upgrade 절차는 diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index 0e6430db..039d83c8 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -323,10 +323,29 @@ test('renders an ignored terminal outcome without calling it applied', async ({ await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0); }); -test('directs profile deployment to the selected server version tab', async ({ page }) => { +test('directs profile deployment to the selected server version tab', async ({ page }, testInfo) => { await installFixture(page); await page.goto('admin/servers'); + const tabs = page.getByTestId('server-profile-tabs'); + await expect(tabs).toBeVisible(); + await expect(tabs.getByRole('link', { name: '상태 설정', exact: true })).toHaveAttribute('aria-current', 'page'); + await expect(page.getByText('버전과 시즌 수명주기', { exact: true })).toHaveCount(0); + const versionTab = tabs.getByRole('link', { name: '버전 업데이트', exact: true }); + const idleTabBackground = await versionTab.evaluate((element) => getComputedStyle(element).backgroundColor); + await versionTab.hover(); + await expect + .poll(() => versionTab.evaluate((element) => getComputedStyle(element).backgroundColor)) + .not.toBe(idleTabBackground); + await versionTab.focus(); + await expect(versionTab).toBeFocused(); + const tabAndHeaderGeometry = await Promise.all([ + tabs.evaluate((element) => element.getBoundingClientRect().top), + page.getByText('hwe:default (hwe)', { exact: true }).evaluate((element) => element.getBoundingClientRect().top), + ]); + expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]); + await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true }); + const releaseLink = page.getByRole('link', { name: '버전 업데이트', exact: true }).last(); await expect(releaseLink).toBeVisible(); await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/servers/hwe%3Adefault/version'); @@ -339,4 +358,5 @@ test('directs profile deployment to the selected server version tab', async ({ p }); expect(linkGeometry.left).toBeGreaterThanOrEqual(0); expect(linkGeometry.right).toBeLessThanOrEqual(linkGeometry.viewportWidth); + await page.screenshot({ path: testInfo.outputPath('status-tabs-mobile.png'), fullPage: true }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index aa456413..c42aaed7 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -34,6 +34,9 @@ type FixtureState = { runtimeRunning: boolean; requestBodies: Array<{ operation: string; body: unknown }>; capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>; + profileListDelayMs?: number; + profileListRequests?: number; + profileListResolved?: boolean; }; const profile = (runtimeRunning: boolean) => ({ @@ -93,6 +96,13 @@ const installFixture = async (page: Page, state: FixtureState) => { await page.route('**/gateway/api/trpc/**', async (route) => { const names = operationNames(route); const body = route.request().postDataJSON() as unknown; + if (names.includes('admin.profiles.list')) { + state.profileListRequests = (state.profileListRequests ?? 0) + 1; + if (state.profileListDelayMs) { + await new Promise((resolve) => setTimeout(resolve, state.profileListDelayMs)); + } + state.profileListResolved = true; + } const results = names.map((name) => { if (route.request().method() === 'POST') { state.requestBodies.push({ operation: name, body }); @@ -235,8 +245,14 @@ test('separates branch and commit semantics and submits a reset from the dedicat await expect(page.getByTestId('server-operations-page')).toBeVisible(); await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/); await expect(page.getByTestId('source-current')).toBeChecked(); - await expect(page.getByTestId('source-help')).toContainText('현재 서버 커밋'); + await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋'); await expect(page.getByTestId('scenario-select')).toHaveValue('2'); + await expect(page.getByTestId('server-profile-tabs')).toBeVisible(); + await expect(page.getByRole('link', { name: '시나리오 초기화', exact: true })).toHaveAttribute( + 'aria-current', + 'page' + ); + await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); const desktopGeometry = await page .getByTestId('server-operations-page') @@ -249,8 +265,8 @@ test('separates branch and commit semantics and submits a reset from the dedicat }); return children; }); - expect(desktopGeometry).toHaveLength(2); - expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x); + expect(desktopGeometry).toHaveLength(1); + expect(desktopGeometry[0]!.width).toBeGreaterThan(800); await page.getByTestId('source-commit').check(); const sourceInput = page.getByTestId('source-ref'); await sourceInput.focus(); @@ -297,8 +313,19 @@ test('separates branch and commit semantics and submits a reset from the dedicat }); return children; }); - expect(mobileGeometry[1]!.y).toBeGreaterThan(mobileGeometry[0]!.y); expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390); + const mobileTabs = await page + .getByTestId('server-profile-tabs') + .locator('a') + .evaluateAll((links) => + links.map((link) => { + const rect = link.getBoundingClientRect(); + return { top: rect.top, width: rect.width, height: rect.height }; + }) + ); + expect(mobileTabs).toHaveLength(3); + expect(mobileTabs[1]!.top).toBeGreaterThan(mobileTabs[0]!.top); + expect(mobileTabs.every((tab) => tab.height >= 44)).toBe(true); await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true }); }); @@ -308,7 +335,12 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/servers/che%3A2/version'); - await expect(page.getByText('Game frontend')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible(); + await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); + await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute( + 'aria-current', + 'page' + ); await page.getByTestId('request-deploy').click(); await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.')).toBeVisible(); @@ -317,6 +349,24 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false); }); +test('renders the fixed-profile version form without waiting for the server list', async ({ page }) => { + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + profileListDelayMs: 1500, + profileListResolved: false, + }; + await installFixture(page, state); + + await page.goto('admin/servers/che%3A2/version'); + await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 }); + expect(state.profileListResolved).toBe(false); + await expect.poll(() => state.profileListResolved).toBe(true); + expect(state.profileListRequests).toBe(1); +}); + test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => { const state: FixtureState = { operations: [], @@ -420,7 +470,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.getByText('SUCCEEDED', { exact: true })).toBeVisible(); - await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible(); + await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); diff --git a/app/gateway-frontend/src/components/ServerProfileTabs.vue b/app/gateway-frontend/src/components/ServerProfileTabs.vue new file mode 100644 index 00000000..6851e282 --- /dev/null +++ b/app/gateway-frontend/src/components/ServerProfileTabs.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue index 5299e1c5..47fcd9e1 100644 --- a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue +++ b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue @@ -109,16 +109,12 @@ const navigation = computed(() => [ ]); onMounted(async () => { - try { - capabilities.value = await adminClient.capabilities.list.query(); - } catch { - capabilities.value = []; - } - try { - profiles.value = await adminClient.profiles.list.query(); - } catch { - profiles.value = []; - } + const [capabilityResult, profileResult] = await Promise.allSettled([ + adminClient.capabilities.list.query(), + adminClient.profiles.list.query(), + ]); + capabilities.value = capabilityResult.status === 'fulfilled' ? capabilityResult.value : []; + profiles.value = profileResult.status === 'fulfilled' ? profileResult.value : []; }); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 8ab8d0f2..88f97d3a 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -1,5 +1,6 @@