merge: add per-profile reset defaults

This commit is contained in:
2026-08-09 14:43:27 +00:00
8 changed files with 618 additions and 28 deletions
+41
View File
@@ -464,6 +464,24 @@ const zInstallOptions = z.object({
gitRef: z.string().min(1).max(128).optional(),
});
const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
const zProfileResetDefaults = zInstallOptions.omit({
scenarioId: true,
openAt: true,
preopenAt: true,
gitRef: true,
});
const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer<typeof zProfileResetDefaults> = {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
};
const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']);
@@ -533,6 +551,16 @@ const readMetaObject = (value: unknown): Record<string, unknown> => {
return value as Record<string, unknown>;
};
const readProfileResetDefaults = (
meta: Record<string, unknown>
): { defaults: z.infer<typeof zProfileResetDefaults>; source: 'SYSTEM' | 'PROFILE' } => {
const parsed = zProfileResetDefaults.partial().safeParse(meta.resetDefaults);
if (meta.resetDefaults === undefined || !parsed.success) {
return { defaults: { ...SYSTEM_PROFILE_RESET_DEFAULTS }, source: 'SYSTEM' };
}
return { defaults: { ...SYSTEM_PROFILE_RESET_DEFAULTS, ...parsed.data }, source: 'PROFILE' };
};
const applyMetaPatch = (
meta: Record<string, unknown>,
patch: Record<string, unknown | null | undefined>
@@ -1474,6 +1502,18 @@ export const adminRouter = router({
}),
}),
profiles: router({
getResetDefaults: adminProcedure
.input(z.object({ profileName: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET], input.profileName);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
}
const meta = readMetaObject(profile.meta);
return readProfileResetDefaults(meta);
}),
listNavigation: adminProcedure.query(async ({ ctx }) => {
const adminAuth = requireAdminAuth(ctx);
return orderGatewayProfiles(await ctx.profiles.listProfiles())
@@ -1660,6 +1700,7 @@ export const adminRouter = router({
nextSeasonIdx: z.number().int().min(0).nullable().optional(),
localAccountAccessGraceDays: z.number().int().min(0).max(365).nullable().optional(),
localAccountGeneralCreationGraceDays: z.number().int().min(0).max(365).nullable().optional(),
resetDefaults: zProfileResetDefaults.nullable().optional(),
}),
reason: z.string().trim().min(3).max(200),
})
+98 -1
View File
@@ -29,6 +29,7 @@ const buildCaller = async (
initialNotice?: string;
initialProfileStatus?: GatewayProfileRecord['status'];
profileScenario?: string;
profileMeta?: GatewayProfileRecord['meta'];
releaseLogVisibilityAfterPolls?: number;
} = {}
) => {
@@ -75,7 +76,7 @@ const buildCaller = async (
status: options.initialProfileStatus ?? ('STOPPED' as const),
buildStatus: 'SUCCEEDED' as const,
buildCommitSha: 'HEAD',
meta: {},
meta: options.profileMeta ?? {},
createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z',
};
@@ -511,6 +512,102 @@ describe('admin operation API', () => {
});
});
it('returns validated profile reset defaults to a scenario-only operator', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{
adminRoles: ['admin.scenarios.reset:che:2'],
firstUserIsAdmin: false,
profileMeta: {
resetDefaults: {
turnTermMinutes: 20,
sync: false,
fiction: 0,
extend: false,
blockGeneralCreate: 2,
npcMode: 1,
showImgLevel: 1,
tournamentTrig: false,
joinMode: 'onlyRandom',
autorunUser: { limitMinutes: 720, options: ['develop', 'train'] },
},
},
}
);
await expect(harness.caller.admin.profiles.getResetDefaults({ profileName: 'che:2' })).resolves.toEqual({
source: 'PROFILE',
defaults: {
turnTermMinutes: 20,
sync: false,
fiction: 0,
extend: false,
blockGeneralCreate: 2,
npcMode: 1,
showImgLevel: 1,
tournamentTrig: false,
joinMode: 'onlyRandom',
autorunUser: { limitMinutes: 720, options: ['develop', 'train'] },
},
});
});
it('falls back to system reset defaults when profile metadata is malformed', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{
adminRoles: ['admin.scenarios.reset:che:2'],
firstUserIsAdmin: false,
profileMeta: { resetDefaults: { npcMode: 99 } },
}
);
await expect(harness.caller.admin.profiles.getResetDefaults({ profileName: 'che:2' })).resolves.toMatchObject({
source: 'SYSTEM',
defaults: { turnTermMinutes: 60, npcMode: 0, tournamentTrig: true, autorunUser: null },
});
});
it('stores reset defaults only after validating the complete metadata object', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false }
);
const resetDefaults = {
turnTermMinutes: 10,
sync: true,
fiction: 1 as const,
extend: true,
blockGeneralCreate: 0 as const,
npcMode: 2 as const,
showImgLevel: 3 as const,
tournamentTrig: true,
joinMode: 'full' as const,
autorunUser: null,
};
await harness.caller.admin.profiles.updateMeta({
profileName: 'che:2',
patch: { resetDefaults },
reason: 'set server reset defaults',
});
expect(harness.updatedMetas.at(-1)).toMatchObject({ resetDefaults });
await expect(
harness.caller.admin.profiles.updateMeta({
profileName: 'che:2',
patch: { resetDefaults: { ...resetDefaults, turnTermMinutes: 7 } },
reason: 'reject invalid reset defaults',
})
).rejects.toBeDefined();
});
it('does not let a scenario-only operator combine a Git update with reset', async () => {
const harness = await buildCaller(
async () => {
@@ -40,9 +40,10 @@ type FixtureState = {
profileNavigationRequests?: number;
profileNavigationResolved?: boolean;
scenarioFailuresRemaining?: number;
resetDefaults?: Record<string, unknown>;
};
const profile = (runtimeRunning: boolean) => ({
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
profileName: 'che:2',
profile: 'che',
scenario: '2',
@@ -51,9 +52,11 @@ const profile = (runtimeRunning: boolean) => ({
buildStatus: 'SUCCEEDED',
buildCommitSha: '0123456789abcdef0123456789abcdef01234567',
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef0123456789abcdef01234567',
meta: {},
meta: resetDefaults ? { resetDefaults } : {},
createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z',
activeOperation: null,
runtimeActions: [],
runtime: {
profileName: 'che:2',
frontendRunning: runtimeRunning,
@@ -132,7 +135,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
state.requestBodies.push({ operation: name, body });
}
if (name === 'admin.profiles.list') {
return response([profile(state.runtimeRunning)]);
return response([profile(state.runtimeRunning, state.resetDefaults)]);
}
if (name === 'admin.profiles.listNavigation') {
return response([
@@ -206,6 +209,26 @@ const installFixture = async (page: Page, state: FixtureState) => {
expect(names).toEqual(['admin.profiles.listScenarios']);
return response(scenarios);
}
if (name === 'admin.profiles.getResetDefaults') {
return response({
source: state.resetDefaults ? 'PROFILE' : 'SYSTEM',
defaults: state.resetDefaults ?? {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
},
});
}
if (name === 'admin.profiles.updateMeta') {
return response(profile(state.runtimeRunning, state.resetDefaults));
}
if (name === 'admin.operations.requestReset') {
const operation: Operation = {
id: '11111111-1111-4111-8111-111111111111',
@@ -436,6 +459,68 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
});
test('loads server metadata defaults into the reset form and submits them', async ({ page }) => {
const state: FixtureState = {
operations: [],
gatewayOperations: [],
runtimeRunning: true,
requestBodies: [],
resetDefaults: {
turnTermMinutes: 20,
sync: false,
fiction: 0,
extend: false,
blockGeneralCreate: 2,
npcMode: 1,
showImgLevel: 1,
tournamentTrig: false,
joinMode: 'onlyRandom',
autorunUser: { limitMinutes: 720, options: ['develop', 'train'] },
},
};
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/servers/che%3A2/scenario');
await expect(page.getByTestId('reset-turn-term')).toHaveValue('20');
await page.getByText('고급 시나리오 옵션').click();
await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타');
await expect(page.getByTestId('reset-npc-mode')).toHaveValue('1');
await page.getByTestId('request-reset').click();
await expect
.poll(() => state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset'))
.toBeTruthy();
const request = JSON.stringify(
state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset')?.body
);
expect(request).toContain('"turnTermMinutes":20');
expect(request).toContain('"npcMode":1');
expect(request).toContain('"joinMode":"onlyRandom"');
expect(request).toContain('"limitMinutes":720');
expect(request).toContain('"options":["develop","train"]');
});
test('edits server reset defaults through profile metadata settings', async ({ page }) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state);
await page.goto('admin/servers/che%3A2');
await page.getByText('서버 리셋 기본 옵션').click();
await page.getByTestId('meta-reset-turn-term').selectOption('10');
await page.getByTestId('meta-reset-npc-mode').selectOption('2');
await page.getByPlaceholder('변경 사유 (필수)').fill('set reset defaults');
await page.getByRole('button', { name: '메타 저장' }).click();
await expect(page.getByText('메타 저장 완료')).toBeVisible();
const request = JSON.stringify(
state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body
);
expect(request).toContain('"resetDefaults"');
expect(request).toContain('"turnTermMinutes":10');
expect(request).toContain('"npcMode":2');
});
test('renders the fixed-profile version form without waiting for the server list', async ({ page }) => {
const state: FixtureState = {
operations: [],
@@ -0,0 +1,74 @@
export const RESET_AUTORUN_OPTIONS = ['develop', 'warp', 'recruit', 'train', 'battle'] as const;
export type ResetAutorunOption = (typeof RESET_AUTORUN_OPTIONS)[number];
export type ProfileResetDefaults = {
turnTermMinutes: number;
sync: boolean;
fiction: 0 | 1;
extend: boolean;
blockGeneralCreate: 0 | 1 | 2;
npcMode: 0 | 1 | 2;
showImgLevel: 0 | 1 | 2 | 3;
tournamentTrig: boolean;
joinMode: 'full' | 'onlyRandom';
autorunUser: {
limitMinutes: number;
options: ResetAutorunOption[];
} | null;
};
export const SYSTEM_PROFILE_RESET_DEFAULTS: ProfileResetDefaults = {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
const enumNumber = <T extends number>(value: unknown, allowed: readonly T[], fallback: T): T =>
typeof value === 'number' && allowed.includes(value as T) ? (value as T) : fallback;
export const normalizeProfileResetDefaults = (value: unknown): ProfileResetDefaults => {
const raw = isRecord(value) ? value : {};
const rawAutorun = isRecord(raw.autorunUser) ? raw.autorunUser : null;
const autorunOptions = Array.isArray(rawAutorun?.options)
? rawAutorun.options.filter((option): option is ResetAutorunOption =>
RESET_AUTORUN_OPTIONS.includes(option as ResetAutorunOption)
)
: [];
const autorunLimit = rawAutorun?.limitMinutes;
return {
turnTermMinutes: enumNumber(raw.turnTermMinutes, [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 24, 30, 40, 60, 120], 60),
sync: typeof raw.sync === 'boolean' ? raw.sync : SYSTEM_PROFILE_RESET_DEFAULTS.sync,
fiction: enumNumber(raw.fiction, [0, 1], SYSTEM_PROFILE_RESET_DEFAULTS.fiction),
extend: typeof raw.extend === 'boolean' ? raw.extend : SYSTEM_PROFILE_RESET_DEFAULTS.extend,
blockGeneralCreate: enumNumber(
raw.blockGeneralCreate,
[0, 1, 2],
SYSTEM_PROFILE_RESET_DEFAULTS.blockGeneralCreate
),
npcMode: enumNumber(raw.npcMode, [0, 1, 2], SYSTEM_PROFILE_RESET_DEFAULTS.npcMode),
showImgLevel: enumNumber(raw.showImgLevel, [0, 1, 2, 3], SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel),
tournamentTrig:
typeof raw.tournamentTrig === 'boolean' ? raw.tournamentTrig : SYSTEM_PROFILE_RESET_DEFAULTS.tournamentTrig,
joinMode: raw.joinMode === 'onlyRandom' ? 'onlyRandom' : 'full',
autorunUser:
typeof autorunLimit === 'number' &&
Number.isInteger(autorunLimit) &&
autorunLimit > 0 &&
autorunLimit <= 43200 &&
autorunOptions.length > 0
? { limitMinutes: autorunLimit, options: autorunOptions }
: null,
};
};
@@ -2,6 +2,11 @@
import { computed, onMounted, ref } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import {
normalizeProfileResetDefaults,
type ProfileResetDefaults,
type ResetAutorunOption,
} from '../utils/resetDefaults';
import { trpc } from '../utils/trpc';
type AdminSection = 'users' | 'servers' | 'system' | 'audit';
@@ -325,6 +330,7 @@ type AdminClient = {
nextSeasonIdx?: number | null;
localAccountAccessGraceDays?: number | null;
localAccountGeneralCreationGraceDays?: number | null;
resetDefaults?: ProfileResetDefaults | null;
};
reason: string;
}) => Promise<AdminProfile | null>;
@@ -379,6 +385,8 @@ const profileEdits = ref<
nextSeasonIdx: string;
localAccountAccessGraceDays: string;
localAccountGeneralCreationGraceDays: string;
resetDefaults: ProfileResetDefaults;
resetAutorunEnabled: boolean;
reason: string;
}
>
@@ -393,6 +401,13 @@ const profileActions = ref<
}
>
>({});
const resetAutorunLabels: Array<{ value: ResetAutorunOption; label: string }> = [
{ value: 'develop', label: '내정' },
{ value: 'warp', label: '이동' },
{ value: 'recruit', label: '징병' },
{ value: 'train', label: '훈련' },
{ value: 'battle', label: '전투' },
];
const profileActionStatus = ref<Record<string, string>>({});
const profileActionSubmitting = ref<Record<string, boolean>>({});
const visibleProfiles = computed(() =>
@@ -543,6 +558,7 @@ const saveNotice = async () => {
const ensureProfileBuffers = (profile: AdminProfile) => {
if (!profileEdits.value[profile.profileName]) {
const meta = (profile.meta ?? {}) as Record<string, unknown>;
const resetDefaults = normalizeProfileResetDefaults(meta.resetDefaults);
profileEdits.value[profile.profileName] = {
korName: String(meta.korName ?? profile.profile),
color: String(meta.color ?? '#ffffff'),
@@ -560,6 +576,8 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
typeof meta.localAccountGeneralCreationGraceDays === 'number'
? String(Math.floor(meta.localAccountGeneralCreationGraceDays))
: '',
resetDefaults,
resetAutorunEnabled: resetDefaults.autorunUser !== null,
reason: '',
};
}
@@ -663,6 +681,19 @@ const updateProfileMeta = async (profileName: string) => {
profileActionStatus.value = { ...profileActionStatus.value, [profileName]: '변경 사유를 입력하세요.' };
return;
}
if (
edit.resetAutorunEnabled &&
(!edit.resetDefaults.autorunUser ||
edit.resetDefaults.autorunUser.limitMinutes <= 0 ||
edit.resetDefaults.autorunUser.limitMinutes > 43200 ||
edit.resetDefaults.autorunUser.options.length === 0)
) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: '유저 자동턴은 제한 시간과 한 개 이상의 동작을 선택해야 합니다.',
};
return;
}
const patch = {
korName: edit.korName.trim() || null,
color: edit.color.trim() || null,
@@ -671,6 +702,10 @@ const updateProfileMeta = async (profileName: string) => {
nextSeasonIdx: nextSeasonIdx === null ? null : Math.floor(nextSeasonIdx),
localAccountAccessGraceDays: accessGraceDays,
localAccountGeneralCreationGraceDays: creationGraceDays,
resetDefaults: {
...edit.resetDefaults,
autorunUser: edit.resetAutorunEnabled ? edit.resetDefaults.autorunUser : null,
},
};
try {
const updated = await adminClient.profiles.updateMeta.mutate({
@@ -695,6 +730,15 @@ const updateProfileMeta = async (profileName: string) => {
}
};
const ensureResetAutorun = (profileName: string) => {
const edit = profileEdits.value[profileName];
if (!edit?.resetAutorunEnabled || edit.resetDefaults.autorunUser) return;
edit.resetDefaults.autorunUser = {
limitMinutes: 1440,
options: resetAutorunLabels.map(({ value }) => value),
};
};
const requestProfileAction = async (profileName: string, action: AdminAction) => {
if (profileActionSubmitting.value[profileName]) {
return;
@@ -2035,6 +2079,168 @@ onMounted(() => {
placeholder="예: 12"
/>
<div class="text-xs text-zinc-500">리셋 시 적용할 시즌 번호를 지정합니다.</div>
<details class="rounded border border-zinc-700 bg-zinc-950/60 p-3">
<summary class="cursor-pointer text-sm font-semibold text-zinc-200">
서버 리셋 기본 옵션
</summary>
<p class="mt-2 text-xs text-zinc-500">
이 서버의 시나리오 초기화 화면을 열 때 자동으로 채울 값을 저장합니다.
시나리오와 예약·오픈 시각은 실행할 때 선택합니다.
</p>
<div class="mt-3 grid gap-3 text-xs sm:grid-cols-2">
<label>
턴 간격
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults.turnTermMinutes
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
data-testid="meta-reset-turn-term"
>
<option
v-for="minutes in [
1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 24, 30, 40, 60, 120,
]"
:key="minutes"
:value="minutes"
>
{{ minutes }}분
</option>
</select>
</label>
<label>
가입 방식
<select
v-model="profileEdits[profile.profileName].resetDefaults.joinMode"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
>
<option value="full">전체</option>
<option value="onlyRandom">랜덤만</option>
</select>
</label>
<label>
가상 장수
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults.fiction
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
>
<option :value="1">허용</option>
<option :value="0">금지</option>
</select>
</label>
<label>
장수 생성 제한
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults
.blockGeneralCreate
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
>
<option :value="0">없음</option>
<option :value="1">제한</option>
<option :value="2">차단</option>
</select>
</label>
<label>
NPC 모드
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults.npcMode
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
data-testid="meta-reset-npc-mode"
>
<option :value="0">기본</option>
<option :value="1">확장</option>
<option :value="2">전체</option>
</select>
</label>
<label>
이미지 표시
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults.showImgLevel
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
>
<option v-for="level in [0, 1, 2, 3]" :key="level" :value="level">
{{ level }}
</option>
</select>
</label>
<label class="flex items-center gap-2">
<input
v-model="profileEdits[profile.profileName].resetDefaults.sync"
type="checkbox"
/>
동기화 사용
</label>
<label class="flex items-center gap-2">
<input
v-model="profileEdits[profile.profileName].resetDefaults.extend"
type="checkbox"
/>
연장 사용
</label>
<label class="flex items-center gap-2">
<input
v-model="
profileEdits[profile.profileName].resetDefaults.tournamentTrig
"
type="checkbox"
/>
토너먼트 사용
</label>
<label class="flex items-center gap-2">
<input
v-model="profileEdits[profile.profileName].resetAutorunEnabled"
type="checkbox"
@change="ensureResetAutorun(profile.profileName)"
/>
유저 자동턴
</label>
<template
v-if="
profileEdits[profile.profileName].resetAutorunEnabled &&
profileEdits[profile.profileName].resetDefaults.autorunUser
"
>
<label>
자동턴 제한 분
<input
v-model.number="
profileEdits[profile.profileName].resetDefaults.autorunUser!
.limitMinutes
"
type="number"
min="1"
max="43200"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
/>
</label>
<div class="flex flex-wrap items-center gap-3 sm:col-span-2">
<label
v-for="option in resetAutorunLabels"
:key="option.value"
class="flex items-center gap-1"
>
<input
v-model="
profileEdits[profile.profileName].resetDefaults
.autorunUser!.options
"
type="checkbox"
:value="option.value"
/>
{{ option.label }}
</label>
</div>
</template>
</div>
</details>
<label class="text-xs text-zinc-400">Kakao 미인증 접근 유예일</label>
<input
v-model="profileEdits[profile.profileName].localAccountAccessGraceDays"
@@ -3,6 +3,11 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch }
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import {
normalizeProfileResetDefaults,
SYSTEM_PROFILE_RESET_DEFAULTS,
type ProfileResetDefaults,
} from '../utils/resetDefaults';
import { directTrpc, trpc } from '../utils/trpc';
type OperationPageMode = 'version' | 'scenario' | 'gateway';
@@ -90,6 +95,7 @@ const catalogAttempted = ref(false);
const submitting = ref(false);
const message = ref('');
const errorMessage = ref('');
const resetDefaultsSource = ref<'SYSTEM' | 'PROFILE'>('SYSTEM');
let pollTimer: ReturnType<typeof setInterval> | undefined;
let stateRequestInFlight = false;
let releaseLogLoopGeneration = 0;
@@ -99,15 +105,15 @@ const form = reactive({
sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT',
sourceRef: 'main',
scenarioId: null as number | null,
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full' as 'full' | 'onlyRandom',
turnTermMinutes: SYSTEM_PROFILE_RESET_DEFAULTS.turnTermMinutes,
sync: SYSTEM_PROFILE_RESET_DEFAULTS.sync,
fiction: SYSTEM_PROFILE_RESET_DEFAULTS.fiction,
extend: SYSTEM_PROFILE_RESET_DEFAULTS.extend,
blockGeneralCreate: SYSTEM_PROFILE_RESET_DEFAULTS.blockGeneralCreate,
npcMode: SYSTEM_PROFILE_RESET_DEFAULTS.npcMode,
showImgLevel: SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel,
tournamentTrig: SYSTEM_PROFILE_RESET_DEFAULTS.tournamentTrig,
joinMode: SYSTEM_PROFILE_RESET_DEFAULTS.joinMode,
autorunEnabled: false,
autorunUserMinutes: 1440,
autorunDevelop: true,
@@ -177,7 +183,12 @@ const toIso = (value: string): string | undefined => {
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
const formatLogTime = (value: string): string =>
new Date(value).toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
new Date(value).toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
const clearStatus = () => {
@@ -185,6 +196,40 @@ const clearStatus = () => {
errorMessage.value = '';
};
const applyResetDefaults = (defaults: ProfileResetDefaults) => {
form.turnTermMinutes = defaults.turnTermMinutes;
form.sync = defaults.sync;
form.fiction = defaults.fiction;
form.extend = defaults.extend;
form.blockGeneralCreate = defaults.blockGeneralCreate;
form.npcMode = defaults.npcMode;
form.showImgLevel = defaults.showImgLevel;
form.tournamentTrig = defaults.tournamentTrig;
form.joinMode = defaults.joinMode;
form.autorunEnabled = defaults.autorunUser !== null;
form.autorunUserMinutes = defaults.autorunUser?.limitMinutes ?? 1440;
const autorunOptions = new Set(defaults.autorunUser?.options ?? []);
form.autorunDevelop = autorunOptions.has('develop');
form.autorunWarp = autorunOptions.has('warp');
form.autorunRecruit = autorunOptions.has('recruit');
form.autorunTrain = autorunOptions.has('train');
form.autorunBattle = autorunOptions.has('battle');
};
const loadResetDefaults = async () => {
if (props.mode !== 'scenario' || !selectedProfileName.value) return;
try {
const result = await adminClient.profiles.getResetDefaults.query({
profileName: selectedProfileName.value,
});
applyResetDefaults(normalizeProfileResetDefaults(result.defaults));
resetDefaultsSource.value = result.source;
} catch {
applyResetDefaults(SYSTEM_PROFILE_RESET_DEFAULTS);
resetDefaultsSource.value = 'SYSTEM';
}
};
const loadCapabilities = async () => {
try {
capabilities.value = (await adminClient.capabilities.list.query()) as typeof capabilities.value;
@@ -244,7 +289,11 @@ const scrollReleaseLogToEnd = async () => {
};
const pollGatewayReleaseLogs = async (operationId: string, generation: number) => {
while (componentMounted && generation === releaseLogLoopGeneration && selectedGatewayOperationId.value === operationId) {
while (
componentMounted &&
generation === releaseLogLoopGeneration &&
selectedGatewayOperationId.value === operationId
) {
try {
const result = await adminClient.releases.logs.query({
id: operationId,
@@ -516,6 +565,7 @@ onMounted(async () => {
await Promise.all([
loadCapabilities(),
loadState(),
loadResetDefaults(),
props.mode === 'scenario' ? loadScenarios() : Promise.resolve(),
]);
pollTimer = setInterval(() => void loadState(true), 3000);
@@ -671,9 +721,10 @@ onBeforeUnmount(() => {
<select
v-model.number="form.turnTermMinutes"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
data-testid="reset-turn-term"
>
<option
v-for="minutes in [1, 2, 5, 10, 20, 30, 60, 120]"
v-for="minutes in [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 24, 30, 40, 60, 120]"
:key="minutes"
:value="minutes"
>
@@ -685,6 +736,13 @@ onBeforeUnmount(() => {
<details v-if="mode === 'scenario'" class="rounded border border-zinc-800 bg-zinc-950/50 p-4">
<summary class="cursor-pointer text-sm font-semibold">고급 시나리오 옵션</summary>
<p class="mt-2 text-xs text-zinc-500" data-testid="reset-defaults-source">
{{
resetDefaultsSource === 'PROFILE'
? '이 서버의 메타에 저장된 기본값을 적용했습니다.'
: '서버별 기본값이 없어 시스템 기본값을 적용했습니다.'
}}
</p>
<div class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
<label
>동기화
@@ -727,7 +785,11 @@ onBeforeUnmount(() => {
</label>
<label
>NPC 모드
<select v-model.number="form.npcMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<select
v-model.number="form.npcMode"
class="ml-2 rounded bg-zinc-900 px-2 py-1"
data-testid="reset-npc-mode"
>
<option :value="0">기본</option>
<option :value="1">확장</option>
<option :value="2">전체</option>
@@ -936,7 +998,13 @@ onBeforeUnmount(() => {
<div
v-for="entry in gatewayReleaseLogs"
:key="entry.cursor"
:class="entry.level === 'ERROR' ? 'text-red-300' : entry.level === 'OUTPUT' ? 'text-zinc-300' : 'text-cyan-300'"
:class="
entry.level === 'ERROR'
? 'text-red-300'
: entry.level === 'OUTPUT'
? 'text-zinc-300'
: 'text-cyan-300'
"
>
<span class="text-zinc-600">{{ formatLogTime(entry.createdAt) }}</span>
<span class="ml-2 text-violet-300">[{{ entry.phase }}]</span>
@@ -973,7 +1041,11 @@ onBeforeUnmount(() => {
<button
type="button"
class="rounded border border-zinc-700 px-2 py-1 text-zinc-300 hover:bg-zinc-800"
:class="operation.id === selectedGatewayOperationId ? 'border-violet-500 text-violet-200' : ''"
:class="
operation.id === selectedGatewayOperationId
? 'border-violet-500 text-violet-200'
: ''
"
@click="selectGatewayReleaseOperation(operation.id)"
>
보기
+16 -9
View File
@@ -48,6 +48,13 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
분리된 요청으로 읽습니다. API가 profile의 현재 scenario를 표시하며 화면은
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
- 서버 상태의 `서버 리셋 기본 옵션``GatewayProfile.meta.resetDefaults`
턴 간격, 동기화, 가상 장수, 연장, 가입 방식, 장수 생성 제한, NPC, 이미지,
토너먼트와 유저 자동턴 기본값을 저장합니다. 시나리오 초기화 화면은 대상
서버를 URL에서 결정한 뒤 이 메타를 별도 권한 검사로 읽어 폼에 적용합니다.
메타가 없거나 유효하지 않으면 기존 시스템 기본값을 사용합니다. 시나리오와
예약·가오픈·정식 오픈 시각은 매 실행마다 선택하므로 서버 기본값에 포함하지
않습니다.
- Gateway 릴리스는 profile 작업과 다른 전역 `admin.releases.manage` 권한을
사용하며 외부 release-controller가 실행합니다. 선택한 릴리스 작업의 단계와
명령 출력을 관리자 화면이 long polling으로 이어 받아 표시하며, 완료된 이력의
@@ -57,15 +64,15 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
## Profile 권한 분류
| capability | 허용 작업 |
| -------------------------------- | ---------------------------------------------------------- |
| `admin.profiles.runtime:<name>` | 시작·정지·일시정지·재개와 시간 조정 |
| `admin.profiles.settings:<name>` | 표시색·표시명·인게임 공지·Kakao 미인증 접근/장수 생성 유예 |
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
| `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 |
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
| `admin.profiles.manage:<name>` | 기존 역할 호환용 포괄 권한 |
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
| capability | 허용 작업 |
| -------------------------------- | --------------------------------------------------------- |
| `admin.profiles.runtime:<name>` | 시작·정지·일시정지·재개와 시간 조정 |
| `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 |
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
| `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 |
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
| `admin.profiles.manage:<name>` | 기존 역할 호환용 포괄 권한 |
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
기존 상태 화면의 `즉시 리셋`·`리셋 예약` 버튼은 실제 DB 초기화 operation과
다른 metadata action이어서 제거했습니다. 초기화와 예약은 시나리오 초기화 탭의
+8
View File
@@ -76,6 +76,14 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필
`admin.profiles.deploy`를 모두 요구합니다. Source와 scenario를 확인한 뒤 turn 간격, 가오픈·정식 오픈,
NPC와 자동 진행 설정을 확인하고 요청해 주세요.
턴 간격과 고급 옵션은 서버 상태 화면에서 저장한
`GatewayProfile.meta.resetDefaults`를 최초값으로 사용합니다. 서버별 기본값을
바꾸려면 `admin.profiles.settings:<name>` 권한으로 메타를 저장하고 시나리오
초기화 화면을 다시 여세요. 값이 없거나 유효하지 않으면 60분, 동기화·가상
장수·연장·토너먼트 사용, 기본 NPC, 이미지 단계 3, 전체 가입과 유저 자동턴
미사용이라는 기존 시스템 기본값으로 돌아갑니다. 실행마다 달라지는 scenario와
예약·가오픈·정식 오픈 시각은 자동 입력하지 않습니다.
이 모드는 build와 migration 후 기존 season/tick metadata를 읽고 scenario seeder를 실행합니다.
빈 profile schema도 migration을 먼저 적용하므로 최초 `world_state` 조회가
table 부재로 실패하지 않습니다. 현 시즌의 장수,