fix(gateway): decouple admin navigation loading

This commit is contained in:
2026-08-09 11:51:00 +00:00
parent fd0f874d55
commit 7361828641
9 changed files with 141 additions and 23 deletions
+12
View File
@@ -1474,6 +1474,18 @@ export const adminRouter = router({
}), }),
}), }),
profiles: 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 }) => { list: adminProcedure.query(async ({ ctx }) => {
const adminAuth = requireAdminAuth(ctx); const adminAuth = requireAdminAuth(ctx);
const profiles = orderGatewayProfiles(await ctx.profiles.listProfiles()).filter((profile) => const profiles = orderGatewayProfiles(await ctx.profiles.listProfiles()).filter((profile) =>
+26 -1
View File
@@ -65,6 +65,7 @@ const buildCaller = async (
const updatedMetas: Record<string, unknown>[] = []; const updatedMetas: Record<string, unknown>[] = [];
const auditEvents: AdminAuditEventRecord[] = []; const auditEvents: AdminAuditEventRecord[] = [];
let reconcileCount = 0; let reconcileCount = 0;
let runtimeStateListCount = 0;
let storedNotice = options.initialNotice ?? ''; let storedNotice = options.initialNotice ?? '';
const profile = { const profile = {
profileName: 'che:2', profileName: 'che:2',
@@ -244,7 +245,10 @@ const buildCaller = async (
} }
}, },
cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }), cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }),
listRuntimeStates: async () => [], listRuntimeStates: async () => {
runtimeStateListCount += 1;
return [];
},
}, },
profileStatus: new InMemoryProfileStatusService(), profileStatus: new InMemoryProfileStatusService(),
requestHeaders: { 'x-session-token': session.sessionToken }, requestHeaders: { 'x-session-token': session.sessionToken },
@@ -293,6 +297,7 @@ const buildCaller = async (
updatedMetas, updatedMetas,
auditEvents, auditEvents,
getReconcileCount: () => reconcileCount, getReconcileCount: () => reconcileCount,
getRuntimeStateListCount: () => runtimeStateListCount,
getStoredNotice: () => storedNotice, getStoredNotice: () => storedNotice,
getReleaseLogPollCount: () => releaseLogPollCount, getReleaseLogPollCount: () => releaseLogPollCount,
setStoredNotice: (notice: string) => { 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', () => { describe('gateway notice API', () => {
const dirtyNotice = const dirtyNotice =
'<b>점검</b><br><script>globalThis.__noticeXss=1</script>' + '<b>점검</b><br><script>globalThis.__noticeXss=1</script>' +
@@ -105,6 +105,7 @@ const installFixture = async (page: Page) => {
}); });
} }
if (operation === 'admin.system.getNotice') return response({ notice: '' }); 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.list') return response([]);
if (operation === 'admin.profiles.listScenarios') return response([]); if (operation === 'admin.profiles.listScenarios') return response([]);
if (operation === 'admin.users.lookup') { if (operation === 'admin.users.lookup') {
@@ -194,7 +195,12 @@ const installFixture = async (page: Page) => {
} }
throw new Error(`Unhandled tRPC operation: ${operation}`); 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; return mutations;
}; };
@@ -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') { if (operation === 'admin.profiles.list') {
const keepPending = requested && postRequestProfileReads++ < (options.pendingProfileReads ?? 0); const keepPending = requested && postRequestProfileReads++ < (options.pendingProfileReads ?? 0);
return response([ return response([
@@ -235,7 +244,9 @@ const installFixture = async (
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: 'application/json', 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 }; return { releaseRequest, releaseInstall, requestBodies };
@@ -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') { if (operation === 'admin.releases.gatewayState') {
return response({ id: 'gateway', updatedAt: '2026-08-01T00:00:00.000Z' }); 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({ await route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify(results), body: JSON.stringify(
new URL(route.request().url()).searchParams.get('batch') === '1' ? results : results[0]
),
}); });
}); });
}; };
@@ -36,8 +36,9 @@ type FixtureState = {
gatewayLogPollCount?: number; gatewayLogPollCount?: number;
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>; capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
profileListDelayMs?: number; profileListDelayMs?: number;
profileListRequests?: number; profileNavigationDelayMs?: number;
profileListResolved?: boolean; profileNavigationRequests?: number;
profileNavigationResolved?: boolean;
}; };
const profile = (runtimeRunning: boolean) => ({ const profile = (runtimeRunning: boolean) => ({
@@ -97,12 +98,16 @@ const installFixture = async (page: Page, state: FixtureState) => {
await page.route('**/gateway/api/trpc/**', async (route) => { await page.route('**/gateway/api/trpc/**', async (route) => {
const names = operationNames(route); const names = operationNames(route);
const body = route.request().postDataJSON() as unknown; const body = route.request().postDataJSON() as unknown;
if (names.includes('admin.profiles.list')) { if (names.includes('admin.profiles.listNavigation')) {
state.profileListRequests = (state.profileListRequests ?? 0) + 1; expect(names).toEqual(['admin.profiles.listNavigation']);
if (state.profileListDelayMs) { state.profileNavigationRequests = (state.profileNavigationRequests ?? 0) + 1;
await new Promise((resolve) => setTimeout(resolve, state.profileListDelayMs)); 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) => { const results = names.map((name) => {
if (route.request().method() === 'POST') { if (route.request().method() === 'POST') {
@@ -111,6 +116,15 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.profiles.list') { if (name === 'admin.profiles.list') {
return response([profile(state.runtimeRunning)]); return response([profile(state.runtimeRunning)]);
} }
if (name === 'admin.profiles.listNavigation') {
return response([
{
profileName: 'che:2',
profile: 'che',
meta: { korName: '천하서버' },
},
]);
}
if (name === 'admin.capabilities.list') { if (name === 'admin.capabilities.list') {
return response( return response(
state.capabilities ?? [ state.capabilities ?? [
@@ -261,7 +275,9 @@ const installFixture = async (page: Page, state: FixtureState) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: 'application/json', 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: [], gatewayOperations: [],
runtimeRunning: true, runtimeRunning: true,
requestBodies: [], requestBodies: [],
profileListDelayMs: 1500, profileNavigationDelayMs: 1500,
profileListResolved: false, profileNavigationResolved: false,
}; };
await installFixture(page, state); await installFixture(page, state);
await page.goto('admin/servers/che%3A2/version'); await page.goto('admin/servers/che%3A2/version');
await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 }); await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 });
expect(state.profileListResolved).toBe(false); expect(state.profileNavigationResolved).toBe(false);
await expect.poll(() => state.profileListResolved).toBe(true); await expect.poll(() => state.profileNavigationResolved).toBe(true);
expect(state.profileListRequests).toBe(1); 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 }) => { test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => {
@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import DefaultLayout from './DefaultLayout.vue'; import DefaultLayout from './DefaultLayout.vue';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { trpc } from '../utils/trpc'; import { directTrpc } from '../utils/trpc';
defineProps<{ defineProps<{
title: string; title: string;
@@ -12,10 +12,10 @@ defineProps<{
const menuOpen = ref(false); const menuOpen = ref(false);
const auth = useAuthStore(); const auth = useAuthStore();
const adminClient = trpc.admin as unknown as { const adminNavigationClient = directTrpc.admin as unknown as {
capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } }; capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } };
profiles: { profiles: {
list: { listNavigation: {
query: () => Promise<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>; query: () => Promise<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>;
}; };
}; };
@@ -110,8 +110,8 @@ const navigation = computed(() => [
onMounted(async () => { onMounted(async () => {
const [capabilityResult, profileResult] = await Promise.allSettled([ const [capabilityResult, profileResult] = await Promise.allSettled([
adminClient.capabilities.list.query(), adminNavigationClient.capabilities.list.query(),
adminClient.profiles.list.query(), adminNavigationClient.profiles.listNavigation.query(),
]); ]);
capabilities.value = capabilityResult.status === 'fulfilled' ? capabilityResult.value : []; capabilities.value = capabilityResult.status === 'fulfilled' ? capabilityResult.value : [];
profiles.value = profileResult.status === 'fulfilled' ? profileResult.value : []; profiles.value = profileResult.status === 'fulfilled' ? profileResult.value : [];
+13 -1
View File
@@ -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'; import type { AppRouter } from '@sammo-ts/gateway-api';
const getSessionToken = (): string | null => { const getSessionToken = (): string | null => {
@@ -19,3 +19,15 @@ export const trpc = createTRPCProxyClient<AppRouter>({
}), }),
], ],
}); });
export const directTrpc = createTRPCProxyClient<AppRouter>({
links: [
httpLink({
url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc',
headers() {
const token = getSessionToken();
return token ? { 'x-session-token': token } : {};
},
}),
],
});
+4
View File
@@ -35,6 +35,10 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를 이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세 기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
runtime·빌드 상태는 상태 설정 탭에서 확인합니다. runtime·빌드 상태는 상태 설정 탭에서 확인합니다.
- 공통 좌측 메뉴는 `admin.profiles.listNavigation`으로 접근 가능한 profile의 이름과
표시명만 읽습니다. 이 요청은 PM2 runtime 상태를 포함하는 본문용
`admin.profiles.list`와 별도의 non-batch 요청으로 전송하므로, 상태 조회가 늦거나
중단되어도 관리자 capability와 서버 메뉴를 함께 기다리게 하지 않습니다.
- `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET` - `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET`
현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다. 현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다.
- 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git - 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git