diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 4de11f67..f6a6a8e7 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -1474,6 +1474,18 @@ export const adminRouter = router({ }), }), profiles: router({ + listNavigation: adminProcedure.query(async ({ ctx }) => { + const adminAuth = requireAdminAuth(ctx); + return orderGatewayProfiles(await ctx.profiles.listProfiles()) + .filter((profile) => canReadProfile(adminAuth, profile.profileName)) + .map((profile) => ({ + profileName: profile.profileName, + profile: profile.profile, + meta: { + ...(typeof profile.meta.korName === 'string' ? { korName: profile.meta.korName } : {}), + }, + })); + }), list: adminProcedure.query(async ({ ctx }) => { const adminAuth = requireAdminAuth(ctx); const profiles = orderGatewayProfiles(await ctx.profiles.listProfiles()).filter((profile) => diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index a579c992..9626a2d1 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -65,6 +65,7 @@ const buildCaller = async ( const updatedMetas: Record[] = []; const auditEvents: AdminAuditEventRecord[] = []; let reconcileCount = 0; + let runtimeStateListCount = 0; let storedNotice = options.initialNotice ?? ''; const profile = { profileName: 'che:2', @@ -244,7 +245,10 @@ const buildCaller = async ( } }, cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }), - listRuntimeStates: async () => [], + listRuntimeStates: async () => { + runtimeStateListCount += 1; + return []; + }, }, profileStatus: new InMemoryProfileStatusService(), requestHeaders: { 'x-session-token': session.sessionToken }, @@ -293,6 +297,7 @@ const buildCaller = async ( updatedMetas, auditEvents, getReconcileCount: () => reconcileCount, + getRuntimeStateListCount: () => runtimeStateListCount, getStoredNotice: () => storedNotice, getReleaseLogPollCount: () => releaseLogPollCount, setStoredNotice: (notice: string) => { @@ -301,6 +306,26 @@ const buildCaller = async ( }; }; +describe('admin profile navigation API', () => { + it('returns the scoped menu inventory without loading PM2 runtime state', async () => { + const harness = await buildCaller( + async () => { + throw new Error('not used'); + }, + { adminRoles: ['admin.profiles.manage:che:2'], firstUserIsAdmin: false } + ); + + await expect(harness.caller.admin.profiles.listNavigation()).resolves.toEqual([ + { + profileName: 'che:2', + profile: 'che', + meta: {}, + }, + ]); + expect(harness.getRuntimeStateListCount()).toBe(0); + }); +}); + describe('gateway notice API', () => { const dirtyNotice = '점검
' + diff --git a/app/gateway-frontend/e2e/admin-account-controls.spec.ts b/app/gateway-frontend/e2e/admin-account-controls.spec.ts index ec9be92a..f48732ae 100644 --- a/app/gateway-frontend/e2e/admin-account-controls.spec.ts +++ b/app/gateway-frontend/e2e/admin-account-controls.spec.ts @@ -105,6 +105,7 @@ const installFixture = async (page: Page) => { }); } if (operation === 'admin.system.getNotice') return response({ notice: '' }); + if (operation === 'admin.profiles.listNavigation') return response([]); if (operation === 'admin.profiles.list') return response([]); if (operation === 'admin.profiles.listScenarios') return response([]); if (operation === 'admin.users.lookup') { @@ -194,7 +195,12 @@ const installFixture = async (page: Page) => { } throw new Error(`Unhandled tRPC operation: ${operation}`); }); - await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + const isBatch = new URL(route.request().url()).searchParams.get('batch') === '1'; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(isBatch ? results : results[0]), + }); }); return mutations; }; diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index 039d83c8..5a5b2ec7 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -145,6 +145,15 @@ const installFixture = async ( }, ]); } + if (operation === 'admin.profiles.listNavigation') { + return response([ + { + profileName: 'hwe:default', + profile: 'hwe', + meta: {}, + }, + ]); + } if (operation === 'admin.profiles.list') { const keepPending = requested && postRequestProfileReads++ < (options.pendingProfileReads ?? 0); return response([ @@ -235,7 +244,9 @@ const installFixture = async ( await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify(results), + body: JSON.stringify( + new URL(route.request().url()).searchParams.get('batch') === '1' ? results : results[0] + ), }); }); return { releaseRequest, releaseInstall, requestBodies }; diff --git a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts index 1d5dc275..541f3c27 100644 --- a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts +++ b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts @@ -52,6 +52,19 @@ const installGatewayFixture = async (page: Page, roles: string[]) => { : [] ); } + if (operation === 'admin.profiles.listNavigation') { + return response( + roles.some((role) => role === 'superuser' || role.includes(':hwe:2')) + ? [ + { + profileName: 'hwe:2', + profile: 'hwe', + meta: { korName: '환상서버' }, + }, + ] + : [] + ); + } if (operation === 'admin.releases.gatewayState') { return response({ id: 'gateway', updatedAt: '2026-08-01T00:00:00.000Z' }); } @@ -85,7 +98,9 @@ const installGatewayFixture = async (page: Page, roles: string[]) => { await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify(results), + body: JSON.stringify( + new URL(route.request().url()).searchParams.get('batch') === '1' ? results : results[0] + ), }); }); }; diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 6c735797..1379b744 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -36,8 +36,9 @@ type FixtureState = { gatewayLogPollCount?: number; capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>; profileListDelayMs?: number; - profileListRequests?: number; - profileListResolved?: boolean; + profileNavigationDelayMs?: number; + profileNavigationRequests?: number; + profileNavigationResolved?: boolean; }; const profile = (runtimeRunning: boolean) => ({ @@ -97,12 +98,16 @@ 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)); + if (names.includes('admin.profiles.listNavigation')) { + expect(names).toEqual(['admin.profiles.listNavigation']); + state.profileNavigationRequests = (state.profileNavigationRequests ?? 0) + 1; + if (state.profileNavigationDelayMs) { + await new Promise((resolve) => setTimeout(resolve, state.profileNavigationDelayMs)); } - state.profileListResolved = true; + state.profileNavigationResolved = true; + } + if (names.includes('admin.profiles.list') && state.profileListDelayMs) { + await new Promise((resolve) => setTimeout(resolve, state.profileListDelayMs)); } const results = names.map((name) => { if (route.request().method() === 'POST') { @@ -111,6 +116,15 @@ const installFixture = async (page: Page, state: FixtureState) => { if (name === 'admin.profiles.list') { return response([profile(state.runtimeRunning)]); } + if (name === 'admin.profiles.listNavigation') { + return response([ + { + profileName: 'che:2', + profile: 'che', + meta: { korName: '천하서버' }, + }, + ]); + } if (name === 'admin.capabilities.list') { return response( state.capabilities ?? [ @@ -261,7 +275,9 @@ const installFixture = async (page: Page, state: FixtureState) => { await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify(results), + body: JSON.stringify( + new URL(route.request().url()).searchParams.get('batch') === '1' ? results : results[0] + ), }); }); }; @@ -387,16 +403,33 @@ test('renders the fixed-profile version form without waiting for the server list gatewayOperations: [], runtimeRunning: true, requestBodies: [], - profileListDelayMs: 1500, - profileListResolved: false, + profileNavigationDelayMs: 1500, + profileNavigationResolved: 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); + expect(state.profileNavigationResolved).toBe(false); + await expect.poll(() => state.profileNavigationResolved).toBe(true); + expect(state.profileNavigationRequests).toBe(1); +}); + +test('renders the server navigation before the detailed runtime profile request resolves', async ({ page }) => { + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + profileListDelayMs: 1500, + }; + await installFixture(page, state); + + await page.goto('admin/servers/che%3A2'); + const navigation = page.getByRole('navigation', { name: '관리자 메뉴' }); + await expect(navigation.getByRole('link', { name: '천하서버 (che:2)' })).toBeVisible({ timeout: 900 }); + await expect(navigation.getByRole('link', { name: 'Gateway 릴리스' })).toBeVisible({ timeout: 900 }); + expect(state.profileNavigationRequests).toBe(1); }); test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => { diff --git a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue index 47fcd9e1..50c4b81f 100644 --- a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue +++ b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue @@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue'; import DefaultLayout from './DefaultLayout.vue'; import { useAuthStore } from '../stores/auth'; -import { trpc } from '../utils/trpc'; +import { directTrpc } from '../utils/trpc'; defineProps<{ title: string; @@ -12,10 +12,10 @@ defineProps<{ const menuOpen = ref(false); const auth = useAuthStore(); -const adminClient = trpc.admin as unknown as { +const adminNavigationClient = directTrpc.admin as unknown as { capabilities: { list: { query: () => Promise> } }; profiles: { - list: { + listNavigation: { query: () => Promise }>>; }; }; @@ -110,8 +110,8 @@ const navigation = computed(() => [ onMounted(async () => { const [capabilityResult, profileResult] = await Promise.allSettled([ - adminClient.capabilities.list.query(), - adminClient.profiles.list.query(), + adminNavigationClient.capabilities.list.query(), + adminNavigationClient.profiles.listNavigation.query(), ]); capabilities.value = capabilityResult.status === 'fulfilled' ? capabilityResult.value : []; profiles.value = profileResult.status === 'fulfilled' ? profileResult.value : []; diff --git a/app/gateway-frontend/src/utils/trpc.ts b/app/gateway-frontend/src/utils/trpc.ts index dc9a03bf..dc1b1f68 100644 --- a/app/gateway-frontend/src/utils/trpc.ts +++ b/app/gateway-frontend/src/utils/trpc.ts @@ -1,4 +1,4 @@ -import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; +import { createTRPCProxyClient, httpBatchLink, httpLink } from '@trpc/client'; import type { AppRouter } from '@sammo-ts/gateway-api'; const getSessionToken = (): string | null => { @@ -19,3 +19,15 @@ export const trpc = createTRPCProxyClient({ }), ], }); + +export const directTrpc = createTRPCProxyClient({ + links: [ + httpLink({ + url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc', + headers() { + const token = getSessionToken(); + return token ? { 'x-session-token': token } : {}; + }, + }), + ], +}); diff --git a/docs/admin-console.md b/docs/admin-console.md index 0e28a247..584f2d84 100644 --- a/docs/admin-console.md +++ b/docs/admin-console.md @@ -35,6 +35,10 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를 기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세 runtime·빌드 상태는 상태 설정 탭에서 확인합니다. +- 공통 좌측 메뉴는 `admin.profiles.listNavigation`으로 접근 가능한 profile의 이름과 + 표시명만 읽습니다. 이 요청은 PM2 runtime 상태를 포함하는 본문용 + `admin.profiles.list`와 별도의 non-batch 요청으로 전송하므로, 상태 조회가 늦거나 + 중단되어도 관리자 capability와 서버 메뉴를 함께 기다리게 하지 않습니다. - `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET`은 현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다. - 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git