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
+4 -3
View File
@@ -228,9 +228,10 @@ direct-navigation URL을 사용합니다. `/image/*`는 외부 Caddy가 소유
완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의
commit-worktree build 경로에서 구성합니다.
관리자 화면의 `DB 유지 배포`는 profile의 game migration만 적용하고 현재
게임 DB를 seed하지 않습니다. `DB 초기화 배포`현재 시즌 테이블을 새
시나리오로 교체하지만 `hall`, `ng_games`, 연감, 과거 장수·국가와 상속 자료는
각 서버의 `버전 업데이트`는 profile의 game migration만 적용하고 현재
게임 DB를 seed하지 않습니다. 별도 `시나리오 초기화`Git 업데이트 없이
현재 게시 commit을 기본으로 사용하며, 필요할 때만 새 버전 배포와 결합합니다.
초기화는 현재 시즌 테이블을 새 시나리오로 교체하지만 `hall`, `ng_games`, 연감, 과거 장수·국가와 상속 자료는
보존합니다. Gateway API·frontend·orchestrator는 외부 release-controller가
함께 전환합니다. 설치와 CLI self-upgrade 절차는
[`app/release-controller/README.md`](app/release-controller/README.md)를 확인해
+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>
+33 -11
View File
@@ -8,32 +8,54 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
좌측 메뉴는 관리 책임을 다음과 같이 분리합니다.
| 메뉴 | 경로 | 책임 |
| ------------- | ------------------------- | ------------------------------------------------------------------------------ |
| 운영 개요 | `/gateway/admin` | 관리 영역 안내와 빠른 진입 |
| 사용자 관리 | `/gateway/admin/users` | 계정 조회·생성, 권한, 특수 접근·OAuth 유예, 제재, 아이콘 복구, 탈퇴 예약과 사용자별 이력 |
| 서버 관리 | `/gateway/admin/servers` | profile 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
| 버전 업데이트 | `/gateway/admin/releases` | profile DB 유지·초기화 배포, Gateway 릴리스·rollback과 작업 이력 |
| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 |
| 감사 로그 | `/gateway/admin/audit` | 관리자 조치 결과, 대상과 사유 조회 |
| 메뉴 | 경로 | 책임 |
| --------------- | ---------------------------------------------- | ------------------------------------------------------------------------- |
| 운영 개요 | `/gateway/admin` | 현재 권한으로 접근할 수 있는 관리 영역 안내 |
| 사용자 관리 | `/gateway/admin/users` | 계정 조회·생성, 권한, 특수 접근·OAuth 유예, 제재, 아이콘 복구 탈퇴 예약 |
| 서버 관리 | `/gateway/admin/servers` | 접근 가능한 profile 목록 |
| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
| 버전 업데이트 | `/gateway/admin/servers/:profileName/version` | 현 DB를 보존하는 profile 코드·migration 배포 |
| 시나리오 초기화 | `/gateway/admin/servers/:profileName/scenario` | 현재 배포 버전 또는 새 버전으로 현 시즌 DB와 시나리오 교체 |
| Gateway 릴리스 | `/gateway/admin/releases` | Gateway control plane 배포와 rollback |
| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 |
| 감사 로그 | `/gateway/admin/audit` | 관리자 조치 결과, 대상과 사유 조회 |
기존 `/gateway/admin/server-operations` 링크는 query string을 보존한 채
`/gateway/admin/releases`로 이동합니다. 즐겨찾기와 이전 운영 보고서의 링크를
`/gateway/admin/servers`로 이동합니다. 즐겨찾기와 이전 운영 보고서의 링크를
즉시 깨뜨리지 않기 위한 호환 경로입니다.
## 운영 경계
- 사용자 페이지는 사용자 한 명에 대한 계정 변경과 사용자별 감사 이력만
표시합니다. 전체 감사 원장은 감사 로그에서 별도로 조회합니다.
- 서버 관리는 현재 실행 상태와 게임 운영 정책을 다룹니다. Git source 선택,
migration, 시나리오 초기화와 rollback은 버전 업데이트에서 수행합니다.
- 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와
시나리오 초기화가 같은 서버 아래에서 서로 다른 탭과 권한으로 노출됩니다.
- `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET`
현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다.
- 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
권한과 버전 배포 권한이 모두 필요합니다.
- Gateway 릴리스는 profile 작업과 다른 전역 `admin.releases.manage` 권한을
사용하며 외부 release-controller가 실행합니다.
- 브라우저의 메뉴 노출은 편의 기능입니다. 권한 판단의 기준은 서버가 인증
session에서 해석한 capability입니다.
## 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 |
기존 상태 화면의 `즉시 리셋`·`리셋 예약` 버튼은 실제 DB 초기화 operation과
다른 metadata action이어서 제거했습니다. 초기화와 예약은 시나리오 초기화 탭의
`GatewayOperation(type=RESET)`만 사용합니다.
## Kakao 없는 특수 계정 접근
운영자 role(`superuser`, `admin`, `admin.*`)은 별도 grant 없이 모든 game
+13 -8
View File
@@ -12,9 +12,11 @@ Gateway 전체는 별도 release-controller가 처리합니다.
| Gateway API·frontend·orchestrator | Gateway 관리자 화면 | 외부 release-controller | `GatewayReleaseOperation`, `GatewayReleaseState` |
| release-controller 자체 | 별도 CLI process | self-upgrade CLI | PM2 `sammo:release-controller` |
관리자 화면은 `/gateway/admin/releases`입니다. 이전 경로
`/gateway/admin/server-operations`는 호환성을 위해 새 화면으로 이동합니다. Profile 작업에는 해당
profile의 `admin.profiles.manage` 권한이 필요합니다. Gateway 전체 릴리스에
Profile 화면은 `/gateway/admin/servers/:profileName/version`
`/gateway/admin/servers/:profileName/scenario`, Gateway 화면은
`/gateway/admin/releases`입니다. 이전 `/gateway/admin/server-operations`
호환성을 위해 서버 목록으로 이동합니다. Profile 작업은 runtime/settings/deploy/reset
capability로 분리되며 기존 `admin.profiles.manage`는 포괄 호환 권한입니다. Gateway 전체 릴리스에는
profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필요합니다.
일반 사용자와 권한이 없는 관리자는 Gateway 릴리스 영역을 사용할 수 없습니다.
@@ -47,7 +49,7 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필
## Profile 배포
관리자 화면에서 profile branch 또는 commit을 선택합니다. Branch는 worker가
버전 업데이트 화면에서 profile branch 또는 commit을 선택합니다. Branch는 worker가
작업을 claim할 때 commit으로 해석하며, commit 입력은 전체 SHA로 고정됩니다.
같은 profile에는 `QUEUED` 또는 `RUNNING` 작업을 동시에 하나만 둘 수 있습니다.
@@ -65,10 +67,13 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필
데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에
별도로 검토해 주세요.
### DB 초기화 배포
### 시나리오 초기화
`DB 초기화 배포`는 새 시즌이나 새 scenario로 현 시즌 데이터를 교체할 때
사용합니다. Source와 scenario를 먼저 불러온 뒤 turn 간격, 가오픈·정식 오픈,
시나리오 초기화는 새 시즌이나 새 scenario로 현 시즌 데이터를 교체할 때
사용합니다. 기본 `현재 배포 버전`은 profile의 게시된 full commit을 서버에서
결정하므로 Git 입력과 `admin.profiles.deploy` 권한이 필요하지 않습니다. 새 branch
또는 commit을 함께 배포하는 모드는 `admin.scenarios.reset`
`admin.profiles.deploy`를 모두 요구합니다. Source와 scenario를 확인한 뒤 turn 간격, 가오픈·정식 오픈,
NPC와 자동 진행 설정을 확인하고 요청해 주세요.
이 모드는 build와 migration 후 기존 season/tick metadata를 읽고 scenario seeder를 실행합니다.
@@ -194,7 +199,7 @@ pnpm --filter @sammo-ts/release-controller self-upgrade COMMIT <full-sha>
- API health, tRPC, SSE와 정적 자산 경로를 확인합니다.
- DB 유지 배포에서는 현재 season/scenario와 핵심 게임 상태가 유지됐는지
확인합니다.
- DB 초기화 배포에서는 새 시즌 상태와 명예의 전당·연감 등 장기보존 자료를
- 시나리오 초기화에서는 새 시즌 상태와 명예의 전당·연감 등 장기보존 자료를
함께 확인합니다.
Local unit, 격리 DB integration과 fixture Chromium 통과는 운영 PM2, 외부