Merge branch 'main' into feature/join-layout-20260808
This commit is contained in:
@@ -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)를 확인해
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -55,6 +55,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';
|
||||
@@ -147,6 +151,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) {
|
||||
@@ -446,6 +465,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>;
|
||||
|
||||
@@ -542,7 +562,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({
|
||||
@@ -707,14 +734,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) {
|
||||
@@ -1000,19 +1033,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) =>
|
||||
@@ -1031,8 +1064,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(),
|
||||
@@ -1040,7 +1073,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.' });
|
||||
@@ -1090,13 +1129,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 });
|
||||
@@ -1107,7 +1157,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.',
|
||||
});
|
||||
@@ -1117,9 +1167,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,
|
||||
@@ -1146,7 +1199,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.' });
|
||||
@@ -1199,7 +1252,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.' });
|
||||
@@ -1228,7 +1281,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({
|
||||
@@ -1244,7 +1303,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) {
|
||||
@@ -1415,22 +1493,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 });
|
||||
@@ -1492,7 +1599,7 @@ export const adminRouter = router({
|
||||
await ctx.orchestrator.reconcileNow();
|
||||
return result;
|
||||
}),
|
||||
updateMeta: profileAdminProcedure
|
||||
updateMeta: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
profileName: z.string().min(1),
|
||||
@@ -1509,6 +1616,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({
|
||||
@@ -1746,22 +1858,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);
|
||||
@@ -1772,11 +1884,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);
|
||||
|
||||
@@ -1793,19 +1907,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({
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }> = {
|
||||
@@ -107,6 +108,7 @@ type AdminCapability = {
|
||||
description: string;
|
||||
risk: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
scope: 'GLOBAL' | 'PROFILE';
|
||||
scopes?: string[];
|
||||
};
|
||||
|
||||
type AdminAuditEvent = {
|
||||
@@ -392,6 +394,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');
|
||||
@@ -454,6 +459,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('');
|
||||
@@ -829,7 +840,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 = [];
|
||||
}
|
||||
@@ -1162,7 +1173,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') {
|
||||
@@ -1956,7 +1967,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"
|
||||
>
|
||||
@@ -1978,8 +1989,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"
|
||||
@@ -2051,7 +2088,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"
|
||||
@@ -2086,12 +2126,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"
|
||||
@@ -2133,18 +2167,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
|
||||
@@ -2207,22 +2229,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
@@ -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
|
||||
|
||||
@@ -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, 외부
|
||||
|
||||
Reference in New Issue
Block a user