diff --git a/README.md b/README.md index cdb7ed22..128d2f4c 100644 --- a/README.md +++ b/README.md @@ -224,4 +224,5 @@ commit-worktree build 경로에서 구성합니다. 보존합니다. Gateway API·frontend·orchestrator는 외부 release-controller가 함께 전환합니다. 설치와 CLI self-upgrade 절차는 [`app/release-controller/README.md`](app/release-controller/README.md)를 확인해 -주세요. +주세요. 관리자 화면의 메뉴와 책임 분리는 +[`docs/admin-console.md`](docs/admin-console.md)에 정리되어 있습니다. diff --git a/app/gateway-frontend/e2e/admin-account-controls.spec.ts b/app/gateway-frontend/e2e/admin-account-controls.spec.ts index c7fa2f88..7b24b2ed 100644 --- a/app/gateway-frontend/e2e/admin-account-controls.spec.ts +++ b/app/gateway-frontend/e2e/admin-account-controls.spec.ts @@ -134,14 +134,13 @@ const installFixture = async (page: Page) => { test('operates OAuth grace and scheduled deletion with reasoned audit history', async ({ page }, testInfo) => { const mutations = await installFixture(page); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin'); + await page.goto('admin/users'); await page.getByPlaceholder('검색 값 입력').fill('target'); await page.getByRole('button', { name: '조회', exact: true }).click(); await expect(page.getByText('Kakao 인증: 미완료')).toBeVisible(); await expect(page.getByRole('cell', { name: 'che:default' })).toBeVisible(); await expect(page.getByText('SUCCEEDED · admin.users.updateSanctions').first()).toBeVisible(); - await expect(page.getByRole('heading', { name: '전체 관리자 감사 원장' })).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-desktop.png'), fullPage: true }); const deletionButton = page.getByRole('button', { name: '보존 기간 후 탈퇴 예약', exact: true }); const baseDeleteColor = await deletionButton.evaluate((button) => getComputedStyle(button).backgroundColor); @@ -163,6 +162,11 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history', expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true); expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true); + await page.getByRole('link', { name: '감사 로그' }).click(); + await expect(page).toHaveURL(/\/gateway\/admin\/audit$/); + await expect(page.getByRole('heading', { name: '전체 관리자 감사 원장' })).toBeVisible(); + await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); const geometry = await page .getByRole('heading', { name: '전체 관리자 감사 원장' }) @@ -173,6 +177,9 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history', }); expect(geometry.left).toBeGreaterThanOrEqual(0); expect(geometry.right).toBeLessThanOrEqual(geometry.viewportWidth); - await writeFile(testInfo.outputPath('gateway-admin-account-controls-mobile-geometry.json'), JSON.stringify(geometry)); + await writeFile( + testInfo.outputPath('gateway-admin-account-controls-mobile-geometry.json'), + JSON.stringify(geometry) + ); await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-mobile.png'), fullPage: true }); }); diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index 29a21b7e..247730b4 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -211,8 +211,8 @@ const installFixture = async ( test('reports clock-shift acceptance separately from actual application', async ({ page }) => { const fixture = await installFixture(page, { deferRequest: true, pendingProfileReads: 1 }); - await page.goto('admin'); - await expect(page.getByRole('heading', { name: '관리자 콘솔' })).toBeVisible(); + await page.goto('admin/servers'); + await expect(page.getByRole('heading', { name: '서버 관리' })).toBeVisible(); const duration = page.locator('input[type="number"][min="1"][max="1440"]'); const accelerate = page.getByRole('button', { name: '가속', exact: true }); @@ -249,7 +249,7 @@ test('blocks another clock shift while any recent action is pending', async ({ p }), ], }); - await page.goto('admin'); + await page.goto('admin/servers'); await page.locator('input[type="number"][min="1"][max="1440"]').fill('15'); await expect(page.getByRole('button', { name: '가속', exact: true })).toBeDisabled(); @@ -264,7 +264,7 @@ test('renders a failed terminal outcome without calling it applied', async ({ pa }), ], }); - await page.goto('admin'); + await page.goto('admin/servers'); const failed = page.getByText('FAILED · ACCELERATE 15분'); await expect(failed).toBeVisible(); @@ -282,7 +282,7 @@ test('renders an ignored terminal outcome without calling it applied', async ({ }), ], }); - await page.goto('admin'); + await page.goto('admin/servers'); const ignored = page.getByText('IGNORED · DELAY 15분'); await expect(ignored).toBeVisible(); @@ -291,32 +291,20 @@ test('renders an ignored terminal outcome without calling it applied', async ({ 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'); +test('directs profile deployment to the centralized version page', async ({ page }) => { + await installFixture(page); + await page.goto('admin/servers'); - 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(); + const releaseLink = page.getByRole('link', { name: '버전 업데이트 열기' }); + await expect(releaseLink).toBeVisible(); + await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/releases'); + await expect(page.getByRole('button', { name: '설치 적용' })).toHaveCount(0); await page.setViewportSize({ width: 390, height: 844 }); - const linkGeometry = await operationLink.evaluate((element) => { + const linkGeometry = await releaseLink.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/general-icon-lifecycle.spec.ts b/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts index 57ca9752..ad20ea31 100644 --- a/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts +++ b/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts @@ -27,7 +27,7 @@ const login = async (page: Page, username: string, password: string): Promise page.locator('tbody tr').filter({ hasText: /^hwe섭/ }); const resetScenario = async (page: Page, scenarioId: string, sourceCommit: string): Promise => { - await page.goto('/gateway/admin/server-operations'); + await page.goto('/gateway/admin/releases'); await page.getByTestId('profile-select').selectOption('hwe:2'); await page.getByTestId('source-commit').check(); await page.getByTestId('source-ref').fill(sourceCommit); diff --git a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts index 7d1ccf71..6d6187f4 100644 --- a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts +++ b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts @@ -85,8 +85,8 @@ test('admin resets and opens hwe, then two users create generals and reach main' await login(page, adminUsername, await readPassword('admin')); await page.getByRole('link', { name: '관리자 페이지' }).click(); await expect(page).toHaveURL(/\/gateway\/admin$/); - await page.getByRole('link', { name: '서버 배포 · 시나리오 초기화' }).click(); - await expect(page).toHaveURL(/\/gateway\/admin\/server-operations$/); + await page.getByRole('link', { name: '버전 업데이트' }).first().click(); + await expect(page).toHaveURL(/\/gateway\/admin\/releases$/); await page.getByTestId('profile-select').selectOption(profileKey); const profileStatus = page.getByTestId('selected-profile-status'); diff --git a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts index 4fb336f4..07fe6928 100644 --- a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts +++ b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page, type Route } from '@playwright/test'; +import { writeFile } from 'node:fs/promises'; const response = (data: unknown) => ({ result: { data } }); @@ -53,7 +54,7 @@ const installGatewayFixture = async (page: Page, roles: string[]) => { }); }; -test('bootstrap superuser can navigate from the lobby to server operations', async ({ page }) => { +test('bootstrap superuser can navigate the administrator workspace from the lobby', async ({ page }, testInfo) => { await installGatewayFixture(page, ['superuser']); await page.goto('lobby'); @@ -62,9 +63,39 @@ test('bootstrap superuser can navigate from the lobby to server operations', asy await adminLink.click(); await expect(page).toHaveURL(/\/gateway\/admin$/); - await expect(page.getByRole('heading', { name: '관리자 콘솔' })).toBeVisible(); - await page.getByRole('link', { name: '서버 배포 · 시나리오 초기화' }).click(); - await expect(page).toHaveURL(/\/gateway\/admin\/server-operations$/); + await expect(page.getByRole('heading', { name: '운영 개요' })).toBeVisible(); + const navigation = page.getByRole('navigation', { name: '관리자 메뉴' }); + await expect(navigation).toBeVisible(); + const userLink = navigation.getByRole('link', { name: '사용자 관리' }); + const baseColor = await userLink.evaluate((element) => getComputedStyle(element).color); + await userLink.hover(); + await expect.poll(() => userLink.evaluate((element) => getComputedStyle(element).color)).not.toBe(baseColor); + await page.screenshot({ path: testInfo.outputPath('admin-overview-desktop.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(navigation).toBeHidden(); + await page.getByRole('button', { name: '관리자 메뉴' }).click(); + await expect(navigation).toBeVisible(); + const geometry = await navigation.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right, top: rect.top, viewportWidth: window.innerWidth }; + }); + expect(geometry.left).toBeGreaterThanOrEqual(0); + expect(geometry.right).toBeLessThanOrEqual(geometry.viewportWidth); + expect(geometry.top).toBeGreaterThanOrEqual(0); + await writeFile(testInfo.outputPath('admin-overview-mobile-geometry.json'), JSON.stringify(geometry)); + await page.screenshot({ path: testInfo.outputPath('admin-overview-mobile-menu.png'), fullPage: true }); + + await navigation.getByRole('link', { name: '버전 업데이트' }).click(); + await expect(page).toHaveURL(/\/gateway\/admin\/releases$/); + await expect(page.getByRole('heading', { name: '버전 업데이트' })).toBeVisible(); +}); + +test('legacy server operations URL keeps query parameters and redirects to releases', async ({ page }) => { + await installGatewayFixture(page, ['superuser']); + + await page.goto('admin/server-operations?operationId=legacy-operation'); + await expect(page).toHaveURL(/\/gateway\/admin\/releases\?operationId=legacy-operation$/); }); test('scoped administrators see the same navigation while ordinary users do not', async ({ browser }) => { diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 30567053..b291be1d 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -218,9 +218,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/server-operations'); + await page.goto('admin/releases'); await expect(page.getByTestId('server-operations-page')).toBeVisible(); - await expect(page).toHaveURL(/\/gateway\/admin\/server-operations$/); + await expect(page).toHaveURL(/\/gateway\/admin\/releases$/); await expect(page.getByTestId('source-help')).toContainText('실제로 시작될 때'); await expect(page.getByTestId('scenario-select')).toHaveValue('2'); @@ -293,7 +293,7 @@ test('starts and stops all runtime roles through the operation controls', async await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/server-operations'); + await page.goto('admin/releases'); await page.getByTestId('start-server').click(); await expect(page.getByText('시작 작업을 요청했습니다.')).toBeVisible(); await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible(); @@ -312,7 +312,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/server-operations'); + await page.goto('admin/releases'); await expect(page.getByText('Game frontend')).toBeVisible(); await page.getByTestId('request-deploy').click(); @@ -327,7 +327,7 @@ test('controls gateway deployment and rollback through the external controller q await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/server-operations'); + await page.goto('admin/releases'); const panel = page.getByTestId('gateway-release-panel'); await expect(panel).toBeVisible(); await expect(panel).toContainText('aaaaaaaaaaaa'); @@ -375,7 +375,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/server-operations'); + await page.goto('admin/releases'); await expect(page.getByText('FAILED', { exact: true })).toBeVisible(); await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible(); const failure = page.getByText(longError); diff --git a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue new file mode 100644 index 00000000..83fb468e --- /dev/null +++ b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/app/gateway-frontend/src/router/index.ts b/app/gateway-frontend/src/router/index.ts index 0666af66..c4aba23b 100644 --- a/app/gateway-frontend/src/router/index.ts +++ b/app/gateway-frontend/src/router/index.ts @@ -1,6 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import HomeView from '../views/HomeView.vue'; import LobbyView from '../views/LobbyView.vue'; +import AdminOverviewView from '../views/AdminOverviewView.vue'; import AdminView from '../views/AdminView.vue'; import ServerOperationsView from '../views/ServerOperationsView.vue'; import AccountView from '../views/AccountView.vue'; @@ -28,12 +29,40 @@ const router = createRouter({ { path: '/admin', name: 'admin', + component: AdminOverviewView, + }, + { + path: '/admin/users', + name: 'admin-users', component: AdminView, + props: { section: 'users' }, + }, + { + path: '/admin/servers', + name: 'admin-servers', + component: AdminView, + props: { section: 'servers' }, + }, + { + path: '/admin/system', + name: 'admin-system', + component: AdminView, + props: { section: 'system' }, + }, + { + path: '/admin/audit', + name: 'admin-audit', + component: AdminView, + props: { section: 'audit' }, + }, + { + path: '/admin/releases', + name: 'admin-releases', + component: ServerOperationsView, }, { path: '/admin/server-operations', - name: 'server-operations', - component: ServerOperationsView, + redirect: (to) => ({ path: '/admin/releases', query: to.query }), }, { path: '/account', diff --git a/app/gateway-frontend/src/views/AdminOverviewView.vue b/app/gateway-frontend/src/views/AdminOverviewView.vue new file mode 100644 index 00000000..6694777f --- /dev/null +++ b/app/gateway-frontend/src/views/AdminOverviewView.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index ad3e1d9c..002a5e3d 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -1,8 +1,39 @@ diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index ea98e7a2..0e070aed 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -1,7 +1,7 @@ diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 73fbc789..153a4fad 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -13,6 +13,7 @@ export default defineConfig({ { text: '개발자', link: '/developer/' }, { text: '플레이어', link: '/user/' }, { text: '아키텍처', link: '/architecture/overview' }, + { text: '관리자 콘솔', link: '/admin-console' }, { text: '릴리스 운영', link: '/release-operations' }, ], sidebar: { @@ -23,6 +24,7 @@ export default defineConfig({ { text: '시작하기', link: '/developer/' }, { text: '아키텍처 개요', link: '/architecture/overview' }, { text: '런타임 아키텍처', link: '/architecture/runtime' }, + { text: '관리자 콘솔', link: '/admin-console' }, { text: '릴리스 운영 매뉴얼', link: '/release-operations' }, { text: '요청·턴·저장 흐름', link: '/developer/request-turn-persistence' }, { text: '도메인 로직과 핵심 클래스', link: '/developer/domain-and-classes' }, diff --git a/docs/admin-console.md b/docs/admin-console.md new file mode 100644 index 00000000..87df0fdf --- /dev/null +++ b/docs/admin-console.md @@ -0,0 +1,38 @@ +# 관리자 콘솔 + +Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로비에서 관리자 +역할 또는 `admin.*` 범위 권한이 확인된 사용자에게만 진입 링크를 표시하며, +실제 조회와 변경 권한은 각 Gateway API가 다시 검사합니다. + +## 화면 구성 + +좌측 메뉴는 관리 책임을 다음과 같이 분리합니다. + +| 메뉴 | 경로 | 책임 | +| ------------- | ------------------------- | ------------------------------------------------------------------------------ | +| 운영 개요 | `/gateway/admin` | 관리 영역 안내와 빠른 진입 | +| 사용자 관리 | `/gateway/admin/users` | 계정 조회·생성, 권한, OAuth 유예, 제재, 아이콘 복구, 탈퇴 예약과 사용자별 이력 | +| 서버 관리 | `/gateway/admin/servers` | profile 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 | +| 버전 업데이트 | `/gateway/admin/releases` | profile DB 유지·초기화 배포, Gateway 릴리스·rollback과 작업 이력 | +| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 | +| 감사 로그 | `/gateway/admin/audit` | 관리자 조치 결과, 대상과 사유 조회 | + +기존 `/gateway/admin/server-operations` 링크는 query string을 보존한 채 +`/gateway/admin/releases`로 이동합니다. 즐겨찾기와 이전 운영 보고서의 링크를 +즉시 깨뜨리지 않기 위한 호환 경로입니다. + +## 운영 경계 + +- 사용자 페이지는 사용자 한 명에 대한 계정 변경과 사용자별 감사 이력만 + 표시합니다. 전체 감사 원장은 감사 로그에서 별도로 조회합니다. +- 서버 관리는 현재 실행 상태와 게임 운영 정책을 다룹니다. Git source 선택, + migration, 시나리오 초기화와 rollback은 버전 업데이트에서 수행합니다. +- `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET`은 + 현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다. +- Gateway 릴리스는 profile 작업과 다른 전역 `admin.releases.manage` 권한을 + 사용하며 외부 release-controller가 실행합니다. +- 브라우저의 메뉴 노출은 편의 기능입니다. 권한 판단의 기준은 서버가 인증 + session에서 해석한 capability입니다. + +상세 배포·복구 절차는 [릴리스 운영 매뉴얼](./release-operations.md)을 +따릅니다. diff --git a/docs/index.md b/docs/index.md index 68e479fd..77166575 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,6 +15,9 @@ hero: - theme: alt text: 릴리스 운영 link: /release-operations + - theme: alt + text: 관리자 콘솔 + link: /admin-console features: - title: 시스템 구조 @@ -32,6 +35,8 @@ features: [시간과 턴](./user/time-and-turns.md)과 [커맨드 목록](./user/command-catalog.generated.md)을 확인해 주세요. Profile과 Gateway 배포는 [릴리스 운영 매뉴얼](./release-operations.md)을 따라 주세요. +관리자 화면의 메뉴와 권한·운영 경계는 +[관리자 콘솔](./admin-console.md)에서 확인할 수 있습니다. 게임 진행 시각과 운영 벽시계의 경계는 [게임 시계](./architecture/game-clock.md)에 설명합니다. [패키지와 파일 경계](./architecture/package-boundaries.md)는 source import와 diff --git a/docs/release-operations.md b/docs/release-operations.md index 34040416..80c0e504 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -12,7 +12,8 @@ Gateway 전체는 별도 release-controller가 처리합니다. | Gateway API·frontend·orchestrator | Gateway 관리자 화면 | 외부 release-controller | `GatewayReleaseOperation`, `GatewayReleaseState` | | release-controller 자체 | 별도 CLI process | self-upgrade CLI | PM2 `sammo:release-controller` | -관리자 화면은 `/gateway/admin/server-operations`입니다. Profile 작업에는 해당 +관리자 화면은 `/gateway/admin/releases`입니다. 이전 경로 +`/gateway/admin/server-operations`는 호환성을 위해 새 화면으로 이동합니다. Profile 작업에는 해당 profile의 `admin.profiles.manage` 권한이 필요합니다. Gateway 전체 릴리스에는 profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필요합니다. 일반 사용자와 권한이 없는 관리자는 Gateway 릴리스 영역을 사용할 수 없습니다.