diff --git a/README.md b/README.md
index 63280eba..3bfa7389 100644
--- a/README.md
+++ b/README.md
@@ -231,6 +231,9 @@ commit-worktree build 경로에서 구성합니다.
각 서버의 `버전 업데이트`는 profile의 game migration만 적용하고 현재
게임 DB를 seed하지 않습니다. 별도 `시나리오 초기화`는 Git 업데이트 없이
현재 게시 commit을 기본으로 사용하며, 필요할 때만 새 버전 배포와 결합합니다.
+상태 설정·버전 업데이트·시나리오 초기화는 서버별 상단 탭으로 이동하며,
+버전/초기화 화면은 URL에 고정된 profile을 다시 선택하거나 전체 profile 상태를
+기다리지 않습니다.
초기화는 현재 시즌 테이블을 새 시나리오로 교체하지만 `hall`, `ng_games`, 연감, 과거 장수·국가와 상속 자료는
보존합니다. Gateway API·frontend·orchestrator는 외부 release-controller가
함께 전환합니다. 설치와 CLI self-upgrade 절차는
diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts
index 0e6430db..039d83c8 100644
--- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts
+++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts
@@ -323,10 +323,29 @@ test('renders an ignored terminal outcome without calling it applied', async ({
await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0);
});
-test('directs profile deployment to the selected server version tab', async ({ page }) => {
+test('directs profile deployment to the selected server version tab', async ({ page }, testInfo) => {
await installFixture(page);
await page.goto('admin/servers');
+ const tabs = page.getByTestId('server-profile-tabs');
+ await expect(tabs).toBeVisible();
+ await expect(tabs.getByRole('link', { name: '상태 설정', exact: true })).toHaveAttribute('aria-current', 'page');
+ await expect(page.getByText('버전과 시즌 수명주기', { exact: true })).toHaveCount(0);
+ const versionTab = tabs.getByRole('link', { name: '버전 업데이트', exact: true });
+ const idleTabBackground = await versionTab.evaluate((element) => getComputedStyle(element).backgroundColor);
+ await versionTab.hover();
+ await expect
+ .poll(() => versionTab.evaluate((element) => getComputedStyle(element).backgroundColor))
+ .not.toBe(idleTabBackground);
+ await versionTab.focus();
+ await expect(versionTab).toBeFocused();
+ const tabAndHeaderGeometry = await Promise.all([
+ tabs.evaluate((element) => element.getBoundingClientRect().top),
+ page.getByText('hwe:default (hwe)', { exact: true }).evaluate((element) => element.getBoundingClientRect().top),
+ ]);
+ expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]);
+ await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true });
+
const releaseLink = page.getByRole('link', { name: '버전 업데이트', exact: true }).last();
await expect(releaseLink).toBeVisible();
await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/servers/hwe%3Adefault/version');
@@ -339,4 +358,5 @@ test('directs profile deployment to the selected server version tab', async ({ p
});
expect(linkGeometry.left).toBeGreaterThanOrEqual(0);
expect(linkGeometry.right).toBeLessThanOrEqual(linkGeometry.viewportWidth);
+ await page.screenshot({ path: testInfo.outputPath('status-tabs-mobile.png'), fullPage: true });
});
diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts
index aa456413..c42aaed7 100644
--- a/app/gateway-frontend/e2e/server-operations.spec.ts
+++ b/app/gateway-frontend/e2e/server-operations.spec.ts
@@ -34,6 +34,9 @@ type FixtureState = {
runtimeRunning: boolean;
requestBodies: Array<{ operation: string; body: unknown }>;
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
+ profileListDelayMs?: number;
+ profileListRequests?: number;
+ profileListResolved?: boolean;
};
const profile = (runtimeRunning: boolean) => ({
@@ -93,6 +96,13 @@ 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));
+ }
+ state.profileListResolved = true;
+ }
const results = names.map((name) => {
if (route.request().method() === 'POST') {
state.requestBodies.push({ operation: name, body });
@@ -235,8 +245,14 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await expect(page.getByTestId('server-operations-page')).toBeVisible();
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('source-help')).toContainText('현재 서버에 배포된 커밋');
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
+ await expect(page.getByTestId('server-profile-tabs')).toBeVisible();
+ await expect(page.getByRole('link', { name: '시나리오 초기화', exact: true })).toHaveAttribute(
+ 'aria-current',
+ 'page'
+ );
+ await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
const desktopGeometry = await page
.getByTestId('server-operations-page')
@@ -249,8 +265,8 @@ test('separates branch and commit semantics and submits a reset from the dedicat
});
return children;
});
- expect(desktopGeometry).toHaveLength(2);
- expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x);
+ expect(desktopGeometry).toHaveLength(1);
+ expect(desktopGeometry[0]!.width).toBeGreaterThan(800);
await page.getByTestId('source-commit').check();
const sourceInput = page.getByTestId('source-ref');
await sourceInput.focus();
@@ -297,8 +313,19 @@ test('separates branch and commit semantics and submits a reset from the dedicat
});
return children;
});
- expect(mobileGeometry[1]!.y).toBeGreaterThan(mobileGeometry[0]!.y);
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390);
+ const mobileTabs = await page
+ .getByTestId('server-profile-tabs')
+ .locator('a')
+ .evaluateAll((links) =>
+ links.map((link) => {
+ const rect = link.getBoundingClientRect();
+ return { top: rect.top, width: rect.width, height: rect.height };
+ })
+ );
+ expect(mobileTabs).toHaveLength(3);
+ expect(mobileTabs[1]!.top).toBeGreaterThan(mobileTabs[0]!.top);
+ expect(mobileTabs.every((tab) => tab.height >= 44)).toBe(true);
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
});
@@ -308,7 +335,12 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/servers/che%3A2/version');
- await expect(page.getByText('Game frontend')).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible();
+ await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
+ await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute(
+ 'aria-current',
+ 'page'
+ );
await page.getByTestId('request-deploy').click();
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.')).toBeVisible();
@@ -317,6 +349,24 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
});
+test('renders the fixed-profile version form without waiting for the server list', async ({ page }) => {
+ const state: FixtureState = {
+ operations: [],
+ gatewayOperations: [],
+ runtimeRunning: true,
+ requestBodies: [],
+ profileListDelayMs: 1500,
+ profileListResolved: 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);
+});
+
test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => {
const state: FixtureState = {
operations: [],
@@ -420,7 +470,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
state.runtimeRunning = true;
await page.getByTestId('refresh-operations').click();
await expect(page.getByText('SUCCEEDED', { exact: true })).toBeVisible();
- await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible();
+ await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
diff --git a/app/gateway-frontend/src/components/ServerProfileTabs.vue b/app/gateway-frontend/src/components/ServerProfileTabs.vue
new file mode 100644
index 00000000..6851e282
--- /dev/null
+++ b/app/gateway-frontend/src/components/ServerProfileTabs.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
diff --git a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue
index 5299e1c5..47fcd9e1 100644
--- a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue
+++ b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue
@@ -109,16 +109,12 @@ const navigation = computed(() => [
]);
onMounted(async () => {
- try {
- capabilities.value = await adminClient.capabilities.list.query();
- } catch {
- capabilities.value = [];
- }
- try {
- profiles.value = await adminClient.profiles.list.query();
- } catch {
- profiles.value = [];
- }
+ const [capabilityResult, profileResult] = await Promise.allSettled([
+ adminClient.capabilities.list.query(),
+ adminClient.profiles.list.query(),
+ ]);
+ capabilities.value = capabilityResult.status === 'fulfilled' ? capabilityResult.value : [];
+ profiles.value = profileResult.status === 'fulfilled' ? profileResult.value : [];
});
diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue
index 8ab8d0f2..88f97d3a 100644
--- a/app/gateway-frontend/src/views/AdminView.vue
+++ b/app/gateway-frontend/src/views/AdminView.vue
@@ -1,5 +1,6 @@