Merge branch 'fix/admin-server-navigation-20260809'
This commit is contained in:
@@ -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) =>
|
||||
|
||||
@@ -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 : [];
|
||||
|
||||
@@ -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 } : {};
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user