feat(gateway): split server lifecycle administration

This commit is contained in:
2026-08-08 17:10:43 +00:00
parent 09ea198420
commit 4f7dbfd19e
16 changed files with 916 additions and 301 deletions
@@ -98,6 +98,38 @@ const installFixture = async (
risk: 'CRITICAL',
scope: 'GLOBAL',
},
{
permission: 'admin.profiles.runtime',
label: 'Profile 실행 관리',
description: '실행 상태를 관리합니다.',
risk: 'HIGH',
scope: 'PROFILE',
scopes: ['*'],
},
{
permission: 'admin.profiles.settings',
label: 'Profile 설정 관리',
description: '설정을 관리합니다.',
risk: 'HIGH',
scope: 'PROFILE',
scopes: ['*'],
},
{
permission: 'admin.profiles.deploy',
label: 'Profile 버전 배포',
description: '버전을 배포합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
scopes: ['*'],
},
{
permission: 'admin.scenarios.reset',
label: '시나리오 초기화',
description: '시나리오를 초기화합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
scopes: ['*'],
},
]);
}
if (operation === 'admin.profiles.listScenarios') {
@@ -212,7 +244,7 @@ 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/servers');
await expect(page.getByRole('heading', { name: '서버 관리' })).toBeVisible();
await expect(page.getByRole('heading', { name: '서버 관리', level: 1 })).toBeVisible();
const duration = page.locator('input[type="number"][min="1"][max="1440"]');
const accelerate = page.getByRole('button', { name: '가속', exact: true });
@@ -291,13 +323,13 @@ test('renders an ignored terminal outcome without calling it applied', async ({
await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0);
});
test('directs profile deployment to the centralized version page', async ({ page }) => {
test('directs profile deployment to the selected server version tab', async ({ page }) => {
await installFixture(page);
await page.goto('admin/servers');
const releaseLink = page.getByRole('link', { name: '버전 업데이트 열기' });
const releaseLink = page.getByRole('link', { name: '버전 업데이트', exact: true }).last();
await expect(releaseLink).toBeVisible();
await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/releases');
await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/servers/hwe%3Adefault/version');
await expect(page.getByRole('button', { name: '설치 적용' })).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 });
@@ -27,8 +27,7 @@ const login = async (page: Page, username: string, password: string): Promise<vo
const hweRow = (page: Page) => page.locator('tbody tr').filter({ hasText: /^hwe섭/ });
const resetScenario = async (page: Page, scenarioId: string, sourceCommit: string): Promise<void> => {
await page.goto('/gateway/admin/releases');
await page.getByTestId('profile-select').selectOption('hwe:2');
await page.goto('/gateway/admin/servers/hwe%3A2/scenario');
await page.getByTestId('source-commit').check();
await page.getByTestId('source-ref').fill(sourceCommit);
await page.getByTestId('load-scenarios').click();
@@ -85,10 +85,9 @@ 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: '버전 업데이트' }).first().click();
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
await page.goto(`/gateway/admin/servers/${encodeURIComponent(profileKey)}/scenario`);
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/.+\/scenario$/);
await page.getByTestId('profile-select').selectOption(profileKey);
const profileStatus = page.getByTestId('selected-profile-status');
if (!skipReset) {
await page.getByTestId('source-commit').check();
@@ -28,13 +28,30 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
}
if (
operation === 'lobby.profiles' ||
operation === 'admin.profiles.list' ||
operation === 'admin.profiles.listScenarios' ||
operation === 'admin.operations.list' ||
operation === 'admin.releases.list'
) {
return response([]);
}
if (operation === 'admin.profiles.list') {
return response(
roles.some((role) => role.includes(':hwe:2'))
? [
{
profileName: 'hwe:2',
profile: 'hwe',
scenario: '1010',
status: 'RUNNING',
buildStatus: 'SUCCEEDED',
meta: { korName: '환상서버' },
runtime: {},
runtimeActions: [],
},
]
: []
);
}
if (operation === 'admin.releases.gatewayState') {
return response({ id: 'gateway', updatedAt: '2026-08-01T00:00:00.000Z' });
}
@@ -42,7 +59,26 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
return response({ enabled: true });
}
if (operation === 'admin.capabilities.list') {
return response([]);
return response(
roles.includes('superuser')
? [
{ permission: 'admin.users.manage', scope: 'GLOBAL', scopes: ['*'] },
{ permission: 'admin.profiles.runtime', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.profiles.settings', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.profiles.deploy', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.releases.manage', scope: 'GLOBAL', scopes: ['*'] },
{ permission: 'admin.notice.manage', scope: 'GLOBAL', scopes: ['*'] },
{ permission: 'admin.audit.read', scope: 'GLOBAL', scopes: ['*'] },
]
: [
{
permission: roles[0]?.split(':')[0],
scope: 'PROFILE',
scopes: ['hwe:2'],
},
]
);
}
throw new Error(`Unhandled tRPC operation: ${operation}`);
});
@@ -86,16 +122,16 @@ test('bootstrap superuser can navigate the administrator workspace from the lobb
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 navigation.getByRole('link', { name: 'Gateway 릴리스' }).click();
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
await expect(page.getByRole('heading', { name: '버전 업데이트' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Gateway 릴리스' })).toBeVisible();
});
test('legacy server operations URL keeps query parameters and redirects to releases', async ({ page }) => {
test('legacy server operations URL keeps query parameters and redirects to the server list', 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$/);
await expect(page).toHaveURL(/\/gateway\/admin\/servers\?operationId=legacy-operation$/);
});
test('scoped administrators see the same navigation while ordinary users do not', async ({ browser }) => {
@@ -104,6 +140,11 @@ test('scoped administrators see the same navigation while ordinary users do not'
await installGatewayFixture(scopedPage, ['admin.profiles.manage:hwe:2']);
await scopedPage.goto('lobby');
await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' });
await expect(scopedNavigation.getByRole('link', { name: '환상서버 (hwe:2)' })).toBeVisible();
await expect(scopedNavigation.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0);
await scopedContext.close();
const userContext = await browser.newContext();
@@ -33,6 +33,7 @@ type FixtureState = {
}>;
runtimeRunning: boolean;
requestBodies: Array<{ operation: string; body: unknown }>;
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
};
const profile = (runtimeRunning: boolean) => ({
@@ -99,6 +100,18 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.profiles.list') {
return response([profile(state.runtimeRunning)]);
}
if (name === 'admin.capabilities.list') {
return response(
state.capabilities ?? [
{ permission: 'admin.profiles.runtime', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.profiles.settings', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.profiles.deploy', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.reset.schedule', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.releases.manage', scope: 'GLOBAL', scopes: ['*'] },
]
);
}
if (name === 'admin.operations.list') {
return response(state.operations);
}
@@ -218,10 +231,11 @@ 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/releases');
await page.goto('admin/servers/che%3A2/scenario');
await expect(page.getByTestId('server-operations-page')).toBeVisible();
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
await expect(page.getByTestId('source-help')).toContainText('실제로 시작될 때');
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/);
await expect(page.getByTestId('source-current')).toBeChecked();
await expect(page.getByTestId('source-help')).toContainText('현재 서버 커밋');
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
const desktopGeometry = await page
@@ -237,6 +251,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
});
expect(desktopGeometry).toHaveLength(2);
expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x);
await page.getByTestId('source-commit').check();
const sourceInput = page.getByTestId('source-ref');
await sourceInput.focus();
const focusedInputStyle = await sourceInput.evaluate((element) => {
@@ -256,7 +271,6 @@ test('separates branch and commit semantics and submits a reset from the dedicat
);
await page.screenshot({ path: testInfo.outputPath('desktop-operations.png'), fullPage: true });
await page.getByTestId('source-commit').check();
await expect(page.getByTestId('source-help')).toContainText('전체 SHA로 고정');
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
await page.getByTestId('load-scenarios').click();
@@ -288,31 +302,12 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
});
test('starts and stops all runtime roles through the operation controls', async ({ page }) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: false, requestBodies: [] };
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
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();
await page.getByTestId('stop-server').click();
await expect(page.getByText('정지 작업을 요청했습니다.')).toBeVisible();
await expect(page.getByText('STOPPED', { exact: true }).first()).toBeVisible();
const serializedRequests = state.requestBodies.map((entry) => JSON.stringify(entry.body)).join('\n');
expect(serializedRequests).toContain('"action":"START"');
expect(serializedRequests).toContain('"action":"STOP"');
});
test('separates DB-preserving profile deployment from DB reset', async ({ page }) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/releases');
await page.goto('admin/servers/che%3A2/version');
await expect(page.getByText('Game frontend')).toBeVisible();
await page.getByTestId('request-deploy').click();
@@ -322,6 +317,33 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
});
test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => {
const state: FixtureState = {
operations: [],
gatewayOperations: [],
runtimeRunning: true,
requestBodies: [],
capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:2'] }],
};
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/servers/che%3A2/scenario');
await expect(page.getByTestId('source-current')).toBeChecked();
await expect(page.getByTestId('source-branch')).toHaveCount(0);
await expect(page.getByTestId('source-commit')).toHaveCount(0);
await expect(page.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible();
await expect
.poll(() => state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset'))
.toBe(true);
const request = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset');
expect(JSON.stringify(request?.body)).toContain('"sourceMode":"CURRENT"');
expect(JSON.stringify(request?.body)).not.toContain('"sourceRef"');
});
test('controls gateway deployment and rollback through the external controller queue', async ({ page }, testInfo) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state);
@@ -375,7 +397,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/releases');
await page.goto('admin/servers/che%3A2/scenario');
await expect(page.getByText('FAILED', { exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
const failure = page.getByText(longError);