feat(gateway): split server lifecycle administration

This commit is contained in:
2026-08-08 17:10:43 +00:00
parent 09ea198420
commit 4f7dbfd19e
16 changed files with 916 additions and 301 deletions
+39 -2
View File
@@ -40,8 +40,36 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [
},
{
permission: 'admin.profiles.manage',
label: 'Profile 운영',
description: '지정 profile의 배포, 초기화와 runtime을 관리합니다.',
label: 'Profile 전체 운영 (호환)',
description: '기존 운영자를 위한 포괄 권한입니다. 새 역할에는 세분화 권한을 사용합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
},
{
permission: 'admin.profiles.runtime',
label: 'Profile 실행 관리',
description: '지정 profile의 시작, 정지와 실행 상태를 관리합니다.',
risk: 'HIGH',
scope: 'PROFILE',
},
{
permission: 'admin.profiles.settings',
label: 'Profile 설정 관리',
description: '지정 profile의 표시 정보와 계정 접근 정책을 변경합니다.',
risk: 'HIGH',
scope: 'PROFILE',
},
{
permission: 'admin.profiles.deploy',
label: 'Profile 버전 배포',
description: '지정 profile의 DB를 유지하면서 코드와 migration을 배포합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
},
{
permission: 'admin.scenarios.reset',
label: '시나리오 초기화',
description: '지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
},
@@ -98,6 +126,15 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
if (action === 'RESUME') return 'admin.resume.when-stopped';
if (action === 'OPEN_SURVEY') return 'admin.survey.open';
}
if (path.endsWith('.operations.requestDeploy')) return 'admin.profiles.deploy';
if (path.endsWith('.operations.requestReset')) return 'admin.scenarios.reset';
if (path.endsWith('.operations.requestRuntime')) return 'admin.profiles.runtime';
if (path.endsWith('.profiles.updateMeta')) return 'admin.profiles.settings';
if (path.endsWith('.profiles.listScenarios')) {
const sourceMode =
rawInput && typeof rawInput === 'object' ? (rawInput as { sourceMode?: unknown }).sourceMode : undefined;
return sourceMode === undefined || sourceMode === 'CURRENT' ? 'admin.scenarios.reset' : 'admin.profiles.deploy';
}
if (path.includes('.operations.') || path.includes('.profiles.')) return 'admin.profiles.manage';
return undefined;
};
+152 -51
View File
@@ -54,6 +54,10 @@ const ROLE_SUPERUSER = 'superuser';
const ROLE_ADMIN_USERS = 'admin.users.manage';
const ROLE_ADMIN_USERS_CREATE = 'admin.users.create';
const ROLE_ADMIN_PROFILES = 'admin.profiles.manage';
const ROLE_ADMIN_PROFILE_RUNTIME = 'admin.profiles.runtime';
const ROLE_ADMIN_PROFILE_SETTINGS = 'admin.profiles.settings';
const ROLE_ADMIN_PROFILE_DEPLOY = 'admin.profiles.deploy';
const ROLE_ADMIN_SCENARIO_RESET = 'admin.scenarios.reset';
const ROLE_ADMIN_RELEASES = 'admin.releases.manage';
const ROLE_ADMIN_NOTICE = 'admin.notice.manage';
const ROLE_ADMIN_AUDIT = 'admin.audit.read';
@@ -146,6 +150,21 @@ const hasScopedPermission = (adminAuth: AdminAuthContext, permission: string, pr
return adminAuth.roles.some((role: string) => roleMatchesScope(role, permission, profileName));
};
const hasAnyScopedPermission = (
adminAuth: AdminAuthContext,
permissions: readonly string[],
profileName?: string
): boolean => permissions.some((permission) => hasScopedPermission(adminAuth, permission, profileName));
const assertAnyPermission = (
adminAuth: AdminAuthContext,
permissions: readonly string[],
profileName?: string
): void => {
if (hasAnyScopedPermission(adminAuth, permissions, profileName)) return;
throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' });
};
const splitRoleScope = (role: string): { permission: string; scope?: string } => {
const separator = role.indexOf(':');
if (separator < 0) {
@@ -437,6 +456,7 @@ const zInstallOptions = z.object({
});
const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']);
type SanctionsPatch = z.infer<typeof zSanctionsPatch>;
@@ -533,7 +553,14 @@ export const adminRouter = router({
const parsed = splitRoleScope(role);
return parsed.permission === entry.permission;
})
);
).map((entry) => {
if (adminAuth.isSuperuser) return { ...entry, scopes: ['*'] };
const scopes = adminAuth.roles
.map(splitRoleScope)
.filter((role) => role.permission === entry.permission)
.map((role) => role.scope ?? '*');
return { ...entry, scopes: Array.from(new Set(scopes)) };
});
}),
}),
audit: router({
@@ -691,14 +718,20 @@ export const adminRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access must expire.' });
}
if (expiresAt.getTime() > now.getTime() + 90 * 24 * 60 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access may last at most 90 days.' });
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Recovery access may last at most 90 days.',
});
}
}
const profiles = [...new Set(input.profiles.map((profile) => profile.toLowerCase()))];
if (profiles.length > 0) {
const knownProfiles = await ctx.profiles.listProfiles();
const knownNames = new Set(
knownProfiles.flatMap((profile) => [profile.profile.toLowerCase(), profile.profileName.toLowerCase()])
knownProfiles.flatMap((profile) => [
profile.profile.toLowerCase(),
profile.profileName.toLowerCase(),
])
);
const unknown = profiles.find((profile) => !knownNames.has(profile));
if (unknown) {
@@ -984,19 +1017,19 @@ export const adminRouter = router({
.query(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
if (input?.profileName) {
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
if (!canReadProfile(adminAuth, input.profileName)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' });
}
return ctx.profiles.listOperations({
profileName: input.profileName,
limit: input.limit,
});
}
if (hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES)) {
if (adminAuth.isSuperuser || adminAuth.roles.some((role) => role.endsWith(':*'))) {
return ctx.profiles.listOperations({ limit: input?.limit });
}
const profiles = await ctx.profiles.listProfiles();
const allowed = profiles.filter((profile) =>
hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES, profile.profileName)
);
const allowed = profiles.filter((profile) => canReadProfile(adminAuth, profile.profileName));
const operations = (
await Promise.all(
allowed.map((profile) =>
@@ -1015,8 +1048,8 @@ export const adminRouter = router({
.input(
z.object({
profileName: z.string().min(1),
sourceMode: zSourceMode,
sourceRef: z.string().min(1).max(128),
sourceMode: zResetSourceMode,
sourceRef: z.string().min(1).max(128).optional(),
install: zOperationInstallOptions,
scheduledAt: z.string().datetime().optional(),
reason: z.string().max(200).optional(),
@@ -1024,7 +1057,13 @@ export const adminRouter = router({
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET], input.profileName);
if (input.sourceMode !== 'CURRENT') {
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName);
}
if (input.scheduledAt) {
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_RESET_SCHEDULE], input.profileName);
}
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
@@ -1074,13 +1113,24 @@ export const adminRouter = router({
});
}
let sourceRef = input.sourceRef.trim();
const sourceMode: 'BRANCH' | 'COMMIT' = input.sourceMode === 'CURRENT' ? 'COMMIT' : input.sourceMode;
let sourceRef =
input.sourceMode === 'CURRENT' ? profile.buildCommitSha?.trim() : input.sourceRef?.trim();
if (!sourceRef) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
input.sourceMode === 'CURRENT'
? 'The profile has no active build commit to reset from.'
: 'sourceRef is required.',
});
}
try {
const resolved =
input.sourceMode === 'BRANCH'
sourceMode === 'BRANCH'
? await resolveGitBranchCommitSha(sourceRef)
: await resolveGitCommitSha(sourceRef);
if (input.sourceMode === 'COMMIT') {
if (sourceMode === 'COMMIT') {
sourceRef = resolved;
}
const scenarios = await listScenarioPreviews({ gitRef: resolved });
@@ -1091,7 +1141,7 @@ export const adminRouter = router({
throw new TRPCError({
code: 'BAD_REQUEST',
message:
input.sourceMode === 'BRANCH'
sourceMode === 'BRANCH'
? 'Branch is invalid or does not contain the scenario.'
: 'Commit is invalid or does not contain the scenario.',
});
@@ -1101,9 +1151,12 @@ export const adminRouter = router({
const operation = await ctx.profiles.createOperation({
profileName: input.profileName,
type: 'RESET',
sourceMode: input.sourceMode,
sourceMode,
sourceRef,
payload: { install: input.install } as GatewayPrisma.JsonObject,
payload: {
install: input.install,
requestedSource: input.sourceMode,
} as GatewayPrisma.JsonObject,
reason: input.reason,
requestedBy: adminAuth.user.id,
scheduledAt: input.scheduledAt,
@@ -1130,7 +1183,7 @@ export const adminRouter = router({
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
@@ -1183,7 +1236,7 @@ export const adminRouter = router({
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME], input.profileName);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
@@ -1212,7 +1265,13 @@ export const adminRouter = router({
if (!previous) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
}
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
const permissions =
previous.type === 'RESET'
? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET]
: previous.type === 'DEPLOY'
? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]
: [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME];
assertAnyPermission(adminAuth, permissions, previous.profileName);
const cancelled = await ctx.profiles.cancelOperation(input.id);
if (!cancelled) {
throw new TRPCError({
@@ -1228,7 +1287,26 @@ export const adminRouter = router({
if (!previous) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
}
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
const permissions =
previous.type === 'RESET'
? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET]
: previous.type === 'DEPLOY'
? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]
: [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME];
assertAnyPermission(adminAuth, permissions, previous.profileName);
if (previous.type === 'RESET') {
const payload = readMetaObject(previous.payload);
if (payload.requestedSource !== 'CURRENT') {
assertAnyPermission(
adminAuth,
[ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY],
previous.profileName
);
}
if (previous.scheduledAt) {
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_RESET_SCHEDULE], previous.profileName);
}
}
try {
const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id);
if (!operation) {
@@ -1399,22 +1477,51 @@ export const adminRouter = router({
},
}));
}),
listScenarios: profileAdminProcedure
listScenarios: adminProcedure
.input(
z
.object({
profileName: z.string().min(1).max(64).optional(),
gitRef: z.string().min(1).max(128).optional(),
sourceMode: zSourceMode.optional(),
sourceMode: zResetSourceMode.optional(),
})
.optional()
)
.query(async ({ input }) => {
const gitRef = input?.gitRef?.trim();
.query(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
const sourceMode = input?.sourceMode ?? 'CURRENT';
let gitRef = input?.gitRef?.trim();
if (sourceMode === 'CURRENT') {
if (!input?.profileName) {
if (!adminAuth.isSuperuser) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'profileName is required.' });
}
} else {
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.' });
gitRef = profile.buildCommitSha?.trim();
if (!gitRef) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'The profile has no active build commit.',
});
}
}
} else if (input?.profileName) {
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName);
} else {
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]);
}
if (!gitRef) {
return listScenarioPreviews();
}
const resolved =
input?.sourceMode === 'BRANCH'
sourceMode === 'BRANCH'
? await resolveGitBranchCommitSha(gitRef)
: await resolveGitCommitSha(gitRef);
return listScenarioPreviews({ gitRef: resolved });
@@ -1476,7 +1583,7 @@ export const adminRouter = router({
await ctx.orchestrator.reconcileNow();
return result;
}),
updateMeta: profileAdminProcedure
updateMeta: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
@@ -1493,6 +1600,11 @@ export const adminRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
assertAnyPermission(
requireAdminAuth(ctx),
[ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_SETTINGS],
input.profileName
);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({
@@ -1730,22 +1842,22 @@ export const adminRouter = router({
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
if (input.action === 'RESET_NOW' || input.action === 'RESET_SCHEDULED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '시나리오 초기화는 operations.requestReset을 사용해 주세요.',
});
}
if ((input.action === 'ACCELERATE' || input.action === 'DELAY') && !input.durationMinutes) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'durationMinutes is required for acceleration or delay.',
});
}
if (input.action === 'RESET_SCHEDULED' && !input.scheduledAt) {
if (input.scheduledAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'scheduledAt is required for scheduled reset.',
});
}
if (input.action !== 'RESET_SCHEDULED' && input.scheduledAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'scheduledAt is supported only for scheduled reset.',
message: 'scheduledAt is supported only by operations.requestReset.',
});
}
const profile = await ctx.profiles.getProfile(input.profileName);
@@ -1756,11 +1868,13 @@ export const adminRouter = router({
});
}
const canManageProfiles = hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES, profile.profileName);
const canManageProfiles = hasAnyScopedPermission(
adminAuth,
[ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME],
profile.profileName
);
const canResume =
canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESUME_WHEN_STOPPED, profile.profileName);
const canResetSchedule =
canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESET_SCHEDULE, profile.profileName);
const canOpenSurvey =
canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
@@ -1777,19 +1891,6 @@ export const adminRouter = router({
message: 'Resume permission is required.',
});
}
} else if (input.action === 'RESET_SCHEDULED') {
if (profile.status !== 'COMPLETED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Reset scheduling is allowed only for COMPLETED profiles.',
});
}
if (!canResetSchedule) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Reset scheduling permission is required.',
});
}
} else if (input.action === 'OPEN_SURVEY') {
if (!canOpenSurvey) {
throw new TRPCError({
+140 -5
View File
@@ -61,6 +61,7 @@ const buildCaller = async (
apiPort: 15003,
status: options.initialProfileStatus ?? ('STOPPED' as const),
buildStatus: 'SUCCEEDED' as const,
buildCommitSha: 'HEAD',
meta: {},
createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z',
@@ -378,6 +379,141 @@ describe('admin operation API', () => {
});
expect(harness.createdInputs[0]).not.toHaveProperty('payload');
});
it('lets a scenario-only operator reset from the active commit without selecting Git', async () => {
const harness = await buildCaller(
async (input) => ({
id: '55555555-5555-4555-8555-555555555555',
profileName: input.profileName,
type: 'RESET',
status: 'QUEUED',
sourceMode: input.sourceMode,
sourceRef: input.sourceRef,
payload: input.payload ?? {},
requestedBy: input.requestedBy,
createdAt: '2026-08-08T00:00:00.000Z',
updatedAt: '2026-08-08T00:00:00.000Z',
}),
{
adminRoles: ['admin.scenarios.reset:che:2'],
firstUserIsAdmin: false,
profileScenario: '1010',
}
);
await harness.caller.admin.operations.requestReset({
profileName: 'che:2',
sourceMode: 'CURRENT',
install: {
scenarioId: 1010,
turnTermMinutes: 60,
sync: false,
fiction: 1,
extend: false,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 0,
tournamentTrig: false,
joinMode: 'full',
},
reason: 'new season only',
});
expect(harness.createdInputs[0]).toMatchObject({
type: 'RESET',
sourceMode: 'COMMIT',
sourceRef: expect.stringMatching(/^[0-9a-f]{40}$/u),
reason: 'new season only',
});
});
it('does not let a scenario-only operator combine a Git update with reset', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: ['admin.scenarios.reset:che:2'], firstUserIsAdmin: false }
);
await expect(
harness.caller.admin.operations.requestReset({
profileName: 'che:2',
sourceMode: 'BRANCH',
sourceRef: 'main',
install: {
scenarioId: 1010,
turnTermMinutes: 60,
sync: false,
fiction: 1,
extend: false,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 0,
tournamentTrig: false,
joinMode: 'full',
},
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('keeps runtime and DB-preserving deploy permissions independent', async () => {
const harness = await buildCaller(
async (input) => ({
id: '66666666-6666-4666-8666-666666666666',
profileName: input.profileName,
type: input.type,
status: 'QUEUED',
payload: {},
requestedBy: input.requestedBy,
createdAt: '2026-08-08T00:00:00.000Z',
updatedAt: '2026-08-08T00:00:00.000Z',
}),
{ adminRoles: ['admin.profiles.runtime:che:2'], firstUserIsAdmin: false }
);
await expect(
harness.caller.admin.operations.requestRuntime({ profileName: 'che:2', action: 'START' })
).resolves.toMatchObject({ type: 'START' });
await expect(
harness.caller.admin.operations.requestDeploy({
profileName: 'che:2',
sourceMode: 'BRANCH',
sourceRef: 'main',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('lets a settings-only operator change profile policy without runtime control', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false }
);
await harness.caller.admin.profiles.updateMeta({
profileName: 'che:2',
patch: { color: '#112233', localAccountAccessGraceDays: 14 },
reason: 'profile policy delegation',
});
expect(harness.updatedMetas.at(-1)).toMatchObject({ color: '#112233', localAccountAccessGraceDays: 14 });
await expect(
harness.caller.admin.operations.requestRuntime({ profileName: 'che:2', action: 'STOP' })
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('returns the authenticated profile scopes with the capability catalog', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: ['admin.scenarios.reset:che:2'], firstUserIsAdmin: false }
);
await expect(harness.caller.admin.capabilities.list()).resolves.toContainEqual(
expect.objectContaining({ permission: 'admin.scenarios.reset', scopes: ['che:2'] })
);
});
});
describe('gateway release API', () => {
@@ -637,7 +773,7 @@ describe('admin runtime clock action API', () => {
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: 'scheduledAt is supported only for scheduled reset.',
message: 'scheduledAt is supported only by operations.requestReset.',
});
expect(harness.createdRuntimeActions).toEqual([]);
});
@@ -954,10 +1090,9 @@ describe('Gateway administrator account controls', () => {
})
).resolves.toMatchObject({ id: grant.id, revokedReason: 'Kakao 인증 수단 복구 완료' });
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-special-access-revoked' });
expect(harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action)).toEqual([
'admin.users.grantSpecialAccess',
'admin.users.revokeSpecialAccess',
]);
expect(
harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action)
).toEqual(['admin.users.grantSpecialAccess', 'admin.users.revokeSpecialAccess']);
});
it('requires recovery access to expire within 90 days', async () => {
@@ -98,6 +98,38 @@ const installFixture = async (
risk: 'CRITICAL',
scope: 'GLOBAL',
},
{
permission: 'admin.profiles.runtime',
label: 'Profile 실행 관리',
description: '실행 상태를 관리합니다.',
risk: 'HIGH',
scope: 'PROFILE',
scopes: ['*'],
},
{
permission: 'admin.profiles.settings',
label: 'Profile 설정 관리',
description: '설정을 관리합니다.',
risk: 'HIGH',
scope: 'PROFILE',
scopes: ['*'],
},
{
permission: 'admin.profiles.deploy',
label: 'Profile 버전 배포',
description: '버전을 배포합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
scopes: ['*'],
},
{
permission: 'admin.scenarios.reset',
label: '시나리오 초기화',
description: '시나리오를 초기화합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
scopes: ['*'],
},
]);
}
if (operation === 'admin.profiles.listScenarios') {
@@ -212,7 +244,7 @@ const installFixture = async (
test('reports clock-shift acceptance separately from actual application', async ({ page }) => {
const fixture = await installFixture(page, { deferRequest: true, pendingProfileReads: 1 });
await page.goto('admin/servers');
await expect(page.getByRole('heading', { name: '서버 관리' })).toBeVisible();
await expect(page.getByRole('heading', { name: '서버 관리', level: 1 })).toBeVisible();
const duration = page.locator('input[type="number"][min="1"][max="1440"]');
const accelerate = page.getByRole('button', { name: '가속', exact: true });
@@ -291,13 +323,13 @@ test('renders an ignored terminal outcome without calling it applied', async ({
await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0);
});
test('directs profile deployment to the centralized version page', async ({ page }) => {
test('directs profile deployment to the selected server version tab', async ({ page }) => {
await installFixture(page);
await page.goto('admin/servers');
const releaseLink = page.getByRole('link', { name: '버전 업데이트 열기' });
const releaseLink = page.getByRole('link', { name: '버전 업데이트', exact: true }).last();
await expect(releaseLink).toBeVisible();
await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/releases');
await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/servers/hwe%3Adefault/version');
await expect(page.getByRole('button', { name: '설치 적용' })).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 });
@@ -27,8 +27,7 @@ const login = async (page: Page, username: string, password: string): Promise<vo
const hweRow = (page: Page) => page.locator('tbody tr').filter({ hasText: /^hwe섭/ });
const resetScenario = async (page: Page, scenarioId: string, sourceCommit: string): Promise<void> => {
await page.goto('/gateway/admin/releases');
await page.getByTestId('profile-select').selectOption('hwe:2');
await page.goto('/gateway/admin/servers/hwe%3A2/scenario');
await page.getByTestId('source-commit').check();
await page.getByTestId('source-ref').fill(sourceCommit);
await page.getByTestId('load-scenarios').click();
@@ -85,10 +85,9 @@ test('admin resets and opens hwe, then two users create generals and reach main'
await login(page, adminUsername, await readPassword('admin'));
await page.getByRole('link', { name: '관리자 페이지' }).click();
await expect(page).toHaveURL(/\/gateway\/admin$/);
await page.getByRole('link', { name: '버전 업데이트' }).first().click();
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
await page.goto(`/gateway/admin/servers/${encodeURIComponent(profileKey)}/scenario`);
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/.+\/scenario$/);
await page.getByTestId('profile-select').selectOption(profileKey);
const profileStatus = page.getByTestId('selected-profile-status');
if (!skipReset) {
await page.getByTestId('source-commit').check();
@@ -28,13 +28,30 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
}
if (
operation === 'lobby.profiles' ||
operation === 'admin.profiles.list' ||
operation === 'admin.profiles.listScenarios' ||
operation === 'admin.operations.list' ||
operation === 'admin.releases.list'
) {
return response([]);
}
if (operation === 'admin.profiles.list') {
return response(
roles.some((role) => role.includes(':hwe:2'))
? [
{
profileName: 'hwe:2',
profile: 'hwe',
scenario: '1010',
status: 'RUNNING',
buildStatus: 'SUCCEEDED',
meta: { korName: '환상서버' },
runtime: {},
runtimeActions: [],
},
]
: []
);
}
if (operation === 'admin.releases.gatewayState') {
return response({ id: 'gateway', updatedAt: '2026-08-01T00:00:00.000Z' });
}
@@ -42,7 +59,26 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
return response({ enabled: true });
}
if (operation === 'admin.capabilities.list') {
return response([]);
return response(
roles.includes('superuser')
? [
{ permission: 'admin.users.manage', scope: 'GLOBAL', scopes: ['*'] },
{ permission: 'admin.profiles.runtime', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.profiles.settings', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.profiles.deploy', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.releases.manage', scope: 'GLOBAL', scopes: ['*'] },
{ permission: 'admin.notice.manage', scope: 'GLOBAL', scopes: ['*'] },
{ permission: 'admin.audit.read', scope: 'GLOBAL', scopes: ['*'] },
]
: [
{
permission: roles[0]?.split(':')[0],
scope: 'PROFILE',
scopes: ['hwe:2'],
},
]
);
}
throw new Error(`Unhandled tRPC operation: ${operation}`);
});
@@ -86,16 +122,16 @@ test('bootstrap superuser can navigate the administrator workspace from the lobb
await writeFile(testInfo.outputPath('admin-overview-mobile-geometry.json'), JSON.stringify(geometry));
await page.screenshot({ path: testInfo.outputPath('admin-overview-mobile-menu.png'), fullPage: true });
await navigation.getByRole('link', { name: '버전 업데이트' }).click();
await navigation.getByRole('link', { name: 'Gateway 릴리스' }).click();
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
await expect(page.getByRole('heading', { name: '버전 업데이트' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Gateway 릴리스' })).toBeVisible();
});
test('legacy server operations URL keeps query parameters and redirects to releases', async ({ page }) => {
test('legacy server operations URL keeps query parameters and redirects to the server list', async ({ page }) => {
await installGatewayFixture(page, ['superuser']);
await page.goto('admin/server-operations?operationId=legacy-operation');
await expect(page).toHaveURL(/\/gateway\/admin\/releases\?operationId=legacy-operation$/);
await expect(page).toHaveURL(/\/gateway\/admin\/servers\?operationId=legacy-operation$/);
});
test('scoped administrators see the same navigation while ordinary users do not', async ({ browser }) => {
@@ -104,6 +140,11 @@ test('scoped administrators see the same navigation while ordinary users do not'
await installGatewayFixture(scopedPage, ['admin.profiles.manage:hwe:2']);
await scopedPage.goto('lobby');
await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' });
await expect(scopedNavigation.getByRole('link', { name: '환상서버 (hwe:2)' })).toBeVisible();
await expect(scopedNavigation.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0);
await scopedContext.close();
const userContext = await browser.newContext();
@@ -33,6 +33,7 @@ type FixtureState = {
}>;
runtimeRunning: boolean;
requestBodies: Array<{ operation: string; body: unknown }>;
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
};
const profile = (runtimeRunning: boolean) => ({
@@ -99,6 +100,18 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.profiles.list') {
return response([profile(state.runtimeRunning)]);
}
if (name === 'admin.capabilities.list') {
return response(
state.capabilities ?? [
{ permission: 'admin.profiles.runtime', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.profiles.settings', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.profiles.deploy', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.reset.schedule', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.releases.manage', scope: 'GLOBAL', scopes: ['*'] },
]
);
}
if (name === 'admin.operations.list') {
return response(state.operations);
}
@@ -218,10 +231,11 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/releases');
await page.goto('admin/servers/che%3A2/scenario');
await expect(page.getByTestId('server-operations-page')).toBeVisible();
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
await expect(page.getByTestId('source-help')).toContainText('실제로 시작될 때');
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/);
await expect(page.getByTestId('source-current')).toBeChecked();
await expect(page.getByTestId('source-help')).toContainText('현재 서버 커밋');
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
const desktopGeometry = await page
@@ -237,6 +251,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
});
expect(desktopGeometry).toHaveLength(2);
expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x);
await page.getByTestId('source-commit').check();
const sourceInput = page.getByTestId('source-ref');
await sourceInput.focus();
const focusedInputStyle = await sourceInput.evaluate((element) => {
@@ -256,7 +271,6 @@ test('separates branch and commit semantics and submits a reset from the dedicat
);
await page.screenshot({ path: testInfo.outputPath('desktop-operations.png'), fullPage: true });
await page.getByTestId('source-commit').check();
await expect(page.getByTestId('source-help')).toContainText('전체 SHA로 고정');
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
await page.getByTestId('load-scenarios').click();
@@ -288,31 +302,12 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
});
test('starts and stops all runtime roles through the operation controls', async ({ page }) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: false, requestBodies: [] };
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/releases');
await page.getByTestId('start-server').click();
await expect(page.getByText('시작 작업을 요청했습니다.')).toBeVisible();
await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible();
await page.getByTestId('stop-server').click();
await expect(page.getByText('정지 작업을 요청했습니다.')).toBeVisible();
await expect(page.getByText('STOPPED', { exact: true }).first()).toBeVisible();
const serializedRequests = state.requestBodies.map((entry) => JSON.stringify(entry.body)).join('\n');
expect(serializedRequests).toContain('"action":"START"');
expect(serializedRequests).toContain('"action":"STOP"');
});
test('separates DB-preserving profile deployment from DB reset', async ({ page }) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/releases');
await page.goto('admin/servers/che%3A2/version');
await expect(page.getByText('Game frontend')).toBeVisible();
await page.getByTestId('request-deploy').click();
@@ -322,6 +317,33 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
});
test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => {
const state: FixtureState = {
operations: [],
gatewayOperations: [],
runtimeRunning: true,
requestBodies: [],
capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:2'] }],
};
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/servers/che%3A2/scenario');
await expect(page.getByTestId('source-current')).toBeChecked();
await expect(page.getByTestId('source-branch')).toHaveCount(0);
await expect(page.getByTestId('source-commit')).toHaveCount(0);
await expect(page.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible();
await expect
.poll(() => state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset'))
.toBe(true);
const request = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset');
expect(JSON.stringify(request?.body)).toContain('"sourceMode":"CURRENT"');
expect(JSON.stringify(request?.body)).not.toContain('"sourceRef"');
});
test('controls gateway deployment and rollback through the external controller queue', async ({ page }, testInfo) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state);
@@ -375,7 +397,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/releases');
await page.goto('admin/servers/che%3A2/scenario');
await expect(page.getByText('FAILED', { exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
const failure = page.getByText(longError);
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import DefaultLayout from './DefaultLayout.vue';
import { useAuthStore } from '../stores/auth';
import { trpc } from '../utils/trpc';
defineProps<{
title: string;
@@ -9,30 +11,115 @@ defineProps<{
}>();
const menuOpen = ref(false);
const auth = useAuthStore();
const adminClient = trpc.admin as unknown as {
capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } };
profiles: {
list: {
query: () => Promise<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>;
};
};
};
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
const profiles = ref<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>([]);
const navigation = [
const isRootAdmin = computed(() =>
(auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser')
);
const hasCapability = (permission: string): boolean =>
isRootAdmin.value || capabilities.value.some((capability) => capability.permission === permission);
const hasAnyProfileCapability = computed(() =>
capabilities.value.some(
(capability) => capability.scopes?.length || capability.permission.startsWith('admin.profiles.')
)
);
const profileLabel = (profile: (typeof profiles.value)[number]): string => {
const korName = profile.meta?.korName;
return typeof korName === 'string' && korName.trim() ? `${korName} (${profile.profileName})` : profile.profileName;
};
const navigation = computed(() => [
{
label: '관리',
items: [
{ to: '/admin', label: '운영 개요', icon: '⌂', exact: true },
{ to: '/admin/users', label: '사용자 관리', icon: '人', exact: false },
{ to: '/admin', label: '운영 개요', icon: '⌂', exact: true, visible: true, child: false },
{
to: '/admin/users',
label: '사용자 관리',
icon: '人',
exact: false,
visible: hasCapability('admin.users.manage') || hasCapability('admin.users.create'),
child: false,
},
],
},
{
label: '서비스 운영',
label: '서버 관리',
items: [
{ to: '/admin/servers', label: '서버 관리', icon: '◫', exact: false },
{ to: '/admin/releases', label: '버전 업데이트', icon: '↥', exact: false },
{
to: '/admin/servers',
label: '서버 목록',
icon: '◫',
exact: true,
visible: isRootAdmin.value || hasAnyProfileCapability.value || profiles.value.length > 0,
child: false,
},
...profiles.value.map((profile) => ({
to: `/admin/servers/${encodeURIComponent(profile.profileName)}`,
label: profileLabel(profile),
icon: '└',
exact: false,
visible: true,
child: true,
})),
],
},
{
label: '시스템',
label: 'Gateway',
items: [
{ to: '/admin/system', label: '공지 · 접속', icon: '⚙', exact: false },
{ to: '/admin/audit', label: '감사 로그', icon: '≡', exact: false },
{
to: '/admin/releases',
label: 'Gateway 릴리스',
icon: '↥',
exact: false,
visible: hasCapability('admin.releases.manage'),
child: false,
},
{
to: '/admin/system',
label: '공지 · 접속',
icon: '⚙',
exact: false,
visible: hasCapability('admin.notice.manage'),
child: false,
},
{
to: '/admin/audit',
label: '감사 로그',
icon: '≡',
exact: false,
visible: hasCapability('admin.audit.read'),
child: false,
},
],
},
] as const;
]);
onMounted(async () => {
try {
capabilities.value = await adminClient.capabilities.list.query();
} catch {
capabilities.value = [];
}
try {
profiles.value = await adminClient.profiles.list.query();
} catch {
profiles.value = [];
}
});
</script>
<template>
@@ -59,13 +146,18 @@ const navigation = [
</div>
<nav aria-label="관리자 메뉴">
<section v-for="group in navigation" :key="group.label" class="admin-nav-group">
<section
v-for="group in navigation.filter((entry) => entry.items.some((item) => item.visible))"
:key="group.label"
class="admin-nav-group"
>
<h2>{{ group.label }}</h2>
<RouterLink
v-for="item in group.items"
v-for="item in group.items.filter((entry) => entry.visible)"
:key="item.to"
:to="item.to"
class="admin-nav-link"
:class="{ child: item.child }"
:active-class="item.exact ? '' : 'active'"
:exact-active-class="item.exact ? 'active' : ''"
@click="menuOpen = false"
@@ -204,6 +296,13 @@ const navigation = [
color: #fde68a;
}
.admin-nav-link.child {
min-height: 34px;
margin-left: 15px;
padding-block: 5px;
font-size: 12px;
}
.admin-nav-icon {
width: 20px;
color: #d4d4d8;
+20 -1
View File
@@ -43,6 +43,24 @@ const router = createRouter({
component: AdminView,
props: { section: 'servers' },
},
{
path: '/admin/servers/:profileName',
name: 'admin-server',
component: AdminView,
props: (route) => ({ section: 'servers', profileName: route.params.profileName }),
},
{
path: '/admin/servers/:profileName/version',
name: 'admin-server-version',
component: ServerOperationsView,
props: (route) => ({ mode: 'version', profileName: route.params.profileName }),
},
{
path: '/admin/servers/:profileName/scenario',
name: 'admin-server-scenario',
component: ServerOperationsView,
props: (route) => ({ mode: 'scenario', profileName: route.params.profileName }),
},
{
path: '/admin/system',
name: 'admin-system',
@@ -59,10 +77,11 @@ const router = createRouter({
path: '/admin/releases',
name: 'admin-releases',
component: ServerOperationsView,
props: { mode: 'gateway' },
},
{
path: '/admin/server-operations',
redirect: (to) => ({ path: '/admin/releases', query: to.query }),
redirect: (to) => ({ path: '/admin/servers', query: to.query }),
},
{
path: '/account',
@@ -1,43 +1,79 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import { useAuthStore } from '../stores/auth';
import { trpc } from '../utils/trpc';
const sections = [
{
to: '/admin/users',
eyebrow: 'Accounts',
title: '사용자 관리',
description: '계정 조회와 생성, 인증 유예, 권한, 제재, 복구 및 탈퇴 예약을 관리합니다.',
tone: 'blue',
},
{
to: '/admin/servers',
eyebrow: 'Profiles',
title: '서버 관리',
description: '프로필별 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작을 관리합니다.',
tone: 'emerald',
},
{
to: '/admin/releases',
eyebrow: 'Releases',
title: '버전 업데이트',
description: '프로필 DB 유지·초기화 배포와 Gateway 릴리스, rollback 및 작업 이력을 확인합니다.',
tone: 'violet',
},
{
to: '/admin/system',
eyebrow: 'System',
title: '공지 · 접속',
description: '로비 공지와 관리자 세션 연결 상태처럼 Gateway 공통 설정을 관리합니다.',
tone: 'amber',
},
{
to: '/admin/audit',
eyebrow: 'Audit',
title: '감사 로그',
description: '누가 어떤 관리자 조치를 수행했는지 결과와 대상, 사유를 추적합니다.',
tone: 'zinc',
},
] as const;
const auth = useAuthStore();
const capabilities = ref<string[]>([]);
const profileCount = ref(0);
const adminClient = trpc.admin as unknown as {
capabilities: { list: { query: () => Promise<Array<{ permission: string }>> } };
profiles: { list: { query: () => Promise<unknown[]> } };
};
const isRootAdmin = computed(() =>
(auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser')
);
const hasCapability = (permission: string): boolean => isRootAdmin.value || capabilities.value.includes(permission);
const sections = computed(
() =>
[
{
to: '/admin/users',
eyebrow: 'Accounts',
title: '사용자 관리',
description: '계정 조회와 생성, 인증 유예, 권한, 제재, 복구 및 탈퇴 예약을 관리합니다.',
tone: 'blue',
visible: hasCapability('admin.users.manage') || hasCapability('admin.users.create'),
},
{
to: '/admin/servers',
eyebrow: 'Profiles',
title: '서버 관리',
description: '프로필별 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작을 관리합니다.',
tone: 'emerald',
visible: isRootAdmin.value || profileCount.value > 0,
},
{
to: '/admin/releases',
eyebrow: 'Releases',
title: 'Gateway 릴리스',
description: 'Gateway API·frontend·orchestrator 배포와 rollback을 별도 제어면에서 관리합니다.',
tone: 'violet',
visible: hasCapability('admin.releases.manage'),
},
{
to: '/admin/system',
eyebrow: 'System',
title: '공지 · 접속',
description: '로비 공지와 관리자 세션 연결 상태처럼 Gateway 공통 설정을 관리합니다.',
tone: 'amber',
visible: hasCapability('admin.notice.manage'),
},
{
to: '/admin/audit',
eyebrow: 'Audit',
title: '감사 로그',
description: '누가 어떤 관리자 조치를 수행했는지 결과와 대상, 사유를 추적합니다.',
tone: 'zinc',
visible: hasCapability('admin.audit.read'),
},
] as const
);
onMounted(async () => {
try {
capabilities.value = (await adminClient.capabilities.list.query()).map((entry) => entry.permission);
} catch {
capabilities.value = [];
}
try {
profileCount.value = (await adminClient.profiles.list.query()).length;
} catch {
profileCount.value = 0;
}
});
</script>
<template>
@@ -56,7 +92,7 @@ const sections = [
<section class="overview-grid" aria-label="관리 기능">
<RouterLink
v-for="section in sections"
v-for="section in sections.filter((entry) => entry.visible)"
:key="section.to"
:to="section.to"
class="overview-card"
+74 -35
View File
@@ -7,6 +7,7 @@ type AdminSection = 'users' | 'servers' | 'system' | 'audit';
const props = defineProps<{
section: AdminSection;
profileName?: string;
}>();
const pageMeta: Record<AdminSection, { title: string; description: string; eyebrow: string }> = {
@@ -93,6 +94,7 @@ type AdminCapability = {
description: string;
risk: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
scope: 'GLOBAL' | 'PROFILE';
scopes?: string[];
};
type AdminAuditEvent = {
@@ -371,6 +373,9 @@ const profileActions = ref<
>({});
const profileActionStatus = ref<Record<string, string>>({});
const profileActionSubmitting = ref<Record<string, boolean>>({});
const visibleProfiles = computed(() =>
props.profileName ? profiles.value.filter((profile) => profile.profileName === props.profileName) : profiles.value
);
const runtimeActionPending = (profile: AdminProfile): boolean => {
return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL');
@@ -419,6 +424,12 @@ const rolesInput = ref('');
const rolesMode = ref<'set' | 'grant' | 'revoke'>('grant');
const rolesStatus = ref('');
const capabilities = ref<AdminCapability[]>([]);
const hasCapability = (permission: string, profileName?: string): boolean =>
capabilities.value.some((entry) => {
if (entry.permission !== permission && entry.permission !== 'admin.profiles.manage') return false;
if (!profileName || entry.scope === 'GLOBAL') return true;
return !entry.scopes?.length || entry.scopes.includes('*') || entry.scopes.includes(profileName);
});
const selectedCapability = ref('');
const capabilityProfile = ref('');
const userActionReason = ref('');
@@ -769,7 +780,7 @@ const loadCapabilities = async () => {
try {
capabilities.value = await adminClient.capabilities.list.query();
selectedCapability.value = capabilities.value[0]?.permission ?? '';
await loadGlobalAudit();
if (props.section === 'users' || props.section === 'audit') await loadGlobalAudit();
} catch {
capabilities.value = [];
}
@@ -1102,7 +1113,7 @@ const createLocalAccount = async () => {
};
onMounted(() => {
if (props.section === 'users' || props.section === 'audit') {
if (props.section === 'users' || props.section === 'audit' || props.section === 'servers') {
void loadCapabilities();
}
if (props.section === 'users') {
@@ -1344,8 +1355,8 @@ onMounted(() => {
<div class="bg-zinc-900 border border-amber-800/60 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
<div class="text-xs text-zinc-400">
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서
서버 범위와 만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다.
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다.
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<select
@@ -1396,7 +1407,8 @@ onMounted(() => {
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-amber-200">
{{ grant.kind }} · {{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }}
{{ grant.kind }} ·
{{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }}
</span>
<button
v-if="!grant.revokedAt"
@@ -1747,7 +1759,7 @@ onMounted(() => {
</button>
</div>
<div
v-for="profile in profiles"
v-for="profile in visibleProfiles"
:key="profile.profileName"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
@@ -1769,8 +1781,34 @@ onMounted(() => {
<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="space-y-2">
<div
v-if="hasCapability('admin.profiles.settings', profile.profileName)"
class="space-y-2"
>
<label class="text-xs text-zinc-400">표시명</label>
<input
v-model="profileEdits[profile.profileName].korName"
@@ -1842,7 +1880,10 @@ onMounted(() => {
</button>
</div>
<div class="space-y-2">
<div
v-if="hasCapability('admin.profiles.runtime', profile.profileName)"
class="space-y-2"
>
<label class="text-xs text-zinc-400">특수 동작 메모</label>
<input
v-model="profileActions[profile.profileName].reason"
@@ -1877,12 +1918,6 @@ onMounted(() => {
>
1~1440 사이의 정수로 입력해 주세요.
</div>
<label class="text-xs text-zinc-400">리셋 예약</label>
<input
v-model="profileActions[profile.profileName].scheduledAt"
type="datetime-local"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<div class="grid grid-cols-2 gap-2 pt-2">
<button
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-3 py-2 rounded"
@@ -1924,18 +1959,6 @@ onMounted(() => {
>
연기
</button>
<button
class="bg-purple-600 hover:bg-purple-500 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'RESET_NOW')"
>
즉시 리셋
</button>
<button
class="bg-purple-800 hover:bg-purple-700 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'RESET_SCHEDULED')"
>
리셋 예약
</button>
<button
class="bg-zinc-800 text-zinc-500 font-semibold px-3 py-2 rounded cursor-not-allowed"
disabled
@@ -1998,22 +2021,38 @@ onMounted(() => {
</div>
</div>
<div class="border-t border-zinc-800 pt-4">
<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>
<h4 class="text-sm font-semibold text-violet-200">버전과 시즌 수명주기</h4>
<p class="mt-1 text-xs text-zinc-500">
버전 선택, DB 유지 배포와 초기화 작업은 버전 업데이트에서 관리합니다.
DB를 보존하는 코드 배포와 DB를 교체하는 시나리오 초기화는 별도 작업입니다.
</p>
</div>
<RouterLink
to="/admin/releases"
class="rounded border border-violet-700 px-3 py-2 text-center text-xs font-semibold text-violet-200 hover:bg-violet-950"
>
버전 업데이트 열기
</RouterLink>
<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>
@@ -4,6 +4,13 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import { trpc } from '../utils/trpc';
type OperationPageMode = 'version' | 'scenario' | 'gateway';
const props = defineProps<{
mode: OperationPageMode;
profileName?: string;
}>();
const adminClient = trpc.admin;
type Profile = {
@@ -79,7 +86,8 @@ const operations = ref<Operation[]>([]);
const gatewayReleaseState = ref<GatewayReleaseState | null>(null);
const gatewayReleaseOperations = ref<GatewayReleaseOperation[]>([]);
const gatewayReleaseAvailable = ref(false);
const selectedProfileName = ref('');
const selectedProfileName = ref(props.profileName ?? '');
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
const loading = ref(false);
const catalogLoading = ref(false);
const submitting = ref(false);
@@ -89,7 +97,7 @@ let pollTimer: ReturnType<typeof setInterval> | undefined;
let stateRequestInFlight = false;
const form = reactive({
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT',
sourceRef: 'main',
scenarioId: 0,
turnTermMinutes: 60,
@@ -123,6 +131,27 @@ const selectedProfile = computed(
() => profiles.value.find((profile) => profile.profileName === selectedProfileName.value) ?? null
);
const hasCapability = (permission: string): boolean =>
capabilities.value.some((entry) => {
if (entry.permission !== permission && entry.permission !== 'admin.profiles.manage') return false;
if (!props.profileName) return true;
return !entry.scopes?.length || entry.scopes.includes('*') || entry.scopes.includes(props.profileName);
});
const pageTitle = computed(() => {
if (props.mode === 'gateway') return 'Gateway 릴리스';
if (props.mode === 'scenario') return `${props.profileName ?? ''} 시나리오 초기화`;
return `${props.profileName ?? ''} 버전 업데이트`;
});
const pageDescription = computed(() => {
if (props.mode === 'gateway') return 'Gateway control plane 배포와 rollback을 별도 권한으로 관리합니다.';
if (props.mode === 'scenario') {
return '현재 배포 버전으로 시나리오만 초기화하거나, 배포 권한이 있을 때 새 버전과 함께 초기화합니다.';
}
return '현재 게임 DB를 유지한 채 코드와 forward migration을 배포합니다.';
});
const activeOperation = computed(
() =>
operations.value.find(
@@ -133,9 +162,11 @@ const activeOperation = computed(
);
const sourceHelp = computed(() =>
form.sourceMode === 'BRANCH'
? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.'
: '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.'
form.sourceMode === 'CURRENT'
? `현재 서버 커밋 ${shortSha(selectedProfile.value?.buildCommitSha)}의 시나리오 리소스를 사용합니다.`
: form.sourceMode === 'BRANCH'
? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.'
: '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.'
);
const toIso = (value: string): string | undefined => {
@@ -163,23 +194,22 @@ const loadState = async (quiet = false) => {
loading.value = true;
}
try {
const profileResult = await adminClient.profiles.list.query();
const operationResult = await adminClient.operations.list.query({ limit: 100 });
profiles.value = profileResult as Profile[];
operations.value = operationResult as Operation[];
try {
const [state, releaseOperations] = await Promise.all([
adminClient.releases.gatewayState.query(),
adminClient.releases.list.query({ limit: 30 }),
]);
capabilities.value = (await adminClient.capabilities.list.query()) as typeof capabilities.value;
if (props.mode === 'gateway') {
const state = await adminClient.releases.gatewayState.query();
const releaseOperations = await adminClient.releases.list.query({ limit: 30 });
gatewayReleaseState.value = state as GatewayReleaseState;
gatewayReleaseOperations.value = releaseOperations as GatewayReleaseOperation[];
gatewayReleaseAvailable.value = true;
} catch {
gatewayReleaseAvailable.value = false;
}
if (!selectedProfileName.value && profiles.value.length > 0) {
selectedProfileName.value = profiles.value[0].profileName;
} else {
const profileResult = await adminClient.profiles.list.query();
const operationResult = await adminClient.operations.list.query({
profileName: props.profileName,
limit: 100,
});
profiles.value = profileResult as Profile[];
operations.value = operationResult as Operation[];
selectedProfileName.value = props.profileName ?? profiles.value[0]?.profileName ?? '';
}
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.';
@@ -191,7 +221,7 @@ const loadState = async (quiet = false) => {
const requestDeploy = async () => {
clearStatus();
if (!selectedProfile.value || activeOperation.value || !form.sourceRef.trim()) {
if (!selectedProfile.value || activeOperation.value || !form.sourceRef.trim() || form.sourceMode === 'CURRENT') {
return;
}
if (
@@ -263,14 +293,15 @@ const requestGatewayRollback = async () => {
const loadScenarios = async () => {
clearStatus();
if (!form.sourceRef.trim()) {
if (form.sourceMode !== 'CURRENT' && !form.sourceRef.trim()) {
errorMessage.value = '브랜치 또는 커밋을 입력해주세요.';
return;
}
catalogLoading.value = true;
try {
const result = await adminClient.profiles.listScenarios.query({
gitRef: form.sourceRef.trim(),
profileName: selectedProfileName.value,
gitRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(),
sourceMode: form.sourceMode,
});
scenarios.value = result as Scenario[];
@@ -288,31 +319,6 @@ const loadScenarios = async () => {
}
};
const requestRuntime = async (action: 'START' | 'STOP') => {
clearStatus();
if (!selectedProfile.value || activeOperation.value) {
return;
}
const label = action === 'START' ? '시작' : '정지';
if (!window.confirm(`${selectedProfile.value.profileName} 서버를 ${label}하시겠습니까?`)) {
return;
}
submitting.value = true;
try {
await adminClient.operations.requestRuntime.mutate({
profileName: selectedProfile.value.profileName,
action,
reason: form.reason.trim() || undefined,
});
message.value = `${label} 작업을 요청했습니다.`;
await loadState(true);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : `${label} 요청에 실패했습니다.`;
} finally {
submitting.value = false;
}
};
const selectedAutorunOptions = (): Array<'develop' | 'warp' | 'recruit' | 'train' | 'battle'> => {
const options: Array<'develop' | 'warp' | 'recruit' | 'train' | 'battle'> = [];
if (form.autorunDevelop) options.push('develop');
@@ -328,14 +334,15 @@ const requestReset = async () => {
if (!selectedProfile.value || activeOperation.value) {
return;
}
if (!form.sourceRef.trim() || !form.scenarioId) {
errorMessage.value = '소스와 시나리오를 먼저 선택해주세요.';
if ((form.sourceMode !== 'CURRENT' && !form.sourceRef.trim()) || !form.scenarioId) {
errorMessage.value = '초기화 소스와 시나리오를 먼저 선택해주세요.';
return;
}
const sourceLabel = form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
const sourceLabel =
form.sourceMode === 'CURRENT' ? '현재 배포 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
if (
!window.confirm(
`${selectedProfile.value.profileName}의 게임 DB를 초기화합니다.\n${sourceLabel}: ${form.sourceRef}\n시나리오: ${form.scenarioId}`
`${selectedProfile.value.profileName}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${form.scenarioId}`
)
) {
return;
@@ -345,7 +352,7 @@ const requestReset = async () => {
await adminClient.operations.requestReset.mutate({
profileName: selectedProfile.value.profileName,
sourceMode: form.sourceMode,
sourceRef: form.sourceRef.trim(),
sourceRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(),
scheduledAt: toIso(form.scheduledAt),
reason: form.reason.trim() || undefined,
install: {
@@ -415,7 +422,7 @@ watch(selectedProfileName, () => {
onMounted(async () => {
await loadState();
await loadScenarios();
if (props.mode === 'scenario') await loadScenarios();
pollTimer = setInterval(() => void loadState(true), 3000);
});
@@ -427,11 +434,7 @@ onBeforeUnmount(() => {
</script>
<template>
<AdminConsoleLayout
title="버전 업데이트"
description="프로필 DB 유지·초기화 배포와 Gateway 릴리스, rollback 및 작업 이력을 관리합니다."
eyebrow="Release operations"
>
<AdminConsoleLayout :title="pageTitle" :description="pageDescription" eyebrow="Release operations">
<template #actions>
<button
class="rounded border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm hover:border-zinc-500 disabled:opacity-50"
@@ -454,7 +457,30 @@ onBeforeUnmount(() => {
{{ message }}
</div>
<section class="grid gap-4 lg:grid-cols-[1.1fr_1.9fr]">
<nav v-if="mode !== 'gateway' && profileName" class="flex flex-wrap gap-2" aria-label="서버 관리 탭">
<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>
@@ -463,6 +489,7 @@ onBeforeUnmount(() => {
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 }}
@@ -540,33 +567,16 @@ onBeforeUnmount(() => {
</div>
<div v-if="selectedProfile.lastError" class="text-red-400">{{ selectedProfile.lastError }}</div>
</div>
<div class="grid grid-cols-2 gap-2">
<button
class="rounded bg-emerald-700 px-3 py-2 font-semibold text-white hover:bg-emerald-600 disabled:opacity-40"
:disabled="submitting || Boolean(activeOperation)"
data-testid="start-server"
@click="requestRuntime('START')"
>
서버 시작
</button>
<button
class="rounded bg-red-800 px-3 py-2 font-semibold text-white hover:bg-red-700 disabled:opacity-40"
:disabled="submitting || Boolean(activeOperation)"
data-testid="stop-server"
@click="requestRuntime('STOP')"
>
서버 정지
</button>
</div>
</div>
<form
class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-5"
@submit.prevent="requestReset"
@submit.prevent="mode === 'scenario' ? requestReset() : requestDeploy()"
>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">프로필 배포 · 시나리오 초기화</h3>
<h3 class="text-lg font-semibold">
{{ mode === 'scenario' ? '시나리오 초기화' : 'DB 보존 버전 업데이트' }}
</h3>
<span
v-if="activeOperation"
class="rounded-full bg-amber-500/15 px-3 py-1 text-xs text-amber-300"
@@ -578,29 +588,44 @@ onBeforeUnmount(() => {
<fieldset class="space-y-2">
<legend class="text-xs text-zinc-400">소스 종류</legend>
<div class="flex gap-5">
<label class="flex items-center gap-2">
<label v-if="mode === 'scenario'" class="flex items-center gap-2">
<input
v-model="form.sourceMode"
type="radio"
value="CURRENT"
data-testid="source-current"
/>
현재 배포 버전
</label>
<label
v-if="mode === 'version' || hasCapability('admin.profiles.deploy')"
class="flex items-center gap-2"
>
<input
v-model="form.sourceMode"
type="radio"
value="BRANCH"
data-testid="source-branch"
/>
브랜치
{{ mode === 'scenario' ? '새 브랜치와 함께' : '브랜치' }}
</label>
<label class="flex items-center gap-2">
<label
v-if="mode === 'version' || hasCapability('admin.profiles.deploy')"
class="flex items-center gap-2"
>
<input
v-model="form.sourceMode"
type="radio"
value="COMMIT"
data-testid="source-commit"
/>
커밋
{{ mode === 'scenario' ? '새 커밋과 함께' : '커밋' }}
</label>
</div>
<p class="text-xs text-amber-200/80" data-testid="source-help">{{ sourceHelp }}</p>
</fieldset>
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
<div v-if="form.sourceMode !== 'CURRENT'" class="grid gap-3 md:grid-cols-[1fr_auto]">
<input
v-model="form.sourceRef"
class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 font-mono text-sm text-white"
@@ -612,6 +637,7 @@ onBeforeUnmount(() => {
data-testid="source-ref"
/>
<button
v-if="mode === 'scenario'"
type="button"
class="rounded border border-zinc-600 bg-zinc-800 px-4 py-2 text-sm hover:bg-zinc-700 disabled:opacity-50"
:disabled="catalogLoading"
@@ -622,7 +648,7 @@ onBeforeUnmount(() => {
</button>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-2">
<label class="text-xs text-zinc-400">
시나리오
<select
@@ -652,7 +678,7 @@ onBeforeUnmount(() => {
</label>
</div>
<details class="rounded border border-zinc-800 bg-zinc-950/50 p-4">
<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>
<div class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
<label
@@ -735,7 +761,7 @@ onBeforeUnmount(() => {
</div>
</details>
<div class="grid gap-4 md:grid-cols-3">
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
<label class="text-xs text-zinc-400"
>작업 예약
<input
@@ -767,9 +793,10 @@ onBeforeUnmount(() => {
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
placeholder="작업 사유 또는 운영 메모"
/>
<div class="grid gap-3 md:grid-cols-2">
<div>
<button
type="button"
v-if="mode === 'version'"
type="submit"
class="rounded bg-sky-700 px-4 py-3 font-bold text-white hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="submitting || Boolean(activeOperation) || !form.sourceRef.trim()"
data-testid="request-deploy"
@@ -778,19 +805,20 @@ onBeforeUnmount(() => {
DB 유지 배포
</button>
<button
v-else
type="submit"
class="rounded bg-amber-500 px-4 py-3 font-bold text-black hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
class="w-full rounded bg-amber-500 px-4 py-3 font-bold text-black hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="submitting || Boolean(activeOperation) || !form.scenarioId"
data-testid="request-reset"
>
{{ form.scheduledAt ? 'DB 초기화 예약' : 'DB 초기화 배포' }}
{{ form.scheduledAt ? '시나리오 초기화 예약' : '시나리오 초기화' }}
</button>
</div>
</form>
</section>
<section
v-if="gatewayReleaseAvailable"
v-if="mode === 'gateway' && gatewayReleaseAvailable"
class="rounded-lg border border-violet-800/70 bg-zinc-900 p-5 space-y-4"
data-testid="gateway-release-panel"
>
@@ -890,7 +918,7 @@ onBeforeUnmount(() => {
</div>
</section>
<section class="rounded-lg border border-zinc-800 bg-zinc-900 p-5">
<section v-if="mode !== 'gateway'" class="rounded-lg border border-zinc-800 bg-zinc-900 p-5">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold">작업 이력</h3>
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>