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({
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) =>
+26 -1
View File
@@ -65,6 +65,7 @@ const buildCaller = async (
const updatedMetas: Record<string, unknown>[] = [];
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 =
'<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.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;
};
@@ -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 };
@@ -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]
),
});
});
};
@@ -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 }) => {
@@ -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<Array<{ permission: string; scopes?: string[] }>> } };
profiles: {
list: {
listNavigation: {
query: () => Promise<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>;
};
};
@@ -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 : [];
+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';
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 목록이나 중복 실행 상태를
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
runtime·빌드 상태는 상태 설정 탭에서 확인합니다.
- 공통 좌측 메뉴는 `admin.profiles.listNavigation`으로 접근 가능한 profile의 이름과
표시명만 읽습니다. 이 요청은 PM2 runtime 상태를 포함하는 본문용
`admin.profiles.list`와 별도의 non-batch 요청으로 전송하므로, 상태 조회가 늦거나
중단되어도 관리자 capability와 서버 메뉴를 함께 기다리게 하지 않습니다.
- `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET`
현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다.
- 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git