Merge branch 'main' into feature/gateway-release-progress-20260809
# Conflicts: # app/gateway-frontend/src/views/ServerOperationsView.vue
This commit is contained in:
@@ -231,6 +231,9 @@ commit-worktree build 경로에서 구성합니다.
|
|||||||
각 서버의 `버전 업데이트`는 profile의 game migration만 적용하고 현재
|
각 서버의 `버전 업데이트`는 profile의 game migration만 적용하고 현재
|
||||||
게임 DB를 seed하지 않습니다. 별도 `시나리오 초기화`는 Git 업데이트 없이
|
게임 DB를 seed하지 않습니다. 별도 `시나리오 초기화`는 Git 업데이트 없이
|
||||||
현재 게시 commit을 기본으로 사용하며, 필요할 때만 새 버전 배포와 결합합니다.
|
현재 게시 commit을 기본으로 사용하며, 필요할 때만 새 버전 배포와 결합합니다.
|
||||||
|
상태 설정·버전 업데이트·시나리오 초기화는 서버별 상단 탭으로 이동하며,
|
||||||
|
버전/초기화 화면은 URL에 고정된 profile을 다시 선택하거나 전체 profile 상태를
|
||||||
|
기다리지 않습니다.
|
||||||
초기화는 현재 시즌 테이블을 새 시나리오로 교체하지만 `hall`, `ng_games`, 연감, 과거 장수·국가와 상속 자료는
|
초기화는 현재 시즌 테이블을 새 시나리오로 교체하지만 `hall`, `ng_games`, 연감, 과거 장수·국가와 상속 자료는
|
||||||
보존합니다. Gateway API·frontend·orchestrator는 외부 release-controller가
|
보존합니다. Gateway API·frontend·orchestrator는 외부 release-controller가
|
||||||
함께 전환합니다. 설치와 CLI self-upgrade 절차는
|
함께 전환합니다. 설치와 CLI self-upgrade 절차는
|
||||||
|
|||||||
@@ -157,6 +157,44 @@ test('prioritizes core general fields and keeps context and inheritance progress
|
|||||||
await expect(page.getByLabel('장수명')).toHaveValue('생성장수');
|
await expect(page.getByLabel('장수명')).toHaveValue('생성장수');
|
||||||
await expect(page.getByLabel('성격')).toBeVisible();
|
await expect(page.getByLabel('성격')).toBeVisible();
|
||||||
await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('55');
|
await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('55');
|
||||||
|
const statActions = page.getByRole('group', { name: '능력치 빠른 설정' });
|
||||||
|
await expect(statActions.getByRole('button')).toHaveText([
|
||||||
|
'랜덤형',
|
||||||
|
'통솔무력형',
|
||||||
|
'통솔지력형',
|
||||||
|
'무력지력형',
|
||||||
|
]);
|
||||||
|
const setRandomValues = async (values: number[]) => {
|
||||||
|
await page.evaluate((nextValues) => {
|
||||||
|
let index = 0;
|
||||||
|
Math.random = () => nextValues[index++] ?? nextValues.at(-1) ?? 0.5;
|
||||||
|
}, values);
|
||||||
|
};
|
||||||
|
|
||||||
|
await setRandomValues([0.2, 0.4, 0.6]);
|
||||||
|
await statActions.getByRole('button', { name: '랜덤형', exact: true }).click();
|
||||||
|
await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('36');
|
||||||
|
await expect(page.locator('.create-form').getByLabel('무력')).toHaveValue('55');
|
||||||
|
await expect(page.locator('.create-form').getByLabel('지력')).toHaveValue('74');
|
||||||
|
|
||||||
|
await setRandomValues([0.9, 0.8, 0.5]);
|
||||||
|
await statActions.getByRole('button', { name: '통솔무력형' }).click();
|
||||||
|
await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('75');
|
||||||
|
await expect(page.locator('.create-form').getByLabel('무력')).toHaveValue('75');
|
||||||
|
await expect(page.locator('.create-form').getByLabel('지력')).toHaveValue('15');
|
||||||
|
|
||||||
|
await setRandomValues([0.9, 0.5, 0.8]);
|
||||||
|
await statActions.getByRole('button', { name: '통솔지력형' }).click();
|
||||||
|
await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('75');
|
||||||
|
await expect(page.locator('.create-form').getByLabel('무력')).toHaveValue('15');
|
||||||
|
await expect(page.locator('.create-form').getByLabel('지력')).toHaveValue('75');
|
||||||
|
|
||||||
|
await setRandomValues([0.5, 0.9, 0.8]);
|
||||||
|
await statActions.getByRole('button', { name: '무력지력형' }).click();
|
||||||
|
await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('15');
|
||||||
|
await expect(page.locator('.create-form').getByLabel('무력')).toHaveValue('75');
|
||||||
|
await expect(page.locator('.create-form').getByLabel('지력')).toHaveValue('75');
|
||||||
|
await expect(page.locator('.stat-summary')).toContainText('능력치 합계: 165');
|
||||||
await expect(advanced).not.toHaveAttribute('open');
|
await expect(advanced).not.toHaveAttribute('open');
|
||||||
await expect(page.getByText('전투 특기 선택')).toBeHidden();
|
await expect(page.getByText('전투 특기 선택')).toBeHidden();
|
||||||
expect(state.mapRequests).toBe(0);
|
expect(state.mapRequests).toBe(0);
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
export type GeneralStatRules = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeneralStats = [leadership: number, strength: number, intel: number];
|
||||||
|
|
||||||
|
type RandomSource = () => number;
|
||||||
|
|
||||||
|
export const abilityRand = (stats: GeneralStatRules, random: RandomSource = Math.random): GeneralStats => {
|
||||||
|
let leadership = random() * 65 + 10;
|
||||||
|
let strength = random() * 65 + 10;
|
||||||
|
let intel = random() * 65 + 10;
|
||||||
|
const rate = leadership + strength + intel;
|
||||||
|
|
||||||
|
leadership = Math.floor((leadership / rate) * stats.total);
|
||||||
|
strength = Math.floor((strength / rate) * stats.total);
|
||||||
|
intel = Math.floor((intel / rate) * stats.total);
|
||||||
|
|
||||||
|
while (leadership + strength + intel < stats.total) {
|
||||||
|
leadership += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
leadership > stats.max ||
|
||||||
|
strength > stats.max ||
|
||||||
|
intel > stats.max ||
|
||||||
|
leadership < stats.min ||
|
||||||
|
strength < stats.min ||
|
||||||
|
intel < stats.min
|
||||||
|
) {
|
||||||
|
return abilityRand(stats, random);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [leadership, strength, intel];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const abilityLeadpow = (stats: GeneralStatRules, random: RandomSource = Math.random): GeneralStats => {
|
||||||
|
let leadership = random() * 6;
|
||||||
|
let strength = random() * 6;
|
||||||
|
let intel = random();
|
||||||
|
const rate = leadership + strength + intel;
|
||||||
|
|
||||||
|
leadership = Math.floor((leadership / rate) * stats.total);
|
||||||
|
strength = Math.floor((strength / rate) * stats.total);
|
||||||
|
intel = Math.floor((intel / rate) * stats.total);
|
||||||
|
|
||||||
|
while (leadership + strength + intel < stats.total) {
|
||||||
|
strength += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intel < stats.min) {
|
||||||
|
leadership -= stats.min - intel;
|
||||||
|
intel = stats.min;
|
||||||
|
}
|
||||||
|
if (leadership > stats.max) {
|
||||||
|
strength += leadership - stats.max;
|
||||||
|
leadership = stats.max;
|
||||||
|
}
|
||||||
|
if (strength > stats.max) {
|
||||||
|
leadership += strength - stats.max;
|
||||||
|
strength = stats.max;
|
||||||
|
}
|
||||||
|
if (leadership > stats.max) {
|
||||||
|
intel += leadership - stats.max;
|
||||||
|
leadership = stats.max;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [leadership, strength, intel];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const abilityLeadint = (stats: GeneralStatRules, random: RandomSource = Math.random): GeneralStats => {
|
||||||
|
let leadership = random() * 6;
|
||||||
|
let strength = random();
|
||||||
|
let intel = random() * 6;
|
||||||
|
const rate = leadership + strength + intel;
|
||||||
|
|
||||||
|
leadership = Math.floor((leadership / rate) * stats.total);
|
||||||
|
strength = Math.floor((strength / rate) * stats.total);
|
||||||
|
intel = Math.floor((intel / rate) * stats.total);
|
||||||
|
|
||||||
|
while (leadership + strength + intel < stats.total) {
|
||||||
|
intel += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strength < stats.min) {
|
||||||
|
leadership -= stats.min - strength;
|
||||||
|
strength = stats.min;
|
||||||
|
}
|
||||||
|
if (leadership > stats.max) {
|
||||||
|
intel += leadership - stats.max;
|
||||||
|
leadership = stats.max;
|
||||||
|
}
|
||||||
|
if (intel > stats.max) {
|
||||||
|
leadership += intel - stats.max;
|
||||||
|
intel = stats.max;
|
||||||
|
}
|
||||||
|
if (leadership > stats.max) {
|
||||||
|
strength += leadership - stats.max;
|
||||||
|
leadership = stats.max;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [leadership, strength, intel];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const abilityPowint = (stats: GeneralStatRules, random: RandomSource = Math.random): GeneralStats => {
|
||||||
|
let leadership = random();
|
||||||
|
let strength = random() * 6;
|
||||||
|
let intel = random() * 6;
|
||||||
|
const rate = leadership + strength + intel;
|
||||||
|
|
||||||
|
leadership = Math.floor((leadership / rate) * stats.total);
|
||||||
|
strength = Math.floor((strength / rate) * stats.total);
|
||||||
|
intel = Math.floor((intel / rate) * stats.total);
|
||||||
|
|
||||||
|
while (leadership + strength + intel < stats.total) {
|
||||||
|
intel += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leadership < stats.min) {
|
||||||
|
strength -= stats.min - leadership;
|
||||||
|
leadership = stats.min;
|
||||||
|
}
|
||||||
|
if (strength > stats.max) {
|
||||||
|
intel += strength - stats.max;
|
||||||
|
strength = stats.max;
|
||||||
|
}
|
||||||
|
if (intel > stats.max) {
|
||||||
|
strength += intel - stats.max;
|
||||||
|
intel = stats.max;
|
||||||
|
}
|
||||||
|
if (strength > stats.max) {
|
||||||
|
leadership += strength - stats.max;
|
||||||
|
strength = stats.max;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [leadership, strength, intel];
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nation
|
|||||||
import { getNpcColor } from '../utils/npcColor';
|
import { getNpcColor } from '../utils/npcColor';
|
||||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||||
|
import { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand, type GeneralStats } from '../utils/generalStats';
|
||||||
|
|
||||||
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
||||||
type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
|
type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
|
||||||
@@ -365,8 +366,6 @@ const inheritTurntimeChoice = computed<string>({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
|
|
||||||
|
|
||||||
const applyBalancedStats = () => {
|
const applyBalancedStats = () => {
|
||||||
const rules = statRules.value;
|
const rules = statRules.value;
|
||||||
if (!rules) {
|
if (!rules) {
|
||||||
@@ -378,36 +377,40 @@ const applyBalancedStats = () => {
|
|||||||
form.value.intel = base;
|
form.value.intel = base;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyStats = (stats: GeneralStats) => {
|
||||||
|
[form.value.leadership, form.value.strength, form.value.intel] = stats;
|
||||||
|
};
|
||||||
|
|
||||||
const applyRandomStats = () => {
|
const applyRandomStats = () => {
|
||||||
const rules = statRules.value;
|
const rules = statRules.value;
|
||||||
if (!rules) {
|
if (!rules) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (let i = 0; i < 40; i += 1) {
|
applyStats(abilityRand(rules));
|
||||||
const leadership = randomInt(rules.min, rules.max);
|
|
||||||
const strength = randomInt(rules.min, rules.max);
|
|
||||||
const intel = rules.total - leadership - strength;
|
|
||||||
if (intel >= rules.min && intel <= rules.max) {
|
|
||||||
form.value.leadership = leadership;
|
|
||||||
form.value.strength = strength;
|
|
||||||
form.value.intel = intel;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
applyBalancedStats();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyFocusedStats = (focus: 'leadership' | 'strength' | 'intel') => {
|
const applyLeadpowStats = () => {
|
||||||
const rules = statRules.value;
|
const rules = statRules.value;
|
||||||
if (!rules) {
|
if (!rules) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const focusValue = Math.min(rules.max, rules.min + Math.floor(rules.total * 0.45));
|
applyStats(abilityLeadpow(rules));
|
||||||
const remain = rules.total - focusValue;
|
};
|
||||||
const side = Math.floor(remain / 2);
|
|
||||||
form.value.leadership = focus === 'leadership' ? focusValue : side;
|
const applyLeadintStats = () => {
|
||||||
form.value.strength = focus === 'strength' ? focusValue : side;
|
const rules = statRules.value;
|
||||||
form.value.intel = focus === 'intel' ? focusValue : remain - side;
|
if (!rules) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyStats(abilityLeadint(rules));
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPowintStats = () => {
|
||||||
|
const rules = statRules.value;
|
||||||
|
if (!rules) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyStats(abilityPowint(rules));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
@@ -709,12 +712,11 @@ onUnmounted(() => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-actions" aria-label="능력치 빠른 설정">
|
<div class="stat-actions" role="group" aria-label="능력치 빠른 설정">
|
||||||
<button type="button" @click="applyRandomStats">랜덤형</button>
|
<button type="button" @click="applyRandomStats">랜덤형</button>
|
||||||
<button type="button" @click="applyFocusedStats('leadership')">통솔형</button>
|
<button type="button" @click="applyLeadpowStats">통솔무력형</button>
|
||||||
<button type="button" @click="applyFocusedStats('strength')">무력형</button>
|
<button type="button" @click="applyLeadintStats">통솔지력형</button>
|
||||||
<button type="button" @click="applyFocusedStats('intel')">지력형</button>
|
<button type="button" @click="applyPowintStats">무력지력형</button>
|
||||||
<button type="button" @click="applyBalancedStats">균형형</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="accountIcons.length" class="icon-choice">
|
<div v-if="accountIcons.length" class="icon-choice">
|
||||||
@@ -751,7 +753,7 @@ onUnmounted(() => {
|
|||||||
<button class="primary-action" type="submit" :disabled="!canSubmit || submitting">
|
<button class="primary-action" type="submit" :disabled="!canSubmit || submitting">
|
||||||
{{ submitting ? '생성 중...' : '장수 생성' }}
|
{{ submitting ? '생성 중...' : '장수 생성' }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="ghost" @click="applyBalancedStats">균형형으로 되돌리기</button>
|
<button type="button" class="ghost" @click="applyBalancedStats">능력치 초기화</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
|
||||||
|
import { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand } from '../src/utils/generalStats.ts';
|
||||||
|
|
||||||
|
const rules = { min: 15, max: 80, total: 165 };
|
||||||
|
const sequence = (...values: number[]) => {
|
||||||
|
let index = 0;
|
||||||
|
return () => values[index++] ?? values.at(-1) ?? 0.5;
|
||||||
|
};
|
||||||
|
|
||||||
|
void describe('generalStats Ref presets', () => {
|
||||||
|
void it('normalizes the random preset to the configured total', () => {
|
||||||
|
assert.deepEqual(abilityRand(rules, sequence(0.2, 0.4, 0.6)), [36, 55, 74]);
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('preserves the Ref two-stat weighted distributions and min/max correction order', () => {
|
||||||
|
assert.deepEqual(abilityLeadpow(rules, sequence(0.9, 0.8, 0.5)), [75, 75, 15]);
|
||||||
|
assert.deepEqual(abilityLeadint(rules, sequence(0.9, 0.5, 0.8)), [75, 15, 75]);
|
||||||
|
assert.deepEqual(abilityPowint(rules, sequence(0.5, 0.9, 0.8)), [15, 75, 75]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -323,10 +323,29 @@ test('renders an ignored terminal outcome without calling it applied', async ({
|
|||||||
await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0);
|
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 installFixture(page);
|
||||||
await page.goto('admin/servers');
|
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();
|
const releaseLink = page.getByRole('link', { name: '버전 업데이트', exact: true }).last();
|
||||||
await expect(releaseLink).toBeVisible();
|
await expect(releaseLink).toBeVisible();
|
||||||
await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/servers/hwe%3Adefault/version');
|
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.left).toBeGreaterThanOrEqual(0);
|
||||||
expect(linkGeometry.right).toBeLessThanOrEqual(linkGeometry.viewportWidth);
|
expect(linkGeometry.right).toBeLessThanOrEqual(linkGeometry.viewportWidth);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('status-tabs-mobile.png'), fullPage: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ type FixtureState = {
|
|||||||
requestBodies: Array<{ operation: string; body: unknown }>;
|
requestBodies: Array<{ operation: string; body: unknown }>;
|
||||||
gatewayLogPollCount?: number;
|
gatewayLogPollCount?: number;
|
||||||
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
|
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
|
||||||
|
profileListDelayMs?: number;
|
||||||
|
profileListRequests?: number;
|
||||||
|
profileListResolved?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const profile = (runtimeRunning: boolean) => ({
|
const profile = (runtimeRunning: boolean) => ({
|
||||||
@@ -94,6 +97,13 @@ 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')) {
|
||||||
|
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) => {
|
const results = names.map((name) => {
|
||||||
if (route.request().method() === 'POST') {
|
if (route.request().method() === 'POST') {
|
||||||
state.requestBodies.push({ operation: name, body });
|
state.requestBodies.push({ operation: name, body });
|
||||||
@@ -267,8 +277,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.getByTestId('server-operations-page')).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/);
|
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/);
|
||||||
await expect(page.getByTestId('source-current')).toBeChecked();
|
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('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
|
const desktopGeometry = await page
|
||||||
.getByTestId('server-operations-page')
|
.getByTestId('server-operations-page')
|
||||||
@@ -281,8 +297,8 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
});
|
});
|
||||||
return children;
|
return children;
|
||||||
});
|
});
|
||||||
expect(desktopGeometry).toHaveLength(2);
|
expect(desktopGeometry).toHaveLength(1);
|
||||||
expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x);
|
expect(desktopGeometry[0]!.width).toBeGreaterThan(800);
|
||||||
await page.getByTestId('source-commit').check();
|
await page.getByTestId('source-commit').check();
|
||||||
const sourceInput = page.getByTestId('source-ref');
|
const sourceInput = page.getByTestId('source-ref');
|
||||||
await sourceInput.focus();
|
await sourceInput.focus();
|
||||||
@@ -329,8 +345,19 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
});
|
});
|
||||||
return children;
|
return children;
|
||||||
});
|
});
|
||||||
expect(mobileGeometry[1]!.y).toBeGreaterThan(mobileGeometry[0]!.y);
|
|
||||||
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390);
|
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 });
|
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -340,7 +367,12 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
|||||||
page.on('dialog', (dialog) => dialog.accept());
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3A2/version');
|
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 page.getByTestId('request-deploy').click();
|
||||||
|
|
||||||
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.')).toBeVisible();
|
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.')).toBeVisible();
|
||||||
@@ -349,6 +381,24 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
|||||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
|
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 }) => {
|
test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => {
|
||||||
const state: FixtureState = {
|
const state: FixtureState = {
|
||||||
operations: [],
|
operations: [],
|
||||||
@@ -466,7 +516,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
|||||||
state.runtimeRunning = true;
|
state.runtimeRunning = true;
|
||||||
await page.getByTestId('refresh-operations').click();
|
await page.getByTestId('refresh-operations').click();
|
||||||
await expect(page.getByText('SUCCEEDED', { exact: true })).toBeVisible();
|
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.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true });
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
type ServerProfileTab = 'status' | 'version' | 'scenario';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
profileName: string;
|
||||||
|
activeTab: ServerProfileTab;
|
||||||
|
canDeploy: boolean;
|
||||||
|
canReset: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const tabs = computed(() =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'status' as const,
|
||||||
|
label: '상태 설정',
|
||||||
|
to: `/admin/servers/${encodeURIComponent(props.profileName)}`,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'version' as const,
|
||||||
|
label: '버전 업데이트',
|
||||||
|
to: `/admin/servers/${encodeURIComponent(props.profileName)}/version`,
|
||||||
|
visible: props.canDeploy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scenario' as const,
|
||||||
|
label: '시나리오 초기화',
|
||||||
|
to: `/admin/servers/${encodeURIComponent(props.profileName)}/scenario`,
|
||||||
|
visible: props.canReset,
|
||||||
|
},
|
||||||
|
].filter((tab) => tab.visible)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav
|
||||||
|
class="server-profile-tabs"
|
||||||
|
:style="{ '--server-tab-count': tabs.length }"
|
||||||
|
:aria-label="`${profileName} 서버 관리 탭`"
|
||||||
|
data-testid="server-profile-tabs"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
:to="tab.to"
|
||||||
|
class="server-profile-tab"
|
||||||
|
:class="{ active: activeTab === tab.id }"
|
||||||
|
:aria-current="activeTab === tab.id ? 'page' : undefined"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.server-profile-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--server-tab-count), minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid #3f3f46;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #09090b;
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-profile-tab {
|
||||||
|
display: flex;
|
||||||
|
min-height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: #d4d4d8;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
transition:
|
||||||
|
border-color 140ms ease,
|
||||||
|
background-color 140ms ease,
|
||||||
|
color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-profile-tab:hover,
|
||||||
|
.server-profile-tab:focus-visible {
|
||||||
|
border-color: #71717a;
|
||||||
|
background: #27272a;
|
||||||
|
color: #fff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-profile-tab.active {
|
||||||
|
border-color: #a78bfa;
|
||||||
|
background: #4c1d95;
|
||||||
|
color: #f5f3ff;
|
||||||
|
box-shadow: inset 0 0 0 1px rgb(196 181 253 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.server-profile-tabs {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -109,16 +109,12 @@ const navigation = computed(() => [
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
const [capabilityResult, profileResult] = await Promise.allSettled([
|
||||||
capabilities.value = await adminClient.capabilities.list.query();
|
adminClient.capabilities.list.query(),
|
||||||
} catch {
|
adminClient.profiles.list.query(),
|
||||||
capabilities.value = [];
|
]);
|
||||||
}
|
capabilities.value = capabilityResult.status === 'fulfilled' ? capabilityResult.value : [];
|
||||||
try {
|
profiles.value = profileResult.status === 'fulfilled' ? profileResult.value : [];
|
||||||
profiles.value = await adminClient.profiles.list.query();
|
|
||||||
} catch {
|
|
||||||
profiles.value = [];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
@@ -1971,6 +1972,13 @@ onMounted(() => {
|
|||||||
:key="profile.profileName"
|
:key="profile.profileName"
|
||||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||||
>
|
>
|
||||||
|
<ServerProfileTabs
|
||||||
|
:profile-name="profile.profileName"
|
||||||
|
active-tab="status"
|
||||||
|
:can-deploy="hasCapability('admin.profiles.deploy', profile.profileName)"
|
||||||
|
:can-reset="hasCapability('admin.scenarios.reset', profile.profileName)"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-base font-semibold">
|
<div class="text-base font-semibold">
|
||||||
@@ -1989,29 +1997,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div class="text-xs text-zinc-400">빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}</div>
|
<div class="text-xs text-zinc-400">빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}</div>
|
||||||
|
|
||||||
<nav class="flex flex-wrap gap-2" :aria-label="`${profile.profileName} 관리 탭`">
|
|
||||||
<RouterLink
|
|
||||||
:to="`/admin/servers/${encodeURIComponent(profile.profileName)}`"
|
|
||||||
class="rounded border border-zinc-600 bg-zinc-800 px-3 py-2 text-xs font-semibold text-white"
|
|
||||||
>
|
|
||||||
상태 · 설정
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
v-if="hasCapability('admin.profiles.deploy', profile.profileName)"
|
|
||||||
:to="`/admin/servers/${encodeURIComponent(profile.profileName)}/version`"
|
|
||||||
class="rounded border border-blue-800 px-3 py-2 text-xs font-semibold text-blue-200 hover:bg-blue-950"
|
|
||||||
>
|
|
||||||
버전 업데이트
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
v-if="hasCapability('admin.scenarios.reset', profile.profileName)"
|
|
||||||
:to="`/admin/servers/${encodeURIComponent(profile.profileName)}/scenario`"
|
|
||||||
class="rounded border border-purple-800 px-3 py-2 text-xs font-semibold text-purple-200 hover:bg-purple-950"
|
|
||||||
>
|
|
||||||
시나리오 초기화
|
|
||||||
</RouterLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-3">
|
<div class="grid md:grid-cols-2 gap-3">
|
||||||
<div
|
<div
|
||||||
v-if="hasCapability('admin.profiles.settings', profile.profileName)"
|
v-if="hasCapability('admin.profiles.settings', profile.profileName)"
|
||||||
@@ -2228,41 +2213,6 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
hasCapability('admin.profiles.deploy', profile.profileName) ||
|
|
||||||
hasCapability('admin.scenarios.reset', profile.profileName)
|
|
||||||
"
|
|
||||||
class="border-t border-zinc-800 pt-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-3 rounded border border-violet-900/70 bg-violet-950/20 p-4 md:flex-row md:items-center md:justify-between"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-semibold text-violet-200">버전과 시즌 수명주기</h4>
|
|
||||||
<p class="mt-1 text-xs text-zinc-500">
|
|
||||||
DB를 보존하는 코드 배포와 DB를 교체하는 시나리오 초기화는 별도 작업입니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<RouterLink
|
|
||||||
v-if="hasCapability('admin.profiles.deploy', profile.profileName)"
|
|
||||||
:to="`/admin/servers/${encodeURIComponent(profile.profileName)}/version`"
|
|
||||||
class="rounded border border-blue-700 px-3 py-2 text-center text-xs font-semibold text-blue-200 hover:bg-blue-950"
|
|
||||||
>
|
|
||||||
버전 업데이트
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
v-if="hasCapability('admin.scenarios.reset', profile.profileName)"
|
|
||||||
:to="`/admin/servers/${encodeURIComponent(profile.profileName)}/scenario`"
|
|
||||||
class="rounded border border-purple-700 px-3 py-2 text-center text-xs font-semibold text-purple-200 hover:bg-purple-950"
|
|
||||||
>
|
|
||||||
시나리오 초기화
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="profileActionStatus.global" class="text-xs text-red-400">
|
<div v-if="profileActionStatus.global" class="text-xs text-red-400">
|
||||||
{{ profileActionStatus.global }}
|
{{ profileActionStatus.global }}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
@@ -13,26 +14,6 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const adminClient = trpc.admin;
|
const adminClient = trpc.admin;
|
||||||
|
|
||||||
type Profile = {
|
|
||||||
profileName: string;
|
|
||||||
profile: string;
|
|
||||||
scenario: string;
|
|
||||||
status: string;
|
|
||||||
buildStatus: string;
|
|
||||||
buildCommitSha?: string;
|
|
||||||
buildWorkspace?: string;
|
|
||||||
buildError?: string;
|
|
||||||
lastError?: string;
|
|
||||||
runtime: {
|
|
||||||
frontendRunning: boolean;
|
|
||||||
apiRunning: boolean;
|
|
||||||
daemonRunning: boolean;
|
|
||||||
auctionRunning: boolean;
|
|
||||||
battleSimRunning: boolean;
|
|
||||||
tournamentRunning: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type Scenario = {
|
type Scenario = {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -88,8 +69,6 @@ type GatewayReleaseLog = {
|
|||||||
message: string;
|
message: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const profiles = ref<Profile[]>([]);
|
|
||||||
const scenarios = ref<Scenario[]>([]);
|
const scenarios = ref<Scenario[]>([]);
|
||||||
const operations = ref<Operation[]>([]);
|
const operations = ref<Operation[]>([]);
|
||||||
const gatewayReleaseState = ref<GatewayReleaseState | null>(null);
|
const gatewayReleaseState = ref<GatewayReleaseState | null>(null);
|
||||||
@@ -101,7 +80,7 @@ const gatewayReleaseLogStatus = ref('');
|
|||||||
const gatewayReleaseLogConnection = ref<'idle' | 'connected' | 'reconnecting'>('idle');
|
const gatewayReleaseLogConnection = ref<'idle' | 'connected' | 'reconnecting'>('idle');
|
||||||
const gatewayReleaseLogViewport = ref<HTMLElement>();
|
const gatewayReleaseLogViewport = ref<HTMLElement>();
|
||||||
const gatewayReleaseAvailable = ref(false);
|
const gatewayReleaseAvailable = ref(false);
|
||||||
const selectedProfileName = ref(props.profileName ?? '');
|
const selectedProfileName = computed(() => props.profileName ?? '');
|
||||||
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
|
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const catalogLoading = ref(false);
|
const catalogLoading = ref(false);
|
||||||
@@ -147,11 +126,6 @@ const gatewayForm = reactive({
|
|||||||
const selectedGatewayOperation = computed(
|
const selectedGatewayOperation = computed(
|
||||||
() => gatewayReleaseOperations.value.find((operation) => operation.id === selectedGatewayOperationId.value) ?? null
|
() => gatewayReleaseOperations.value.find((operation) => operation.id === selectedGatewayOperationId.value) ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedProfile = computed(
|
|
||||||
() => profiles.value.find((profile) => profile.profileName === selectedProfileName.value) ?? null
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasCapability = (permission: string): boolean =>
|
const hasCapability = (permission: string): boolean =>
|
||||||
capabilities.value.some((entry) => {
|
capabilities.value.some((entry) => {
|
||||||
if (entry.permission !== permission && entry.permission !== 'admin.profiles.manage') return false;
|
if (entry.permission !== permission && entry.permission !== 'admin.profiles.manage') return false;
|
||||||
@@ -184,7 +158,7 @@ const activeOperation = computed(
|
|||||||
|
|
||||||
const sourceHelp = computed(() =>
|
const sourceHelp = computed(() =>
|
||||||
form.sourceMode === 'CURRENT'
|
form.sourceMode === 'CURRENT'
|
||||||
? `현재 서버 커밋 ${shortSha(selectedProfile.value?.buildCommitSha)}의 시나리오 리소스를 사용합니다.`
|
? '현재 서버에 배포된 커밋의 시나리오 리소스를 사용합니다.'
|
||||||
: form.sourceMode === 'BRANCH'
|
: form.sourceMode === 'BRANCH'
|
||||||
? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.'
|
? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.'
|
||||||
: '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.'
|
: '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.'
|
||||||
@@ -208,6 +182,15 @@ const clearStatus = () => {
|
|||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadCapabilities = async () => {
|
||||||
|
try {
|
||||||
|
capabilities.value = (await adminClient.capabilities.list.query()) as typeof capabilities.value;
|
||||||
|
} catch (error) {
|
||||||
|
capabilities.value = [];
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '관리 권한을 불러오지 못했습니다.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadState = async (quiet = false) => {
|
const loadState = async (quiet = false) => {
|
||||||
if (stateRequestInFlight) {
|
if (stateRequestInFlight) {
|
||||||
return;
|
return;
|
||||||
@@ -217,10 +200,11 @@ const loadState = async (quiet = false) => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
capabilities.value = (await adminClient.capabilities.list.query()) as typeof capabilities.value;
|
|
||||||
if (props.mode === 'gateway') {
|
if (props.mode === 'gateway') {
|
||||||
const state = await adminClient.releases.gatewayState.query();
|
const [state, releaseOperations] = await Promise.all([
|
||||||
const releaseOperations = await adminClient.releases.list.query({ limit: 30 });
|
adminClient.releases.gatewayState.query(),
|
||||||
|
adminClient.releases.list.query({ limit: 30 }),
|
||||||
|
]);
|
||||||
gatewayReleaseState.value = state as GatewayReleaseState;
|
gatewayReleaseState.value = state as GatewayReleaseState;
|
||||||
gatewayReleaseOperations.value = releaseOperations as GatewayReleaseOperation[];
|
gatewayReleaseOperations.value = releaseOperations as GatewayReleaseOperation[];
|
||||||
const active = gatewayReleaseOperations.value.find((operation) =>
|
const active = gatewayReleaseOperations.value.find((operation) =>
|
||||||
@@ -236,14 +220,11 @@ const loadState = async (quiet = false) => {
|
|||||||
}
|
}
|
||||||
gatewayReleaseAvailable.value = true;
|
gatewayReleaseAvailable.value = true;
|
||||||
} else {
|
} else {
|
||||||
const profileResult = await adminClient.profiles.list.query();
|
|
||||||
const operationResult = await adminClient.operations.list.query({
|
const operationResult = await adminClient.operations.list.query({
|
||||||
profileName: props.profileName,
|
profileName: props.profileName,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
profiles.value = profileResult as Profile[];
|
|
||||||
operations.value = operationResult as Operation[];
|
operations.value = operationResult as Operation[];
|
||||||
selectedProfileName.value = props.profileName ?? profiles.value[0]?.profileName ?? '';
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.';
|
errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.';
|
||||||
@@ -306,12 +287,17 @@ const selectGatewayReleaseOperation = (operationId: string) => {
|
|||||||
|
|
||||||
const requestDeploy = async () => {
|
const requestDeploy = async () => {
|
||||||
clearStatus();
|
clearStatus();
|
||||||
if (!selectedProfile.value || activeOperation.value || !form.sourceRef.trim() || form.sourceMode === 'CURRENT') {
|
if (
|
||||||
|
!selectedProfileName.value ||
|
||||||
|
activeOperation.value ||
|
||||||
|
!form.sourceRef.trim() ||
|
||||||
|
form.sourceMode === 'CURRENT'
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
`${selectedProfile.value.profileName}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?`
|
`${selectedProfileName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?`
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -319,7 +305,7 @@ const requestDeploy = async () => {
|
|||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
await adminClient.operations.requestDeploy.mutate({
|
await adminClient.operations.requestDeploy.mutate({
|
||||||
profileName: selectedProfile.value.profileName,
|
profileName: selectedProfileName.value,
|
||||||
sourceMode: form.sourceMode,
|
sourceMode: form.sourceMode,
|
||||||
sourceRef: form.sourceRef.trim(),
|
sourceRef: form.sourceRef.trim(),
|
||||||
reason: form.reason.trim() || undefined,
|
reason: form.reason.trim() || undefined,
|
||||||
@@ -393,9 +379,7 @@ const loadScenarios = async () => {
|
|||||||
});
|
});
|
||||||
scenarios.value = result as Scenario[];
|
scenarios.value = result as Scenario[];
|
||||||
if (!scenarios.value.some((scenario) => scenario.id === form.scenarioId)) {
|
if (!scenarios.value.some((scenario) => scenario.id === form.scenarioId)) {
|
||||||
const profileScenario = Number(selectedProfile.value?.scenario);
|
form.scenarioId = scenarios.value[0]?.id ?? 0;
|
||||||
form.scenarioId =
|
|
||||||
scenarios.value.find((scenario) => scenario.id === profileScenario)?.id ?? scenarios.value[0]?.id ?? 0;
|
|
||||||
}
|
}
|
||||||
message.value = `${scenarios.value.length}개 시나리오를 확인했습니다.`;
|
message.value = `${scenarios.value.length}개 시나리오를 확인했습니다.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -418,7 +402,7 @@ const selectedAutorunOptions = (): Array<'develop' | 'warp' | 'recruit' | 'train
|
|||||||
|
|
||||||
const requestReset = async () => {
|
const requestReset = async () => {
|
||||||
clearStatus();
|
clearStatus();
|
||||||
if (!selectedProfile.value || activeOperation.value) {
|
if (!selectedProfileName.value || activeOperation.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((form.sourceMode !== 'CURRENT' && !form.sourceRef.trim()) || !form.scenarioId) {
|
if ((form.sourceMode !== 'CURRENT' && !form.sourceRef.trim()) || !form.scenarioId) {
|
||||||
@@ -429,7 +413,7 @@ const requestReset = async () => {
|
|||||||
form.sourceMode === 'CURRENT' ? '현재 배포 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
|
form.sourceMode === 'CURRENT' ? '현재 배포 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
`${selectedProfile.value.profileName}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${form.scenarioId}`
|
`${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${form.scenarioId}`
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -437,7 +421,7 @@ const requestReset = async () => {
|
|||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
await adminClient.operations.requestReset.mutate({
|
await adminClient.operations.requestReset.mutate({
|
||||||
profileName: selectedProfile.value.profileName,
|
profileName: selectedProfileName.value,
|
||||||
sourceMode: form.sourceMode,
|
sourceMode: form.sourceMode,
|
||||||
sourceRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(),
|
sourceRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(),
|
||||||
scheduledAt: toIso(form.scheduledAt),
|
scheduledAt: toIso(form.scheduledAt),
|
||||||
@@ -500,13 +484,6 @@ const retryOperation = async (operation: Operation) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(selectedProfileName, () => {
|
|
||||||
const scenarioId = Number(selectedProfile.value?.scenario);
|
|
||||||
if (Number.isFinite(scenarioId)) {
|
|
||||||
form.scenarioId = scenarioId;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(selectedGatewayOperationId, (operationId) => {
|
watch(selectedGatewayOperationId, (operationId) => {
|
||||||
releaseLogLoopGeneration += 1;
|
releaseLogLoopGeneration += 1;
|
||||||
gatewayReleaseLogs.value = [];
|
gatewayReleaseLogs.value = [];
|
||||||
@@ -518,8 +495,11 @@ watch(selectedGatewayOperationId, (operationId) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
componentMounted = true;
|
componentMounted = true;
|
||||||
await loadState();
|
await Promise.all([
|
||||||
if (props.mode === 'scenario') await loadScenarios();
|
loadCapabilities(),
|
||||||
|
loadState(),
|
||||||
|
props.mode === 'scenario' ? loadScenarios() : Promise.resolve(),
|
||||||
|
]);
|
||||||
pollTimer = setInterval(() => void loadState(true), 3000);
|
pollTimer = setInterval(() => void loadState(true), 3000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -546,6 +526,14 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="space-y-6" data-testid="server-operations-page">
|
<div class="space-y-6" data-testid="server-operations-page">
|
||||||
|
<ServerProfileTabs
|
||||||
|
v-if="mode !== 'gateway' && profileName"
|
||||||
|
:profile-name="profileName"
|
||||||
|
:active-tab="mode === 'scenario' ? 'scenario' : 'version'"
|
||||||
|
:can-deploy="hasCapability('admin.profiles.deploy')"
|
||||||
|
:can-reset="hasCapability('admin.scenarios.reset')"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-if="errorMessage" class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-200">
|
<div v-if="errorMessage" class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-200">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
@@ -556,118 +544,7 @@ onBeforeUnmount(() => {
|
|||||||
{{ message }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav v-if="mode !== 'gateway' && profileName" class="flex flex-wrap gap-2" aria-label="서버 관리 탭">
|
<section v-if="mode !== 'gateway'">
|
||||||
<RouterLink
|
|
||||||
:to="`/admin/servers/${encodeURIComponent(profileName)}`"
|
|
||||||
class="rounded border border-zinc-700 px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-900"
|
|
||||||
>
|
|
||||||
상태 · 설정
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
v-if="hasCapability('admin.profiles.deploy')"
|
|
||||||
:to="`/admin/servers/${encodeURIComponent(profileName)}/version`"
|
|
||||||
class="rounded border border-blue-700 px-3 py-2 text-xs text-blue-200 hover:bg-blue-950"
|
|
||||||
>
|
|
||||||
버전 업데이트
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
v-if="hasCapability('admin.scenarios.reset')"
|
|
||||||
:to="`/admin/servers/${encodeURIComponent(profileName)}/scenario`"
|
|
||||||
class="rounded border border-purple-700 px-3 py-2 text-xs text-purple-200 hover:bg-purple-950"
|
|
||||||
>
|
|
||||||
시나리오 초기화
|
|
||||||
</RouterLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section v-if="mode !== 'gateway'" class="grid gap-4 lg:grid-cols-[1.1fr_1.9fr]">
|
|
||||||
<div class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-xs text-zinc-400" for="profile-select">운영 프로필</label>
|
|
||||||
<select
|
|
||||||
id="profile-select"
|
|
||||||
v-model="selectedProfileName"
|
|
||||||
class="mt-2 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
|
||||||
data-testid="profile-select"
|
|
||||||
:disabled="Boolean(profileName)"
|
|
||||||
>
|
|
||||||
<option v-for="profile in profiles" :key="profile.profileName" :value="profile.profileName">
|
|
||||||
{{ profile.profileName }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="selectedProfile"
|
|
||||||
class="grid grid-cols-2 gap-3 text-sm"
|
|
||||||
data-testid="selected-profile-status"
|
|
||||||
>
|
|
||||||
<div class="rounded bg-zinc-950 p-3">
|
|
||||||
<div class="text-xs text-zinc-500">목표 상태</div>
|
|
||||||
<div class="mt-1 font-semibold">{{ selectedProfile.status }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded bg-zinc-950 p-3">
|
|
||||||
<div class="text-xs text-zinc-500">빌드</div>
|
|
||||||
<div class="mt-1 font-semibold">{{ selectedProfile.buildStatus }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded bg-zinc-950 p-3">
|
|
||||||
<div class="text-xs text-zinc-500">Game frontend</div>
|
|
||||||
<div
|
|
||||||
:class="selectedProfile.runtime.frontendRunning ? 'text-emerald-400' : 'text-zinc-500'"
|
|
||||||
>
|
|
||||||
{{ selectedProfile.runtime.frontendRunning ? 'RUNNING' : 'STOPPED' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded bg-zinc-950 p-3">
|
|
||||||
<div class="text-xs text-zinc-500">Game API</div>
|
|
||||||
<div :class="selectedProfile.runtime.apiRunning ? 'text-emerald-400' : 'text-zinc-500'">
|
|
||||||
{{ selectedProfile.runtime.apiRunning ? 'RUNNING' : 'STOPPED' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded bg-zinc-950 p-3">
|
|
||||||
<div class="text-xs text-zinc-500">Turn daemon</div>
|
|
||||||
<div :class="selectedProfile.runtime.daemonRunning ? 'text-emerald-400' : 'text-zinc-500'">
|
|
||||||
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded bg-zinc-950 p-3">
|
|
||||||
<div class="text-xs text-zinc-500">Auction worker</div>
|
|
||||||
<div :class="selectedProfile.runtime.auctionRunning ? 'text-emerald-400' : 'text-zinc-500'">
|
|
||||||
{{ selectedProfile.runtime.auctionRunning ? 'RUNNING' : 'STOPPED' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded bg-zinc-950 p-3">
|
|
||||||
<div class="text-xs text-zinc-500">Battle sim worker</div>
|
|
||||||
<div
|
|
||||||
:class="selectedProfile.runtime.battleSimRunning ? 'text-emerald-400' : 'text-zinc-500'"
|
|
||||||
>
|
|
||||||
{{ selectedProfile.runtime.battleSimRunning ? 'RUNNING' : 'STOPPED' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded bg-zinc-950 p-3">
|
|
||||||
<div class="text-xs text-zinc-500">Tournament worker</div>
|
|
||||||
<div
|
|
||||||
:class="
|
|
||||||
selectedProfile.runtime.tournamentRunning ? 'text-emerald-400' : 'text-zinc-500'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ selectedProfile.runtime.tournamentRunning ? 'RUNNING' : 'STOPPED' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="selectedProfile" class="space-y-1 text-xs text-zinc-500">
|
|
||||||
<div>
|
|
||||||
현재 커밋:
|
|
||||||
<span class="font-mono text-zinc-300">{{ shortSha(selectedProfile.buildCommitSha) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="break-all">worktree: {{ selectedProfile.buildWorkspace ?? '기본 workspace' }}</div>
|
|
||||||
<div v-if="selectedProfile.buildError" class="text-red-400">
|
|
||||||
{{ selectedProfile.buildError }}
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedProfile.lastError" class="text-red-400">{{ selectedProfile.lastError }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
<form
|
||||||
class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-5"
|
class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-5"
|
||||||
@submit.prevent="mode === 'scenario' ? requestReset() : requestDeploy()"
|
@submit.prevent="mode === 'scenario' ? requestReset() : requestDeploy()"
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
|||||||
- 사용자 페이지는 사용자 한 명에 대한 계정 변경과 사용자별 감사 이력만
|
- 사용자 페이지는 사용자 한 명에 대한 계정 변경과 사용자별 감사 이력만
|
||||||
표시합니다. 전체 감사 원장은 감사 로그에서 별도로 조회합니다.
|
표시합니다. 전체 감사 원장은 감사 로그에서 별도로 조회합니다.
|
||||||
- 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와
|
- 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와
|
||||||
시나리오 초기화가 같은 서버 아래에서 서로 다른 탭과 권한으로 노출됩니다.
|
시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과
|
||||||
|
`aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다.
|
||||||
|
- 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가
|
||||||
|
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
|
||||||
|
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
|
||||||
|
runtime·빌드 상태는 상태 설정 탭에서 확인합니다.
|
||||||
- `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET`은
|
- `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET`은
|
||||||
현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다.
|
현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다.
|
||||||
- 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git
|
- 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git
|
||||||
|
|||||||
Reference in New Issue
Block a user