Merge branch 'main' into feature/join-layout-20260808

This commit is contained in:
2026-08-08 17:13:08 +00:00
16 changed files with 909 additions and 294 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
@@ -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({
+137 -1
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([]);
});
@@ -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"
+70 -32
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 }> = {
@@ -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
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, 외부