merge: recover gateway lobby profile updates
This commit is contained in:
@@ -52,6 +52,7 @@ type LobbyFixtureOptions = {
|
|||||||
starttime?: string;
|
starttime?: string;
|
||||||
opentime?: string;
|
opentime?: string;
|
||||||
turntime?: string;
|
turntime?: string;
|
||||||
|
lobbyBundleFailures?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
|
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
|
||||||
@@ -76,7 +77,9 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
|||||||
starttime = '2026-07-30 00:00:00',
|
starttime = '2026-07-30 00:00:00',
|
||||||
opentime = '2026-07-30 00:00:00',
|
opentime = '2026-07-30 00:00:00',
|
||||||
turntime = '2026-07-30 00:05:00',
|
turntime = '2026-07-30 00:05:00',
|
||||||
|
lobbyBundleFailures = 0,
|
||||||
} = options;
|
} = options;
|
||||||
|
let remainingLobbyBundleFailures = lobbyBundleFailures;
|
||||||
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
|
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
@@ -143,7 +146,18 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
|||||||
await page.route('**/hwe/api/trpc/**', async (route) => {
|
await page.route('**/hwe/api/trpc/**', async (route) => {
|
||||||
expect(new URL(route.request().url()).pathname).toContain('/hwe/api/trpc/');
|
expect(new URL(route.request().url()).pathname).toContain('/hwe/api/trpc/');
|
||||||
const authorization = route.request().headers().authorization;
|
const authorization = route.request().headers().authorization;
|
||||||
const results = operationNames(route).map((operation) => {
|
const operations = operationNames(route);
|
||||||
|
if (operations.includes('lobby.info') && remainingLobbyBundleFailures > 0) {
|
||||||
|
remainingLobbyBundleFailures -= 1;
|
||||||
|
gameOperations.push(...operations.map((operation) => ({ operation, authorization })));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 502,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: 'profile runtime is switching' }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = operations.map((operation) => {
|
||||||
gameOperations.push({ operation, authorization });
|
gameOperations.push({ operation, authorization });
|
||||||
if (operation === 'auth.exchangeGatewayToken') {
|
if (operation === 'auth.exchangeGatewayToken') {
|
||||||
return response({
|
return response({
|
||||||
@@ -216,6 +230,67 @@ test('exchanges the gateway token before loading authenticated lobby general dat
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('automatically recovers profile details after a transient update outage', async ({ page }) => {
|
||||||
|
const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 });
|
||||||
|
|
||||||
|
await page.goto('lobby');
|
||||||
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||||
|
await expect(row.getByTestId('profile-info-retrying')).toContainText('서버 응답을 기다리고 있습니다.');
|
||||||
|
await expect(row.getByRole('button', { name: '지금 다시 확인' })).toBeVisible();
|
||||||
|
await expect(row).toContainText('선택장수', { timeout: 8_000 });
|
||||||
|
expect(gameOperations.filter(({ operation }) => operation === 'lobby.info')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offers a keyboard-accessible immediate retry without mobile overflow', async ({ page }, testInfo) => {
|
||||||
|
await installFixture(page, { lobbyBundleFailures: 1 });
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
|
||||||
|
await page.goto('lobby');
|
||||||
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||||
|
const retry = row.getByRole('button', { name: '지금 다시 확인' });
|
||||||
|
const tableScroll = page.getByTestId('profile-table-scroll');
|
||||||
|
await expect(retry).toBeVisible();
|
||||||
|
await retry.focus();
|
||||||
|
await expect(retry).toBeFocused();
|
||||||
|
const geometry = await tableScroll.evaluate((scrollElement) => {
|
||||||
|
const row = scrollElement.querySelector('tbody tr');
|
||||||
|
const button = row?.querySelector('button');
|
||||||
|
if (!row) throw new Error('expected profile row');
|
||||||
|
if (!button) throw new Error('expected profile retry button');
|
||||||
|
const scrollRect = scrollElement.getBoundingClientRect();
|
||||||
|
const rowRect = row.getBoundingClientRect();
|
||||||
|
const buttonRect = button.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(button);
|
||||||
|
return {
|
||||||
|
pageScrollWidth: document.documentElement.scrollWidth,
|
||||||
|
scroll: {
|
||||||
|
left: scrollRect.left,
|
||||||
|
right: scrollRect.right,
|
||||||
|
clientWidth: scrollElement.clientWidth,
|
||||||
|
scrollWidth: scrollElement.scrollWidth,
|
||||||
|
},
|
||||||
|
row: { left: rowRect.left, right: rowRect.right, width: rowRect.width },
|
||||||
|
button: { left: buttonRect.left, right: buttonRect.right, width: buttonRect.width },
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
outlineStyle: style.outlineStyle,
|
||||||
|
outlineWidth: style.outlineWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.pageScrollWidth).toBe(geometry.viewportWidth);
|
||||||
|
expect(geometry.scroll.clientWidth).toBeLessThanOrEqual(geometry.viewportWidth);
|
||||||
|
expect(geometry.scroll.scrollWidth).toBe(760);
|
||||||
|
expect(geometry.row.width).toBe(760);
|
||||||
|
expect(geometry.button.left).toBeGreaterThanOrEqual(geometry.scroll.left);
|
||||||
|
expect(geometry.button.right).toBeLessThanOrEqual(geometry.scroll.right);
|
||||||
|
expect(geometry.outlineStyle).toBe('solid');
|
||||||
|
expect(geometry.outlineWidth).toBe('2px');
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('gateway-profile-retry-mobile.png'), fullPage: true });
|
||||||
|
|
||||||
|
await retry.click();
|
||||||
|
await expect(row).toContainText('선택장수');
|
||||||
|
await expect(row.getByTestId('profile-info-retrying')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('applies the signed general-acquisition policy to both create and possession actions', async ({ page }) => {
|
test('applies the signed general-acquisition policy to both create and possession actions', async ({ page }) => {
|
||||||
await installFixture(page, {
|
await installFixture(page, {
|
||||||
canCreateGeneral: false,
|
canCreateGeneral: false,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatServerDateTime } from '@sammo-ts/common';
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, ref, onMounted, watch } from 'vue';
|
import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import type { inferRouterOutputs } from '@trpc/server';
|
import type { inferRouterOutputs } from '@trpc/server';
|
||||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||||
@@ -26,6 +26,13 @@ type MapPreviewBundle = {
|
|||||||
mapData: PublicMap;
|
mapData: PublicMap;
|
||||||
mapLayout: PublicMapLayout;
|
mapLayout: PublicMapLayout;
|
||||||
};
|
};
|
||||||
|
type ProfileLoadState = {
|
||||||
|
status: 'loading' | 'retrying' | 'ready';
|
||||||
|
failures: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROFILE_REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
const PROFILE_RETRY_DELAYS_MS = [1_000, 2_000, 3_000, 5_000, 8_000, 15_000] as const;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const me = ref<MeOutput>(null);
|
const me = ref<MeOutput>(null);
|
||||||
@@ -33,11 +40,15 @@ const notice = ref('');
|
|||||||
const profiles = ref<LobbyProfile[]>([]);
|
const profiles = ref<LobbyProfile[]>([]);
|
||||||
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
||||||
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
||||||
|
const profileLoadStates = ref<Record<string, ProfileLoadState | undefined>>({});
|
||||||
const selectedMapProfileName = ref<string | null>(null);
|
const selectedMapProfileName = ref<string | null>(null);
|
||||||
const entryLoading = ref<Record<string, boolean>>({});
|
const entryLoading = ref<Record<string, boolean>>({});
|
||||||
const logoutLoading = ref(false);
|
const logoutLoading = ref(false);
|
||||||
const logoutError = ref('');
|
const logoutError = ref('');
|
||||||
const { error: showErrorToast } = useToast();
|
const { error: showErrorToast } = useToast();
|
||||||
|
const profileRetryTimers = new Map<string, number>();
|
||||||
|
const profileRequestControllers = new Map<string, AbortController>();
|
||||||
|
let lobbyMounted = true;
|
||||||
|
|
||||||
watch(logoutError, (value) => value && showErrorToast(value), { flush: 'sync' });
|
watch(logoutError, (value) => value && showErrorToast(value), { flush: 'sync' });
|
||||||
const canAccessAdmin = computed(
|
const canAccessAdmin = computed(
|
||||||
@@ -98,6 +109,23 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
|
|||||||
|
|
||||||
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
|
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
|
||||||
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
||||||
|
const profileLoadState = (profileName: string): ProfileLoadState | undefined => profileLoadStates.value[profileName];
|
||||||
|
const setProfileLoadState = (profileName: string, state: ProfileLoadState): void => {
|
||||||
|
profileLoadStates.value = {
|
||||||
|
...profileLoadStates.value,
|
||||||
|
[profileName]: state,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const clearProfileRetry = (profileName: string): void => {
|
||||||
|
const timer = profileRetryTimers.get(profileName);
|
||||||
|
if (timer !== undefined) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
profileRetryTimers.delete(profileName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const requestOptions = (componentSignal: AbortSignal): { signal: AbortSignal } => ({
|
||||||
|
signal: AbortSignal.any([componentSignal, AbortSignal.timeout(PROFILE_REQUEST_TIMEOUT_MS)]),
|
||||||
|
});
|
||||||
const encodeLegacyIconPath = (value: string): string =>
|
const encodeLegacyIconPath = (value: string): string =>
|
||||||
value
|
value
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -122,6 +150,93 @@ const handleGeneralPictureError = (event: Event): void => {
|
|||||||
image.src = `${sharedIconBaseUrl}/default.jpg`;
|
image.src = `${sharedIconBaseUrl}/default.jpg`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadProfileDetails = async (profile: LobbyProfile, sessionToken: string | null): Promise<void> => {
|
||||||
|
if (!lobbyMounted || (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearProfileRetry(profile.profileName);
|
||||||
|
profileRequestControllers.get(profile.profileName)?.abort();
|
||||||
|
const requestController = new AbortController();
|
||||||
|
profileRequestControllers.set(profile.profileName, requestController);
|
||||||
|
const previousFailures = profileLoadState(profile.profileName)?.failures ?? 0;
|
||||||
|
setProfileLoadState(profile.profileName, { status: 'loading', failures: previousFailures });
|
||||||
|
|
||||||
|
const publicGameTrpc = createGameTrpc(profile.profile, profile.apiPort);
|
||||||
|
let gameTrpc = publicGameTrpc;
|
||||||
|
let authenticated = sessionToken === null;
|
||||||
|
if (sessionToken) {
|
||||||
|
try {
|
||||||
|
const issued = await trpc.auth.issueGameSession.mutate(
|
||||||
|
{
|
||||||
|
sessionToken,
|
||||||
|
profile: profile.profileName,
|
||||||
|
},
|
||||||
|
requestOptions(requestController.signal)
|
||||||
|
);
|
||||||
|
const exchanged = await publicGameTrpc.auth.exchangeGatewayToken.mutate(
|
||||||
|
{
|
||||||
|
gatewayToken: issued.gameToken,
|
||||||
|
},
|
||||||
|
requestOptions(requestController.signal)
|
||||||
|
);
|
||||||
|
gameTrpc = createGameTrpc(profile.profile, profile.apiPort, exchanged.accessToken);
|
||||||
|
authenticated = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to authenticate lobby game session for ${profile.profileName}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [infoResult, layoutResult, mapResult] = await Promise.allSettled([
|
||||||
|
gameTrpc.lobby.info.query(undefined, requestOptions(requestController.signal)),
|
||||||
|
gameTrpc.public.getMapLayout.query(undefined, requestOptions(requestController.signal)),
|
||||||
|
gameTrpc.public.getCachedMap.query(undefined, requestOptions(requestController.signal)),
|
||||||
|
]);
|
||||||
|
if (profileRequestControllers.get(profile.profileName) !== requestController) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
profileRequestControllers.delete(profile.profileName);
|
||||||
|
if (!lobbyMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (infoResult.status === 'fulfilled') {
|
||||||
|
profileDetails.value[profile.profileName] = infoResult.value;
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to fetch info for ${profile.profileName}`, infoResult.reason);
|
||||||
|
}
|
||||||
|
if (layoutResult.status === 'fulfilled' && mapResult.status === 'fulfilled') {
|
||||||
|
profileMapPreviews.value[profile.profileName] = {
|
||||||
|
mapLayout: layoutResult.value,
|
||||||
|
mapData: mapResult.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullyLoaded =
|
||||||
|
authenticated &&
|
||||||
|
infoResult.status === 'fulfilled' &&
|
||||||
|
layoutResult.status === 'fulfilled' &&
|
||||||
|
mapResult.status === 'fulfilled';
|
||||||
|
if (fullyLoaded) {
|
||||||
|
setProfileLoadState(profile.profileName, { status: 'ready', failures: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failures = previousFailures + 1;
|
||||||
|
setProfileLoadState(profile.profileName, { status: 'retrying', failures });
|
||||||
|
const retryDelay = PROFILE_RETRY_DELAYS_MS[Math.min(failures - 1, PROFILE_RETRY_DELAYS_MS.length - 1)];
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
profileRetryTimers.delete(profile.profileName);
|
||||||
|
void loadProfileDetails(profile, sessionToken);
|
||||||
|
}, retryDelay);
|
||||||
|
profileRetryTimers.set(profile.profileName, timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryProfileDetails = (profile: LobbyProfile): void => {
|
||||||
|
clearProfileRetry(profile.profileName);
|
||||||
|
setProfileLoadState(profile.profileName, { status: 'loading', failures: 0 });
|
||||||
|
void loadProfileDetails(profile, window.localStorage.getItem('sammo-session-token'));
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
me.value = await trpc.me.query();
|
me.value = await trpc.me.query();
|
||||||
@@ -133,53 +248,24 @@ onMounted(async () => {
|
|||||||
notice.value = await trpc.lobby.notice.query();
|
notice.value = await trpc.lobby.notice.query();
|
||||||
profiles.value = await trpc.lobby.profiles.query();
|
profiles.value = await trpc.lobby.profiles.query();
|
||||||
const sessionToken = window.localStorage.getItem('sammo-session-token');
|
const sessionToken = window.localStorage.getItem('sammo-session-token');
|
||||||
|
await Promise.all(profiles.value.map((profile) => loadProfileDetails(profile, sessionToken)));
|
||||||
const detailTasks = profiles.value.map(async (profile) => {
|
|
||||||
if (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const publicGameTrpc = createGameTrpc(profile.profile, profile.apiPort);
|
|
||||||
let gameToken: string | undefined;
|
|
||||||
if (sessionToken) {
|
|
||||||
try {
|
|
||||||
const issued = await trpc.auth.issueGameSession.mutate({
|
|
||||||
sessionToken,
|
|
||||||
profile: profile.profileName,
|
|
||||||
});
|
|
||||||
const exchanged = await publicGameTrpc.auth.exchangeGatewayToken.mutate({
|
|
||||||
gatewayToken: issued.gameToken,
|
|
||||||
});
|
|
||||||
gameToken = exchanged.accessToken;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to authenticate lobby game session for ${profile.profileName}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const gameTrpc = gameToken ? createGameTrpc(profile.profile, profile.apiPort, gameToken) : publicGameTrpc;
|
|
||||||
const [infoResult, layoutResult, mapResult] = await Promise.allSettled([
|
|
||||||
gameTrpc.lobby.info.query(),
|
|
||||||
gameTrpc.public.getMapLayout.query(),
|
|
||||||
gameTrpc.public.getCachedMap.query(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (infoResult.status === 'fulfilled') {
|
|
||||||
profileDetails.value[profile.profileName] = infoResult.value;
|
|
||||||
} else {
|
|
||||||
console.error(`Failed to fetch info for ${profile.profileName}`, infoResult.reason);
|
|
||||||
}
|
|
||||||
if (layoutResult.status === 'fulfilled' && mapResult.status === 'fulfilled') {
|
|
||||||
profileMapPreviews.value[profile.profileName] = {
|
|
||||||
mapLayout: layoutResult.value,
|
|
||||||
mapData: mapResult.value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(detailTasks);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load lobby', e);
|
console.error('Failed to load lobby', e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
lobbyMounted = false;
|
||||||
|
for (const timer of profileRetryTimers.values()) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
profileRetryTimers.clear();
|
||||||
|
for (const controller of profileRequestControllers.values()) {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
profileRequestControllers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
if (logoutLoading.value) {
|
if (logoutLoading.value) {
|
||||||
return;
|
return;
|
||||||
@@ -320,183 +406,206 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
>
|
>
|
||||||
서 버 선 택
|
서 버 선 택
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full text-sm text-left">
|
<div class="overflow-x-auto" data-testid="profile-table-scroll">
|
||||||
<thead class="bg-zinc-800 text-zinc-400 uppercase text-xs">
|
<table class="w-full min-w-[760px] text-sm text-left">
|
||||||
<tr>
|
<thead class="bg-zinc-800 text-zinc-400 uppercase text-xs">
|
||||||
<th class="px-4 py-3 border-b border-zinc-700 w-24 text-center">서 버</th>
|
<tr>
|
||||||
<th class="px-4 py-3 border-b border-zinc-700">정 보</th>
|
<th class="px-4 py-3 border-b border-zinc-700 w-24 text-center">서 버</th>
|
||||||
<th class="px-4 py-3 border-b border-zinc-700 w-48 text-center" colspan="2">캐 릭 터</th>
|
<th class="px-4 py-3 border-b border-zinc-700">정 보</th>
|
||||||
<th class="px-4 py-3 border-b border-zinc-700 w-32 text-center">선 택</th>
|
<th class="px-4 py-3 border-b border-zinc-700 w-48 text-center" colspan="2">
|
||||||
</tr>
|
캐 릭 터
|
||||||
</thead>
|
</th>
|
||||||
<tbody class="divide-y divide-zinc-800">
|
<th class="px-4 py-3 border-b border-zinc-700 w-32 text-center">선 택</th>
|
||||||
<tr
|
</tr>
|
||||||
v-for="profile in profiles"
|
</thead>
|
||||||
:key="profile.profileName"
|
<tbody class="divide-y divide-zinc-800">
|
||||||
class="hover:bg-zinc-800/50 transition-colors"
|
<tr
|
||||||
>
|
v-for="profile in profiles"
|
||||||
<!-- Server Name -->
|
:key="profile.profileName"
|
||||||
<td class="px-4 py-4 text-center border-r border-zinc-800">
|
class="hover:bg-zinc-800/50 transition-colors"
|
||||||
<div
|
>
|
||||||
:style="{ color: profile.color }"
|
<!-- Server Name -->
|
||||||
class="text-lg font-bold cursor-help"
|
<td class="px-4 py-4 text-center border-r border-zinc-800">
|
||||||
:title="
|
<div
|
||||||
profileDetails[profile.profileName]
|
:style="{ color: profile.color }"
|
||||||
? serverSeasonStatus(profileDetails[profile.profileName]!).period
|
class="text-lg font-bold cursor-help"
|
||||||
: ''
|
:title="
|
||||||
"
|
profileDetails[profile.profileName]
|
||||||
>
|
? serverSeasonStatus(profileDetails[profile.profileName]!).period
|
||||||
{{ profile.korName }}섭
|
: ''
|
||||||
</div>
|
"
|
||||||
<div
|
|
||||||
v-if="profileDetails[profile.profileName]"
|
|
||||||
class="season-status mt-1 whitespace-nowrap text-xs text-zinc-500"
|
|
||||||
>
|
|
||||||
{{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="profile.localAccountPolicy?.specialAccess"
|
|
||||||
class="mt-2 text-xs text-emerald-300"
|
|
||||||
>
|
|
||||||
특수 접근 · {{ profile.localAccountPolicy.specialAccess.kind }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="
|
|
||||||
profile.localAccountPolicy?.requiresKakaoVerification &&
|
|
||||||
!profile.localAccountPolicy.canCreateGeneral
|
|
||||||
"
|
|
||||||
class="mt-2 text-xs text-red-400"
|
|
||||||
>
|
|
||||||
인증 전 생성 불가
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="profile.localAccountPolicy?.requiresKakaoVerification"
|
|
||||||
class="mt-2 text-xs text-amber-300"
|
|
||||||
>
|
|
||||||
{{ formatGraceEndsAt(profile.localAccountPolicy.graceEndsAt) }}까지 유예
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Server Info -->
|
|
||||||
<td class="px-4 py-4 border-r border-zinc-800">
|
|
||||||
<template v-if="profileDetails[profile.profileName]">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div>
|
|
||||||
서기 {{ profileDetails[profile.profileName]?.year }}년
|
|
||||||
{{ profileDetails[profile.profileName]?.month }}월 (<span
|
|
||||||
class="text-orange-400"
|
|
||||||
>{{ profile.scenario }}</span
|
|
||||||
>)
|
|
||||||
</div>
|
|
||||||
<div class="text-zinc-400">
|
|
||||||
유저 : {{ profileDetails[profile.profileName]?.userCnt }} /
|
|
||||||
{{ profileDetails[profile.profileName]?.maxUserCnt }}명
|
|
||||||
<span class="text-cyan-400 ml-2"
|
|
||||||
>NPC : {{ profileDetails[profile.profileName]?.npcCnt }}명</span
|
|
||||||
>
|
|
||||||
<span class="text-green-400 ml-2"
|
|
||||||
>({{ profileDetails[profile.profileName]?.turnTerm }}분 턴 서버)</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-zinc-500">
|
|
||||||
(상성 설정:{{ profileDetails[profile.profileName]?.fictionMode }}), (기타
|
|
||||||
설정:{{ profileDetails[profile.profileName]?.otherTextInfo }})
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="profile.status === 'STOPPED'">
|
|
||||||
<div class="text-center text-zinc-600 py-2">- 폐 쇄 중 -</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="text-center text-zinc-500 py-2">정보를 불러오는 중...</div>
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Character Info -->
|
|
||||||
<td class="px-2 py-4 w-16 border-r border-zinc-800">
|
|
||||||
<div
|
|
||||||
v-if="profileDetails[profile.profileName]?.myGeneral"
|
|
||||||
class="w-12 h-12 mx-auto bg-zinc-800 rounded overflow-hidden border border-zinc-700"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="resolveGeneralPicture(profileDetails[profile.profileName]!.myGeneral!)"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
@error="handleGeneralPictureError"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4 border-r border-zinc-800 text-center">
|
|
||||||
<div v-if="profileDetails[profile.profileName]?.myGeneral" class="font-medium">
|
|
||||||
{{ profileDetails[profile.profileName]?.myGeneral?.name }}
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-zinc-600">- 미 등 록 -</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Action -->
|
|
||||||
<td class="px-4 py-4 text-center">
|
|
||||||
<template v-if="profileDetails[profile.profileName]">
|
|
||||||
<button
|
|
||||||
v-if="profileDetails[profile.profileName]?.myGeneral"
|
|
||||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
|
||||||
:disabled="entryLoading[profile.profileName]"
|
|
||||||
@click="handleEnter(profile, '/')"
|
|
||||||
>
|
>
|
||||||
입장
|
{{ profile.korName }}섭
|
||||||
</button>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="profileDetails[profile.profileName]"
|
||||||
|
class="season-status mt-1 whitespace-nowrap text-xs text-zinc-500"
|
||||||
|
>
|
||||||
|
{{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="profile.localAccountPolicy?.specialAccess"
|
||||||
|
class="mt-2 text-xs text-emerald-300"
|
||||||
|
>
|
||||||
|
특수 접근 · {{ profile.localAccountPolicy.specialAccess.kind }}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="
|
v-else-if="
|
||||||
profileDetails[profile.profileName]!.userCnt >=
|
profile.localAccountPolicy?.requiresKakaoVerification &&
|
||||||
profileDetails[profile.profileName]!.maxUserCnt
|
!profile.localAccountPolicy.canCreateGeneral
|
||||||
"
|
"
|
||||||
class="text-zinc-500"
|
class="mt-2 text-xs text-red-400"
|
||||||
>
|
>
|
||||||
- 장수 등록 마감 -
|
인증 전 생성 불가
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="grid gap-1">
|
<div
|
||||||
|
v-else-if="profile.localAccountPolicy?.requiresKakaoVerification"
|
||||||
|
class="mt-2 text-xs text-amber-300"
|
||||||
|
>
|
||||||
|
{{ formatGraceEndsAt(profile.localAccountPolicy.graceEndsAt) }}까지 유예
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Server Info -->
|
||||||
|
<td class="px-4 py-4 border-r border-zinc-800">
|
||||||
|
<template v-if="profileDetails[profile.profileName]">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div>
|
||||||
|
서기 {{ profileDetails[profile.profileName]?.year }}년
|
||||||
|
{{ profileDetails[profile.profileName]?.month }}월 (<span
|
||||||
|
class="text-orange-400"
|
||||||
|
>{{ profile.scenario }}</span
|
||||||
|
>)
|
||||||
|
</div>
|
||||||
|
<div class="text-zinc-400">
|
||||||
|
유저 : {{ profileDetails[profile.profileName]?.userCnt }} /
|
||||||
|
{{ profileDetails[profile.profileName]?.maxUserCnt }}명
|
||||||
|
<span class="text-cyan-400 ml-2"
|
||||||
|
>NPC : {{ profileDetails[profile.profileName]?.npcCnt }}명</span
|
||||||
|
>
|
||||||
|
<span class="text-green-400 ml-2"
|
||||||
|
>({{ profileDetails[profile.profileName]?.turnTerm }}분 턴
|
||||||
|
서버)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">
|
||||||
|
(상성 설정:{{ profileDetails[profile.profileName]?.fictionMode }}),
|
||||||
|
(기타 설정:{{ profileDetails[profile.profileName]?.otherTextInfo }})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="profile.status === 'STOPPED'">
|
||||||
|
<div class="text-center text-zinc-600 py-2">- 폐 쇄 중 -</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="profileLoadState(profile.profileName)?.status === 'retrying'">
|
||||||
|
<div
|
||||||
|
class="text-center text-zinc-500 py-1"
|
||||||
|
role="status"
|
||||||
|
data-testid="profile-info-retrying"
|
||||||
|
>
|
||||||
|
<div>서버 응답을 기다리고 있습니다.</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-2 text-xs text-orange-300 underline underline-offset-2 hover:text-orange-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-orange-300"
|
||||||
|
@click="retryProfileDetails(profile)"
|
||||||
|
>
|
||||||
|
지금 다시 확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="text-center text-zinc-500 py-2">정보를 불러오는 중...</div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Character Info -->
|
||||||
|
<td class="px-2 py-4 w-16 border-r border-zinc-800">
|
||||||
|
<div
|
||||||
|
v-if="profileDetails[profile.profileName]?.myGeneral"
|
||||||
|
class="w-12 h-12 mx-auto bg-zinc-800 rounded overflow-hidden border border-zinc-700"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="
|
||||||
|
resolveGeneralPicture(profileDetails[profile.profileName]!.myGeneral!)
|
||||||
|
"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
@error="handleGeneralPictureError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4 border-r border-zinc-800 text-center">
|
||||||
|
<div v-if="profileDetails[profile.profileName]?.myGeneral" class="font-medium">
|
||||||
|
{{ profileDetails[profile.profileName]?.myGeneral?.name }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-zinc-600">- 미 등 록 -</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<td class="px-4 py-4 text-center">
|
||||||
|
<template v-if="profileDetails[profile.profileName]">
|
||||||
<button
|
<button
|
||||||
v-if="profileDetails[profile.profileName]?.selectionPoolEnabled"
|
v-if="profileDetails[profile.profileName]?.myGeneral"
|
||||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||||
:disabled="entryLoading[profile.profileName]"
|
:disabled="entryLoading[profile.profileName]"
|
||||||
@click="handleEnter(profile, '/select-general')"
|
@click="handleEnter(profile, '/')"
|
||||||
>
|
>
|
||||||
장수선택
|
입장
|
||||||
</button>
|
</button>
|
||||||
<template v-else>
|
<div
|
||||||
|
v-else-if="
|
||||||
|
profileDetails[profile.profileName]!.userCnt >=
|
||||||
|
profileDetails[profile.profileName]!.maxUserCnt
|
||||||
|
"
|
||||||
|
class="text-zinc-500"
|
||||||
|
>
|
||||||
|
- 장수 등록 마감 -
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid gap-1">
|
||||||
<button
|
<button
|
||||||
|
v-if="profileDetails[profile.profileName]?.selectionPoolEnabled"
|
||||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||||
:disabled="
|
:disabled="entryLoading[profile.profileName]"
|
||||||
entryLoading[profile.profileName] ||
|
@click="handleEnter(profile, '/select-general')"
|
||||||
profile.localAccountPolicy?.canCreateGeneral === false
|
|
||||||
"
|
|
||||||
@click="handleEnter(profile, '/join')"
|
|
||||||
>
|
>
|
||||||
{{
|
장수선택
|
||||||
profile.localAccountPolicy?.canCreateGeneral === false
|
|
||||||
? '인증 필요'
|
|
||||||
: '장수생성'
|
|
||||||
}}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<template v-else>
|
||||||
v-if="profileDetails[profile.profileName]?.npcPossessionEnabled"
|
<button
|
||||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||||
:disabled="
|
:disabled="
|
||||||
entryLoading[profile.profileName] ||
|
entryLoading[profile.profileName] ||
|
||||||
profile.localAccountPolicy?.canCreateGeneral === false
|
profile.localAccountPolicy?.canCreateGeneral === false
|
||||||
"
|
"
|
||||||
@click="handleEnter(profile, '/join?tab=possess')"
|
@click="handleEnter(profile, '/join')"
|
||||||
>
|
>
|
||||||
장수빙의
|
{{
|
||||||
</button>
|
profile.localAccountPolicy?.canCreateGeneral === false
|
||||||
</template>
|
? '인증 필요'
|
||||||
</div>
|
: '장수생성'
|
||||||
</template>
|
}}
|
||||||
<template v-else-if="profile.status === 'STOPPED'">
|
</button>
|
||||||
<span class="text-zinc-700">-</span>
|
<button
|
||||||
</template>
|
v-if="profileDetails[profile.profileName]?.npcPossessionEnabled"
|
||||||
</td>
|
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||||
</tr>
|
:disabled="
|
||||||
</tbody>
|
entryLoading[profile.profileName] ||
|
||||||
</table>
|
profile.localAccountPolicy?.canCreateGeneral === false
|
||||||
|
"
|
||||||
|
@click="handleEnter(profile, '/join?tab=possess')"
|
||||||
|
>
|
||||||
|
장수빙의
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="profile.status === 'STOPPED'">
|
||||||
|
<span class="text-zinc-700">-</span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
<!-- Footer Info -->
|
<!-- Footer Info -->
|
||||||
<div class="bg-zinc-800/50 p-4 text-xs text-zinc-500 space-y-2 border-t border-zinc-800">
|
<div class="bg-zinc-800/50 p-4 text-xs text-zinc-500 space-y-2 border-t border-zinc-800">
|
||||||
<p class="text-red-500 font-bold">
|
<p class="text-red-500 font-bold">
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필
|
|||||||
데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에
|
데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에
|
||||||
별도로 검토해 주세요.
|
별도로 검토해 주세요.
|
||||||
|
|
||||||
|
Profile process 전환 중에는 frontend/API port가 잠시 닫힐 수 있습니다. 이때 이미
|
||||||
|
열린 Gateway 로비의 profile 상세 조회가 실패하면 로비는 10초 request timeout과
|
||||||
|
1·2·3·5·8·15초(이후 15초 상한) 재시도로 자동 복구를 시도합니다. 상세가 아직
|
||||||
|
없으면 `서버 응답을 기다리고 있습니다.`와 `지금 다시 확인`을
|
||||||
|
표시합니다. 정상 응답을 한 번 받은 profile은 실패한 지도·인증 재확인 중에도
|
||||||
|
마지막 상세를 유지합니다.
|
||||||
|
|
||||||
### 시나리오 초기화
|
### 시나리오 초기화
|
||||||
|
|
||||||
시나리오 초기화는 새 시즌이나 새 scenario로 현 시즌 데이터를 교체할 때
|
시나리오 초기화는 새 시즌이나 새 scenario로 현 시즌 데이터를 교체할 때
|
||||||
@@ -117,6 +124,22 @@ process 복구를 시도합니다. 관리자 화면의 오류와 PM2 process 상
|
|||||||
뒤 원인을 해결하고 실패한 작업을 재시도해 주세요. 재시도는 처음 고정된 commit을
|
뒤 원인을 해결하고 실패한 작업을 재시도해 주세요. 재시도는 처음 고정된 commit을
|
||||||
사용합니다.
|
사용합니다.
|
||||||
|
|
||||||
|
로비 한 행만 위 안내에 머물 때는 먼저 새 배포를 요청하지 말고 다음 순서로
|
||||||
|
구분합니다.
|
||||||
|
|
||||||
|
1. 로비의 `지금 다시 확인` 또는 browser 새로고침으로 같은 profile을 다시
|
||||||
|
조회합니다.
|
||||||
|
2. 공개 `/<profile>/api/trpc/lobby.info`가 이미 200이면 runtime은 복구된 것이므로
|
||||||
|
`DB 유지 배포`, `중지`, `재개`를 누르지 않습니다. 기존 로비의 전환 중 1회
|
||||||
|
실패였을 가능성이 큽니다.
|
||||||
|
3. 응답이 계속 502/connection refused이면 관리자 작업 이력에서 활성 DEPLOY/RESET과
|
||||||
|
terminal 오류, 해당 profile의 API/daemon/worker runtime 상태를 확인합니다. 활성
|
||||||
|
작업이 있으면 중복 작업을 만들지 말고 readiness 또는 rollback 종료를 기다립니다.
|
||||||
|
4. profile이 실제 `STOPPED`/`PAUSED`이고 활성 작업이 없을 때만 `재개`를 사용합니다.
|
||||||
|
metadata는 RUNNING인데 process가 계속 없으면 자동 reconcile과 operation 오류를
|
||||||
|
먼저 확인하고, 원인이 없는 상태에서만 마지막 복구 수단으로 `중지` 후 `재개`를
|
||||||
|
사용합니다. DB 보존 배포는 health restart 버튼이 아닙니다.
|
||||||
|
|
||||||
## Gateway 전체 배포
|
## Gateway 전체 배포
|
||||||
|
|
||||||
Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면에서 `Gateway
|
Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면에서 `Gateway
|
||||||
|
|||||||
Reference in New Issue
Block a user