Merge branch 'fix/profile-identity-split-20260813'
This commit is contained in:
@@ -50,6 +50,8 @@ integration('gateway runtime action consumer', () => {
|
||||
create: {
|
||||
profileName,
|
||||
profile: 'runtime',
|
||||
instanceKey: 'consumer-integration',
|
||||
currentScenario: 'consumer-integration',
|
||||
scenario: 'consumer-integration',
|
||||
apiPort: 15998,
|
||||
status: 'RUNNING',
|
||||
|
||||
@@ -1280,7 +1280,10 @@ export const adminRouter = router({
|
||||
sourceRef = resolved;
|
||||
}
|
||||
const scenarios = await listScenarioPreviews({ gitRef: resolved });
|
||||
if (!scenarios.some((scenario) => String(scenario.id) === profile.scenario)) {
|
||||
if (
|
||||
profile.currentScenario === null ||
|
||||
!scenarios.some((scenario) => String(scenario.id) === profile.currentScenario)
|
||||
) {
|
||||
throw new Error('Current scenario is not available at source.');
|
||||
}
|
||||
} catch {
|
||||
@@ -1579,6 +1582,8 @@ export const adminRouter = router({
|
||||
.map((profile) => ({
|
||||
profileName: profile.profileName,
|
||||
profile: profile.profile,
|
||||
instanceKey: profile.instanceKey,
|
||||
currentScenario: profile.currentScenario,
|
||||
meta: {
|
||||
...(typeof profile.meta.korName === 'string' ? { korName: profile.meta.korName } : {}),
|
||||
},
|
||||
@@ -1661,7 +1666,8 @@ export const adminRouter = router({
|
||||
);
|
||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||
if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||
const parsedScenarioId = Number(profile.scenario);
|
||||
const parsedScenarioId =
|
||||
profile.currentScenario === null ? Number.NaN : Number(profile.currentScenario);
|
||||
currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null;
|
||||
gitRef = profile.buildCommitSha?.trim();
|
||||
if (!gitRef) {
|
||||
@@ -1692,8 +1698,13 @@ export const adminRouter = router({
|
||||
upsert: profileAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
profile: z.string().min(1).max(32),
|
||||
scenario: z.string().min(1).max(64),
|
||||
profile: z.string().regex(/^[a-z0-9-]{1,32}$/),
|
||||
instanceKey: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]{1,64}$/)
|
||||
.optional(),
|
||||
currentScenario: z.string().min(1).max(64).nullable().optional(),
|
||||
scenario: z.string().min(1).max(64).optional(),
|
||||
apiPort: z.number().int().min(1).max(65535),
|
||||
status: zProfileStatus.optional(),
|
||||
preopenAt: z.string().datetime().optional(),
|
||||
@@ -1706,6 +1717,8 @@ export const adminRouter = router({
|
||||
const status = input.status ?? 'STOPPED';
|
||||
return ctx.profiles.upsertProfile({
|
||||
profile: input.profile,
|
||||
instanceKey: input.instanceKey,
|
||||
currentScenario: input.currentScenario,
|
||||
scenario: input.scenario,
|
||||
apiPort: input.apiPort,
|
||||
status,
|
||||
|
||||
@@ -21,6 +21,9 @@ export type LobbyGeneralStatus = {
|
||||
export type LobbyProfileStatus = {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
||||
scenario: string;
|
||||
status: GatewayProfileStatus;
|
||||
apiPort: number;
|
||||
@@ -87,6 +90,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
||||
return {
|
||||
profileName: row.profileName,
|
||||
profile: row.profile,
|
||||
instanceKey: row.instanceKey,
|
||||
currentScenario: row.currentScenario,
|
||||
scenario: row.scenario,
|
||||
status: row.status,
|
||||
apiPort: row.apiPort,
|
||||
|
||||
@@ -396,7 +396,7 @@ export const buildProcessDefinitions = (
|
||||
...baseEnv,
|
||||
GAME_API_ROLE: 'server',
|
||||
PROFILE: profile.profile,
|
||||
SCENARIO: profile.scenario,
|
||||
SCENARIO: profile.currentScenario ?? 'default',
|
||||
GAME_PROFILE_NAME: profile.profileName,
|
||||
GAME_API_PORT: String(profile.apiPort),
|
||||
GAME_TRPC_PATH: `/${profile.profile}/api/trpc`,
|
||||
@@ -411,7 +411,7 @@ export const buildProcessDefinitions = (
|
||||
GAME_ENGINE_ROLE: 'turn-daemon',
|
||||
TURN_PROFILE: profile.profile,
|
||||
PROFILE: profile.profile,
|
||||
SCENARIO: profile.scenario,
|
||||
SCENARIO: profile.currentScenario ?? 'default',
|
||||
TURN_PROFILE_NAME: profile.profileName,
|
||||
};
|
||||
return {
|
||||
@@ -1422,8 +1422,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
} = parseInstallOptions(action);
|
||||
const tickOverride =
|
||||
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
|
||||
const scenarioId = installScenarioId ?? parseScenarioId(profile.scenario);
|
||||
if (!scenarioId) {
|
||||
const scenarioId = installScenarioId ?? parseScenarioId(profile.currentScenario);
|
||||
if (scenarioId === null) {
|
||||
return { status: 'FAILED', detail: 'scenarioId is missing' };
|
||||
}
|
||||
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
|
||||
@@ -1547,7 +1547,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
|
||||
const publishedProfile = await updateClaimedProfile(
|
||||
{
|
||||
scenario: String(scenarioId),
|
||||
currentScenario: String(scenarioId),
|
||||
status: desiredStatus,
|
||||
buildStatus: 'SUCCEEDED',
|
||||
buildWorkspace: workspace.root,
|
||||
@@ -1564,8 +1564,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
completedAt,
|
||||
error: null,
|
||||
});
|
||||
if (String(scenarioId) !== profile.scenario) {
|
||||
await this.repository.updateScenario(profile.profileName, String(scenarioId));
|
||||
if (String(scenarioId) !== profile.currentScenario) {
|
||||
await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId));
|
||||
}
|
||||
return this.repository.updateStatus(profile.profileName, desiredStatus, {
|
||||
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
|
||||
@@ -1577,6 +1577,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
releasePrepared = true;
|
||||
const builtProfile = publishedProfile ?? {
|
||||
...profile,
|
||||
currentScenario: String(scenarioId),
|
||||
scenario: String(scenarioId),
|
||||
status: desiredStatus,
|
||||
buildWorkspace: workspace.root,
|
||||
@@ -1642,7 +1643,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
meta: Record<string, unknown>;
|
||||
}> {
|
||||
const databaseUrl = databaseUrlOverride ?? this.resolveProfileDatabaseUrl(profile);
|
||||
let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.scenario);
|
||||
let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.currentScenario);
|
||||
let tickSeconds: number | undefined = overrides?.tickSeconds;
|
||||
let meta: Record<string, unknown> = {};
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||
|
||||
@@ -78,6 +78,9 @@ export interface GatewayOperationLogInput {
|
||||
export interface GatewayProfileRecord {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
||||
scenario: string;
|
||||
apiPort: number;
|
||||
status: GatewayProfileStatus;
|
||||
@@ -100,7 +103,10 @@ export interface GatewayProfileRecord {
|
||||
|
||||
export interface GatewayProfileUpsertInput {
|
||||
profile: string;
|
||||
scenario: string;
|
||||
instanceKey?: string;
|
||||
currentScenario?: string | null;
|
||||
/** @deprecated Accepted while older bootstrap clients are still supported. */
|
||||
scenario?: string;
|
||||
apiPort: number;
|
||||
status?: GatewayProfileStatus;
|
||||
preopenAt?: string;
|
||||
@@ -111,7 +117,7 @@ export interface GatewayProfileUpsertInput {
|
||||
}
|
||||
|
||||
export interface GatewayClaimedProfileUpdate {
|
||||
scenario?: string;
|
||||
currentScenario?: string | null;
|
||||
status?: GatewayProfileStatus;
|
||||
buildStatus?: GatewayBuildStatus;
|
||||
buildCommitSha?: string | null;
|
||||
@@ -131,7 +137,7 @@ export interface GatewayProfileRepository {
|
||||
listProfiles(): Promise<GatewayProfileRecord[]>;
|
||||
getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
|
||||
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
|
||||
updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null>;
|
||||
updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null>;
|
||||
updateStatus(
|
||||
profileName: string,
|
||||
status: GatewayProfileStatus,
|
||||
@@ -219,6 +225,8 @@ export const buildRetryOperationSource = (previous: {
|
||||
type GatewayProfileRow = {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
scenario: string;
|
||||
apiPort: number;
|
||||
status: GatewayProfileStatus;
|
||||
@@ -265,6 +273,8 @@ type GatewayOperationRow = {
|
||||
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
|
||||
profileName: row.profileName,
|
||||
profile: row.profile,
|
||||
instanceKey: row.instanceKey,
|
||||
currentScenario: row.currentScenario,
|
||||
scenario: row.scenario,
|
||||
apiPort: row.apiPort,
|
||||
status: row.status,
|
||||
@@ -285,7 +295,24 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`;
|
||||
export const buildGatewayProfileName = (profile: string, instanceKey: string): string => `${profile}:${instanceKey}`;
|
||||
|
||||
export const resolveGatewayProfileIdentity = (
|
||||
input: GatewayProfileUpsertInput
|
||||
): {
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
shouldUpdateCurrentScenario: boolean;
|
||||
} => {
|
||||
const instanceKey = input.instanceKey ?? input.scenario ?? 'default';
|
||||
if (input.currentScenario !== undefined) {
|
||||
return { instanceKey, currentScenario: input.currentScenario, shouldUpdateCurrentScenario: true };
|
||||
}
|
||||
if (input.instanceKey === undefined && input.scenario !== undefined && input.scenario !== 'default') {
|
||||
return { instanceKey, currentScenario: input.scenario, shouldUpdateCurrentScenario: true };
|
||||
}
|
||||
return { instanceKey, currentScenario: null, shouldUpdateCurrentScenario: false };
|
||||
};
|
||||
|
||||
const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({
|
||||
id: row.id,
|
||||
@@ -331,7 +358,7 @@ const mapOperationLog = (row: {
|
||||
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
|
||||
async listProfiles(): Promise<GatewayProfileRecord[]> {
|
||||
const rows = await prisma.gatewayProfile.findMany({
|
||||
orderBy: [{ profile: 'asc' }, { scenario: 'asc' }],
|
||||
orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
|
||||
});
|
||||
return rows.map(mapProfile);
|
||||
},
|
||||
@@ -342,13 +369,16 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
return row ? mapProfile(row) : null;
|
||||
},
|
||||
async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> {
|
||||
const profileName = buildProfileName(input.profile, input.scenario);
|
||||
const { instanceKey, currentScenario, shouldUpdateCurrentScenario } = resolveGatewayProfileIdentity(input);
|
||||
const profileName = buildGatewayProfileName(input.profile, instanceKey);
|
||||
const row = await prisma.gatewayProfile.upsert({
|
||||
where: { profileName },
|
||||
create: {
|
||||
profileName,
|
||||
profile: input.profile,
|
||||
scenario: input.scenario,
|
||||
instanceKey,
|
||||
currentScenario,
|
||||
scenario: currentScenario ?? 'default',
|
||||
apiPort: input.apiPort,
|
||||
status: input.status ?? 'STOPPED',
|
||||
preopenAt: input.preopenAt ? new Date(input.preopenAt) : null,
|
||||
@@ -358,6 +388,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
meta: (input.meta ?? {}) as GatewayPrisma.JsonObject,
|
||||
},
|
||||
update: {
|
||||
currentScenario: shouldUpdateCurrentScenario ? currentScenario : undefined,
|
||||
scenario: shouldUpdateCurrentScenario ? (currentScenario ?? 'default') : undefined,
|
||||
apiPort: input.apiPort,
|
||||
status: input.status,
|
||||
preopenAt: input.preopenAt ? new Date(input.preopenAt) : input.preopenAt === null ? null : undefined,
|
||||
@@ -373,11 +405,12 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
});
|
||||
return mapProfile(row);
|
||||
},
|
||||
async updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null> {
|
||||
async updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null> {
|
||||
const row = await prisma.gatewayProfile.update({
|
||||
where: { profileName },
|
||||
data: {
|
||||
scenario,
|
||||
currentScenario: scenario,
|
||||
scenario: scenario ?? 'default',
|
||||
},
|
||||
});
|
||||
return row ? mapProfile(row) : null;
|
||||
@@ -700,7 +733,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
return tx.gatewayProfile.update({
|
||||
where: { profileName },
|
||||
data: {
|
||||
scenario: patch.scenario,
|
||||
currentScenario: patch.currentScenario,
|
||||
scenario: patch.currentScenario === undefined ? undefined : (patch.currentScenario ?? 'default'),
|
||||
status: patch.status,
|
||||
buildStatus: patch.buildStatus,
|
||||
buildCommitSha: patch.buildCommitSha,
|
||||
|
||||
@@ -3,8 +3,8 @@ export const GATEWAY_PROFILE_ORDER = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya',
|
||||
const gatewayProfileOrder = new Map<string, number>(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index]));
|
||||
|
||||
export const compareGatewayProfiles = (
|
||||
left: { profile: string; scenario: string },
|
||||
right: { profile: string; scenario: string }
|
||||
left: { profile: string; instanceKey: string },
|
||||
right: { profile: string; instanceKey: string }
|
||||
): number => {
|
||||
const unknownRank = GATEWAY_PROFILE_ORDER.length;
|
||||
const profileOrder =
|
||||
@@ -14,8 +14,8 @@ export const compareGatewayProfiles = (
|
||||
|
||||
const profileNameOrder = left.profile.localeCompare(right.profile);
|
||||
if (profileNameOrder !== 0) return profileNameOrder;
|
||||
return left.scenario.localeCompare(right.scenario);
|
||||
return left.instanceKey.localeCompare(right.instanceKey);
|
||||
};
|
||||
|
||||
export const orderGatewayProfiles = <T extends { profile: string; scenario: string }>(profiles: readonly T[]): T[] =>
|
||||
export const orderGatewayProfiles = <T extends { profile: string; instanceKey: string }>(profiles: readonly T[]): T[] =>
|
||||
[...profiles].sort(compareGatewayProfiles);
|
||||
|
||||
@@ -85,6 +85,8 @@ const buildCaller = async (
|
||||
const profile = {
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
instanceKey: '2',
|
||||
currentScenario: options.profileScenario ?? '2',
|
||||
scenario: options.profileScenario ?? '2',
|
||||
apiPort: 15003,
|
||||
status: options.initialProfileStatus ?? ('STOPPED' as const),
|
||||
@@ -98,7 +100,7 @@ const buildCaller = async (
|
||||
listProfiles: async () => [profile],
|
||||
getProfile: async () => profile,
|
||||
upsertProfile: async () => profile,
|
||||
updateScenario: async () => profile,
|
||||
updateCurrentScenario: async () => profile,
|
||||
updateStatus: async (_profileName, status) => {
|
||||
updatedStatuses.push(status);
|
||||
return { ...profile, status };
|
||||
@@ -362,6 +364,8 @@ describe('admin profile navigation API', () => {
|
||||
{
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
instanceKey: '2',
|
||||
currentScenario: '2',
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -15,6 +15,8 @@ import { appRouter } from '../src/router.js';
|
||||
const profile = {
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
instanceKey: 'default',
|
||||
currentScenario: null,
|
||||
scenario: 'default',
|
||||
apiPort: 15003,
|
||||
status: 'RUNNING' as const,
|
||||
@@ -28,7 +30,7 @@ const profiles: GatewayProfileRepository = {
|
||||
listProfiles: async () => [profile],
|
||||
getProfile: async (profileName) => (profileName === profile.profileName ? profile : null),
|
||||
upsertProfile: async () => profile,
|
||||
updateScenario: async () => profile,
|
||||
updateCurrentScenario: async () => profile,
|
||||
updateStatus: async () => profile,
|
||||
updateBuildStatus: async () => profile,
|
||||
updateMeta: async () => profile,
|
||||
|
||||
@@ -92,6 +92,8 @@ const buildCaller = (
|
||||
{
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
instanceKey: 'default',
|
||||
currentScenario: null,
|
||||
scenario: 'default',
|
||||
apiPort: 15003,
|
||||
status: 'RUNNING' as const,
|
||||
@@ -103,6 +105,8 @@ const buildCaller = (
|
||||
{
|
||||
profileName: 'hwe:default',
|
||||
profile: 'hwe',
|
||||
instanceKey: 'default',
|
||||
currentScenario: null,
|
||||
scenario: 'default',
|
||||
apiPort: 15015,
|
||||
status: 'RUNNING' as const,
|
||||
@@ -119,7 +123,7 @@ const buildCaller = (
|
||||
upsertProfile: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
updateScenario: async () => null,
|
||||
updateCurrentScenario: async () => null,
|
||||
updateStatus: async () => null,
|
||||
updateBuildStatus: async () => null,
|
||||
updateMeta: async () => null,
|
||||
@@ -167,6 +171,8 @@ const buildCaller = (
|
||||
profileRows.map((profile) => ({
|
||||
profileName: profile.profileName,
|
||||
profile: profile.profile,
|
||||
instanceKey: profile.instanceKey,
|
||||
currentScenario: profile.currentScenario,
|
||||
scenario: profile.scenario,
|
||||
status: profile.status,
|
||||
apiPort: profile.apiPort,
|
||||
|
||||
@@ -13,6 +13,8 @@ import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
|
||||
const profile: GatewayProfileRecord = {
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
instanceKey: '2',
|
||||
currentScenario: '2',
|
||||
scenario: '2',
|
||||
apiPort: 15003,
|
||||
status: 'STOPPED',
|
||||
@@ -58,7 +60,7 @@ const createHarness = (
|
||||
listProfiles: async () => [profile],
|
||||
getProfile: async () => profile,
|
||||
upsertProfile: async () => profile,
|
||||
updateScenario: async () => profile,
|
||||
updateCurrentScenario: async () => profile,
|
||||
updateStatus: async (_profileName, status) => {
|
||||
statuses.push(status);
|
||||
return { ...profile, status };
|
||||
|
||||
@@ -14,6 +14,8 @@ import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository
|
||||
const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
instanceKey: '2',
|
||||
currentScenario: '2',
|
||||
scenario: '2',
|
||||
apiPort: 15003,
|
||||
status: 'RUNNING',
|
||||
@@ -172,6 +174,39 @@ describe('buildProcessDefinitions', () => {
|
||||
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||
});
|
||||
|
||||
it('keeps the instance identity stable while passing the mutable current scenario', () => {
|
||||
const definitions = buildProcessDefinitions(
|
||||
{
|
||||
...buildProfile(),
|
||||
profileName: 'che:default',
|
||||
instanceKey: 'default',
|
||||
currentScenario: '1010',
|
||||
scenario: '1010',
|
||||
},
|
||||
processConfig
|
||||
);
|
||||
|
||||
expect(definitions.api.name).toBe('sammo:che:default:game-api');
|
||||
expect(definitions.api.env).toMatchObject({
|
||||
GAME_PROFILE_NAME: 'che:default',
|
||||
SCENARIO: '1010',
|
||||
});
|
||||
expect(definitions.daemon.env).toMatchObject({
|
||||
TURN_PROFILE_NAME: 'che:default',
|
||||
SCENARIO: '1010',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the legacy default scenario marker only for an uninitialized instance runtime', () => {
|
||||
const definitions = buildProcessDefinitions(
|
||||
{ ...buildProfile(), currentScenario: null, scenario: 'default' },
|
||||
processConfig
|
||||
);
|
||||
|
||||
expect(definitions.api.env.SCENARIO).toBe('default');
|
||||
expect(definitions.daemon.env.SCENARIO).toBe('default');
|
||||
});
|
||||
|
||||
it('does not forward PM2 identity or parent runtime roles to profile processes', () => {
|
||||
const definitions = buildProcessDefinitions(buildProfile(), {
|
||||
...processConfig,
|
||||
|
||||
@@ -14,6 +14,8 @@ const makeProfile = (
|
||||
): GatewayProfileRecord => ({
|
||||
profileName,
|
||||
profile: profileName.split(':')[0] ?? 'che',
|
||||
instanceKey: profileName.split(':')[1] ?? 'default',
|
||||
currentScenario: null,
|
||||
scenario: profileName.split(':')[1] ?? 'default',
|
||||
apiPort: 15_003,
|
||||
status: 'RUNNING',
|
||||
|
||||
@@ -50,6 +50,8 @@ describe('profile DEPLOY operation', () => {
|
||||
const profile: GatewayProfileRecord = {
|
||||
profileName: 'che:1010',
|
||||
profile: 'che',
|
||||
instanceKey: '1010',
|
||||
currentScenario: '1010',
|
||||
scenario: '1010',
|
||||
apiPort: 15003,
|
||||
status: 'RUNNING',
|
||||
@@ -80,7 +82,7 @@ describe('profile DEPLOY operation', () => {
|
||||
listProfiles: async () => [profile],
|
||||
getProfile: async () => profile,
|
||||
upsertProfile: async () => profile,
|
||||
updateScenario: async () => profile,
|
||||
updateCurrentScenario: async () => profile,
|
||||
updateStatus: async () => profile,
|
||||
updateBuildStatus: async () => profile,
|
||||
updateMeta: async () => profile,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildGatewayProfileName,
|
||||
resolveGatewayProfileIdentity,
|
||||
type GatewayProfileUpsertInput,
|
||||
} from '../src/orchestrator/profileRepository.js';
|
||||
|
||||
const resolve = (input: Partial<GatewayProfileUpsertInput>) =>
|
||||
resolveGatewayProfileIdentity({
|
||||
profile: 'che',
|
||||
apiPort: 15003,
|
||||
...input,
|
||||
});
|
||||
|
||||
describe('Gateway profile identity', () => {
|
||||
it('builds the immutable technical id from profile and instance key', () => {
|
||||
expect(buildGatewayProfileName('che', 'default')).toBe('che:default');
|
||||
});
|
||||
|
||||
it('does not treat a new default instance as an initialized scenario', () => {
|
||||
expect(resolve({ instanceKey: 'default' })).toEqual({
|
||||
instanceKey: 'default',
|
||||
currentScenario: null,
|
||||
shouldUpdateCurrentScenario: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts the old bootstrap default marker without clearing an existing scenario on upsert', () => {
|
||||
expect(resolve({ scenario: 'default' })).toEqual({
|
||||
instanceKey: 'default',
|
||||
currentScenario: null,
|
||||
shouldUpdateCurrentScenario: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a legacy non-default scenario to both identity and current state', () => {
|
||||
expect(resolve({ scenario: '2' })).toEqual({
|
||||
instanceKey: '2',
|
||||
currentScenario: '2',
|
||||
shouldUpdateCurrentScenario: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a default instance stable when its current scenario changes', () => {
|
||||
expect(resolve({ instanceKey: 'default', currentScenario: '1010' })).toEqual({
|
||||
instanceKey: 'default',
|
||||
currentScenario: '1010',
|
||||
shouldUpdateCurrentScenario: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,25 +6,25 @@ describe('orderGatewayProfiles', () => {
|
||||
it('uses the public server order instead of alphabetical profile order', () => {
|
||||
const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({
|
||||
profile,
|
||||
scenario: 'default',
|
||||
instanceKey: 'default',
|
||||
}));
|
||||
|
||||
expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER);
|
||||
});
|
||||
|
||||
it('orders scenarios within a profile and places unknown profiles afterward', () => {
|
||||
it('orders instance keys within a profile and places unknown profiles afterward', () => {
|
||||
const profiles = [
|
||||
{ profile: 'zeta', scenario: 'default' },
|
||||
{ profile: 'che', scenario: '20' },
|
||||
{ profile: 'alpha', scenario: 'default' },
|
||||
{ profile: 'che', scenario: '10' },
|
||||
{ profile: 'zeta', instanceKey: 'default' },
|
||||
{ profile: 'che', instanceKey: '20' },
|
||||
{ profile: 'alpha', instanceKey: 'default' },
|
||||
{ profile: 'che', instanceKey: '10' },
|
||||
];
|
||||
|
||||
expect(orderGatewayProfiles(profiles)).toEqual([
|
||||
{ profile: 'che', scenario: '10' },
|
||||
{ profile: 'che', scenario: '20' },
|
||||
{ profile: 'alpha', scenario: 'default' },
|
||||
{ profile: 'zeta', scenario: 'default' },
|
||||
{ profile: 'che', instanceKey: '10' },
|
||||
{ profile: 'che', instanceKey: '20' },
|
||||
{ profile: 'alpha', instanceKey: 'default' },
|
||||
{ profile: 'zeta', instanceKey: 'default' },
|
||||
]);
|
||||
expect(profiles[0]?.profile).toBe('zeta');
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
|
||||
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260811000000_add_gateway_operation_logs',
|
||||
gatewaySchemaHead: '20260813000000_split_gateway_profile_identity',
|
||||
gameSchemaHead: '20260803000000_add_logical_game_clock',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +150,8 @@ const installFixture = async (
|
||||
{
|
||||
profileName: 'hwe:default',
|
||||
profile: 'hwe',
|
||||
instanceKey: 'default',
|
||||
currentScenario: '1010',
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
@@ -160,6 +162,8 @@ const installFixture = async (
|
||||
{
|
||||
profileName: 'hwe:default',
|
||||
profile: 'hwe',
|
||||
instanceKey: 'default',
|
||||
currentScenario: '1010',
|
||||
scenario: '1010',
|
||||
apiPort: 15015,
|
||||
status: 'RUNNING',
|
||||
@@ -352,7 +356,9 @@ test('directs profile deployment to the selected server version tab', async ({ p
|
||||
await expect(versionTab).toBeFocused();
|
||||
const tabAndHeaderGeometry = await Promise.all([
|
||||
tabs.evaluate((element) => element.getBoundingClientRect().top),
|
||||
page.getByText('hwe:default (hwe)', { exact: true }).evaluate((element) => element.getBoundingClientRect().top),
|
||||
page
|
||||
.getByText('서버 ID: hwe:default · 인스턴스: default', { exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().top),
|
||||
]);
|
||||
expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]);
|
||||
await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true });
|
||||
|
||||
@@ -41,6 +41,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
|
||||
{
|
||||
profileName: 'hwe:2',
|
||||
profile: 'hwe',
|
||||
instanceKey: '2',
|
||||
currentScenario: '1010',
|
||||
scenario: '1010',
|
||||
status: 'RUNNING',
|
||||
buildStatus: 'SUCCEEDED',
|
||||
@@ -59,6 +61,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
|
||||
{
|
||||
profileName: 'hwe:2',
|
||||
profile: 'hwe',
|
||||
instanceKey: '2',
|
||||
currentScenario: '1010',
|
||||
meta: { korName: '환상서버' },
|
||||
},
|
||||
]
|
||||
@@ -209,7 +213,7 @@ test('scoped administrators see the same navigation while ordinary users do not'
|
||||
await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
|
||||
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
|
||||
const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' });
|
||||
await expect(scopedNavigation.getByRole('link', { name: '환상서버 (hwe:2)' })).toBeVisible();
|
||||
await expect(scopedNavigation.getByRole('link', { name: '환상서버 [2]' })).toBeVisible();
|
||||
await expect(scopedNavigation.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
|
||||
await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0);
|
||||
await scopedContext.close();
|
||||
|
||||
@@ -55,8 +55,10 @@ type FixtureState = {
|
||||
};
|
||||
|
||||
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
instanceKey: 'default',
|
||||
currentScenario: '2',
|
||||
scenario: '2',
|
||||
apiPort: 15003,
|
||||
status: runtimeRunning ? 'RUNNING' : 'STOPPED',
|
||||
@@ -69,7 +71,7 @@ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown
|
||||
activeOperation: null,
|
||||
runtimeActions: [],
|
||||
runtime: {
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
frontendRunning: runtimeRunning,
|
||||
apiRunning: runtimeRunning,
|
||||
daemonRunning: runtimeRunning,
|
||||
@@ -145,10 +147,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
await route.abort('failed');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
names.includes('admin.releases.gatewayState') &&
|
||||
(state.gatewayStateFailuresRemaining ?? 0) > 0
|
||||
) {
|
||||
if (names.includes('admin.releases.gatewayState') && (state.gatewayStateFailuresRemaining ?? 0) > 0) {
|
||||
state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1;
|
||||
state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1;
|
||||
await route.fulfill({
|
||||
@@ -171,8 +170,10 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (name === 'admin.profiles.listNavigation') {
|
||||
return response([
|
||||
{
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
instanceKey: 'default',
|
||||
currentScenario: '2',
|
||||
meta: { korName: '천하서버' },
|
||||
},
|
||||
]);
|
||||
@@ -314,7 +315,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (name === 'admin.operations.requestReset') {
|
||||
const operation: Operation = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
type: 'RESET',
|
||||
status: 'QUEUED',
|
||||
sourceMode: 'COMMIT',
|
||||
@@ -330,7 +331,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (name === 'admin.operations.requestDeploy') {
|
||||
const operation: Operation = {
|
||||
id: '66666666-6666-4666-8666-666666666666',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
type: 'DEPLOY',
|
||||
status: 'QUEUED',
|
||||
sourceMode: 'BRANCH',
|
||||
@@ -368,7 +369,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
type === 'START'
|
||||
? '22222222-2222-4222-8222-222222222222'
|
||||
: '33333333-3333-4333-8333-333333333333',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
type,
|
||||
status: 'SUCCEEDED',
|
||||
payload: {},
|
||||
@@ -382,7 +383,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (name === 'admin.operations.retry') {
|
||||
const operation: Operation = {
|
||||
id: '44444444-4444-4444-8444-444444444444',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
type: 'RESET',
|
||||
status: 'QUEUED',
|
||||
sourceMode: 'COMMIT',
|
||||
@@ -420,9 +421,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||
await expect(page.getByTestId('server-operations-page')).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/);
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/scenario$/);
|
||||
await expect(page.getByTestId('source-current')).toBeChecked();
|
||||
await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋');
|
||||
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
|
||||
@@ -497,7 +498,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('RESET');
|
||||
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.');
|
||||
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-status')).toContainText('SUCCEEDED');
|
||||
const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
|
||||
@@ -614,7 +615,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3A2/version');
|
||||
await page.goto('admin/servers/che%3Adefault/version');
|
||||
await expect(page.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible();
|
||||
await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute(
|
||||
@@ -626,7 +627,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('DEPLOY');
|
||||
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.');
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.');
|
||||
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);
|
||||
@@ -655,7 +656,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||
await expect(page.getByTestId('reset-turn-term')).toHaveValue('20');
|
||||
await page.getByText('고급 시나리오 옵션').click();
|
||||
await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타');
|
||||
@@ -679,7 +680,7 @@ test('edits server reset defaults through profile metadata settings', async ({ p
|
||||
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
|
||||
await installFixture(page, state);
|
||||
|
||||
await page.goto('admin/servers/che%3A2');
|
||||
await page.goto('admin/servers/che%3Adefault');
|
||||
await page.getByText('서버 리셋 기본 옵션').click();
|
||||
await page.getByTestId('meta-reset-turn-term').selectOption('10');
|
||||
await page.getByTestId('meta-reset-npc-mode').selectOption('2');
|
||||
@@ -742,7 +743,7 @@ test('shows a dismissible error toast when profile metadata persistence fails',
|
||||
};
|
||||
await installFixture(page, state);
|
||||
|
||||
await page.goto('admin/servers/che%3A2');
|
||||
await page.goto('admin/servers/che%3Adefault');
|
||||
await page.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error');
|
||||
await page.getByRole('button', { name: '메타 저장' }).click();
|
||||
|
||||
@@ -765,7 +766,7 @@ test('renders the fixed-profile version form without waiting for the server list
|
||||
};
|
||||
await installFixture(page, state);
|
||||
|
||||
await page.goto('admin/servers/che%3A2/version');
|
||||
await page.goto('admin/servers/che%3Adefault/version');
|
||||
await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 });
|
||||
expect(state.profileNavigationResolved).toBe(false);
|
||||
await expect.poll(() => state.profileNavigationResolved).toBe(true);
|
||||
@@ -782,7 +783,7 @@ test('recovers the current-version scenario catalog after the initial request fa
|
||||
};
|
||||
await installFixture(page, state);
|
||||
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||
await expect(page.getByTestId('scenario-select')).toContainText('선택할 수 있는 시나리오가 없습니다.');
|
||||
await expect(page.getByTestId('request-reset')).toBeDisabled();
|
||||
await page.getByTestId('load-scenarios').click();
|
||||
@@ -790,7 +791,9 @@ test('recovers the current-version scenario catalog after the initial request fa
|
||||
await expect(page.getByTestId('request-reset')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('renders the server navigation before the detailed runtime profile request resolves', async ({ page }) => {
|
||||
test('renders the stable server identity without exposing the default suffix as the display name', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: FixtureState = {
|
||||
operations: [],
|
||||
gatewayOperations: [],
|
||||
@@ -800,10 +803,42 @@ test('renders the server navigation before the detailed runtime profile request
|
||||
};
|
||||
await installFixture(page, state);
|
||||
|
||||
await page.goto('admin/servers/che%3A2');
|
||||
await page.goto('admin/servers/che%3Adefault');
|
||||
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' });
|
||||
await expect(navigation.getByRole('link', { name: '천하서버 (che:2)' })).toBeVisible({ timeout: 900 });
|
||||
const profileLink = navigation.getByRole('link', { name: '천하서버' });
|
||||
await expect(profileLink).toBeVisible({ timeout: 900 });
|
||||
await expect(profileLink).toHaveAttribute('title', '서버 ID: che:default');
|
||||
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('현재 시나리오: 2')).toBeVisible();
|
||||
await profileLink.focus();
|
||||
const desktop = await profileLink.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
x: rect.x,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
overflow: element.scrollWidth - element.clientWidth,
|
||||
backgroundColor: style.backgroundColor,
|
||||
color: style.color,
|
||||
};
|
||||
});
|
||||
expect(desktop.width).toBeGreaterThan(100);
|
||||
expect(desktop.height).toBeGreaterThan(30);
|
||||
expect(desktop.overflow).toBeLessThanOrEqual(0);
|
||||
expect(desktop.backgroundColor).toBe('rgb(45, 27, 8)');
|
||||
expect(desktop.color).toBe('rgb(253, 230, 138)');
|
||||
await page.screenshot({ path: testInfo.outputPath('profile-identity-desktop.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.getByRole('button', { name: '관리자 메뉴' }).click();
|
||||
await expect(profileLink).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
).toBeLessThanOrEqual(0);
|
||||
await page.screenshot({ path: testInfo.outputPath('profile-identity-mobile.png'), fullPage: true });
|
||||
expect(state.profileNavigationRequests).toBe(1);
|
||||
});
|
||||
|
||||
@@ -813,12 +848,12 @@ test('scenario-only operator resets the current version without Git or Gateway c
|
||||
gatewayOperations: [],
|
||||
runtimeRunning: true,
|
||||
requestBodies: [],
|
||||
capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:2'] }],
|
||||
capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:default'] }],
|
||||
};
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||
await expect(page.getByTestId('source-current')).toBeChecked();
|
||||
await expect(page.getByTestId('source-branch')).toHaveCount(0);
|
||||
await expect(page.getByTestId('source-commit')).toHaveCount(0);
|
||||
@@ -996,9 +1031,7 @@ test('moves long Gateway release errors out of the table column into an expandab
|
||||
expect(mobileGeometry.detailWidth).toBeGreaterThanOrEqual(mobileGeometry.tableWidth - 1);
|
||||
expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1);
|
||||
expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(
|
||||
mobileGeometry.viewportWidth
|
||||
);
|
||||
expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||||
expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-mobile.png'), fullPage: true });
|
||||
|
||||
@@ -1043,7 +1076,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
||||
operations: [
|
||||
{
|
||||
id: '55555555-5555-4555-8555-555555555555',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
type: 'RESET',
|
||||
status: 'FAILED',
|
||||
sourceMode: 'COMMIT',
|
||||
@@ -1064,7 +1097,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
|
||||
const failure = page.getByTestId('operations-table').getByText(longError);
|
||||
|
||||
@@ -16,12 +16,28 @@ 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; meta?: Record<string, unknown> }>>;
|
||||
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; meta?: Record<string, unknown> }>>([]);
|
||||
const profiles = ref<
|
||||
Array<{
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const isRootAdmin = computed(() =>
|
||||
(auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser')
|
||||
@@ -38,7 +54,8 @@ const hasAnyProfileCapability = computed(() =>
|
||||
|
||||
const profileLabel = (profile: (typeof profiles.value)[number]): string => {
|
||||
const korName = profile.meta?.korName;
|
||||
return typeof korName === 'string' && korName.trim() ? `${korName} (${profile.profileName})` : profile.profileName;
|
||||
const displayName = typeof korName === 'string' && korName.trim() ? korName.trim() : profile.profile;
|
||||
return profile.instanceKey === 'default' ? displayName : `${displayName} [${profile.instanceKey}]`;
|
||||
};
|
||||
|
||||
const navigation = computed(() => [
|
||||
@@ -70,6 +87,7 @@ const navigation = computed(() => [
|
||||
...profiles.value.map((profile) => ({
|
||||
to: `/admin/servers/${encodeURIComponent(profile.profileName)}`,
|
||||
label: profileLabel(profile),
|
||||
title: `서버 ID: ${profile.profileName}`,
|
||||
icon: '└',
|
||||
exact: false,
|
||||
visible: true,
|
||||
@@ -156,6 +174,7 @@ onMounted(async () => {
|
||||
:class="{ child: item.child }"
|
||||
:active-class="item.exact ? '' : 'active'"
|
||||
:exact-active-class="item.exact ? 'active' : ''"
|
||||
:title="'title' in item ? item.title : undefined"
|
||||
@click="menuOpen = false"
|
||||
>
|
||||
<span class="admin-nav-icon" aria-hidden="true">{{ item.icon }}</span>
|
||||
|
||||
@@ -176,6 +176,9 @@ type AdminPublicUser = {
|
||||
type AdminProfile = {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
||||
scenario: string;
|
||||
status: string;
|
||||
apiPort: number;
|
||||
@@ -2058,9 +2061,14 @@ 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.profileName }} ({{ profile.profile }})
|
||||
{{ profile.meta.korName ?? profile.profile }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">
|
||||
서버 ID: {{ profile.profileName }} · 인스턴스: {{ profile.instanceKey }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">
|
||||
현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">시나리오: {{ profile.scenario }}</div>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-400">
|
||||
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
||||
|
||||
@@ -31,6 +31,10 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
- 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와
|
||||
시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과
|
||||
`aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다.
|
||||
- `profileName`은 `${profile}:${instanceKey}` 형식의 불변 기술 ID입니다.
|
||||
`che:default`의 `default`는 현재 시나리오가 아니라 기본 인스턴스 키입니다.
|
||||
좌측 메뉴는 기본 인스턴스의 suffix를 숨기고 표시명만 보여 주며, 상태 상세에서
|
||||
기술 ID·인스턴스 키·nullable 현재 시나리오를 분리해 확인할 수 있습니다.
|
||||
- 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가
|
||||
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
|
||||
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
|
||||
@@ -45,7 +49,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
|
||||
권한과 버전 배포 권한이 모두 필요합니다.
|
||||
- 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와
|
||||
분리된 요청으로 읽습니다. API가 profile의 현재 scenario를 표시하며 화면은
|
||||
분리된 요청으로 읽습니다. API가 profile의 `currentScenario`를 표시하며 화면은
|
||||
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
|
||||
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
|
||||
- 서버 상태의 `서버 리셋 기본 옵션`은 `GatewayProfile.meta.resetDefaults`에
|
||||
|
||||
@@ -131,6 +131,13 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면
|
||||
확인합니다.
|
||||
6. 모두 준비된 경우에만 현재·이전 commit과 workspace를 게시합니다.
|
||||
|
||||
Gateway migration은 앱 rollback 때 자동으로 역방향 적용되지 않습니다. 따라서
|
||||
profile identity 분리의 첫 단계는 기존 `profile_name`과 legacy `scenario`를 유지한
|
||||
채 `instance_key`와 nullable `current_scenario`를 추가합니다. DB trigger가 구버전의
|
||||
`scenario` write와 신버전의 `current_scenario` write를 양방향 동기화하므로 migration
|
||||
적용 뒤 readiness가 실패해 직전 Gateway worktree로 돌아가도 기존 DB와 시즌을
|
||||
그대로 사용할 수 있습니다.
|
||||
|
||||
release-controller는 PM2 process 안에서 실행되므로 부모의 `args`, `pm_id`,
|
||||
`pm_exec_path`, `name`, `NODE_APP_INSTANCE`와 `axm_*` 같은 PM2 내부 값을 자식
|
||||
환경으로 전달하지 않습니다. 특히 부모의 `args=daemon`이 frontend의
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
-- Keep profile_name stable because it is referenced by operations, runtime
|
||||
-- actions, permission scopes, process names, Redis namespaces, and routes.
|
||||
-- instance_key identifies the immutable slot while current_scenario records the
|
||||
-- mutable game selection. The legacy scenario column remains during the
|
||||
-- expansion phase so the previous Gateway release can still be restored.
|
||||
ALTER TABLE "gateway_profile"
|
||||
ADD COLUMN "instance_key" TEXT,
|
||||
ADD COLUMN "current_scenario" TEXT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "gateway_profile"
|
||||
WHERE left("profile_name", length("profile") + 1) <> "profile" || ':'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'gateway_profile.profile_name must start with profile followed by a colon';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
UPDATE "gateway_profile"
|
||||
SET
|
||||
"instance_key" = substring("profile_name" FROM length("profile") + 2),
|
||||
"current_scenario" = NULLIF("scenario", 'default');
|
||||
|
||||
ALTER TABLE "gateway_profile"
|
||||
ALTER COLUMN "instance_key" SET NOT NULL;
|
||||
|
||||
DROP INDEX "gateway_profile_profile_scenario_key";
|
||||
|
||||
ALTER TABLE "gateway_profile"
|
||||
ADD CONSTRAINT "gateway_profile_profile_instance_key_key" UNIQUE ("profile", "instance_key"),
|
||||
ADD CONSTRAINT "gateway_profile_identity_check"
|
||||
CHECK (
|
||||
length("instance_key") > 0
|
||||
AND "profile_name" = "profile" || ':' || "instance_key"
|
||||
);
|
||||
|
||||
CREATE FUNCTION "sync_gateway_profile_scenario_compat"()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW."instance_key" IS NULL THEN
|
||||
IF left(NEW."profile_name", length(NEW."profile") + 1) <> NEW."profile" || ':' THEN
|
||||
RAISE EXCEPTION 'gateway_profile.profile_name must start with profile followed by a colon';
|
||||
END IF;
|
||||
NEW."instance_key" := substring(NEW."profile_name" FROM length(NEW."profile") + 2);
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW."current_scenario" IS NULL THEN
|
||||
NEW."current_scenario" := NULLIF(NEW."scenario", 'default');
|
||||
ELSE
|
||||
NEW."scenario" := NEW."current_scenario";
|
||||
END IF;
|
||||
ELSIF NEW."current_scenario" IS DISTINCT FROM OLD."current_scenario" THEN
|
||||
NEW."scenario" := COALESCE(NEW."current_scenario", 'default');
|
||||
ELSIF NEW."scenario" IS DISTINCT FROM OLD."scenario" THEN
|
||||
NEW."current_scenario" := NULLIF(NEW."scenario", 'default');
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER "gateway_profile_scenario_compat"
|
||||
BEFORE INSERT OR UPDATE ON "gateway_profile"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "sync_gateway_profile_scenario_compat"();
|
||||
@@ -208,6 +208,10 @@ model LegacyRootKeyValue {
|
||||
model GatewayProfile {
|
||||
profileName String @id @map("profile_name")
|
||||
profile String
|
||||
instanceKey String @map("instance_key")
|
||||
currentScenario String? @map("current_scenario")
|
||||
/// Legacy compatibility mirror. The database trigger keeps this synchronized
|
||||
/// with currentScenario while the previous Gateway release remains rollbackable.
|
||||
scenario String
|
||||
apiPort Int @map("api_port")
|
||||
status GatewayProfileStatus
|
||||
@@ -229,7 +233,7 @@ model GatewayProfile {
|
||||
operations GatewayOperation[]
|
||||
runtimeActions GatewayRuntimeAction[]
|
||||
|
||||
@@unique([profile, scenario])
|
||||
@@unique([profile, instanceKey])
|
||||
@@map("gateway_profile")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"controllerProtocol": 2,
|
||||
"gatewaySchemaHead": "20260811000000_add_gateway_operation_logs",
|
||||
"gatewaySchemaHead": "20260813000000_split_gateway_profile_identity",
|
||||
"gameSchemaHead": "20260803000000_add_logical_game_clock",
|
||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user