fix(gateway): preserve configured server display order
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
import type { GatewayApiContext } from './context.js';
|
import type { GatewayApiContext } from './context.js';
|
||||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||||
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
|
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
|
||||||
|
import { orderGatewayProfiles } from './profileOrder.js';
|
||||||
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
|
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
|
||||||
|
|
||||||
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
||||||
@@ -643,7 +644,7 @@ export const adminRouter = router({
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
|
||||||
}
|
}
|
||||||
const profiles = await ctx.profiles.listProfiles();
|
const profiles = orderGatewayProfiles(await ctx.profiles.listProfiles());
|
||||||
const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id);
|
const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id);
|
||||||
return {
|
return {
|
||||||
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
|
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
|
||||||
@@ -1352,7 +1353,7 @@ export const adminRouter = router({
|
|||||||
profiles: router({
|
profiles: router({
|
||||||
list: adminProcedure.query(async ({ ctx }) => {
|
list: adminProcedure.query(async ({ ctx }) => {
|
||||||
const adminAuth = requireAdminAuth(ctx);
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
const profiles = (await ctx.profiles.listProfiles()).filter((profile) =>
|
const profiles = orderGatewayProfiles(await ctx.profiles.listProfiles()).filter((profile) =>
|
||||||
canReadProfile(adminAuth, profile.profileName)
|
canReadProfile(adminAuth, profile.profileName)
|
||||||
);
|
);
|
||||||
const profileNames = profiles.map((profile) => profile.profileName);
|
const profileNames = profiles.map((profile) => profile.profileName);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
GatewayProfileRepository,
|
GatewayProfileRepository,
|
||||||
GatewayProfileStatus,
|
GatewayProfileStatus,
|
||||||
} from '../orchestrator/profileRepository.js';
|
} from '../orchestrator/profileRepository.js';
|
||||||
|
import { orderGatewayProfiles } from '../profileOrder.js';
|
||||||
|
|
||||||
export type LobbyMapSnapshot = {
|
export type LobbyMapSnapshot = {
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
@@ -47,7 +48,7 @@ export class InMemoryProfileStatusService implements GatewayProfileStatusService
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listLobbyProfiles(): Promise<LobbyProfileStatus[]> {
|
async listLobbyProfiles(): Promise<LobbyProfileStatus[]> {
|
||||||
return this.profiles;
|
return orderGatewayProfiles(this.profiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
setProfiles(profiles: LobbyProfileStatus[]): void {
|
setProfiles(profiles: LobbyProfileStatus[]): void {
|
||||||
@@ -63,7 +64,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listLobbyProfiles(): Promise<LobbyProfileStatus[]> {
|
async listLobbyProfiles(): Promise<LobbyProfileStatus[]> {
|
||||||
const rows = await this.profiles.listProfiles();
|
const rows = orderGatewayProfiles(await this.profiles.listProfiles());
|
||||||
const runtimeStates = await this.orchestrator.listRuntimeStates(rows.map((profile) => profile.profileName));
|
const runtimeStates = await this.orchestrator.listRuntimeStates(rows.map((profile) => profile.profileName));
|
||||||
const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state]));
|
const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state]));
|
||||||
return rows.map((row) => this.mapProfile(row, runtimeMap));
|
return rows.map((row) => this.mapProfile(row, runtimeMap));
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export const GATEWAY_PROFILE_ORDER = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe'] as const;
|
||||||
|
|
||||||
|
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 }
|
||||||
|
): number => {
|
||||||
|
const unknownRank = GATEWAY_PROFILE_ORDER.length;
|
||||||
|
const profileOrder =
|
||||||
|
(gatewayProfileOrder.get(left.profile) ?? unknownRank) -
|
||||||
|
(gatewayProfileOrder.get(right.profile) ?? unknownRank);
|
||||||
|
if (profileOrder !== 0) return profileOrder;
|
||||||
|
|
||||||
|
const profileNameOrder = left.profile.localeCompare(right.profile);
|
||||||
|
if (profileNameOrder !== 0) return profileNameOrder;
|
||||||
|
return left.scenario.localeCompare(right.scenario);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const orderGatewayProfiles = <T extends { profile: string; scenario: string }>(profiles: readonly T[]): T[] =>
|
||||||
|
[...profiles].sort(compareGatewayProfiles);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { GATEWAY_PROFILE_ORDER, orderGatewayProfiles } from '../src/profileOrder.js';
|
||||||
|
|
||||||
|
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',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders scenarios 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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(orderGatewayProfiles(profiles)).toEqual([
|
||||||
|
{ profile: 'che', scenario: '10' },
|
||||||
|
{ profile: 'che', scenario: '20' },
|
||||||
|
{ profile: 'alpha', scenario: 'default' },
|
||||||
|
{ profile: 'zeta', scenario: 'default' },
|
||||||
|
]);
|
||||||
|
expect(profiles[0]?.profile).toBe('zeta');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,6 +42,24 @@ const profiles: ProfileFixture[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const orderedProfileData: ReadonlyArray<readonly [string, string, number]> = [
|
||||||
|
['che', '체', 15003],
|
||||||
|
['kwe', '퀘', 15005],
|
||||||
|
['pwe', '풰', 15007],
|
||||||
|
['twe', '퉤', 15009],
|
||||||
|
['nya', '냐', 15011],
|
||||||
|
['pya', '퍄', 15013],
|
||||||
|
['hwe', '훼', 15015],
|
||||||
|
];
|
||||||
|
const orderedProfiles: ProfileFixture[] = orderedProfileData.map(([profile, korName, apiPort]) => ({
|
||||||
|
profileName: `${profile}:default`,
|
||||||
|
profile,
|
||||||
|
korName,
|
||||||
|
color: '#b0b0b0',
|
||||||
|
status: 'STOPPED',
|
||||||
|
apiPort,
|
||||||
|
}));
|
||||||
|
|
||||||
const fulfill = async (route: Route, results: unknown[]): Promise<void> => {
|
const fulfill = async (route: Route, results: unknown[]): Promise<void> => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -212,3 +230,22 @@ test('treats an all-closed profile list as a normal empty login status', async (
|
|||||||
expect(gameRequestCount).toBe(0);
|
expect(gameRequestCount).toBe(0);
|
||||||
await page.screenshot({ path: testInfo.outputPath('login-no-public-server.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('login-no-public-server.png'), fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renders the Gateway profile order returned by the API', async ({ page }, testInfo) => {
|
||||||
|
await installGatewayFixture(page, orderedProfiles, true);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('lobby');
|
||||||
|
|
||||||
|
const renderedProfiles = await page.locator('tbody tr td:first-child > div:first-child').allTextContents();
|
||||||
|
expect(renderedProfiles.map((name) => name.trim())).toEqual([
|
||||||
|
'체섭',
|
||||||
|
'퀘섭',
|
||||||
|
'풰섭',
|
||||||
|
'퉤섭',
|
||||||
|
'냐섭',
|
||||||
|
'퍄섭',
|
||||||
|
'훼섭',
|
||||||
|
]);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('gateway-profile-order.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user