merge: Gateway 프로필 표시명 경계를 main에 반영한다

This commit is contained in:
2026-08-24 07:18:48 +00:00
19 changed files with 421 additions and 127 deletions
+42 -3
View File
@@ -27,7 +27,11 @@ import type { GatewayApiContext } from './context.js';
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js'; import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js'; import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
import { readProfileReleaseSource } from './orchestrator/profileReleaseSource.js'; import { readProfileReleaseSource } from './orchestrator/profileReleaseSource.js';
import { orderGatewayProfiles, resolveGatewayProfileKoreanName } from './profileOrder.js'; import {
orderGatewayProfiles,
resolveGatewayProfileDisplayName,
resolveGatewayProfileKoreanName,
} from './profileOrder.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES); const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
@@ -650,7 +654,25 @@ export const adminRouter = router({
}) })
.optional() .optional()
) )
.query(({ ctx, input }) => (ctx as GatewayApiContext).adminAudit.list(input)), .query(async ({ ctx, input }) => {
const gatewayCtx = ctx as GatewayApiContext;
const [events, profiles] = await Promise.all([
gatewayCtx.adminAudit.list(input),
gatewayCtx.profiles.listProfiles(),
]);
const displayNames = new Map(
profiles.map((profile) => [
profile.profileName,
resolveGatewayProfileDisplayName(profile.profile, profile.instanceKey, profile.meta.korName),
])
);
return events.map((event) => ({
...event,
...(event.profileName && displayNames.has(event.profileName)
? { profileDisplayName: displayNames.get(event.profileName) }
: {}),
}));
}),
}), }),
system: router({ system: router({
getNotice: adminProcedure.query(async ({ ctx }) => { getNotice: adminProcedure.query(async ({ ctx }) => {
@@ -760,6 +782,13 @@ export const adminRouter = router({
specialAccessGrants, specialAccessGrants,
profiles: profiles.map((profile) => ({ profiles: profiles.map((profile) => ({
profileName: profile.profileName, profileName: profile.profileName,
profile: profile.profile,
instanceKey: profile.instanceKey,
displayName: resolveGatewayProfileDisplayName(
profile.profile,
profile.instanceKey,
profile.meta.korName
),
...resolveLocalAccountProfilePolicy({ ...resolveLocalAccountProfilePolicy({
profile: profile.profile, profile: profile.profile,
profileName: profile.profileName, profileName: profile.profileName,
@@ -1727,6 +1756,11 @@ export const adminRouter = router({
profileName: profile.profileName, profileName: profile.profileName,
profile: profile.profile, profile: profile.profile,
instanceKey: profile.instanceKey, instanceKey: profile.instanceKey,
displayName: resolveGatewayProfileDisplayName(
profile.profile,
profile.instanceKey,
profile.meta.korName
),
currentScenario: profile.currentScenario, currentScenario: profile.currentScenario,
meta: { meta: {
korName: resolveGatewayProfileKoreanName(profile.profile, profile.meta.korName), korName: resolveGatewayProfileKoreanName(profile.profile, profile.meta.korName),
@@ -1771,6 +1805,11 @@ export const adminRouter = router({
const runtimeSettingsMap = new Map(runtimeSettings.map((settings) => [settings.profileName, settings])); const runtimeSettingsMap = new Map(runtimeSettings.map((settings) => [settings.profileName, settings]));
return profiles.map((profile) => ({ return profiles.map((profile) => ({
...profile, ...profile,
displayName: resolveGatewayProfileDisplayName(
profile.profile,
profile.instanceKey,
profile.meta.korName
),
runtimeActions: runtimeActionsByProfile.get(profile.profileName) ?? [], runtimeActions: runtimeActionsByProfile.get(profile.profileName) ?? [],
runtimeSettings: runtimeSettingsMap.get(profile.profileName) ?? null, runtimeSettings: runtimeSettingsMap.get(profile.profileName) ?? null,
activeOperation: activeOperationByProfile.get(profile.profileName) ?? null, activeOperation: activeOperationByProfile.get(profile.profileName) ?? null,
@@ -1805,7 +1844,7 @@ export const adminRouter = router({
if (sourceMode === 'CURRENT') { if (sourceMode === 'CURRENT') {
if (!input?.profileName) { if (!input?.profileName) {
if (!adminAuth.isSuperuser) { if (!adminAuth.isSuperuser) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'profileName is required.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '대상 서버를 선택해야 합니다.' });
} }
} else { } else {
assertPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, input.profileName); assertPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, input.profileName);
@@ -23,7 +23,7 @@ import {
import { isRecord } from '@sammo-ts/common'; import { isRecord } from '@sammo-ts/common';
import { resolveGatewayPostgresConfigFromEnv } from '../gatewayPostgresConfig.js'; import { resolveGatewayPostgresConfigFromEnv } from '../gatewayPostgresConfig.js';
import { resolveGatewayProfileKoreanName } from '../profileOrder.js'; import { resolveGatewayProfileDisplayName, resolveGatewayProfileKoreanName } from '../profileOrder.js';
import { import {
buildTurboReleaseCommand, buildTurboReleaseCommand,
@@ -1626,7 +1626,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.processConfig.workspaceRoot this.processConfig.workspaceRoot
)), )),
]; ];
await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`); await this.appendOperationLog(
operationId,
'build',
`${resolveGatewayProfileDisplayName(profile.profile, profile.instanceKey, profile.meta.korName)} 구성 요소를 빌드합니다.`
);
const result = await this.releaseBuildRunner.run(commands, this.buildProgress(operationId, 'build'), { const result = await this.releaseBuildRunner.run(commands, this.buildProgress(operationId, 'build'), {
signal: this.activeOperationAbortSignal, signal: this.activeOperationAbortSignal,
}); });
@@ -2233,7 +2237,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.appendOperationLog( await this.appendOperationLog(
operationId, operationId,
'build', 'build',
`${profile?.profileName ?? 'profile'} 구성 요소를 빌드합니다.` `${
profile
? resolveGatewayProfileDisplayName(profile.profile, profile.instanceKey, profile.meta.korName)
: '대상 서버'
} 구성 요소를 빌드합니다.`
); );
} }
return { return {
@@ -2435,7 +2443,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private async stageStaticProfileFrontend(profile: GatewayProfileRecord): Promise<StagedFrontendArtifact> { private async stageStaticProfileFrontend(profile: GatewayProfileRecord): Promise<StagedFrontendArtifact> {
if (!profile.buildCommitSha) { if (!profile.buildCommitSha) {
throw new Error(`Profile ${profile.profileName} is missing the build commit SHA.`); throw new Error(
`${resolveGatewayProfileDisplayName(
profile.profile,
profile.instanceKey,
profile.meta.korName
)} 서버의 build commit SHA가 없습니다.`
);
} }
const runtimeWorkspace = profile.buildWorkspace ?? this.processConfig.workspaceRoot; const runtimeWorkspace = profile.buildWorkspace ?? this.processConfig.workspaceRoot;
const sharedSourceRoot = buildSharedProfileFrontendOutDir(runtimeWorkspace); const sharedSourceRoot = buildSharedProfileFrontendOutDir(runtimeWorkspace);
+13
View File
@@ -20,6 +20,19 @@ export const resolveGatewayProfileKoreanName = (profile: string, configuredName?
return gatewayProfileKoreanNames.get(profile) ?? profile; return gatewayProfileKoreanNames.get(profile) ?? profile;
}; };
/**
* User-facing profile label. The immutable profileName (`che:default`) remains
* an internal routing/storage key and must not leak into ordinary UI copy.
*/
export const resolveGatewayProfileDisplayName = (
profile: string,
instanceKey: string,
configuredName?: unknown
): string => {
const koreanName = resolveGatewayProfileKoreanName(profile, configuredName);
return instanceKey === 'default' ? koreanName : `${koreanName} [${instanceKey}]`;
};
const compareGatewayProfiles = ( const compareGatewayProfiles = (
left: { profile: string; instanceKey: string }, left: { profile: string; instanceKey: string },
right: { profile: string; instanceKey: string } right: { profile: string; instanceKey: string }
+37 -10
View File
@@ -10,6 +10,8 @@ import {
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra'; import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import webPush from 'web-push'; import webPush from 'web-push';
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
export interface WebPushCoordinatorConfig { export interface WebPushCoordinatorConfig {
enabled: boolean; enabled: boolean;
vapidSubject?: string; vapidSubject?: string;
@@ -104,7 +106,14 @@ export class WebPushCoordinator {
const [profiles, preferences, subscriptionCount, currentSubscription] = await Promise.all([ const [profiles, preferences, subscriptionCount, currentSubscription] = await Promise.all([
this.prisma.gatewayProfile.findMany({ this.prisma.gatewayProfile.findMany({
orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }], orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
select: { profileName: true, profile: true, currentScenario: true, status: true }, select: {
profileName: true,
profile: true,
instanceKey: true,
currentScenario: true,
status: true,
meta: true,
},
}), }),
this.prisma.webPushPreference.findMany({ this.prisma.webPushPreference.findMany({
where: { userId }, where: { userId },
@@ -128,7 +137,15 @@ export class WebPushCoordinator {
capability: this.getCapability(), capability: this.getCapability(),
eventTypes: WEB_PUSH_EVENT_TYPES, eventTypes: WEB_PUSH_EVENT_TYPES,
profiles: profiles.map((profile) => ({ profiles: profiles.map((profile) => ({
...profile, profileName: profile.profileName,
profile: profile.profile,
instanceKey: profile.instanceKey,
displayName: resolveGatewayProfileDisplayName(
profile.profile,
profile.instanceKey,
(profile.meta as Record<string, unknown> | null)?.korName
),
currentScenario: profile.currentScenario,
status: String(profile.status), status: String(profile.status),
})), })),
preferences: preferences.filter((preference) => isWebPushEventType(preference.eventType)), preferences: preferences.filter((preference) => isWebPushEventType(preference.eventType)),
@@ -218,7 +235,7 @@ export class WebPushCoordinator {
if (!this.configured) return false; if (!this.configured) return false;
const profile = await tx.gatewayProfile.findUnique({ const profile = await tx.gatewayProfile.findUnique({
where: { profileName: event.profileName }, where: { profileName: event.profileName },
select: { profile: true, profileName: true }, select: { profile: true, profileName: true, instanceKey: true, meta: true },
}); });
if (!profile) return false; if (!profile) return false;
const receipt = await tx.webPushEventReceipt.createMany({ const receipt = await tx.webPushEventReceipt.createMany({
@@ -258,7 +275,16 @@ export class WebPushCoordinator {
ids.push(subscription.id); ids.push(subscription.id);
subscriptionIdsByUser.set(subscription.userId, ids); subscriptionIdsByUser.set(subscription.userId, ids);
} }
const copy = copyFor(event.eventType, profile.profile, event.year, event.month); const copy = copyFor(
event.eventType,
resolveGatewayProfileDisplayName(
profile.profile,
profile.instanceKey,
(profile.meta as Record<string, unknown> | null)?.korName
),
event.year,
event.month
);
for (const userId of selectedUserIds) { for (const userId of selectedUserIds) {
const subscriptionIds = subscriptionIdsByUser.get(userId) ?? []; const subscriptionIds = subscriptionIdsByUser.get(userId) ?? [];
if (subscriptionIds.length === 0) continue; if (subscriptionIds.length === 0) continue;
@@ -395,10 +421,7 @@ export class WebPushCoordinator {
}); });
for (const delivery of claimed) { for (const delivery of claimed) {
if ( if (delivery.subscription.expirationTime && delivery.subscription.expirationTime.getTime() <= Date.now()) {
delivery.subscription.expirationTime &&
delivery.subscription.expirationTime.getTime() <= Date.now()
) {
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.webPushDelivery.updateMany({ await tx.webPushDelivery.updateMany({
where: { id: delivery.id, lockOwner: this.owner }, where: { id: delivery.id, lockOwner: this.owner },
@@ -445,11 +468,15 @@ export class WebPushCoordinator {
typeof error === 'object' && error !== null && 'statusCode' in error typeof error === 'object' && error !== null && 'statusCode' in error
? Number((error as { statusCode?: unknown }).statusCode) ? Number((error as { statusCode?: unknown }).statusCode)
: 0; : 0;
const terminal = statusCode === 404 || statusCode === 410 || (statusCode >= 400 && statusCode < 500 && statusCode !== 429); const terminal =
statusCode === 404 ||
statusCode === 410 ||
(statusCode >= 400 && statusCode < 500 && statusCode !== 429);
const attempts = delivery.attempts; const attempts = delivery.attempts;
const exhausted = attempts >= 8; const exhausted = attempts >= 8;
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8)); const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
const safeError = statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.'; const safeError =
statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.';
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.webPushDelivery.updateMany({ await tx.webPushDelivery.updateMany({
where: { id: delivery.id, lockOwner: this.owner }, where: { id: delivery.id, lockOwner: this.owner },
@@ -380,6 +380,7 @@ describe('admin profile navigation API', () => {
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2', instanceKey: '2',
displayName: '체 [2]',
currentScenario: '2', currentScenario: '2',
meta: { korName: '체' }, meta: { korName: '체' },
}, },
+10
View File
@@ -4,6 +4,7 @@ import {
GATEWAY_PROFILE_KOREAN_NAMES, GATEWAY_PROFILE_KOREAN_NAMES,
GATEWAY_PROFILE_ORDER, GATEWAY_PROFILE_ORDER,
orderGatewayProfiles, orderGatewayProfiles,
resolveGatewayProfileDisplayName,
resolveGatewayProfileKoreanName, resolveGatewayProfileKoreanName,
} from '../src/profileOrder.js'; } from '../src/profileOrder.js';
@@ -56,3 +57,12 @@ describe('resolveGatewayProfileKoreanName', () => {
expect(resolveGatewayProfileKoreanName('custom')).toBe('custom'); expect(resolveGatewayProfileKoreanName('custom')).toBe('custom');
}); });
}); });
describe('resolveGatewayProfileDisplayName', () => {
it('hides the default instance key and distinguishes non-default instances', () => {
expect(resolveGatewayProfileDisplayName('che', 'default')).toBe('체');
expect(resolveGatewayProfileDisplayName('hwe', '2')).toBe('훼 [2]');
expect(resolveGatewayProfileDisplayName('che', 'default', ' 천하서버 ')).toBe('천하서버');
expect(resolveGatewayProfileDisplayName('custom', 'blue')).toBe('custom [blue]');
});
});
@@ -131,6 +131,9 @@ const installFixture = async (page: Page) => {
profiles: [ profiles: [
{ {
profileName: 'che:default', profileName: 'che:default',
profile: 'che',
instanceKey: 'default',
displayName: '체',
requiresKakaoVerification: true, requiresKakaoVerification: true,
kakaoVerified: false, kakaoVerified: false,
accessAllowed: true, accessAllowed: true,
@@ -219,15 +222,16 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
await page.getByRole('button', { name: /접근 · 권한/ }).click(); await page.getByRole('button', { name: /접근 · 권한/ }).click();
await expect(page.getByRole('option', { name: /Profile 전체 운영/ })).toHaveCount(0); await expect(page.getByRole('option', { name: /Profile 전체 운영/ })).toHaveCount(0);
await expect(page.getByRole('option', { name: /Profile 실행 관리/ })).toHaveCount(1); await expect(page.getByRole('option', { name: /Profile 실행 관리/ })).toHaveCount(1);
await expect(page.getByRole('cell', { name: 'che:default' })).toBeVisible(); await expect(page.getByRole('cell', { name: '' })).toBeVisible();
await expect(page.getByText('che:default', { exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-desktop.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-desktop.png'), fullPage: true });
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중'); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
await page.getByLabel('특수 접근 만료 시각').fill('2026-08-20T00:00'); await page.getByLabel('특수 접근 만료 시각').fill('2026-08-20T00:00');
await page.getByPlaceholder('che 또는 che:2 (쉼표 구분, 비우면 전체)').fill('che'); await page.getByRole('checkbox', { name: '체' }).check();
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('휴대폰 분실 임시 복구'); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('휴대폰 분실 임시 복구');
await page.getByRole('button', { name: '특수 접근 부여', exact: true }).click(); await page.getByRole('button', { name: '특수 접근 부여', exact: true }).click();
await expect(page.getByText('특수 접근 자격을 부여했습니다.').first()).toBeVisible(); await expect(page.getByText('특수 접근 자격을 부여했습니다.').first()).toBeVisible();
await expect(page.getByText(/RECOVERY · che/)).toBeVisible(); await expect(page.getByText(/RECOVERY · /)).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('gateway-admin-special-access-granted.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('gateway-admin-special-access-granted.png'), fullPage: true });
const gracePanel = page.getByRole('heading', { name: 'Kakao 인증 유예' }).locator('..'); const gracePanel = page.getByRole('heading', { name: 'Kakao 인증 유예' }).locator('..');
@@ -165,6 +165,7 @@ const installFixture = async (
profileName: 'hwe:default', profileName: 'hwe:default',
profile: 'hwe', profile: 'hwe',
instanceKey: 'default', instanceKey: 'default',
displayName: '훼',
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario, currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
meta: {}, meta: {},
}, },
@@ -177,6 +178,7 @@ const installFixture = async (
profileName: 'hwe:default', profileName: 'hwe:default',
profile: 'hwe', profile: 'hwe',
instanceKey: 'default', instanceKey: 'default',
displayName: '훼',
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario, currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
scenario: options.currentScenario ?? 'default', scenario: options.currentScenario ?? 'default',
apiPort: 15015, apiPort: 15015,
@@ -542,7 +544,7 @@ test('directs profile deployment to the selected server version tab', async ({ p
const tabAndHeaderGeometry = await Promise.all([ const tabAndHeaderGeometry = await Promise.all([
tabs.evaluate((element) => element.getBoundingClientRect().top), tabs.evaluate((element) => element.getBoundingClientRect().top),
page page
.getByText('서버 ID: hwe:default · 인스턴스: default', { exact: true }) .getByText('현재 시나리오: 1010', { exact: true })
.evaluate((element) => element.getBoundingClientRect().top), .evaluate((element) => element.getBoundingClientRect().top),
]); ]);
expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]); expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]);
@@ -48,6 +48,7 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
profileName: 'hwe:2', profileName: 'hwe:2',
profile: 'hwe', profile: 'hwe',
instanceKey: '2', instanceKey: '2',
displayName: '환상서버 [2]',
currentScenario: '1010', currentScenario: '1010',
scenario: '1010', scenario: '1010',
status: 'RUNNING', status: 'RUNNING',
@@ -68,6 +69,7 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
profileName: 'hwe:2', profileName: 'hwe:2',
profile: 'hwe', profile: 'hwe',
instanceKey: '2', instanceKey: '2',
displayName: '환상서버 [2]',
currentScenario: '1010', currentScenario: '1010',
meta: { korName: '환상서버' }, meta: { korName: '환상서버' },
}, },
@@ -306,23 +308,26 @@ test('keeps mobile account actions on clean rows for users and administrators',
const adminLink = adminPage.getByRole('link', { name: '관리자 페이지' }); const adminLink = adminPage.getByRole('link', { name: '관리자 페이지' });
const baseBackground = await adminLink.evaluate((element) => getComputedStyle(element).backgroundColor); const baseBackground = await adminLink.evaluate((element) => getComputedStyle(element).backgroundColor);
await adminLink.hover(); await adminLink.hover();
await expect.poll(() => adminLink.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe( await expect
baseBackground .poll(() => adminLink.evaluate((element) => getComputedStyle(element).backgroundColor))
); .not.toBe(baseBackground);
await adminLink.focus(); await adminLink.focus();
await expect(adminLink).toBeFocused(); await expect(adminLink).toBeFocused();
await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-500px.png'), fullPage: true }); await adminPage.screenshot({
path: testInfo.outputPath('gateway-account-actions-admin-500px.png'),
fullPage: true,
});
await adminPage.setViewportSize({ width: 390, height: 844 }); await adminPage.setViewportSize({ width: 390, height: 844 });
const adminNarrowGeometry = await measureAccountActions(adminPage); const adminNarrowGeometry = await measureAccountActions(adminPage);
expect(adminNarrowGeometry.documentWidth).toBe(adminNarrowGeometry.viewportWidth); expect(adminNarrowGeometry.documentWidth).toBe(adminNarrowGeometry.viewportWidth);
expect(adminNarrowGeometry.items[0]?.top).toBeCloseTo(adminNarrowGeometry.items[1]?.top ?? 0, 0); expect(adminNarrowGeometry.items[0]?.top).toBeCloseTo(adminNarrowGeometry.items[1]?.top ?? 0, 0);
expect(adminNarrowGeometry.items[2]?.top).toBeCloseTo( expect(adminNarrowGeometry.items[2]?.top).toBeCloseTo((adminNarrowGeometry.items[1]?.bottom ?? 0) + 16, 0);
(adminNarrowGeometry.items[1]?.bottom ?? 0) + 16,
0
);
expect(adminNarrowGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true); expect(adminNarrowGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-390px.png'), fullPage: true }); await adminPage.screenshot({
path: testInfo.outputPath('gateway-account-actions-admin-390px.png'),
fullPage: true,
});
await adminPage.setViewportSize({ width: 360, height: 800 }); await adminPage.setViewportSize({ width: 360, height: 800 });
const adminSmallGeometry = await measureAccountActions(adminPage); const adminSmallGeometry = await measureAccountActions(adminPage);
@@ -71,6 +71,7 @@ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown
profileName: 'che:default', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default', instanceKey: 'default',
displayName: '천하서버',
currentScenario: '2', currentScenario: '2',
scenario: '2', scenario: '2',
apiPort: 15003, apiPort: 15003,
@@ -200,6 +201,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
profileName: 'che:default', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default', instanceKey: 'default',
displayName: '천하서버',
currentScenario: '2', currentScenario: '2',
meta: { korName: '천하서버' }, meta: { korName: '천하서버' },
}, },
@@ -639,7 +641,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('시나리오 초기화'); await expect(page.getByTestId('operations-table')).toContainText('시나리오 초기화');
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('천하서버 구성 요소를 빌드합니다.');
await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.');
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
const operationTable = page.getByTestId('operations-table'); const operationTable = page.getByTestId('operations-table');
@@ -652,7 +654,8 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await detailsToggle.click(); await detailsToggle.click();
await expect(detailsToggle).toHaveAttribute('aria-expanded', 'true'); await expect(detailsToggle).toHaveAttribute('aria-expanded', 'true');
const operationDetail = operationTable.getByTestId('operation-detail'); const operationDetail = operationTable.getByTestId('operation-detail');
await expect(operationDetail).toContainText('che:default'); await expect(operationDetail).toContainText('천하서버');
await expect(operationDetail).not.toContainText('che:default');
await expect(operationDetail).toContainText('0123456789abcdef0123456789abcdef01234567'); await expect(operationDetail).toContainText('0123456789abcdef0123456789abcdef01234567');
await expect(operationDetail).toContainText('admin'); await expect(operationDetail).toContainText('admin');
const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => { const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
@@ -807,7 +810,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('버전 업데이트'); await expect(page.getByTestId('operations-table')).toContainText('버전 업데이트');
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('천하서버 구성 요소를 빌드합니다.');
await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete'); await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete');
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true); expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true);
@@ -834,7 +837,7 @@ test('submits a separately authorized destructive game cancellation on desktop a
await page.goto('admin/servers/che%3Adefault/cancel'); await page.goto('admin/servers/che%3Adefault/cancel');
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/cancel$/); await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/cancel$/);
await expect(page.getByRole('heading', { name: 'che:default 게임 취소' })).toBeVisible(); await expect(page.getByRole('heading', { name: '천하서버 게임 취소' })).toBeVisible();
await expect(page.getByRole('link', { name: '게임 취소', exact: true })).toHaveAttribute('aria-current', 'page'); await expect(page.getByRole('link', { name: '게임 취소', exact: true })).toHaveAttribute('aria-current', 'page');
await expect(page.getByRole('link', { name: '시나리오 초기화', exact: true })).toHaveCount(0); await expect(page.getByRole('link', { name: '시나리오 초기화', exact: true })).toHaveCount(0);
await expect(page.getByTestId('request-game-cancellation')).toBeDisabled(); await expect(page.getByTestId('request-game-cancellation')).toBeDisabled();
@@ -843,7 +846,7 @@ test('submits a separately authorized destructive game cancellation on desktop a
await page.getByTestId('cancellation-general-mode').selectOption('RETAIN'); await page.getByTestId('cancellation-general-mode').selectOption('RETAIN');
await page.getByTestId('cancellation-retention-percent').fill('35'); await page.getByTestId('cancellation-retention-percent').fill('35');
await page.getByTestId('cancellation-reason').fill('잘못된 시나리오로 개장함'); await page.getByTestId('cancellation-reason').fill('잘못된 시나리오로 개장함');
await page.getByTestId('cancellation-confirmation').fill('che:default'); await page.getByTestId('cancellation-confirmation').fill('천하서버 게임 취소');
const cancelButton = page.getByTestId('request-game-cancellation'); const cancelButton = page.getByTestId('request-game-cancellation');
await expect(cancelButton).toBeEnabled(); await expect(cancelButton).toBeEnabled();
await cancelButton.hover(); await cancelButton.hover();
@@ -1348,10 +1351,11 @@ test('renders the stable server identity without exposing the default suffix as
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' }); const navigation = page.getByRole('navigation', { name: '관리자 메뉴' });
const profileLink = navigation.getByRole('link', { name: '천하서버' }); const profileLink = navigation.getByRole('link', { name: '천하서버' });
await expect(profileLink).toBeVisible({ timeout: 900 }); await expect(profileLink).toBeVisible({ timeout: 900 });
await expect(profileLink).toHaveAttribute('title', '서버 ID: che:default'); await expect(profileLink).toHaveAttribute('title', '천하서버 서버 관리');
await expect(navigation).not.toContainText('천하서버 (che:default)'); await expect(navigation).not.toContainText('천하서버 (che:default)');
await expect(navigation.getByRole('link', { name: 'Gateway 릴리스' })).toBeVisible({ timeout: 900 }); await expect(navigation.getByRole('link', { name: 'Gateway 릴리스' })).toBeVisible({ timeout: 900 });
await expect(page.getByText('서버 ID: che:default · 인스턴스: default')).toBeVisible(); await expect(page.getByText('서버 ID: che:default · 인스턴스: default')).toHaveCount(0);
await expect(page.getByText('천하서버', { exact: true })).toBeVisible();
await expect(page.getByText('현재 시나리오: 2')).toBeVisible(); await expect(page.getByText('현재 시나리오: 2')).toBeVisible();
await profileLink.focus(); await profileLink.focus();
const desktop = await profileLink.evaluate((element) => { const desktop = await profileLink.evaluate((element) => {
@@ -59,6 +59,8 @@ const installFixture = async (page: Page) => {
{ {
profileName: 'hwe:default', profileName: 'hwe:default',
profile: 'hwe', profile: 'hwe',
instanceKey: 'default',
displayName: '훼',
currentScenario: 'default', currentScenario: 'default',
status: 'RUNNING', status: 'RUNNING',
}, },
@@ -93,6 +95,8 @@ test('web push settings are default-off and remain configurable while delivery i
const table = page.locator('#notification-table'); const table = page.locator('#notification-table');
await expect(table).toBeVisible(); await expect(table).toBeVisible();
await expect(table).toContainText('준비됨 · 운영 비활성'); await expect(table).toContainText('준비됨 · 운영 비활성');
await expect(table).toContainText('훼 · default');
await expect(table).not.toContainText('hwe:default');
await expect(page.getByRole('button', { name: '이 기기 알림 켜기' })).toBeDisabled(); await expect(page.getByRole('button', { name: '이 기기 알림 켜기' })).toBeDisabled();
const checkboxes = table.getByRole('checkbox'); const checkboxes = table.getByRole('checkbox');
await expect(checkboxes).toHaveCount(9); await expect(checkboxes).toHaveCount(9);
@@ -5,6 +5,7 @@ type ServerProfileTab = 'status' | 'version' | 'scenario' | 'cancel';
const props = defineProps<{ const props = defineProps<{
profileName: string; profileName: string;
profileLabel: string;
activeTab: ServerProfileTab; activeTab: ServerProfileTab;
canDeploy: boolean; canDeploy: boolean;
canReset: boolean; canReset: boolean;
@@ -45,7 +46,7 @@ const tabs = computed(() =>
<nav <nav
class="server-profile-tabs" class="server-profile-tabs"
:style="{ '--server-tab-count': tabs.length }" :style="{ '--server-tab-count': tabs.length }"
:aria-label="`${profileName} 서버 관리 `" :aria-label="`${profileLabel} 서버 관리 `"
data-testid="server-profile-tabs" data-testid="server-profile-tabs"
> >
<RouterLink <RouterLink
@@ -0,0 +1,30 @@
import { directTrpc } from '../utils/trpc';
export type AdminProfileNavigationItem = {
profileName: string;
profile: string;
instanceKey: string;
displayName?: string;
currentScenario: string | null;
meta?: Record<string, unknown>;
};
let cachedProfiles: AdminProfileNavigationItem[] | undefined;
let cachedAt = 0;
let inFlight: Promise<AdminProfileNavigationItem[]> | undefined;
export const loadAdminProfileNavigation = async (): Promise<AdminProfileNavigationItem[]> => {
if (cachedProfiles && Date.now() - cachedAt < 5_000) return cachedProfiles;
if (inFlight) return inFlight;
inFlight = directTrpc.admin.profiles.listNavigation
.query()
.then((profiles) => {
cachedProfiles = profiles as AdminProfileNavigationItem[];
cachedAt = Date.now();
return cachedProfiles;
})
.finally(() => {
inFlight = undefined;
});
return inFlight;
};
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
import DefaultLayout from './DefaultLayout.vue'; import DefaultLayout from './DefaultLayout.vue';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { directTrpc } from '../utils/trpc'; import { directTrpc } from '../utils/trpc';
import { loadAdminProfileNavigation, type AdminProfileNavigationItem } from '../composables/useAdminProfileNavigation';
defineProps<{ defineProps<{
title: string; title: string;
@@ -14,30 +15,9 @@ const menuOpen = ref(false);
const auth = useAuthStore(); const auth = useAuthStore();
const adminNavigationClient = directTrpc.admin as unknown as { const adminNavigationClient = directTrpc.admin as unknown as {
capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } }; capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } };
profiles: {
listNavigation: {
query: () => Promise<
Array<{
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
meta?: Record<string, unknown>;
}>
>;
};
};
}; };
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]); const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
const profiles = ref< const profiles = ref<AdminProfileNavigationItem[]>([]);
Array<{
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
meta?: Record<string, unknown>;
}>
>([]);
const isRootAdmin = computed(() => const isRootAdmin = computed(() =>
(auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser') (auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser')
@@ -52,10 +32,12 @@ const hasAnyProfileCapability = computed(() =>
) )
); );
const profileLabel = (profile: (typeof profiles.value)[number]): string => { const profileDisplayName = (profile: (typeof profiles.value)[number]): string => {
const korName = profile.meta?.korName; if (profile.displayName?.trim()) return profile.displayName.trim();
const displayName = typeof korName === 'string' && korName.trim() ? korName.trim() : profile.profile; const configuredName = profile.meta?.korName;
return profile.instanceKey === 'default' ? displayName : `${displayName} [${profile.instanceKey}]`; const baseName =
typeof configuredName === 'string' && configuredName.trim() ? configuredName.trim() : profile.profile;
return profile.instanceKey === 'default' ? baseName : `${baseName} [${profile.instanceKey}]`;
}; };
const navigation = computed(() => [ const navigation = computed(() => [
@@ -86,8 +68,8 @@ const navigation = computed(() => [
}, },
...profiles.value.map((profile) => ({ ...profiles.value.map((profile) => ({
to: `/admin/servers/${encodeURIComponent(profile.profileName)}`, to: `/admin/servers/${encodeURIComponent(profile.profileName)}`,
label: profileLabel(profile), label: profileDisplayName(profile),
title: `서버 ID: ${profile.profileName}`, title: `${profileDisplayName(profile)} 서버 관리`,
icon: '└', icon: '└',
exact: false, exact: false,
visible: true, visible: true,
@@ -129,7 +111,7 @@ const navigation = computed(() => [
onMounted(async () => { onMounted(async () => {
const [capabilityResult, profileResult] = await Promise.allSettled([ const [capabilityResult, profileResult] = await Promise.allSettled([
adminNavigationClient.capabilities.list.query(), adminNavigationClient.capabilities.list.query(),
adminNavigationClient.profiles.listNavigation.query(), loadAdminProfileNavigation(),
]); ]);
capabilities.value = capabilityResult.status === 'fulfilled' ? capabilityResult.value : []; capabilities.value = capabilityResult.status === 'fulfilled' ? capabilityResult.value : [];
profiles.value = profileResult.status === 'fulfilled' ? profileResult.value : []; profiles.value = profileResult.status === 'fulfilled' ? profileResult.value : [];
@@ -851,7 +851,8 @@ onBeforeUnmount(() => {
:key="profile.profileName" :key="profile.profileName"
:value="profile.profileName" :value="profile.profileName"
> >
{{ profile.profile }} · {{ profile.currentScenario ?? profile.profileName }} {{ profile.displayName ?? profile.profile }} ·
{{ profile.currentScenario ?? '시나리오 미설정' }}
</option> </option>
</select> </select>
<span v-if="currentProfile" class="notification-profile-status">{{ <span v-if="currentProfile" class="notification-profile-status">{{
+98 -29
View File
@@ -134,6 +134,7 @@ type AdminAuditEvent = {
targetType?: string; targetType?: string;
targetId?: string; targetId?: string;
profileName?: string; profileName?: string;
profileDisplayName?: string;
action: string; action: string;
outcome: 'STARTED' | 'SUCCEEDED' | 'FAILED'; outcome: 'STARTED' | 'SUCCEEDED' | 'FAILED';
reason?: string; reason?: string;
@@ -144,6 +145,9 @@ type AdminAuditEvent = {
type KakaoGracePolicy = { type KakaoGracePolicy = {
profileName: string; profileName: string;
profile: string;
instanceKey: string;
displayName: string;
requiresKakaoVerification: boolean; requiresKakaoVerification: boolean;
kakaoVerified: boolean; kakaoVerified: boolean;
accessAllowed: boolean; accessAllowed: boolean;
@@ -185,6 +189,7 @@ type AdminProfile = {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string; instanceKey: string;
displayName?: string;
currentScenario: string | null; currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */ /** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string; scenario: string;
@@ -444,6 +449,14 @@ const visibleProfiles = computed(() =>
props.profileName ? profiles.value.filter((profile) => profile.profileName === props.profileName) : profiles.value props.profileName ? profiles.value.filter((profile) => profile.profileName === props.profileName) : profiles.value
); );
const adminProfileDisplayName = (profile: AdminProfile): string => {
if (profile.displayName?.trim()) return profile.displayName.trim();
const configuredName = profile.meta.korName;
const baseName =
typeof configuredName === 'string' && configuredName.trim() ? configuredName.trim() : profile.profile;
return profile.instanceKey === 'default' ? baseName : `${baseName} [${profile.instanceKey}]`;
};
const runtimeActionPending = (profile: AdminProfile): boolean => { const runtimeActionPending = (profile: AdminProfile): boolean => {
return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL'); return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL');
}; };
@@ -566,7 +579,7 @@ const kakaoGraceStatus = ref('');
const kakaoPolicies = ref<KakaoGracePolicy[]>([]); const kakaoPolicies = ref<KakaoGracePolicy[]>([]);
const specialAccessGrants = ref<SpecialAccountAccessGrant[]>([]); const specialAccessGrants = ref<SpecialAccountAccessGrant[]>([]);
const specialAccessKind = ref<SpecialAccountAccessGrant['kind']>('RECOVERY'); const specialAccessKind = ref<SpecialAccountAccessGrant['kind']>('RECOVERY');
const specialAccessProfiles = ref(''); const specialAccessProfiles = ref<string[]>([]);
const specialAccessAllowsGeneralCreation = ref(true); const specialAccessAllowsGeneralCreation = ref(true);
const specialAccessExpiresAt = ref(''); const specialAccessExpiresAt = ref('');
const specialAccessStatus = ref(''); const specialAccessStatus = ref('');
@@ -574,6 +587,38 @@ const userHistory = ref<AdminAuditEvent[]>([]);
const globalAuditHistory = ref<AdminAuditEvent[]>([]); const globalAuditHistory = ref<AdminAuditEvent[]>([]);
const globalAuditStatus = ref(''); const globalAuditStatus = ref('');
const profileScopeLabel = (scope: string): string => {
const exact = kakaoPolicies.value.find((policy) => policy.profileName === scope);
if (exact) return exact.displayName;
const base = kakaoPolicies.value.filter((policy) => policy.profile === scope);
if (!base.length) return '삭제되었거나 접근할 수 없는 서버';
const baseName = base[0]!.displayName.replace(/ \[[^\]]+\]$/, '');
return base.length > 1 ? `${baseName} 전체 인스턴스` : baseName;
};
const specialAccessScopeOptions = computed(() => {
const grouped = new Map<string, KakaoGracePolicy[]>();
for (const policy of kakaoPolicies.value) {
const bucket = grouped.get(policy.profile) ?? [];
bucket.push(policy);
grouped.set(policy.profile, bucket);
}
return [...grouped.entries()].flatMap(([profile, entries]) => {
if (entries.length === 1) return [{ value: profile, label: entries[0]!.displayName }];
const baseName = entries[0]!.displayName.replace(/ \[[^\]]+\]$/, '');
return [
{ value: profile, label: `${baseName} 전체 인스턴스` },
...entries.map((entry) => ({ value: entry.profileName, label: entry.displayName })),
];
});
});
const auditTargetLabel = (event: AdminAuditEvent): string => {
if (event.profileDisplayName) return event.profileDisplayName;
if (event.targetType === 'PROFILE' || event.profileName) return '삭제되었거나 접근할 수 없는 서버';
return event.targetId ?? '';
};
const { feedback: showFeedbackToast } = useToast(); const { feedback: showFeedbackToast } = useToast();
const actionFeedback = [ const actionFeedback = [
noticeStatus, noticeStatus,
@@ -1059,7 +1104,7 @@ const applyCapabilitySelection = () => {
const capability = capabilities.value.find((entry) => entry.permission === selectedCapability.value); const capability = capabilities.value.find((entry) => entry.permission === selectedCapability.value);
if (!capability) return; if (!capability) return;
if (capability.scope === 'PROFILE' && !capabilityProfile.value.trim()) { if (capability.scope === 'PROFILE' && !capabilityProfile.value.trim()) {
rolesStatus.value = 'Profile 범위를 입력하세요.'; rolesStatus.value = '대상 서버를 선택하세요.';
return; return;
} }
rolesInput.value = rolesInput.value =
@@ -1101,10 +1146,7 @@ const grantSpecialAccess = async () => {
await adminClient.users.grantSpecialAccess.mutate({ await adminClient.users.grantSpecialAccess.mutate({
userId: userResult.value.id, userId: userResult.value.id,
kind: specialAccessKind.value, kind: specialAccessKind.value,
profiles: specialAccessProfiles.value profiles: specialAccessProfiles.value,
.split(',')
.map((profile) => profile.trim())
.filter(Boolean),
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value, allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
expiresAt: specialAccessExpiresAt.value expiresAt: specialAccessExpiresAt.value
? (serverDateTimeInputToIso(specialAccessExpiresAt.value) ?? null) ? (serverDateTimeInputToIso(specialAccessExpiresAt.value) ?? null)
@@ -1266,7 +1308,7 @@ const applyRestriction = async () => {
const reason = requireUserActionReason(); const reason = requireUserActionReason();
if (!reason) return; if (!reason) return;
if (!restrictionProfile.value.trim()) { if (!restrictionProfile.value.trim()) {
restrictionStatus.value = '서버 프로필명을 입력하세요.'; restrictionStatus.value = '대상 서버를 선택하세요.';
return; return;
} }
const features = restrictionFeatures.value const features = restrictionFeatures.value
@@ -1301,7 +1343,7 @@ const clearRestriction = async () => {
const reason = requireUserActionReason(); const reason = requireUserActionReason();
if (!reason) return; if (!reason) return;
if (!restrictionProfile.value.trim()) { if (!restrictionProfile.value.trim()) {
restrictionStatus.value = '서버 프로필명을 입력하세요.'; restrictionStatus.value = '대상 서버를 선택하세요.';
return; return;
} }
try { try {
@@ -1706,13 +1748,21 @@ onMounted(() => {
{{ capability.label }} · {{ capability.risk }} {{ capability.label }} · {{ capability.risk }}
</option> </option>
</select> </select>
<input <select
v-model="capabilityProfile" v-model="capabilityProfile"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white" class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="Profile 범위 (예: che:default)" :disabled="!hasUser || !kakaoPolicies.length"
:disabled="!hasUser" aria-label="권한 대상 서버"
/> >
<option value="">대상 서버 선택</option>
<option
v-for="policy in kakaoPolicies"
:key="policy.profileName"
:value="policy.profileName"
>
{{ policy.displayName }}
</option>
</select>
<button <button
class="bg-zinc-700 hover:bg-zinc-600 px-3 py-2 rounded text-sm" class="bg-zinc-700 hover:bg-zinc-600 px-3 py-2 rounded text-sm"
:disabled="!hasUser" :disabled="!hasUser"
@@ -1779,13 +1829,22 @@ onMounted(() => {
:disabled="!hasUser" :disabled="!hasUser"
aria-label="특수 접근 만료 시각" aria-label="특수 접근 만료 시각"
/> />
<fieldset class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white">
<legend class="px-1 text-xs text-zinc-400">허용 서버 (비우면 전체)</legend>
<label
v-for="option in specialAccessScopeOptions"
:key="option.value"
class="mr-4 inline-flex items-center gap-2"
>
<input <input
v-model="specialAccessProfiles" v-model="specialAccessProfiles"
type="text" type="checkbox"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white" :value="option.value"
placeholder="che 또는 che:2 (쉼표 구분, 비우면 전체)"
:disabled="!hasUser" :disabled="!hasUser"
/> />
{{ option.label }}
</label>
</fieldset>
<label class="flex items-center gap-2 text-sm text-zinc-300 px-2"> <label class="flex items-center gap-2 text-sm text-zinc-300 px-2">
<input <input
v-model="specialAccessAllowsGeneralCreation" v-model="specialAccessAllowsGeneralCreation"
@@ -1812,7 +1871,11 @@ onMounted(() => {
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-amber-200"> <span class="font-semibold text-amber-200">
{{ grant.kind }} · {{ grant.kind }} ·
{{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }} {{
grant.profiles.length
? grant.profiles.map(profileScopeLabel).join(', ')
: '전체 서버'
}}
</span> </span>
<button <button
v-if="!grant.revokedAt" v-if="!grant.revokedAt"
@@ -1871,7 +1934,7 @@ onMounted(() => {
<table class="w-full min-w-[620px] text-xs"> <table class="w-full min-w-[620px] text-xs">
<thead class="text-zinc-500"> <thead class="text-zinc-500">
<tr> <tr>
<th class="p-2 text-left">Profile</th> <th class="p-2 text-left">서버</th>
<th>접근</th> <th>접근</th>
<th>장수 생성</th> <th>장수 생성</th>
<th>기본 접근 유예</th> <th>기본 접근 유예</th>
@@ -1885,7 +1948,7 @@ onMounted(() => {
:key="policy.profileName" :key="policy.profileName"
class="border-t border-zinc-800" class="border-t border-zinc-800"
> >
<td class="p-2">{{ policy.profileName }}</td> <td class="p-2">{{ policy.displayName }}</td>
<td class="text-center">{{ policy.accessAllowed ? '허용' : '차단' }}</td> <td class="text-center">{{ policy.accessAllowed ? '허용' : '차단' }}</td>
<td class="text-center">{{ policy.canCreateGeneral ? '허용' : '차단' }}</td> <td class="text-center">{{ policy.canCreateGeneral ? '허용' : '차단' }}</td>
<td class="text-center">{{ policy.accessGraceDays }}</td> <td class="text-center">{{ policy.accessGraceDays }}</td>
@@ -1944,13 +2007,21 @@ onMounted(() => {
> >
<h4 class="text-base font-semibold">서버별 기능 제재 (서버 시간 UTC+9)</h4> <h4 class="text-base font-semibold">서버별 기능 제재 (서버 시간 UTC+9)</h4>
<div class="grid gap-2"> <div class="grid gap-2">
<input <select
v-model="restrictionProfile" v-model="restrictionProfile"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white" class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="profile:scenario" :disabled="!hasUser || !kakaoPolicies.length"
:disabled="!hasUser" aria-label="제재 대상 서버"
/> >
<option value="">대상 서버 선택</option>
<option
v-for="policy in kakaoPolicies"
:key="policy.profileName"
:value="policy.profileName"
>
{{ policy.displayName }}
</option>
</select>
<input <input
v-model="restrictionFeatures" v-model="restrictionFeatures"
type="text" type="text"
@@ -2115,7 +2186,7 @@ onMounted(() => {
</div> </div>
<div class="text-zinc-400"> <div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.targetType ?? '-' }} {{ event.actorUsername }} · {{ event.targetType ?? '-' }}
{{ event.targetId ?? event.profileName ?? '' }} · {{ event.reason ?? '사유 없음' }} {{ auditTargetLabel(event) }} · {{ event.reason ?? '사유 없음' }}
</div> </div>
</div> </div>
</div> </div>
@@ -2180,6 +2251,7 @@ onMounted(() => {
> >
<ServerProfileTabs <ServerProfileTabs
:profile-name="profile.profileName" :profile-name="profile.profileName"
:profile-label="adminProfileDisplayName(profile)"
active-tab="status" active-tab="status"
:can-deploy="hasCapability('admin.profiles.deploy', profile.profileName)" :can-deploy="hasCapability('admin.profiles.deploy', profile.profileName)"
:can-reset="hasCapability('admin.scenarios.reset', profile.profileName)" :can-reset="hasCapability('admin.scenarios.reset', profile.profileName)"
@@ -2189,10 +2261,7 @@ onMounted(() => {
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div> <div>
<div class="text-base font-semibold"> <div class="text-base font-semibold">
{{ profile.meta.korName ?? profile.profile }} {{ adminProfileDisplayName(profile) }}
</div>
<div class="text-xs text-zinc-500">
서버 ID: {{ profile.profileName }} · 인스턴스: {{ profile.instanceKey }}
</div> </div>
<div class="text-xs text-zinc-500"> <div class="text-xs text-zinc-500">
현재 시나리오: {{ profile.currentScenario ?? '미설정' }} 현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch }
import CompactHelp from '../components/CompactHelp.vue'; import CompactHelp from '../components/CompactHelp.vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue'; import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import { useToast } from '../composables/useToast'; import { useToast } from '../composables/useToast';
import { loadAdminProfileNavigation, type AdminProfileNavigationItem } from '../composables/useAdminProfileNavigation';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import { import {
normalizeProfileResetDefaults, normalizeProfileResetDefaults,
@@ -103,6 +104,28 @@ const gatewayReleaseLogConnection = ref<'idle' | 'connected' | 'reconnecting'>('
const gatewayReleaseLogViewport = ref<HTMLElement>(); const gatewayReleaseLogViewport = ref<HTMLElement>();
const gatewayReleaseAvailable = ref(false); const gatewayReleaseAvailable = ref(false);
const selectedProfileName = computed(() => props.profileName ?? ''); const selectedProfileName = computed(() => props.profileName ?? '');
const profileIdentities = ref<AdminProfileNavigationItem[]>([]);
const profileDisplayName = (profileName: string): string => {
const profile = profileIdentities.value.find((candidate) => candidate.profileName === profileName);
if (!profile) return '삭제되었거나 접근할 수 없는 서버';
if (profile.displayName?.trim()) return profile.displayName.trim();
const configuredName = profile.meta?.korName;
const baseName =
typeof configuredName === 'string' && configuredName.trim() ? configuredName.trim() : profile.profile;
return profile.instanceKey === 'default' ? baseName : `${baseName} [${profile.instanceKey}]`;
};
const selectedProfileIdentityReady = computed(() =>
profileIdentities.value.some((profile) => profile.profileName === selectedProfileName.value)
);
const selectedProfileDisplayName = computed(() => {
if (!selectedProfileName.value) return '대상 서버';
return selectedProfileIdentityReady.value ? profileDisplayName(selectedProfileName.value) : '대상 서버';
});
const cancellationConfirmation = computed(() => `${selectedProfileDisplayName.value} 게임 취소`);
const displayOperationText = (value?: string): string => {
if (!value || !selectedProfileName.value) return value ?? '';
return value.replaceAll(selectedProfileName.value, selectedProfileDisplayName.value);
};
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]); const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
const loading = ref(false); const loading = ref(false);
const catalogLoading = ref(false); const catalogLoading = ref(false);
@@ -205,7 +228,7 @@ const profileOperationLogEmptyMessage = computed(() => {
return '오케스트레이터 로그를 기다리고 있습니다…'; return '오케스트레이터 로그를 기다리고 있습니다…';
} }
if (operation.error) { if (operation.error) {
return `이 작업에는 진행 로그가 기록되지 않았습니다. 작업 오류: ${operation.error}`; return `이 작업에는 진행 로그가 기록되지 않았습니다. 작업 오류: ${displayOperationText(operation.error)}`;
} }
return '이 작업에는 진행 로그가 기록되지 않았습니다. 로그 기능 적용 전 작업일 수 있습니다.'; return '이 작업에는 진행 로그가 기록되지 않았습니다. 로그 기능 적용 전 작업일 수 있습니다.';
}); });
@@ -229,9 +252,9 @@ const hasCapability = (permission: string): boolean =>
const pageTitle = computed(() => { const pageTitle = computed(() => {
if (props.mode === 'gateway') return 'Gateway 릴리스'; if (props.mode === 'gateway') return 'Gateway 릴리스';
if (props.mode === 'cancel') return `${props.profileName ?? ''} 게임 취소`; if (props.mode === 'cancel') return `${selectedProfileDisplayName.value} 게임 취소`;
if (props.mode === 'scenario') return `${props.profileName ?? ''} 시나리오 초기화`; if (props.mode === 'scenario') return `${selectedProfileDisplayName.value} 시나리오 초기화`;
return `${props.profileName ?? ''} 버전 업데이트`; return `${selectedProfileDisplayName.value} 버전 업데이트`;
}); });
const pageDescription = computed(() => { const pageDescription = computed(() => {
@@ -345,6 +368,15 @@ const loadResetDefaults = async () => {
} }
}; };
const loadProfileIdentities = async () => {
if (props.mode === 'gateway') return;
try {
profileIdentities.value = await loadAdminProfileNavigation();
} catch {
profileIdentities.value = [];
}
};
const loadCapabilities = async () => { const loadCapabilities = async () => {
try { try {
capabilities.value = (await adminClient.capabilities.list.query()) as typeof capabilities.value; capabilities.value = (await adminClient.capabilities.list.query()) as typeof capabilities.value;
@@ -531,6 +563,7 @@ const requestDeploy = async () => {
clearStatus(); clearStatus();
if ( if (
!selectedProfileName.value || !selectedProfileName.value ||
!selectedProfileIdentityReady.value ||
activeOperation.value || activeOperation.value ||
!form.sourceRef.trim() || !form.sourceRef.trim() ||
form.sourceMode === 'CURRENT' form.sourceMode === 'CURRENT'
@@ -539,7 +572,7 @@ const requestDeploy = async () => {
} }
if ( if (
!window.confirm( !window.confirm(
`${selectedProfileName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?` `${selectedProfileDisplayName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?`
) )
) { ) {
return; return;
@@ -682,7 +715,7 @@ const selectedAutorunOptions = (): ResetAutorunOption[] => {
const requestReset = async () => { const requestReset = async () => {
clearStatus(); clearStatus();
if (!selectedProfileName.value || activeOperation.value) { if (!selectedProfileName.value || !selectedProfileIdentityReady.value || activeOperation.value) {
return; return;
} }
if ((form.sourceMode !== 'CURRENT' && !form.sourceRef.trim()) || form.scenarioId === null) { if ((form.sourceMode !== 'CURRENT' && !form.sourceRef.trim()) || form.scenarioId === null) {
@@ -698,7 +731,7 @@ const requestReset = async () => {
form.sourceMode === 'CURRENT' ? '서버 지정 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋'; form.sourceMode === 'CURRENT' ? '서버 지정 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
if ( if (
!window.confirm( !window.confirm(
`${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}${form.publishSchedule ? '\n예약 등록 즉시 로비에 오픈 일정을 공개합니다.' : ''}` `${selectedProfileDisplayName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}${form.publishSchedule ? '\n예약 등록 즉시 로비에 오픈 일정을 공개합니다.' : ''}`
) )
) { ) {
return; return;
@@ -746,13 +779,13 @@ const requestReset = async () => {
const requestGameCancellation = async () => { const requestGameCancellation = async () => {
clearStatus(); clearStatus();
const profileName = selectedProfileName.value; const profileName = selectedProfileName.value;
if (!profileName || activeOperation.value) return; if (!profileName || !selectedProfileIdentityReady.value || activeOperation.value) return;
if (cancellationForm.reason.trim().length < 5) { if (cancellationForm.reason.trim().length < 5) {
errorMessage.value = '취소 사유를 5자 이상 입력해주세요.'; errorMessage.value = '취소 사유를 5자 이상 입력해주세요.';
return; return;
} }
if (cancellationForm.confirmation.trim() !== profileName) { if (cancellationForm.confirmation.trim() !== cancellationConfirmation.value) {
errorMessage.value = `확인란에 ${profileName} 정확히 입력해주세요.`; errorMessage.value = `확인란에 ${cancellationConfirmation.value} 정확히 입력해주세요.`;
return; return;
} }
const historyText = const historyText =
@@ -760,7 +793,7 @@ const requestGameCancellation = async () => {
const generalText = cancellationForm.generalMode === 'RETAIN' ? '장수 기록 보존' : '장수 기록 삭제'; const generalText = cancellationForm.generalMode === 'RETAIN' ? '장수 기록 보존' : '장수 기록 삭제';
if ( if (
!window.confirm( !window.confirm(
`${profileName}의 진행 중 게임을 취소합니다.\n${historyText}\n${generalText}\n유산 획득분 ${cancellationForm.earnedPointRetentionPercent}% 보전\n취소 후 시나리오 초기화 전에는 재개할 수 없습니다.` `${selectedProfileDisplayName.value}의 진행 중 게임을 취소합니다.\n${historyText}\n${generalText}\n유산 획득분 ${cancellationForm.earnedPointRetentionPercent}% 보전\n취소 후 시나리오 초기화 전에는 재개할 수 없습니다.`
) )
) { ) {
return; return;
@@ -853,6 +886,7 @@ onMounted(async () => {
componentMounted = true; componentMounted = true;
await Promise.all([ await Promise.all([
loadCapabilities(), loadCapabilities(),
loadProfileIdentities(),
loadState(), loadState(),
loadResetDefaults(), loadResetDefaults(),
props.mode === 'scenario' ? loadScenarios() : Promise.resolve(), props.mode === 'scenario' ? loadScenarios() : Promise.resolve(),
@@ -887,6 +921,7 @@ onBeforeUnmount(() => {
<ServerProfileTabs <ServerProfileTabs
v-if="mode !== 'gateway' && profileName" v-if="mode !== 'gateway' && profileName"
:profile-name="profileName" :profile-name="profileName"
:profile-label="selectedProfileDisplayName"
:active-tab="mode === 'scenario' ? 'scenario' : mode === 'cancel' ? 'cancel' : 'version'" :active-tab="mode === 'scenario' ? 'scenario' : mode === 'cancel' ? 'cancel' : 'version'"
:can-deploy="hasCapability('admin.profiles.deploy')" :can-deploy="hasCapability('admin.profiles.deploy')"
:can-reset="hasCapability('admin.scenarios.reset')" :can-reset="hasCapability('admin.scenarios.reset')"
@@ -977,11 +1012,11 @@ onBeforeUnmount(() => {
></textarea> ></textarea>
</label> </label>
<label class="block text-sm text-zinc-300"> <label class="block text-sm text-zinc-300">
확인을 위해 <strong>{{ selectedProfileName }}</strong> 입력 확인을 위해 <strong>{{ cancellationConfirmation }}</strong> 입력
<input <input
v-model="cancellationForm.confirmation" v-model="cancellationForm.confirmation"
class="mt-1 w-full rounded border border-red-800 bg-zinc-950 px-3 py-2 font-mono" class="mt-1 w-full rounded border border-red-800 bg-zinc-950 px-3 py-2 font-mono"
:placeholder="selectedProfileName" :placeholder="cancellationConfirmation"
data-testid="cancellation-confirmation" data-testid="cancellation-confirmation"
/> />
</label> </label>
@@ -990,9 +1025,10 @@ onBeforeUnmount(() => {
class="w-full rounded bg-red-700 px-4 py-3 font-bold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-40" class="w-full rounded bg-red-700 px-4 py-3 font-bold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-40"
:disabled=" :disabled="
submitting || submitting ||
!selectedProfileIdentityReady ||
Boolean(activeOperation) || Boolean(activeOperation) ||
cancellationForm.reason.trim().length < 5 || cancellationForm.reason.trim().length < 5 ||
cancellationForm.confirmation.trim() !== selectedProfileName cancellationForm.confirmation.trim() !== cancellationConfirmation
" "
data-testid="request-game-cancellation" data-testid="request-game-cancellation"
> >
@@ -1454,7 +1490,12 @@ onBeforeUnmount(() => {
v-if="mode === 'version'" v-if="mode === 'version'"
type="submit" 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" 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()" :disabled="
submitting ||
!selectedProfileIdentityReady ||
Boolean(activeOperation) ||
!form.sourceRef.trim()
"
data-testid="request-deploy" data-testid="request-deploy"
@click="requestDeploy" @click="requestDeploy"
> >
@@ -1464,7 +1505,12 @@ onBeforeUnmount(() => {
v-else v-else
type="submit" type="submit"
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" 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 === null" :disabled="
submitting ||
!selectedProfileIdentityReady ||
Boolean(activeOperation) ||
form.scenarioId === null
"
data-testid="request-reset" data-testid="request-reset"
> >
{{ form.scheduledAt ? '시나리오 초기화 예약' : '시나리오 초기화' }} {{ form.scheduledAt ? '시나리오 초기화 예약' : '시나리오 초기화' }}
@@ -1783,7 +1829,9 @@ onBeforeUnmount(() => {
> >
<span class="text-zinc-600">{{ formatLogTime(entry.createdAt) }}</span> <span class="text-zinc-600">{{ formatLogTime(entry.createdAt) }}</span>
<span class="ml-2 text-violet-300">[{{ entry.phase }}]</span> <span class="ml-2 text-violet-300">[{{ entry.phase }}]</span>
<span class="ml-2 whitespace-pre-wrap break-all">{{ entry.message }}</span> <span class="ml-2 whitespace-pre-wrap break-all">{{
displayOperationText(entry.message)
}}</span>
</div> </div>
</div> </div>
</section> </section>
@@ -1903,9 +1951,9 @@ onBeforeUnmount(() => {
> >
<dl class="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2"> <dl class="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
<div> <div>
<dt class="text-[11px] font-semibold text-zinc-500">서버 ID</dt> <dt class="text-[11px] font-semibold text-zinc-500">서버</dt>
<dd class="mt-1 break-all font-mono text-xs text-zinc-300"> <dd class="mt-1 break-all text-xs text-zinc-300">
{{ operation.profileName }} {{ profileDisplayName(operation.profileName) }}
</dd> </dd>
</div> </div>
<div> <div>
@@ -1953,7 +2001,7 @@ onBeforeUnmount(() => {
<div v-if="operation.error" class="sm:col-span-2"> <div v-if="operation.error" class="sm:col-span-2">
<dt class="text-[11px] font-semibold text-red-400">오류</dt> <dt class="text-[11px] font-semibold text-red-400">오류</dt>
<dd class="mt-1 whitespace-pre-wrap break-all text-xs text-red-300"> <dd class="mt-1 whitespace-pre-wrap break-all text-xs text-red-300">
{{ operation.error }} {{ displayOperationText(operation.error) }}
</dd> </dd>
</div> </div>
</dl> </dl>
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, it } from 'node:test';
const collectVueFiles = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(
entries.map((entry) => {
const path = join(directory, entry.name);
return entry.isDirectory() ? collectVueFiles(path) : entry.name.endsWith('.vue') ? [path] : [];
})
);
return nested.flat();
};
describe('gateway profile display boundary', () => {
it('keeps immutable raw profile names out of ordinary Vue copy', async () => {
const files = await collectVueFiles(new URL('../src', import.meta.url).pathname);
const forbidden = [
{ pattern: /\{\{\s*(?:profile|policy|operation)\.profileName\s*\}\}/, reason: 'direct interpolation' },
{ pattern: /서버 ID:/, reason: 'raw server ID label' },
{ pattern: /che:default/, reason: 'raw default profile example' },
{ pattern: /profile:scenario/, reason: 'raw profile placeholder' },
{
pattern: /currentScenario\s*\?\?\s*profile\.profileName/,
reason: 'raw notification fallback',
},
];
for (const file of files) {
const source = await readFile(file, 'utf8');
for (const contract of forbidden) {
assert.doesNotMatch(source, contract.pattern, `${file}: ${contract.reason}`);
}
}
});
});
+7 -5
View File
@@ -34,21 +34,23 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
`aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다. `aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다.
- `profileName``${profile}:${instanceKey}` 형식의 불변 기술 ID입니다. - `profileName``${profile}:${instanceKey}` 형식의 불변 기술 ID입니다.
`che:default``default`는 현재 시나리오가 아니라 기본 인스턴스 키입니다. `che:default``default`는 현재 시나리오가 아니라 기본 인스턴스 키입니다.
좌측 메뉴는 기본 인스턴스의 suffix를 숨기고 표시명만 보여 주며, 상태 상세에서 route, 권한 scope, mutation payload, DB FK, process와 Redis namespace는 이 값을
기술 ID·인스턴스 키·nullable 현재 시나리오를 분리해 확인할 수 있습니다. 유지합니다. 화면·알림·확인 문구·작업 로그는 API의 `displayName`만 소비하여 기본
인스턴스는 `체`, 비기본 인스턴스는 `체 [2]`처럼 구분합니다.
- 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가 - 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를 이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세 기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
runtime·빌드 상태는 상태 설정 탭에서 확인합니다. runtime·빌드 상태는 상태 설정 탭에서 확인합니다.
- 버전 업데이트·시나리오 초기화·게임 취소 화면의 작업 이력은 요청·작업, 상태, - 버전 업데이트·시나리오 초기화·게임 취소 화면의 작업 이력은 요청·작업, 상태,
보기의 3열 요약으로 표시합니다. `진행 중`, `완료`, `실패` 등 현재 상태와 로그를 보기의 3열 요약으로 표시합니다. `진행 중`, `완료`, `실패` 등 현재 상태와 로그를
먼저 확인하고, 서버 ID·작업 ID·소스·커밋·요청자·사유·완료 시각·오류 원문은 먼저 확인하고, 서버 표시명·작업 ID·소스·커밋·요청자·사유·완료 시각·오류 원문은
각 작업의 `상세` 행에서 확인합니다. 상세 버튼은 `aria-expanded` 각 작업의 `상세` 행에서 확인합니다. 상세 버튼은 `aria-expanded`
`aria-controls`로 연결되며 500px 화면에서도 페이지 가로 스크롤 없이 열립니다. `aria-controls`로 연결되며 500px 화면에서도 페이지 가로 스크롤 없이 열립니다.
- 공통 좌측 메뉴는 `admin.profiles.listNavigation`으로 접근 가능한 profile의 이름과 - 공통 좌측 메뉴는 `admin.profiles.listNavigation`으로 접근 가능한 profile의 이름과
표시명만 읽습니다. 이 요청은 PM2 runtime 상태를 포함하는 본문용 표시명만 읽습니다. 이 요청은 PM2 runtime 상태를 포함하는 본문용
`admin.profiles.list`와 별도의 non-batch 요청으로 전송하므로, 상태 조회가 늦거나 `admin.profiles.list`와 별도의 non-batch 요청으로 전송하되 한 페이지 안의 layout과
중단되어도 관리자 capability와 서버 메뉴를 함께 기다리게 하지 않습니다. 작업 화면은 같은 in-flight 결과를 공유합니다. 상태 조회가 늦거나 중단되어도
관리자 capability와 서버 메뉴를 함께 기다리게 하지 않습니다.
- `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET` - `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET`
현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다. 현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다.
- `CANCEL_GAME``RESET`과 다른 비가역 운영 작업입니다. `PREOPEN`, `RUNNING`, - `CANCEL_GAME``RESET`과 다른 비가역 운영 작업입니다. `PREOPEN`, `RUNNING`,