fix: Gateway 화면에서 raw 프로필 ID 노출을 제거한다

불변 profileName은 라우팅과 저장 경계에 유지하고 사용자 출력은 공통 표시명을 사용한다. 기본 인스턴스 suffix를 숨기고 비기본 인스턴스만 사람이 읽을 수 있게 구분한다.
This commit is contained in:
2026-08-24 07:15:25 +00:00
parent 8837e49d62
commit d80ea1bd0e
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 { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.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';
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
@@ -650,7 +654,25 @@ export const adminRouter = router({
})
.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({
getNotice: adminProcedure.query(async ({ ctx }) => {
@@ -760,6 +782,13 @@ export const adminRouter = router({
specialAccessGrants,
profiles: profiles.map((profile) => ({
profileName: profile.profileName,
profile: profile.profile,
instanceKey: profile.instanceKey,
displayName: resolveGatewayProfileDisplayName(
profile.profile,
profile.instanceKey,
profile.meta.korName
),
...resolveLocalAccountProfilePolicy({
profile: profile.profile,
profileName: profile.profileName,
@@ -1727,6 +1756,11 @@ export const adminRouter = router({
profileName: profile.profileName,
profile: profile.profile,
instanceKey: profile.instanceKey,
displayName: resolveGatewayProfileDisplayName(
profile.profile,
profile.instanceKey,
profile.meta.korName
),
currentScenario: profile.currentScenario,
meta: {
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]));
return profiles.map((profile) => ({
...profile,
displayName: resolveGatewayProfileDisplayName(
profile.profile,
profile.instanceKey,
profile.meta.korName
),
runtimeActions: runtimeActionsByProfile.get(profile.profileName) ?? [],
runtimeSettings: runtimeSettingsMap.get(profile.profileName) ?? null,
activeOperation: activeOperationByProfile.get(profile.profileName) ?? null,
@@ -1805,7 +1844,7 @@ export const adminRouter = router({
if (sourceMode === 'CURRENT') {
if (!input?.profileName) {
if (!adminAuth.isSuperuser) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'profileName is required.' });
throw new TRPCError({ code: 'BAD_REQUEST', message: '대상 서버를 선택해야 합니다.' });
}
} else {
assertPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, input.profileName);
@@ -23,7 +23,7 @@ import {
import { isRecord } from '@sammo-ts/common';
import { resolveGatewayPostgresConfigFromEnv } from '../gatewayPostgresConfig.js';
import { resolveGatewayProfileKoreanName } from '../profileOrder.js';
import { resolveGatewayProfileDisplayName, resolveGatewayProfileKoreanName } from '../profileOrder.js';
import {
buildTurboReleaseCommand,
@@ -1626,7 +1626,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
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'), {
signal: this.activeOperationAbortSignal,
});
@@ -2233,7 +2237,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.appendOperationLog(
operationId,
'build',
`${profile?.profileName ?? 'profile'} 구성 요소를 빌드합니다.`
`${
profile
? resolveGatewayProfileDisplayName(profile.profile, profile.instanceKey, profile.meta.korName)
: '대상 서버'
} 구성 요소를 빌드합니다.`
);
}
return {
@@ -2435,7 +2443,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private async stageStaticProfileFrontend(profile: GatewayProfileRecord): Promise<StagedFrontendArtifact> {
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 sharedSourceRoot = buildSharedProfileFrontendOutDir(runtimeWorkspace);
+13
View File
@@ -20,6 +20,19 @@ export const resolveGatewayProfileKoreanName = (profile: string, configuredName?
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 = (
left: { 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 webPush from 'web-push';
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
export interface WebPushCoordinatorConfig {
enabled: boolean;
vapidSubject?: string;
@@ -104,7 +106,14 @@ export class WebPushCoordinator {
const [profiles, preferences, subscriptionCount, currentSubscription] = await Promise.all([
this.prisma.gatewayProfile.findMany({
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({
where: { userId },
@@ -128,7 +137,15 @@ export class WebPushCoordinator {
capability: this.getCapability(),
eventTypes: WEB_PUSH_EVENT_TYPES,
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),
})),
preferences: preferences.filter((preference) => isWebPushEventType(preference.eventType)),
@@ -218,7 +235,7 @@ export class WebPushCoordinator {
if (!this.configured) return false;
const profile = await tx.gatewayProfile.findUnique({
where: { profileName: event.profileName },
select: { profile: true, profileName: true },
select: { profile: true, profileName: true, instanceKey: true, meta: true },
});
if (!profile) return false;
const receipt = await tx.webPushEventReceipt.createMany({
@@ -258,7 +275,16 @@ export class WebPushCoordinator {
ids.push(subscription.id);
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) {
const subscriptionIds = subscriptionIdsByUser.get(userId) ?? [];
if (subscriptionIds.length === 0) continue;
@@ -395,10 +421,7 @@ export class WebPushCoordinator {
});
for (const delivery of claimed) {
if (
delivery.subscription.expirationTime &&
delivery.subscription.expirationTime.getTime() <= Date.now()
) {
if (delivery.subscription.expirationTime && delivery.subscription.expirationTime.getTime() <= Date.now()) {
await this.prisma.$transaction(async (tx) => {
await tx.webPushDelivery.updateMany({
where: { id: delivery.id, lockOwner: this.owner },
@@ -445,11 +468,15 @@ export class WebPushCoordinator {
typeof error === 'object' && error !== null && 'statusCode' in error
? Number((error as { statusCode?: unknown }).statusCode)
: 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 exhausted = 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 tx.webPushDelivery.updateMany({
where: { id: delivery.id, lockOwner: this.owner },
@@ -380,6 +380,7 @@ describe('admin profile navigation API', () => {
profileName: 'che:2',
profile: 'che',
instanceKey: '2',
displayName: '체 [2]',
currentScenario: '2',
meta: { korName: '체' },
},
+10
View File
@@ -4,6 +4,7 @@ import {
GATEWAY_PROFILE_KOREAN_NAMES,
GATEWAY_PROFILE_ORDER,
orderGatewayProfiles,
resolveGatewayProfileDisplayName,
resolveGatewayProfileKoreanName,
} from '../src/profileOrder.js';
@@ -56,3 +57,12 @@ describe('resolveGatewayProfileKoreanName', () => {
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: [
{
profileName: 'che:default',
profile: 'che',
instanceKey: 'default',
displayName: '체',
requiresKakaoVerification: true,
kakaoVerified: false,
accessAllowed: true,
@@ -219,15 +222,16 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
await page.getByRole('button', { name: /접근 · 권한/ }).click();
await expect(page.getByRole('option', { name: /Profile 전체 운영/ })).toHaveCount(0);
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.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
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.getByRole('button', { name: '특수 접근 부여', exact: true }).click();
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 });
const gracePanel = page.getByRole('heading', { name: 'Kakao 인증 유예' }).locator('..');
@@ -165,6 +165,7 @@ const installFixture = async (
profileName: 'hwe:default',
profile: 'hwe',
instanceKey: 'default',
displayName: '훼',
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
meta: {},
},
@@ -177,6 +178,7 @@ const installFixture = async (
profileName: 'hwe:default',
profile: 'hwe',
instanceKey: 'default',
displayName: '훼',
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
scenario: options.currentScenario ?? 'default',
apiPort: 15015,
@@ -542,7 +544,7 @@ test('directs profile deployment to the selected server version tab', async ({ p
const tabAndHeaderGeometry = await Promise.all([
tabs.evaluate((element) => element.getBoundingClientRect().top),
page
.getByText('서버 ID: hwe:default · 인스턴스: default', { exact: true })
.getByText('현재 시나리오: 1010', { exact: true })
.evaluate((element) => element.getBoundingClientRect().top),
]);
expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]);
@@ -48,6 +48,7 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
profileName: 'hwe:2',
profile: 'hwe',
instanceKey: '2',
displayName: '환상서버 [2]',
currentScenario: '1010',
scenario: '1010',
status: 'RUNNING',
@@ -68,6 +69,7 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
profileName: 'hwe:2',
profile: 'hwe',
instanceKey: '2',
displayName: '환상서버 [2]',
currentScenario: '1010',
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 baseBackground = await adminLink.evaluate((element) => getComputedStyle(element).backgroundColor);
await adminLink.hover();
await expect.poll(() => adminLink.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe(
baseBackground
);
await expect
.poll(() => adminLink.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe(baseBackground);
await adminLink.focus();
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 });
const adminNarrowGeometry = await measureAccountActions(adminPage);
expect(adminNarrowGeometry.documentWidth).toBe(adminNarrowGeometry.viewportWidth);
expect(adminNarrowGeometry.items[0]?.top).toBeCloseTo(adminNarrowGeometry.items[1]?.top ?? 0, 0);
expect(adminNarrowGeometry.items[2]?.top).toBeCloseTo(
(adminNarrowGeometry.items[1]?.bottom ?? 0) + 16,
0
);
expect(adminNarrowGeometry.items[2]?.top).toBeCloseTo((adminNarrowGeometry.items[1]?.bottom ?? 0) + 16, 0);
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 });
const adminSmallGeometry = await measureAccountActions(adminPage);
@@ -71,6 +71,7 @@ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown
profileName: 'che:default',
profile: 'che',
instanceKey: 'default',
displayName: '천하서버',
currentScenario: '2',
scenario: '2',
apiPort: 15003,
@@ -200,6 +201,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
profileName: 'che:default',
profile: 'che',
instanceKey: 'default',
displayName: '천하서버',
currentScenario: '2',
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.getByTestId('operations-table')).toContainText('시나리오 초기화');
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-status')).toContainText('SUCCEEDED');
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 expect(detailsToggle).toHaveAttribute('aria-expanded', 'true');
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('admin');
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.getByTestId('operations-table')).toContainText('버전 업데이트');
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-status')).toContainText('SUCCEEDED');
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 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 })).toHaveCount(0);
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-retention-percent').fill('35');
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');
await expect(cancelButton).toBeEnabled();
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 profileLink = navigation.getByRole('link', { name: '천하서버' });
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.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 profileLink.focus();
const desktop = await profileLink.evaluate((element) => {
@@ -59,6 +59,8 @@ const installFixture = async (page: Page) => {
{
profileName: 'hwe:default',
profile: 'hwe',
instanceKey: 'default',
displayName: '훼',
currentScenario: 'default',
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');
await expect(table).toBeVisible();
await expect(table).toContainText('준비됨 · 운영 비활성');
await expect(table).toContainText('훼 · default');
await expect(table).not.toContainText('hwe:default');
await expect(page.getByRole('button', { name: '이 기기 알림 켜기' })).toBeDisabled();
const checkboxes = table.getByRole('checkbox');
await expect(checkboxes).toHaveCount(9);
@@ -5,6 +5,7 @@ type ServerProfileTab = 'status' | 'version' | 'scenario' | 'cancel';
const props = defineProps<{
profileName: string;
profileLabel: string;
activeTab: ServerProfileTab;
canDeploy: boolean;
canReset: boolean;
@@ -45,7 +46,7 @@ const tabs = computed(() =>
<nav
class="server-profile-tabs"
:style="{ '--server-tab-count': tabs.length }"
:aria-label="`${profileName} 서버 관리 `"
:aria-label="`${profileLabel} 서버 관리 `"
data-testid="server-profile-tabs"
>
<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 { useAuthStore } from '../stores/auth';
import { directTrpc } from '../utils/trpc';
import { loadAdminProfileNavigation, type AdminProfileNavigationItem } from '../composables/useAdminProfileNavigation';
defineProps<{
title: string;
@@ -14,30 +15,9 @@ const menuOpen = ref(false);
const auth = useAuthStore();
const adminNavigationClient = directTrpc.admin as unknown as {
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 profiles = ref<
Array<{
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
meta?: Record<string, unknown>;
}>
>([]);
const profiles = ref<AdminProfileNavigationItem[]>([]);
const isRootAdmin = computed(() =>
(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 korName = profile.meta?.korName;
const displayName = typeof korName === 'string' && korName.trim() ? korName.trim() : profile.profile;
return profile.instanceKey === 'default' ? displayName : `${displayName} [${profile.instanceKey}]`;
const profileDisplayName = (profile: (typeof profiles.value)[number]): 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 navigation = computed(() => [
@@ -86,8 +68,8 @@ const navigation = computed(() => [
},
...profiles.value.map((profile) => ({
to: `/admin/servers/${encodeURIComponent(profile.profileName)}`,
label: profileLabel(profile),
title: `서버 ID: ${profile.profileName}`,
label: profileDisplayName(profile),
title: `${profileDisplayName(profile)} 서버 관리`,
icon: '└',
exact: false,
visible: true,
@@ -129,7 +111,7 @@ const navigation = computed(() => [
onMounted(async () => {
const [capabilityResult, profileResult] = await Promise.allSettled([
adminNavigationClient.capabilities.list.query(),
adminNavigationClient.profiles.listNavigation.query(),
loadAdminProfileNavigation(),
]);
capabilities.value = capabilityResult.status === 'fulfilled' ? capabilityResult.value : [];
profiles.value = profileResult.status === 'fulfilled' ? profileResult.value : [];
@@ -851,7 +851,8 @@ onBeforeUnmount(() => {
:key="profile.profileName"
:value="profile.profileName"
>
{{ profile.profile }} · {{ profile.currentScenario ?? profile.profileName }}
{{ profile.displayName ?? profile.profile }} ·
{{ profile.currentScenario ?? '시나리오 미설정' }}
</option>
</select>
<span v-if="currentProfile" class="notification-profile-status">{{
+102 -33
View File
@@ -134,6 +134,7 @@ type AdminAuditEvent = {
targetType?: string;
targetId?: string;
profileName?: string;
profileDisplayName?: string;
action: string;
outcome: 'STARTED' | 'SUCCEEDED' | 'FAILED';
reason?: string;
@@ -144,6 +145,9 @@ type AdminAuditEvent = {
type KakaoGracePolicy = {
profileName: string;
profile: string;
instanceKey: string;
displayName: string;
requiresKakaoVerification: boolean;
kakaoVerified: boolean;
accessAllowed: boolean;
@@ -185,6 +189,7 @@ type AdminProfile = {
profileName: string;
profile: string;
instanceKey: string;
displayName?: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string;
@@ -444,6 +449,14 @@ const visibleProfiles = computed(() =>
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 => {
return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL');
};
@@ -566,7 +579,7 @@ const kakaoGraceStatus = ref('');
const kakaoPolicies = ref<KakaoGracePolicy[]>([]);
const specialAccessGrants = ref<SpecialAccountAccessGrant[]>([]);
const specialAccessKind = ref<SpecialAccountAccessGrant['kind']>('RECOVERY');
const specialAccessProfiles = ref('');
const specialAccessProfiles = ref<string[]>([]);
const specialAccessAllowsGeneralCreation = ref(true);
const specialAccessExpiresAt = ref('');
const specialAccessStatus = ref('');
@@ -574,6 +587,38 @@ const userHistory = ref<AdminAuditEvent[]>([]);
const globalAuditHistory = ref<AdminAuditEvent[]>([]);
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 actionFeedback = [
noticeStatus,
@@ -1059,7 +1104,7 @@ const applyCapabilitySelection = () => {
const capability = capabilities.value.find((entry) => entry.permission === selectedCapability.value);
if (!capability) return;
if (capability.scope === 'PROFILE' && !capabilityProfile.value.trim()) {
rolesStatus.value = 'Profile 범위를 입력하세요.';
rolesStatus.value = '대상 서버를 선택하세요.';
return;
}
rolesInput.value =
@@ -1101,10 +1146,7 @@ const grantSpecialAccess = async () => {
await adminClient.users.grantSpecialAccess.mutate({
userId: userResult.value.id,
kind: specialAccessKind.value,
profiles: specialAccessProfiles.value
.split(',')
.map((profile) => profile.trim())
.filter(Boolean),
profiles: specialAccessProfiles.value,
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
expiresAt: specialAccessExpiresAt.value
? (serverDateTimeInputToIso(specialAccessExpiresAt.value) ?? null)
@@ -1266,7 +1308,7 @@ const applyRestriction = async () => {
const reason = requireUserActionReason();
if (!reason) return;
if (!restrictionProfile.value.trim()) {
restrictionStatus.value = '서버 프로필명을 입력하세요.';
restrictionStatus.value = '대상 서버를 선택하세요.';
return;
}
const features = restrictionFeatures.value
@@ -1301,7 +1343,7 @@ const clearRestriction = async () => {
const reason = requireUserActionReason();
if (!reason) return;
if (!restrictionProfile.value.trim()) {
restrictionStatus.value = '서버 프로필명을 입력하세요.';
restrictionStatus.value = '대상 서버를 선택하세요.';
return;
}
try {
@@ -1706,13 +1748,21 @@ onMounted(() => {
{{ capability.label }} · {{ capability.risk }}
</option>
</select>
<input
<select
v-model="capabilityProfile"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="Profile 범위 (예: che:default)"
:disabled="!hasUser"
/>
:disabled="!hasUser || !kakaoPolicies.length"
aria-label="권한 대상 서버"
>
<option value="">대상 서버 선택</option>
<option
v-for="policy in kakaoPolicies"
:key="policy.profileName"
:value="policy.profileName"
>
{{ policy.displayName }}
</option>
</select>
<button
class="bg-zinc-700 hover:bg-zinc-600 px-3 py-2 rounded text-sm"
:disabled="!hasUser"
@@ -1779,13 +1829,22 @@ onMounted(() => {
:disabled="!hasUser"
aria-label="특수 접근 만료 시각"
/>
<input
v-model="specialAccessProfiles"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="che 또는 che:2 (쉼표 구분, 비우면 전체)"
:disabled="!hasUser"
/>
<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
v-model="specialAccessProfiles"
type="checkbox"
:value="option.value"
:disabled="!hasUser"
/>
{{ option.label }}
</label>
</fieldset>
<label class="flex items-center gap-2 text-sm text-zinc-300 px-2">
<input
v-model="specialAccessAllowsGeneralCreation"
@@ -1812,7 +1871,11 @@ onMounted(() => {
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-amber-200">
{{ grant.kind }} ·
{{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }}
{{
grant.profiles.length
? grant.profiles.map(profileScopeLabel).join(', ')
: '전체 서버'
}}
</span>
<button
v-if="!grant.revokedAt"
@@ -1871,7 +1934,7 @@ onMounted(() => {
<table class="w-full min-w-[620px] text-xs">
<thead class="text-zinc-500">
<tr>
<th class="p-2 text-left">Profile</th>
<th class="p-2 text-left">서버</th>
<th>접근</th>
<th>장수 생성</th>
<th>기본 접근 유예</th>
@@ -1885,7 +1948,7 @@ onMounted(() => {
:key="policy.profileName"
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.canCreateGeneral ? '허용' : '차단' }}</td>
<td class="text-center">{{ policy.accessGraceDays }}</td>
@@ -1944,13 +2007,21 @@ onMounted(() => {
>
<h4 class="text-base font-semibold">서버별 기능 제재 (서버 시간 UTC+9)</h4>
<div class="grid gap-2">
<input
<select
v-model="restrictionProfile"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="profile:scenario"
:disabled="!hasUser"
/>
:disabled="!hasUser || !kakaoPolicies.length"
aria-label="제재 대상 서버"
>
<option value="">대상 서버 선택</option>
<option
v-for="policy in kakaoPolicies"
:key="policy.profileName"
:value="policy.profileName"
>
{{ policy.displayName }}
</option>
</select>
<input
v-model="restrictionFeatures"
type="text"
@@ -2115,7 +2186,7 @@ onMounted(() => {
</div>
<div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.targetType ?? '-' }}
{{ event.targetId ?? event.profileName ?? '' }} · {{ event.reason ?? '사유 없음' }}
{{ auditTargetLabel(event) }} · {{ event.reason ?? '사유 없음' }}
</div>
</div>
</div>
@@ -2180,6 +2251,7 @@ onMounted(() => {
>
<ServerProfileTabs
:profile-name="profile.profileName"
:profile-label="adminProfileDisplayName(profile)"
active-tab="status"
:can-deploy="hasCapability('admin.profiles.deploy', 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>
<div class="text-base font-semibold">
{{ profile.meta.korName ?? profile.profile }}
</div>
<div class="text-xs text-zinc-500">
서버 ID: {{ profile.profileName }} · 인스턴스: {{ profile.instanceKey }}
{{ adminProfileDisplayName(profile) }}
</div>
<div class="text-xs text-zinc-500">
현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch }
import CompactHelp from '../components/CompactHelp.vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import { useToast } from '../composables/useToast';
import { loadAdminProfileNavigation, type AdminProfileNavigationItem } from '../composables/useAdminProfileNavigation';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import {
normalizeProfileResetDefaults,
@@ -103,6 +104,28 @@ const gatewayReleaseLogConnection = ref<'idle' | 'connected' | 'reconnecting'>('
const gatewayReleaseLogViewport = ref<HTMLElement>();
const gatewayReleaseAvailable = ref(false);
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 loading = ref(false);
const catalogLoading = ref(false);
@@ -205,7 +228,7 @@ const profileOperationLogEmptyMessage = computed(() => {
return '오케스트레이터 로그를 기다리고 있습니다…';
}
if (operation.error) {
return `이 작업에는 진행 로그가 기록되지 않았습니다. 작업 오류: ${operation.error}`;
return `이 작업에는 진행 로그가 기록되지 않았습니다. 작업 오류: ${displayOperationText(operation.error)}`;
}
return '이 작업에는 진행 로그가 기록되지 않았습니다. 로그 기능 적용 전 작업일 수 있습니다.';
});
@@ -229,9 +252,9 @@ const hasCapability = (permission: string): boolean =>
const pageTitle = computed(() => {
if (props.mode === 'gateway') return 'Gateway 릴리스';
if (props.mode === 'cancel') return `${props.profileName ?? ''} 게임 취소`;
if (props.mode === 'scenario') return `${props.profileName ?? ''} 시나리오 초기화`;
return `${props.profileName ?? ''} 버전 업데이트`;
if (props.mode === 'cancel') return `${selectedProfileDisplayName.value} 게임 취소`;
if (props.mode === 'scenario') return `${selectedProfileDisplayName.value} 시나리오 초기화`;
return `${selectedProfileDisplayName.value} 버전 업데이트`;
});
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 () => {
try {
capabilities.value = (await adminClient.capabilities.list.query()) as typeof capabilities.value;
@@ -531,6 +563,7 @@ const requestDeploy = async () => {
clearStatus();
if (
!selectedProfileName.value ||
!selectedProfileIdentityReady.value ||
activeOperation.value ||
!form.sourceRef.trim() ||
form.sourceMode === 'CURRENT'
@@ -539,7 +572,7 @@ const requestDeploy = async () => {
}
if (
!window.confirm(
`${selectedProfileName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?`
`${selectedProfileDisplayName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?`
)
) {
return;
@@ -682,7 +715,7 @@ const selectedAutorunOptions = (): ResetAutorunOption[] => {
const requestReset = async () => {
clearStatus();
if (!selectedProfileName.value || activeOperation.value) {
if (!selectedProfileName.value || !selectedProfileIdentityReady.value || activeOperation.value) {
return;
}
if ((form.sourceMode !== 'CURRENT' && !form.sourceRef.trim()) || form.scenarioId === null) {
@@ -698,7 +731,7 @@ const requestReset = async () => {
form.sourceMode === 'CURRENT' ? '서버 지정 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
if (
!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;
@@ -746,13 +779,13 @@ const requestReset = async () => {
const requestGameCancellation = async () => {
clearStatus();
const profileName = selectedProfileName.value;
if (!profileName || activeOperation.value) return;
if (!profileName || !selectedProfileIdentityReady.value || activeOperation.value) return;
if (cancellationForm.reason.trim().length < 5) {
errorMessage.value = '취소 사유를 5자 이상 입력해주세요.';
return;
}
if (cancellationForm.confirmation.trim() !== profileName) {
errorMessage.value = `확인란에 ${profileName} 정확히 입력해주세요.`;
if (cancellationForm.confirmation.trim() !== cancellationConfirmation.value) {
errorMessage.value = `확인란에 ${cancellationConfirmation.value} 정확히 입력해주세요.`;
return;
}
const historyText =
@@ -760,7 +793,7 @@ const requestGameCancellation = async () => {
const generalText = cancellationForm.generalMode === 'RETAIN' ? '장수 기록 보존' : '장수 기록 삭제';
if (
!window.confirm(
`${profileName}의 진행 중 게임을 취소합니다.\n${historyText}\n${generalText}\n유산 획득분 ${cancellationForm.earnedPointRetentionPercent}% 보전\n취소 후 시나리오 초기화 전에는 재개할 수 없습니다.`
`${selectedProfileDisplayName.value}의 진행 중 게임을 취소합니다.\n${historyText}\n${generalText}\n유산 획득분 ${cancellationForm.earnedPointRetentionPercent}% 보전\n취소 후 시나리오 초기화 전에는 재개할 수 없습니다.`
)
) {
return;
@@ -853,6 +886,7 @@ onMounted(async () => {
componentMounted = true;
await Promise.all([
loadCapabilities(),
loadProfileIdentities(),
loadState(),
loadResetDefaults(),
props.mode === 'scenario' ? loadScenarios() : Promise.resolve(),
@@ -887,6 +921,7 @@ onBeforeUnmount(() => {
<ServerProfileTabs
v-if="mode !== 'gateway' && profileName"
:profile-name="profileName"
:profile-label="selectedProfileDisplayName"
:active-tab="mode === 'scenario' ? 'scenario' : mode === 'cancel' ? 'cancel' : 'version'"
:can-deploy="hasCapability('admin.profiles.deploy')"
:can-reset="hasCapability('admin.scenarios.reset')"
@@ -977,11 +1012,11 @@ onBeforeUnmount(() => {
></textarea>
</label>
<label class="block text-sm text-zinc-300">
확인을 위해 <strong>{{ selectedProfileName }}</strong> 입력
확인을 위해 <strong>{{ cancellationConfirmation }}</strong> 입력
<input
v-model="cancellationForm.confirmation"
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"
/>
</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"
:disabled="
submitting ||
!selectedProfileIdentityReady ||
Boolean(activeOperation) ||
cancellationForm.reason.trim().length < 5 ||
cancellationForm.confirmation.trim() !== selectedProfileName
cancellationForm.confirmation.trim() !== cancellationConfirmation
"
data-testid="request-game-cancellation"
>
@@ -1454,7 +1490,12 @@ onBeforeUnmount(() => {
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()"
:disabled="
submitting ||
!selectedProfileIdentityReady ||
Boolean(activeOperation) ||
!form.sourceRef.trim()
"
data-testid="request-deploy"
@click="requestDeploy"
>
@@ -1464,7 +1505,12 @@ onBeforeUnmount(() => {
v-else
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"
:disabled="submitting || Boolean(activeOperation) || form.scenarioId === null"
:disabled="
submitting ||
!selectedProfileIdentityReady ||
Boolean(activeOperation) ||
form.scenarioId === null
"
data-testid="request-reset"
>
{{ form.scheduledAt ? '시나리오 초기화 예약' : '시나리오 초기화' }}
@@ -1783,7 +1829,9 @@ onBeforeUnmount(() => {
>
<span class="text-zinc-600">{{ formatLogTime(entry.createdAt) }}</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>
</section>
@@ -1903,9 +1951,9 @@ onBeforeUnmount(() => {
>
<dl class="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
<div>
<dt class="text-[11px] font-semibold text-zinc-500">서버 ID</dt>
<dd class="mt-1 break-all font-mono text-xs text-zinc-300">
{{ operation.profileName }}
<dt class="text-[11px] font-semibold text-zinc-500">서버</dt>
<dd class="mt-1 break-all text-xs text-zinc-300">
{{ profileDisplayName(operation.profileName) }}
</dd>
</div>
<div>
@@ -1953,7 +2001,7 @@ onBeforeUnmount(() => {
<div v-if="operation.error" class="sm:col-span-2">
<dt class="text-[11px] font-semibold text-red-400">오류</dt>
<dd class="mt-1 whitespace-pre-wrap break-all text-xs text-red-300">
{{ operation.error }}
{{ displayOperationText(operation.error) }}
</dd>
</div>
</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}`);
}
}
});
});