merge: add per-profile reset defaults
This commit is contained in:
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
@@ -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이어서 제거했습니다. 초기화와 예약은 시나리오 초기화 탭의
|
||||
|
||||
@@ -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 부재로 실패하지 않습니다. 현 시즌의 장수,
|
||||
|
||||
Reference in New Issue
Block a user