Gateway 화면을 route 단위로 지연 로딩하고 common runtime import를 실제 소유 leaf export로 분리한다.\n\npackage boundary와 route loading 회귀 테스트로 root barrel 재유입을 차단한다.
1865 lines
95 KiB
Vue
1865 lines
95 KiB
Vue
<script setup lang="ts">
|
|
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common/time/ServerDateTime';
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
|
|
|
import CompactHelp from '../components/CompactHelp.vue';
|
|
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
|
import { useToast } from '../composables/useToast';
|
|
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
|
import {
|
|
normalizeProfileResetDefaults,
|
|
RESET_AUTORUN_LABELS,
|
|
RESET_OPTION_COPY,
|
|
SYSTEM_PROFILE_RESET_DEFAULTS,
|
|
type ProfileResetDefaults,
|
|
type ResetAutorunOption,
|
|
} from '../utils/resetDefaults';
|
|
import { directTrpc, trpc } from '../utils/trpc';
|
|
|
|
type OperationPageMode = 'version' | 'scenario' | 'cancel' | 'gateway';
|
|
|
|
const props = defineProps<{
|
|
mode: OperationPageMode;
|
|
profileName?: string;
|
|
}>();
|
|
|
|
const adminClient = trpc.admin;
|
|
const scenarioClient = directTrpc.admin;
|
|
|
|
type Scenario = {
|
|
id: number;
|
|
title: string;
|
|
year: number | null;
|
|
npcCount: number;
|
|
isCurrent: boolean;
|
|
};
|
|
|
|
type Operation = {
|
|
id: string;
|
|
profileName: string;
|
|
type: 'RESET' | 'DEPLOY' | 'CANCEL_GAME' | 'START' | 'STOP';
|
|
status: 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
|
|
sourceMode?: 'BRANCH' | 'COMMIT';
|
|
sourceRef?: string;
|
|
resolvedCommitSha?: string;
|
|
reason?: string;
|
|
requestedBy: string;
|
|
scheduledAt?: string;
|
|
startedAt?: string;
|
|
completedAt?: string;
|
|
error?: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type GatewayReleaseState = {
|
|
activeCommitSha?: string;
|
|
activeWorkspace?: string;
|
|
previousCommitSha?: string;
|
|
previousWorkspace?: string;
|
|
lastSuccessfulAt?: string;
|
|
lastError?: string;
|
|
};
|
|
|
|
type GatewayReleaseOperation = {
|
|
id: string;
|
|
type: 'DEPLOY' | 'ROLLBACK';
|
|
status: 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
|
|
sourceMode?: 'BRANCH' | 'COMMIT';
|
|
sourceRef?: string;
|
|
resolvedCommitSha?: string;
|
|
requestedBy: string;
|
|
reason?: string;
|
|
error?: string;
|
|
createdAt: string;
|
|
completedAt?: string;
|
|
};
|
|
|
|
type GatewayReleaseLog = {
|
|
cursor: string;
|
|
operationId: string;
|
|
level: 'INFO' | 'OUTPUT' | 'ERROR';
|
|
phase: string;
|
|
message: string;
|
|
createdAt: string;
|
|
};
|
|
const scenarios = ref<Scenario[]>([]);
|
|
const operations = ref<Operation[]>([]);
|
|
const selectedProfileOperationId = ref('');
|
|
const profileOperationLogs = ref<GatewayReleaseLog[]>([]);
|
|
const profileOperationLogCursor = ref<string>();
|
|
const profileOperationLogStatus = ref('');
|
|
const profileOperationLogConnection = ref<'idle' | 'connected' | 'reconnecting'>('idle');
|
|
const profileOperationLogViewport = ref<HTMLElement>();
|
|
const gatewayReleaseState = ref<GatewayReleaseState | null>(null);
|
|
const gatewayReleaseOperations = ref<GatewayReleaseOperation[]>([]);
|
|
const selectedGatewayOperationId = ref('');
|
|
const expandedGatewayErrorOperationId = ref('');
|
|
const gatewayReleaseLogs = ref<GatewayReleaseLog[]>([]);
|
|
const gatewayReleaseLogCursor = ref<string>();
|
|
const gatewayReleaseLogStatus = ref('');
|
|
const gatewayReleaseLogConnection = ref<'idle' | 'connected' | 'reconnecting'>('idle');
|
|
const gatewayReleaseLogViewport = ref<HTMLElement>();
|
|
const gatewayReleaseAvailable = ref(false);
|
|
const selectedProfileName = computed(() => props.profileName ?? '');
|
|
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
|
|
const loading = ref(false);
|
|
const catalogLoading = ref(false);
|
|
const catalogAttempted = ref(false);
|
|
const submitting = ref(false);
|
|
const message = ref('');
|
|
const errorMessage = ref('');
|
|
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
|
|
|
watch(message, (value) => value && showSuccessToast(value), { flush: 'sync' });
|
|
watch(errorMessage, (value) => value && showErrorToast(value), { flush: 'sync' });
|
|
const resetDefaultsSource = ref<'SYSTEM' | 'PROFILE'>('SYSTEM');
|
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
let stateRequestInFlight = false;
|
|
let releaseLogLoopGeneration = 0;
|
|
let profileLogLoopGeneration = 0;
|
|
let componentMounted = false;
|
|
let gatewayReleaseTransitionActive = false;
|
|
const LOG_SCROLL_FOLLOW_THRESHOLD_PX = 40;
|
|
|
|
const isLogViewportNearEnd = (viewport?: HTMLElement): boolean => {
|
|
if (!viewport) return true;
|
|
return viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop <= LOG_SCROLL_FOLLOW_THRESHOLD_PX;
|
|
};
|
|
|
|
const form = reactive({
|
|
sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT',
|
|
sourceRef: 'main',
|
|
scenarioId: null as number | null,
|
|
turnTermMinutes: SYSTEM_PROFILE_RESET_DEFAULTS.turnTermMinutes,
|
|
sync: SYSTEM_PROFILE_RESET_DEFAULTS.sync,
|
|
fiction: SYSTEM_PROFILE_RESET_DEFAULTS.fiction,
|
|
extend: SYSTEM_PROFILE_RESET_DEFAULTS.extend,
|
|
blockGeneralCreate: SYSTEM_PROFILE_RESET_DEFAULTS.blockGeneralCreate,
|
|
npcMode: SYSTEM_PROFILE_RESET_DEFAULTS.npcMode,
|
|
showImgLevel: SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel,
|
|
tournamentTrig: SYSTEM_PROFILE_RESET_DEFAULTS.tournamentTrig,
|
|
joinMode: SYSTEM_PROFILE_RESET_DEFAULTS.joinMode,
|
|
autorunEnabled: false,
|
|
autorunUserMinutes: 1440,
|
|
autorunDevelop: true,
|
|
autorunWarp: true,
|
|
autorunRecruit: true,
|
|
autorunRecruitHigh: true,
|
|
autorunTrain: true,
|
|
autorunBattle: true,
|
|
autorunChief: true,
|
|
openAt: '',
|
|
preopenAt: '',
|
|
scheduledAt: '',
|
|
reason: '',
|
|
});
|
|
const RESET_AUTORUN_FORM_KEYS = {
|
|
develop: 'autorunDevelop',
|
|
warp: 'autorunWarp',
|
|
recruit: 'autorunRecruit',
|
|
recruit_high: 'autorunRecruitHigh',
|
|
train: 'autorunTrain',
|
|
battle: 'autorunBattle',
|
|
chief: 'autorunChief',
|
|
} as const satisfies Record<ResetAutorunOption, keyof typeof form>;
|
|
const RESET_SCHEDULE_COPY = {
|
|
scheduledAt: {
|
|
label: '초기화 시작',
|
|
help: 'Gateway가 빌드, DB 초기화와 시나리오 생성을 시작합니다. 비우면 즉시 시작하며, 완료되어도 가오픈 전에는 접속을 차단합니다.',
|
|
},
|
|
preopenAt: {
|
|
label: '가오픈 시작',
|
|
help: '게임 접속과 장수 생성, 예약턴 입력을 허용하지만 턴은 진행하지 않습니다. 가오픈을 비우고 정식 오픈만 지정하면 초기화 완료 후 바로 가오픈합니다.',
|
|
},
|
|
openAt: {
|
|
label: '정식 오픈',
|
|
help: '턴 진행을 시작합니다. 비우면 초기화가 완료되는 즉시 정식 오픈합니다.',
|
|
},
|
|
} as const;
|
|
const gatewayForm = reactive({
|
|
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
|
|
sourceRef: 'main',
|
|
reason: '',
|
|
});
|
|
const cancellationForm = reactive({
|
|
historyMode: 'RETAIN_ABANDONED' as 'RETAIN_ABANDONED' | 'DELETE',
|
|
generalMode: 'RETAIN' as 'RETAIN' | 'DELETE',
|
|
earnedPointRetentionPercent: 0,
|
|
reason: '',
|
|
confirmation: '',
|
|
});
|
|
|
|
const selectedGatewayOperation = computed(
|
|
() => gatewayReleaseOperations.value.find((operation) => operation.id === selectedGatewayOperationId.value) ?? null
|
|
);
|
|
const selectedProfileOperation = computed(
|
|
() => operations.value.find((operation) => operation.id === selectedProfileOperationId.value) ?? null
|
|
);
|
|
const profileOperationLogEmptyMessage = computed(() => {
|
|
const operation = selectedProfileOperation.value;
|
|
const status = profileOperationLogStatus.value || operation?.status;
|
|
if (!operation || !status || ['QUEUED', 'RUNNING'].includes(status)) {
|
|
return '오케스트레이터 로그를 기다리고 있습니다…';
|
|
}
|
|
if (operation.error) {
|
|
return `이 작업에는 진행 로그가 기록되지 않았습니다. 작업 오류: ${operation.error}`;
|
|
}
|
|
return '이 작업에는 진행 로그가 기록되지 않았습니다. 로그 기능 적용 전 작업일 수 있습니다.';
|
|
});
|
|
const gatewayReleaseLogEmptyMessage = computed(() => {
|
|
const operation = selectedGatewayOperation.value;
|
|
const status = gatewayReleaseLogStatus.value || operation?.status;
|
|
if (!operation || !status || ['QUEUED', 'RUNNING'].includes(status)) {
|
|
return 'controller 로그를 기다리고 있습니다…';
|
|
}
|
|
if (operation.error) {
|
|
return '이 작업에는 controller 로그가 기록되지 않았습니다. 작업 이력의 오류 상세를 확인하세요.';
|
|
}
|
|
return '이 작업에는 controller 로그가 기록되지 않았습니다. 로그 지원 controller 적용 전 작업일 수 있습니다.';
|
|
});
|
|
const hasCapability = (permission: string): boolean =>
|
|
capabilities.value.some((entry) => {
|
|
if (entry.permission !== permission) return false;
|
|
if (!props.profileName) return true;
|
|
return !entry.scopes?.length || entry.scopes.includes('*') || entry.scopes.includes(props.profileName);
|
|
});
|
|
|
|
const pageTitle = computed(() => {
|
|
if (props.mode === 'gateway') return 'Gateway 릴리스';
|
|
if (props.mode === 'cancel') return `${props.profileName ?? ''} 게임 취소`;
|
|
if (props.mode === 'scenario') return `${props.profileName ?? ''} 시나리오 초기화`;
|
|
return `${props.profileName ?? ''} 버전 업데이트`;
|
|
});
|
|
|
|
const pageDescription = computed(() => {
|
|
if (props.mode === 'gateway') return 'Gateway control plane 배포와 rollback을 별도 권한으로 관리합니다.';
|
|
if (props.mode === 'cancel') {
|
|
return '진행 중 게임을 닫고 정식 기수에서 제외하며 장수 기록과 유산 포인트 보전 범위를 선택합니다.';
|
|
}
|
|
if (props.mode === 'scenario') {
|
|
return '서버에 지정된 브랜치의 최신 버전 또는 고정 커밋으로 시나리오를 초기화합니다.';
|
|
}
|
|
return '현재 게임 DB를 유지한 채 코드와 forward migration을 배포합니다.';
|
|
});
|
|
|
|
const activeOperation = computed(
|
|
() =>
|
|
operations.value.find(
|
|
(operation) =>
|
|
operation.profileName === selectedProfileName.value &&
|
|
(operation.status === 'QUEUED' || operation.status === 'RUNNING')
|
|
) ?? null
|
|
);
|
|
|
|
const sourceHelp = computed(() =>
|
|
form.sourceMode === 'CURRENT'
|
|
? '서버가 브랜치를 추적하면 작업 시작 시 최신 커밋을 사용하고, 커밋 고정 상태면 그 버전을 유지합니다.'
|
|
: form.sourceMode === 'BRANCH'
|
|
? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.'
|
|
: '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.'
|
|
);
|
|
|
|
const toIso = (value: string): string | undefined => {
|
|
return serverDateTimeInputToIso(value);
|
|
};
|
|
|
|
const formatTime = (value?: string): string => formatServerDateTime(value, { fallback: '-' });
|
|
const formatLogTime = (value: string): string => formatServerDateTime(value, { format: 'timeSeconds' });
|
|
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
|
|
|
|
const clearStatus = () => {
|
|
message.value = '';
|
|
errorMessage.value = '';
|
|
};
|
|
|
|
const applyResetDefaults = (defaults: ProfileResetDefaults) => {
|
|
form.turnTermMinutes = defaults.turnTermMinutes;
|
|
form.sync = defaults.sync;
|
|
form.fiction = defaults.fiction;
|
|
form.extend = defaults.extend;
|
|
form.blockGeneralCreate = defaults.blockGeneralCreate;
|
|
form.npcMode = defaults.npcMode;
|
|
form.showImgLevel = defaults.showImgLevel;
|
|
form.tournamentTrig = defaults.tournamentTrig;
|
|
form.joinMode = defaults.joinMode;
|
|
form.autorunEnabled = defaults.autorunUser !== null;
|
|
form.autorunUserMinutes = defaults.autorunUser?.limitMinutes ?? 1440;
|
|
const autorunOptions = new Set(defaults.autorunUser?.options ?? []);
|
|
form.autorunDevelop = autorunOptions.has('develop');
|
|
form.autorunWarp = autorunOptions.has('warp');
|
|
form.autorunRecruit = autorunOptions.has('recruit');
|
|
form.autorunRecruitHigh = autorunOptions.has('recruit_high');
|
|
form.autorunTrain = autorunOptions.has('train');
|
|
form.autorunBattle = autorunOptions.has('battle');
|
|
form.autorunChief = autorunOptions.has('chief');
|
|
};
|
|
|
|
const loadResetDefaults = async () => {
|
|
if (props.mode !== 'scenario' || !selectedProfileName.value) return;
|
|
try {
|
|
const result = await adminClient.profiles.getResetDefaults.query({
|
|
profileName: selectedProfileName.value,
|
|
});
|
|
applyResetDefaults(normalizeProfileResetDefaults(result.defaults));
|
|
resetDefaultsSource.value = result.source;
|
|
} catch {
|
|
applyResetDefaults(SYSTEM_PROFILE_RESET_DEFAULTS);
|
|
resetDefaultsSource.value = 'SYSTEM';
|
|
}
|
|
};
|
|
|
|
const loadCapabilities = async () => {
|
|
try {
|
|
capabilities.value = (await adminClient.capabilities.list.query()) as typeof capabilities.value;
|
|
} catch (error) {
|
|
capabilities.value = [];
|
|
errorMessage.value = error instanceof Error ? error.message : '관리 권한을 불러오지 못했습니다.';
|
|
}
|
|
};
|
|
|
|
const loadState = async (quiet = false) => {
|
|
if (stateRequestInFlight) {
|
|
return;
|
|
}
|
|
stateRequestInFlight = true;
|
|
if (!quiet) {
|
|
loading.value = true;
|
|
}
|
|
try {
|
|
if (props.mode === 'gateway') {
|
|
const [state, releaseOperations] = await Promise.all([
|
|
adminClient.releases.gatewayState.query(),
|
|
adminClient.releases.list.query({ limit: 30 }),
|
|
]);
|
|
gatewayReleaseState.value = state as GatewayReleaseState;
|
|
gatewayReleaseOperations.value = releaseOperations as GatewayReleaseOperation[];
|
|
const active = gatewayReleaseOperations.value.find((operation) =>
|
|
['QUEUED', 'RUNNING'].includes(operation.status)
|
|
);
|
|
gatewayReleaseTransitionActive = Boolean(active);
|
|
if (active && selectedGatewayOperationId.value !== active.id) {
|
|
selectedGatewayOperationId.value = active.id;
|
|
} else if (
|
|
!selectedGatewayOperationId.value ||
|
|
!gatewayReleaseOperations.value.some((operation) => operation.id === selectedGatewayOperationId.value)
|
|
) {
|
|
selectedGatewayOperationId.value = gatewayReleaseOperations.value[0]?.id ?? '';
|
|
}
|
|
gatewayReleaseAvailable.value = true;
|
|
} else {
|
|
const operationResult = await adminClient.operations.list.query({
|
|
profileName: props.profileName,
|
|
limit: 100,
|
|
});
|
|
operations.value = operationResult as Operation[];
|
|
const active = operations.value.find((operation) => ['QUEUED', 'RUNNING'].includes(operation.status));
|
|
if (active && selectedProfileOperationId.value !== active.id) {
|
|
selectedProfileOperationId.value = active.id;
|
|
} else if (
|
|
!selectedProfileOperationId.value ||
|
|
!operations.value.some((operation) => operation.id === selectedProfileOperationId.value)
|
|
) {
|
|
selectedProfileOperationId.value = operations.value[0]?.id ?? '';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Gateway release가 process를 교체하는 동안에는 background poll의 HTTP 연결이
|
|
// 일시적으로 끊길 수 있다. 이 경우는 기존 상태를 유지하고 다음 poll에서 복구한다.
|
|
if (!quiet || props.mode !== 'gateway' || !gatewayReleaseTransitionActive) {
|
|
errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.';
|
|
}
|
|
} finally {
|
|
loading.value = false;
|
|
stateRequestInFlight = false;
|
|
}
|
|
};
|
|
|
|
const scrollProfileOperationLogToEnd = async (shouldFollow: boolean) => {
|
|
await nextTick();
|
|
const viewport = profileOperationLogViewport.value;
|
|
if (viewport && shouldFollow) viewport.scrollTop = viewport.scrollHeight;
|
|
};
|
|
|
|
const pollProfileOperationLogs = async (operationId: string, generation: number) => {
|
|
while (
|
|
componentMounted &&
|
|
generation === profileLogLoopGeneration &&
|
|
selectedProfileOperationId.value === operationId
|
|
) {
|
|
try {
|
|
const result = await adminClient.operations.logs.query({
|
|
id: operationId,
|
|
afterCursor: profileOperationLogCursor.value,
|
|
limit: 200,
|
|
timeoutMs: 20_000,
|
|
});
|
|
if (generation !== profileLogLoopGeneration || selectedProfileOperationId.value !== operationId) return;
|
|
profileOperationLogConnection.value = 'connected';
|
|
const entries = result.entries as GatewayReleaseLog[];
|
|
if (entries.length) {
|
|
const shouldFollow = isLogViewportNearEnd(profileOperationLogViewport.value);
|
|
const known = new Set(profileOperationLogs.value.map((entry) => entry.cursor));
|
|
profileOperationLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
|
|
profileOperationLogs.value = profileOperationLogs.value.slice(-1_000);
|
|
profileOperationLogCursor.value = result.nextCursor;
|
|
await scrollProfileOperationLogToEnd(shouldFollow);
|
|
}
|
|
const operation = result.operation as Operation;
|
|
profileOperationLogStatus.value = operation.status;
|
|
const index = operations.value.findIndex((entry) => entry.id === operation.id);
|
|
if (index >= 0) operations.value[index] = operation;
|
|
if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) return;
|
|
} catch {
|
|
if (generation !== profileLogLoopGeneration || !componentMounted) return;
|
|
profileOperationLogConnection.value = 'reconnecting';
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 1_000));
|
|
}
|
|
}
|
|
};
|
|
|
|
const selectProfileOperation = (operationId: string) => {
|
|
if (selectedProfileOperationId.value === operationId) {
|
|
profileLogLoopGeneration += 1;
|
|
profileOperationLogs.value = [];
|
|
profileOperationLogCursor.value = undefined;
|
|
profileOperationLogStatus.value = '';
|
|
profileOperationLogConnection.value = 'idle';
|
|
void pollProfileOperationLogs(operationId, profileLogLoopGeneration);
|
|
return;
|
|
}
|
|
selectedProfileOperationId.value = operationId;
|
|
};
|
|
|
|
const scrollReleaseLogToEnd = async (shouldFollow: boolean) => {
|
|
await nextTick();
|
|
const viewport = gatewayReleaseLogViewport.value;
|
|
if (viewport && shouldFollow) viewport.scrollTop = viewport.scrollHeight;
|
|
};
|
|
|
|
const pollGatewayReleaseLogs = async (operationId: string, generation: number) => {
|
|
while (
|
|
componentMounted &&
|
|
generation === releaseLogLoopGeneration &&
|
|
selectedGatewayOperationId.value === operationId
|
|
) {
|
|
try {
|
|
const result = await adminClient.releases.logs.query({
|
|
id: operationId,
|
|
afterCursor: gatewayReleaseLogCursor.value,
|
|
limit: 200,
|
|
timeoutMs: 20_000,
|
|
});
|
|
if (generation !== releaseLogLoopGeneration || selectedGatewayOperationId.value !== operationId) return;
|
|
gatewayReleaseLogConnection.value = 'connected';
|
|
const entries = result.entries as GatewayReleaseLog[];
|
|
if (entries.length) {
|
|
const shouldFollow = isLogViewportNearEnd(gatewayReleaseLogViewport.value);
|
|
const known = new Set(gatewayReleaseLogs.value.map((entry) => entry.cursor));
|
|
gatewayReleaseLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
|
|
gatewayReleaseLogs.value = gatewayReleaseLogs.value.slice(-1_000);
|
|
gatewayReleaseLogCursor.value = result.nextCursor;
|
|
await scrollReleaseLogToEnd(shouldFollow);
|
|
}
|
|
const operation = result.operation as GatewayReleaseOperation;
|
|
gatewayReleaseLogStatus.value = operation.status;
|
|
const index = gatewayReleaseOperations.value.findIndex((entry) => entry.id === operation.id);
|
|
if (index >= 0) gatewayReleaseOperations.value[index] = operation;
|
|
if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) return;
|
|
} catch {
|
|
if (generation !== releaseLogLoopGeneration || !componentMounted) return;
|
|
gatewayReleaseLogConnection.value = 'reconnecting';
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 1_000));
|
|
}
|
|
}
|
|
};
|
|
|
|
const selectGatewayReleaseOperation = (operationId: string) => {
|
|
if (selectedGatewayOperationId.value === operationId) {
|
|
releaseLogLoopGeneration += 1;
|
|
gatewayReleaseLogs.value = [];
|
|
gatewayReleaseLogCursor.value = undefined;
|
|
gatewayReleaseLogStatus.value = '';
|
|
gatewayReleaseLogConnection.value = 'idle';
|
|
void pollGatewayReleaseLogs(operationId, releaseLogLoopGeneration);
|
|
return;
|
|
}
|
|
selectedGatewayOperationId.value = operationId;
|
|
};
|
|
|
|
const toggleGatewayReleaseError = (operationId: string) => {
|
|
expandedGatewayErrorOperationId.value = expandedGatewayErrorOperationId.value === operationId ? '' : operationId;
|
|
};
|
|
|
|
const requestDeploy = async () => {
|
|
clearStatus();
|
|
if (
|
|
!selectedProfileName.value ||
|
|
activeOperation.value ||
|
|
!form.sourceRef.trim() ||
|
|
form.sourceMode === 'CURRENT'
|
|
) {
|
|
return;
|
|
}
|
|
if (
|
|
!window.confirm(
|
|
`${selectedProfileName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?`
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
submitting.value = true;
|
|
try {
|
|
const operation = await adminClient.operations.requestDeploy.mutate({
|
|
profileName: selectedProfileName.value,
|
|
sourceMode: form.sourceMode,
|
|
sourceRef: form.sourceRef.trim(),
|
|
reason: form.reason.trim() || undefined,
|
|
});
|
|
selectedProfileOperationId.value = operation.id;
|
|
message.value = 'DB 보존 배포 작업을 등록했습니다.';
|
|
await loadState(true);
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof Error ? error.message : 'DB 보존 배포 요청에 실패했습니다.';
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
};
|
|
|
|
const requestGatewayDeploy = async () => {
|
|
clearStatus();
|
|
if (!gatewayForm.sourceRef.trim()) return;
|
|
if (!window.confirm(`Gateway 전체를 ${gatewayForm.sourceRef.trim()} 버전으로 전환하시겠습니까?`)) return;
|
|
submitting.value = true;
|
|
try {
|
|
const operation = await adminClient.releases.requestGatewayDeploy.mutate({
|
|
sourceMode: gatewayForm.sourceMode,
|
|
sourceRef: gatewayForm.sourceRef.trim(),
|
|
reason: gatewayForm.reason.trim() || undefined,
|
|
});
|
|
gatewayReleaseTransitionActive = true;
|
|
selectedGatewayOperationId.value = operation.id;
|
|
message.value = 'Gateway 배포 작업을 등록했습니다. 외부 release-controller가 처리합니다.';
|
|
await loadState(true);
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof Error ? error.message : 'Gateway 배포 요청에 실패했습니다.';
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
};
|
|
|
|
const requestGatewayRollback = async () => {
|
|
clearStatus();
|
|
if (!gatewayReleaseState.value?.previousCommitSha) return;
|
|
if (
|
|
!window.confirm(
|
|
`Gateway를 이전 버전 ${shortSha(gatewayReleaseState.value.previousCommitSha)}로 되돌리시겠습니까?`
|
|
)
|
|
)
|
|
return;
|
|
submitting.value = true;
|
|
try {
|
|
const operation = await adminClient.releases.requestGatewayRollback.mutate({
|
|
reason: gatewayForm.reason.trim() || undefined,
|
|
});
|
|
gatewayReleaseTransitionActive = true;
|
|
selectedGatewayOperationId.value = operation.id;
|
|
message.value = 'Gateway rollback 작업을 등록했습니다.';
|
|
await loadState(true);
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof Error ? error.message : 'Gateway rollback 요청에 실패했습니다.';
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
};
|
|
|
|
const cancelGatewayRelease = async (operation: GatewayReleaseOperation) => {
|
|
clearStatus();
|
|
const prompt =
|
|
operation.status === 'RUNNING'
|
|
? '실행 중인 Gateway 빌드를 중단하시겠습니까? process 전환 또는 migration이 시작된 뒤에는 중단할 수 없습니다.'
|
|
: '대기 중인 Gateway 릴리스를 취소하시겠습니까?';
|
|
if (!window.confirm(prompt)) return;
|
|
try {
|
|
await adminClient.releases.cancel.mutate({ id: operation.id });
|
|
selectedGatewayOperationId.value = operation.id;
|
|
message.value =
|
|
operation.status === 'RUNNING' ? 'Gateway 빌드를 중단했습니다.' : 'Gateway 릴리스를 취소했습니다.';
|
|
await loadState(true);
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof Error ? error.message : 'Gateway 릴리스 중단에 실패했습니다.';
|
|
}
|
|
};
|
|
|
|
const retryGatewayRelease = async (operation: GatewayReleaseOperation) => {
|
|
clearStatus();
|
|
if (!window.confirm('같은 고정 커밋으로 Gateway 릴리스를 다시 실행하시겠습니까?')) return;
|
|
try {
|
|
const retried = await adminClient.releases.retry.mutate({ id: operation.id });
|
|
selectedGatewayOperationId.value = retried.id;
|
|
message.value = 'Gateway 릴리스 재시도 작업을 등록했습니다.';
|
|
await loadState(true);
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof Error ? error.message : 'Gateway 릴리스 재시도에 실패했습니다.';
|
|
}
|
|
};
|
|
|
|
const loadScenarios = async () => {
|
|
clearStatus();
|
|
if (form.sourceMode !== 'CURRENT' && !form.sourceRef.trim()) {
|
|
errorMessage.value = '브랜치 또는 커밋을 입력해주세요.';
|
|
return;
|
|
}
|
|
catalogLoading.value = true;
|
|
try {
|
|
const result = await scenarioClient.profiles.listScenarios.query({
|
|
profileName: selectedProfileName.value,
|
|
gitRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(),
|
|
sourceMode: form.sourceMode,
|
|
});
|
|
scenarios.value = result as Scenario[];
|
|
if (!scenarios.value.some((scenario) => scenario.id === form.scenarioId)) {
|
|
form.scenarioId =
|
|
scenarios.value.find((scenario) => scenario.isCurrent)?.id ?? scenarios.value[0]?.id ?? null;
|
|
}
|
|
message.value = `${scenarios.value.length}개 시나리오를 확인했습니다.`;
|
|
} catch (error) {
|
|
scenarios.value = [];
|
|
errorMessage.value = error instanceof Error ? error.message : '소스에서 시나리오를 읽지 못했습니다.';
|
|
} finally {
|
|
catalogAttempted.value = true;
|
|
catalogLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const selectedAutorunOptions = (): ResetAutorunOption[] => {
|
|
const options: ResetAutorunOption[] = [];
|
|
if (form.autorunDevelop) options.push('develop');
|
|
if (form.autorunWarp) options.push('warp');
|
|
if (form.autorunRecruit) options.push('recruit');
|
|
if (form.autorunRecruitHigh) options.push('recruit_high');
|
|
if (form.autorunTrain) options.push('train');
|
|
if (form.autorunBattle) options.push('battle');
|
|
if (form.autorunChief) options.push('chief');
|
|
return options;
|
|
};
|
|
|
|
const requestReset = async () => {
|
|
clearStatus();
|
|
if (!selectedProfileName.value || activeOperation.value) {
|
|
return;
|
|
}
|
|
if ((form.sourceMode !== 'CURRENT' && !form.sourceRef.trim()) || form.scenarioId === null) {
|
|
errorMessage.value = '초기화 소스와 시나리오를 먼저 선택해주세요.';
|
|
return;
|
|
}
|
|
const scenarioId = form.scenarioId;
|
|
const sourceLabel =
|
|
form.sourceMode === 'CURRENT' ? '서버 지정 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
|
|
if (
|
|
!window.confirm(
|
|
`${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}`
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
submitting.value = true;
|
|
try {
|
|
const operation = await adminClient.operations.requestReset.mutate({
|
|
profileName: selectedProfileName.value,
|
|
sourceMode: form.sourceMode,
|
|
sourceRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(),
|
|
scheduledAt: toIso(form.scheduledAt),
|
|
reason: form.reason.trim() || undefined,
|
|
install: {
|
|
scenarioId,
|
|
turnTermMinutes: form.turnTermMinutes,
|
|
sync: form.sync,
|
|
fiction: form.fiction,
|
|
extend: form.extend,
|
|
blockGeneralCreate: form.blockGeneralCreate,
|
|
npcMode: form.npcMode,
|
|
showImgLevel: form.showImgLevel,
|
|
tournamentTrig: form.tournamentTrig,
|
|
joinMode: form.joinMode,
|
|
autorunUser: form.autorunEnabled
|
|
? {
|
|
limitMinutes: form.autorunUserMinutes,
|
|
options: selectedAutorunOptions(),
|
|
}
|
|
: null,
|
|
openAt: toIso(form.openAt),
|
|
preopenAt: toIso(form.preopenAt),
|
|
},
|
|
});
|
|
selectedProfileOperationId.value = operation.id;
|
|
message.value = form.scheduledAt ? '예약 초기화 작업을 등록했습니다.' : '초기화 작업을 등록했습니다.';
|
|
await loadState(true);
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof Error ? error.message : '초기화 요청에 실패했습니다.';
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
};
|
|
|
|
const requestGameCancellation = async () => {
|
|
clearStatus();
|
|
const profileName = selectedProfileName.value;
|
|
if (!profileName || activeOperation.value) return;
|
|
if (cancellationForm.reason.trim().length < 5) {
|
|
errorMessage.value = '취소 사유를 5자 이상 입력해주세요.';
|
|
return;
|
|
}
|
|
if (cancellationForm.confirmation.trim() !== profileName) {
|
|
errorMessage.value = `확인란에 ${profileName}을 정확히 입력해주세요.`;
|
|
return;
|
|
}
|
|
const historyText =
|
|
cancellationForm.historyMode === 'RETAIN_ABANDONED' ? '취소 게임으로 보존' : '기수 행 물리 삭제';
|
|
const generalText = cancellationForm.generalMode === 'RETAIN' ? '장수 기록 보존' : '장수 기록 삭제';
|
|
if (
|
|
!window.confirm(
|
|
`${profileName}의 진행 중 게임을 취소합니다.\n${historyText}\n${generalText}\n유산 획득분 ${cancellationForm.earnedPointRetentionPercent}% 보전\n취소 후 시나리오 초기화 전에는 재개할 수 없습니다.`
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
submitting.value = true;
|
|
try {
|
|
const operation = await adminClient.operations.requestGameCancellation.mutate({
|
|
profileName,
|
|
historyMode: cancellationForm.historyMode,
|
|
generalMode: cancellationForm.generalMode,
|
|
earnedPointRetentionPercent: cancellationForm.earnedPointRetentionPercent,
|
|
reason: cancellationForm.reason.trim(),
|
|
});
|
|
selectedProfileOperationId.value = operation.id;
|
|
cancellationForm.confirmation = '';
|
|
message.value = '게임 취소 작업을 등록했습니다.';
|
|
await loadState(true);
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof Error ? error.message : '게임 취소 요청에 실패했습니다.';
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
};
|
|
|
|
const cancelOperation = async (operation: Operation) => {
|
|
clearStatus();
|
|
const prompt =
|
|
operation.status === 'RUNNING'
|
|
? '실행 중인 프로필 빌드를 중단하시겠습니까? 기존 runtime과 게임 DB는 유지됩니다.'
|
|
: '대기 중인 작업을 취소하시겠습니까?';
|
|
if (!window.confirm(prompt)) {
|
|
return;
|
|
}
|
|
try {
|
|
await adminClient.operations.cancel.mutate({ id: operation.id });
|
|
selectedProfileOperationId.value = operation.id;
|
|
message.value = operation.status === 'RUNNING' ? '프로필 빌드를 중단했습니다.' : '작업을 취소했습니다.';
|
|
await loadState(true);
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof Error ? error.message : '작업 취소에 실패했습니다.';
|
|
}
|
|
};
|
|
|
|
const retryOperation = async (operation: Operation) => {
|
|
clearStatus();
|
|
if (!window.confirm('같은 입력으로 작업을 다시 실행하시겠습니까?')) {
|
|
return;
|
|
}
|
|
try {
|
|
const retried = await adminClient.operations.retry.mutate({ id: operation.id });
|
|
selectedProfileOperationId.value = retried.id;
|
|
message.value = '재시도 작업을 등록했습니다.';
|
|
await loadState(true);
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof Error ? error.message : '작업 재시도에 실패했습니다.';
|
|
}
|
|
};
|
|
|
|
watch(selectedProfileOperationId, (operationId) => {
|
|
profileLogLoopGeneration += 1;
|
|
profileOperationLogs.value = [];
|
|
profileOperationLogCursor.value = undefined;
|
|
profileOperationLogStatus.value = '';
|
|
profileOperationLogConnection.value = operationId ? 'connected' : 'idle';
|
|
if (operationId && componentMounted) void pollProfileOperationLogs(operationId, profileLogLoopGeneration);
|
|
});
|
|
|
|
watch(selectedGatewayOperationId, (operationId) => {
|
|
releaseLogLoopGeneration += 1;
|
|
gatewayReleaseLogs.value = [];
|
|
gatewayReleaseLogCursor.value = undefined;
|
|
gatewayReleaseLogStatus.value = '';
|
|
gatewayReleaseLogConnection.value = operationId ? 'connected' : 'idle';
|
|
if (operationId && componentMounted) void pollGatewayReleaseLogs(operationId, releaseLogLoopGeneration);
|
|
});
|
|
|
|
watch(
|
|
() => form.sourceMode,
|
|
(sourceMode) => {
|
|
scenarios.value = [];
|
|
form.scenarioId = null;
|
|
catalogAttempted.value = false;
|
|
if (sourceMode === 'CURRENT' && componentMounted) {
|
|
void loadScenarios();
|
|
}
|
|
}
|
|
);
|
|
|
|
onMounted(async () => {
|
|
componentMounted = true;
|
|
await Promise.all([
|
|
loadCapabilities(),
|
|
loadState(),
|
|
loadResetDefaults(),
|
|
props.mode === 'scenario' ? loadScenarios() : Promise.resolve(),
|
|
]);
|
|
pollTimer = setInterval(() => void loadState(true), 3000);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
componentMounted = false;
|
|
profileLogLoopGeneration += 1;
|
|
releaseLogLoopGeneration += 1;
|
|
if (pollTimer) {
|
|
clearInterval(pollTimer);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<AdminConsoleLayout :title="pageTitle" :description="pageDescription" eyebrow="Release operations">
|
|
<template #actions>
|
|
<button
|
|
class="rounded border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm hover:border-zinc-500 disabled:opacity-50"
|
|
:disabled="loading"
|
|
data-testid="refresh-operations"
|
|
@click="loadState()"
|
|
>
|
|
상태 새로고침
|
|
</button>
|
|
</template>
|
|
|
|
<div class="space-y-6" data-testid="server-operations-page">
|
|
<ServerProfileTabs
|
|
v-if="mode !== 'gateway' && profileName"
|
|
:profile-name="profileName"
|
|
:active-tab="mode === 'scenario' ? 'scenario' : mode === 'cancel' ? 'cancel' : 'version'"
|
|
:can-deploy="hasCapability('admin.profiles.deploy')"
|
|
:can-reset="hasCapability('admin.scenarios.reset')"
|
|
:can-cancel="hasCapability('admin.games.cancel')"
|
|
/>
|
|
|
|
<div v-if="errorMessage" class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-200">
|
|
{{ errorMessage }}
|
|
</div>
|
|
<div
|
|
v-if="message"
|
|
class="rounded border border-emerald-800 bg-emerald-950/40 px-4 py-3 text-sm text-emerald-200"
|
|
>
|
|
{{ message }}
|
|
</div>
|
|
|
|
<section v-if="mode === 'cancel'">
|
|
<form
|
|
class="space-y-5 rounded-lg border border-red-900/80 bg-zinc-900 p-5"
|
|
data-testid="game-cancellation-form"
|
|
@submit.prevent="requestGameCancellation"
|
|
>
|
|
<div class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-100">
|
|
이 작업은 게임을 즉시 닫고 profile을 <strong>CANCELLED</strong> 상태로 만듭니다. 버전
|
|
rollback이나 단순 서버 정지가 아니며, 다시 열려면 새 시나리오 초기화가 필요합니다.
|
|
</div>
|
|
<fieldset class="grid gap-4 md:grid-cols-2">
|
|
<label class="text-sm text-zinc-300">
|
|
기수 데이터
|
|
<select
|
|
v-model="cancellationForm.historyMode"
|
|
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2"
|
|
data-testid="cancellation-history-mode"
|
|
>
|
|
<option value="RETAIN_ABANDONED">취소 게임으로 DB에 보존</option>
|
|
<option value="DELETE">기수 행 물리 삭제</option>
|
|
</select>
|
|
</label>
|
|
<label class="text-sm text-zinc-300">
|
|
플레이 장수 기록
|
|
<select
|
|
v-model="cancellationForm.generalMode"
|
|
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2"
|
|
data-testid="cancellation-general-mode"
|
|
>
|
|
<option value="RETAIN">내 지난 플레이에 취소 게임 기록 보존</option>
|
|
<option value="DELETE">장수 과거 기록 삭제</option>
|
|
</select>
|
|
</label>
|
|
</fieldset>
|
|
<label class="block text-sm text-zinc-300">
|
|
당기 획득 유산 포인트 보전율
|
|
<div class="mt-1 flex items-center gap-3">
|
|
<input
|
|
v-model.number="cancellationForm.earnedPointRetentionPercent"
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
step="1"
|
|
class="w-full"
|
|
data-testid="cancellation-retention-range"
|
|
/>
|
|
<input
|
|
v-model.number="cancellationForm.earnedPointRetentionPercent"
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
step="1"
|
|
class="w-24 rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-right"
|
|
data-testid="cancellation-retention-percent"
|
|
/>
|
|
<span>%</span>
|
|
</div>
|
|
<span class="mt-1 block text-xs text-zinc-500">
|
|
개장 시 보유 원금은 사용 여부와 관계없이 전액 복구되고, 이 비율은 이번 게임에서 획득한
|
|
몫에만 적용됩니다.
|
|
</span>
|
|
</label>
|
|
<label class="block text-sm text-zinc-300">
|
|
취소 사유
|
|
<textarea
|
|
v-model="cancellationForm.reason"
|
|
rows="3"
|
|
maxlength="500"
|
|
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2"
|
|
placeholder="잘못된 기수/시나리오 설정 등 감사 기록에 남길 사유"
|
|
data-testid="cancellation-reason"
|
|
></textarea>
|
|
</label>
|
|
<label class="block text-sm text-zinc-300">
|
|
확인을 위해 <strong>{{ selectedProfileName }}</strong> 입력
|
|
<input
|
|
v-model="cancellationForm.confirmation"
|
|
class="mt-1 w-full rounded border border-red-800 bg-zinc-950 px-3 py-2 font-mono"
|
|
:placeholder="selectedProfileName"
|
|
data-testid="cancellation-confirmation"
|
|
/>
|
|
</label>
|
|
<button
|
|
type="submit"
|
|
class="w-full rounded bg-red-700 px-4 py-3 font-bold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-40"
|
|
:disabled="
|
|
submitting ||
|
|
Boolean(activeOperation) ||
|
|
cancellationForm.reason.trim().length < 5 ||
|
|
cancellationForm.confirmation.trim() !== selectedProfileName
|
|
"
|
|
data-testid="request-game-cancellation"
|
|
>
|
|
진행 게임 취소
|
|
</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section v-if="mode !== 'gateway' && mode !== 'cancel'">
|
|
<form
|
|
class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-5"
|
|
@submit.prevent="mode === 'scenario' ? requestReset() : requestDeploy()"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold">
|
|
{{ mode === 'scenario' ? '시나리오 초기화' : 'DB 보존 버전 업데이트' }}
|
|
</h3>
|
|
<span
|
|
v-if="activeOperation"
|
|
class="rounded-full bg-amber-500/15 px-3 py-1 text-xs text-amber-300"
|
|
>
|
|
{{ activeOperation.type }} · {{ activeOperation.status }}
|
|
</span>
|
|
</div>
|
|
|
|
<fieldset class="space-y-2">
|
|
<legend class="text-xs text-zinc-400">소스 종류</legend>
|
|
<div class="flex gap-5">
|
|
<label v-if="mode === 'scenario'" class="flex items-center gap-2">
|
|
<input
|
|
v-model="form.sourceMode"
|
|
type="radio"
|
|
value="CURRENT"
|
|
data-testid="source-current"
|
|
/>
|
|
서버 지정 버전
|
|
</label>
|
|
<label
|
|
v-if="mode === 'version' || hasCapability('admin.profiles.deploy')"
|
|
class="flex items-center gap-2"
|
|
>
|
|
<input
|
|
v-model="form.sourceMode"
|
|
type="radio"
|
|
value="BRANCH"
|
|
data-testid="source-branch"
|
|
/>
|
|
{{ mode === 'scenario' ? '새 브랜치와 함께' : '브랜치' }}
|
|
</label>
|
|
<label
|
|
v-if="mode === 'version' || hasCapability('admin.profiles.deploy')"
|
|
class="flex items-center gap-2"
|
|
>
|
|
<input
|
|
v-model="form.sourceMode"
|
|
type="radio"
|
|
value="COMMIT"
|
|
data-testid="source-commit"
|
|
/>
|
|
{{ mode === 'scenario' ? '새 커밋과 함께' : '커밋' }}
|
|
</label>
|
|
</div>
|
|
<p class="text-xs text-amber-200/80" data-testid="source-help">{{ sourceHelp }}</p>
|
|
</fieldset>
|
|
|
|
<div
|
|
v-if="form.sourceMode !== 'CURRENT' || mode === 'scenario'"
|
|
class="grid gap-3 md:grid-cols-[1fr_auto]"
|
|
>
|
|
<input
|
|
v-if="form.sourceMode !== 'CURRENT'"
|
|
v-model="form.sourceRef"
|
|
class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 font-mono text-sm text-white"
|
|
:placeholder="
|
|
form.sourceMode === 'BRANCH'
|
|
? '예: main 또는 release/season-12'
|
|
: '예: 40자리 commit SHA'
|
|
"
|
|
data-testid="source-ref"
|
|
/>
|
|
<button
|
|
v-if="mode === 'scenario'"
|
|
type="button"
|
|
class="rounded border border-zinc-600 bg-zinc-800 px-4 py-2 text-sm hover:bg-zinc-700 disabled:opacity-50"
|
|
:disabled="catalogLoading"
|
|
data-testid="load-scenarios"
|
|
@click="loadScenarios"
|
|
>
|
|
{{ catalogLoading ? '확인 중…' : catalogAttempted ? '시나리오 재확인' : '시나리오 확인' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-2">
|
|
<label class="text-xs text-zinc-400">
|
|
시나리오
|
|
<select
|
|
v-model.number="form.scenarioId"
|
|
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
|
|
data-testid="scenario-select"
|
|
>
|
|
<option v-if="catalogLoading" :value="null" disabled>
|
|
시나리오를 불러오는 중입니다…
|
|
</option>
|
|
<option v-else-if="scenarios.length === 0" :value="null" disabled>
|
|
선택할 수 있는 시나리오가 없습니다.
|
|
</option>
|
|
<option v-for="scenario in scenarios" :key="scenario.id" :value="scenario.id">
|
|
{{ scenario.id }} · {{ scenario.title }} (NPC {{ scenario.npcCount }})
|
|
{{ scenario.isCurrent ? '· 현재 시나리오' : '' }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<div class="space-y-1 text-xs text-zinc-400">
|
|
<div class="flex items-center gap-1.5">
|
|
<label for="reset-turn-term">{{ RESET_OPTION_COPY.turnTerm.label }}</label>
|
|
<CompactHelp
|
|
:label="RESET_OPTION_COPY.turnTerm.label"
|
|
:text="RESET_OPTION_COPY.turnTerm.help"
|
|
test-id="reset-help-turn-term"
|
|
/>
|
|
</div>
|
|
<select
|
|
id="reset-turn-term"
|
|
v-model.number="form.turnTermMinutes"
|
|
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
|
|
data-testid="reset-turn-term"
|
|
>
|
|
<option
|
|
v-for="minutes in [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 24, 30, 40, 60, 120]"
|
|
:key="minutes"
|
|
:value="minutes"
|
|
>
|
|
{{ minutes }}분
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<details v-if="mode === 'scenario'" class="rounded border border-zinc-800 bg-zinc-950/50 p-4">
|
|
<summary class="cursor-pointer text-sm font-semibold">고급 시나리오 옵션</summary>
|
|
<p class="mt-2 text-xs text-zinc-500" data-testid="reset-defaults-source">
|
|
{{
|
|
resetDefaultsSource === 'PROFILE'
|
|
? '이 서버의 메타에 저장된 기본값을 적용했습니다.'
|
|
: '서버별 기본값이 없어 시스템 기본값을 적용했습니다.'
|
|
}}
|
|
</p>
|
|
<div class="mt-4 grid gap-4 text-sm md:grid-cols-2">
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<label for="reset-sync">{{ RESET_OPTION_COPY.sync.label }}</label>
|
|
<CompactHelp
|
|
:label="RESET_OPTION_COPY.sync.label"
|
|
:text="RESET_OPTION_COPY.sync.help"
|
|
test-id="reset-help-sync"
|
|
/>
|
|
</div>
|
|
<select
|
|
id="reset-sync"
|
|
v-model="form.sync"
|
|
class="w-full rounded bg-zinc-900 px-2 py-1.5"
|
|
>
|
|
<option :value="true">사용</option>
|
|
<option :value="false">미사용</option>
|
|
</select>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<label for="reset-fiction">{{ RESET_OPTION_COPY.fiction.label }}</label>
|
|
<CompactHelp
|
|
:label="RESET_OPTION_COPY.fiction.label"
|
|
:text="RESET_OPTION_COPY.fiction.help"
|
|
test-id="reset-help-fiction"
|
|
/>
|
|
</div>
|
|
<select
|
|
id="reset-fiction"
|
|
v-model.number="form.fiction"
|
|
class="w-full rounded bg-zinc-900 px-2 py-1.5"
|
|
data-testid="reset-fiction"
|
|
>
|
|
<option :value="0">연의</option>
|
|
<option :value="1">가상</option>
|
|
</select>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<label for="reset-extend">{{ RESET_OPTION_COPY.extend.label }}</label>
|
|
<CompactHelp
|
|
:label="RESET_OPTION_COPY.extend.label"
|
|
:text="RESET_OPTION_COPY.extend.help"
|
|
test-id="reset-help-extend"
|
|
/>
|
|
</div>
|
|
<select
|
|
id="reset-extend"
|
|
v-model="form.extend"
|
|
class="w-full rounded bg-zinc-900 px-2 py-1.5"
|
|
data-testid="reset-extend"
|
|
>
|
|
<option :value="true">포함</option>
|
|
<option :value="false">미포함</option>
|
|
</select>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<label for="reset-block-general-create">
|
|
{{ RESET_OPTION_COPY.blockGeneralCreate.label }}
|
|
</label>
|
|
<CompactHelp
|
|
:label="RESET_OPTION_COPY.blockGeneralCreate.label"
|
|
:text="RESET_OPTION_COPY.blockGeneralCreate.help"
|
|
test-id="reset-help-block-general-create"
|
|
/>
|
|
</div>
|
|
<select
|
|
id="reset-block-general-create"
|
|
v-model.number="form.blockGeneralCreate"
|
|
class="w-full rounded bg-zinc-900 px-2 py-1.5"
|
|
data-testid="reset-block-general-create"
|
|
>
|
|
<option :value="0">가능</option>
|
|
<option :value="2">장수명 무작위</option>
|
|
<option :value="1">불가</option>
|
|
</select>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<label for="reset-npc-mode">{{ RESET_OPTION_COPY.npcMode.label }}</label>
|
|
<CompactHelp
|
|
:label="RESET_OPTION_COPY.npcMode.label"
|
|
:text="RESET_OPTION_COPY.npcMode.help"
|
|
test-id="reset-help-npc-mode"
|
|
/>
|
|
</div>
|
|
<select
|
|
id="reset-npc-mode"
|
|
v-model.number="form.npcMode"
|
|
class="w-full rounded bg-zinc-900 px-2 py-1.5"
|
|
data-testid="reset-npc-mode"
|
|
>
|
|
<option :value="0">불가</option>
|
|
<option :value="1">가능</option>
|
|
<option :value="2">선택 생성 가능</option>
|
|
</select>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<label for="reset-autorun-enabled">{{ RESET_OPTION_COPY.autorun.label }}</label>
|
|
<CompactHelp
|
|
:label="RESET_OPTION_COPY.autorun.label"
|
|
:text="RESET_OPTION_COPY.autorun.help"
|
|
test-id="reset-help-autorun"
|
|
/>
|
|
</div>
|
|
<select
|
|
id="reset-autorun-enabled"
|
|
v-model="form.autorunEnabled"
|
|
class="w-full rounded bg-zinc-900 px-2 py-1.5"
|
|
data-testid="reset-autorun-enabled"
|
|
>
|
|
<option :value="false">꺼짐</option>
|
|
<option :value="true">사용</option>
|
|
</select>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<label for="reset-join-mode">{{ RESET_OPTION_COPY.joinMode.label }}</label>
|
|
<CompactHelp
|
|
:label="RESET_OPTION_COPY.joinMode.label"
|
|
:text="RESET_OPTION_COPY.joinMode.help"
|
|
test-id="reset-help-join-mode"
|
|
/>
|
|
</div>
|
|
<select
|
|
id="reset-join-mode"
|
|
v-model="form.joinMode"
|
|
class="w-full rounded bg-zinc-900 px-2 py-1.5"
|
|
data-testid="reset-join-mode"
|
|
>
|
|
<option value="full">일반</option>
|
|
<option value="onlyRandom">랜덤 임관</option>
|
|
</select>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<label for="reset-show-img-level">{{ RESET_OPTION_COPY.showImgLevel.label }}</label>
|
|
<CompactHelp
|
|
:label="RESET_OPTION_COPY.showImgLevel.label"
|
|
:text="RESET_OPTION_COPY.showImgLevel.help"
|
|
test-id="reset-help-show-img-level"
|
|
/>
|
|
</div>
|
|
<select
|
|
id="reset-show-img-level"
|
|
v-model.number="form.showImgLevel"
|
|
class="w-full rounded bg-zinc-900 px-2 py-1.5"
|
|
data-testid="reset-show-img-level"
|
|
>
|
|
<option :value="0">안함</option>
|
|
<option :value="1">전콘</option>
|
|
<option :value="2">전콘, 병종</option>
|
|
<option :value="3">전콘, 병종, NPC</option>
|
|
</select>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<label for="reset-tournament-trig">
|
|
{{ RESET_OPTION_COPY.tournamentTrig.label }}
|
|
</label>
|
|
<CompactHelp
|
|
:label="RESET_OPTION_COPY.tournamentTrig.label"
|
|
:text="RESET_OPTION_COPY.tournamentTrig.help"
|
|
test-id="reset-help-tournament-trig"
|
|
/>
|
|
</div>
|
|
<select
|
|
id="reset-tournament-trig"
|
|
v-model="form.tournamentTrig"
|
|
class="w-full rounded bg-zinc-900 px-2 py-1.5"
|
|
data-testid="reset-tournament-trig"
|
|
>
|
|
<option :value="false">수동</option>
|
|
<option :value="true">자동</option>
|
|
</select>
|
|
</div>
|
|
<div v-if="form.autorunEnabled" class="space-y-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<label for="reset-autorun-minutes">{{
|
|
RESET_OPTION_COPY.autorunLimit.label
|
|
}}</label>
|
|
<CompactHelp
|
|
:label="RESET_OPTION_COPY.autorunLimit.label"
|
|
:text="RESET_OPTION_COPY.autorunLimit.help"
|
|
test-id="reset-help-autorun-limit"
|
|
/>
|
|
</div>
|
|
<input
|
|
id="reset-autorun-minutes"
|
|
v-model.number="form.autorunUserMinutes"
|
|
type="number"
|
|
min="1"
|
|
max="43200"
|
|
class="w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5"
|
|
data-testid="reset-autorun-minutes"
|
|
/>
|
|
</div>
|
|
<fieldset
|
|
v-if="form.autorunEnabled"
|
|
class="space-y-2 rounded border border-zinc-800 p-3 md:col-span-2"
|
|
>
|
|
<legend class="px-1 text-xs text-zinc-400">자율행동 종류</legend>
|
|
<div class="flex flex-wrap gap-x-4 gap-y-2 text-xs">
|
|
<label
|
|
v-for="option in RESET_AUTORUN_LABELS"
|
|
:key="option.value"
|
|
class="flex items-center gap-1.5"
|
|
>
|
|
<input
|
|
v-model="form[RESET_AUTORUN_FORM_KEYS[option.value]]"
|
|
type="checkbox"
|
|
:data-testid="
|
|
option.value === 'recruit_high'
|
|
? 'reset-autorun-recruit-high'
|
|
: option.value === 'chief'
|
|
? 'reset-autorun-chief'
|
|
: undefined
|
|
"
|
|
/>
|
|
{{ option.label }}
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
</details>
|
|
|
|
<div v-if="mode === 'scenario'" class="space-y-2 rounded border border-zinc-800 p-3">
|
|
<p class="text-xs leading-5 text-zinc-400">
|
|
초기화 시작 → 가오픈 시작 → 정식 오픈 순서입니다. 초기화 시작을 비우면 바로 작업합니다.
|
|
</p>
|
|
<div class="grid gap-4 md:grid-cols-3">
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1.5 text-xs text-zinc-400">
|
|
<label for="reset-scheduled-at">{{ RESET_SCHEDULE_COPY.scheduledAt.label }}</label>
|
|
<CompactHelp
|
|
:label="RESET_SCHEDULE_COPY.scheduledAt.label"
|
|
:text="RESET_SCHEDULE_COPY.scheduledAt.help"
|
|
test-id="reset-help-scheduled-at"
|
|
/>
|
|
<span>(선택 · UTC+9)</span>
|
|
</div>
|
|
<input
|
|
id="reset-scheduled-at"
|
|
v-model="form.scheduledAt"
|
|
type="datetime-local"
|
|
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
|
data-testid="reset-scheduled-at"
|
|
/>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1.5 text-xs text-zinc-400">
|
|
<label for="reset-preopen-at">{{ RESET_SCHEDULE_COPY.preopenAt.label }}</label>
|
|
<CompactHelp
|
|
:label="RESET_SCHEDULE_COPY.preopenAt.label"
|
|
:text="RESET_SCHEDULE_COPY.preopenAt.help"
|
|
test-id="reset-help-preopen-at"
|
|
/>
|
|
<span>(선택 · UTC+9)</span>
|
|
</div>
|
|
<input
|
|
id="reset-preopen-at"
|
|
v-model="form.preopenAt"
|
|
type="datetime-local"
|
|
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
|
data-testid="reset-preopen-at"
|
|
/>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1.5 text-xs text-zinc-400">
|
|
<label for="reset-open-at">{{ RESET_SCHEDULE_COPY.openAt.label }}</label>
|
|
<CompactHelp
|
|
:label="RESET_SCHEDULE_COPY.openAt.label"
|
|
:text="RESET_SCHEDULE_COPY.openAt.help"
|
|
test-id="reset-help-open-at"
|
|
/>
|
|
<span>(선택 · UTC+9)</span>
|
|
</div>
|
|
<input
|
|
id="reset-open-at"
|
|
v-model="form.openAt"
|
|
type="datetime-local"
|
|
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
|
data-testid="reset-open-at"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
v-model="form.reason"
|
|
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
|
|
placeholder="작업 사유 또는 운영 메모"
|
|
/>
|
|
<div>
|
|
<button
|
|
v-if="mode === 'version'"
|
|
type="submit"
|
|
class="rounded bg-sky-700 px-4 py-3 font-bold text-white hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-40"
|
|
:disabled="submitting || Boolean(activeOperation) || !form.sourceRef.trim()"
|
|
data-testid="request-deploy"
|
|
@click="requestDeploy"
|
|
>
|
|
DB 유지 배포
|
|
</button>
|
|
<button
|
|
v-else
|
|
type="submit"
|
|
class="w-full rounded bg-amber-500 px-4 py-3 font-bold text-black hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
|
|
:disabled="submitting || Boolean(activeOperation) || form.scenarioId === null"
|
|
data-testid="request-reset"
|
|
>
|
|
{{ form.scheduledAt ? '시나리오 초기화 예약' : '시나리오 초기화' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section
|
|
v-if="mode === 'gateway' && gatewayReleaseAvailable"
|
|
class="rounded-lg border border-violet-800/70 bg-zinc-900 p-5 space-y-4"
|
|
data-testid="gateway-release-panel"
|
|
>
|
|
<div
|
|
class="rounded border border-amber-800/80 bg-amber-950/30 px-4 py-3 text-sm text-amber-100"
|
|
data-testid="gateway-build-recovery-guide"
|
|
>
|
|
<strong>빌드가 멈춘 경우:</strong> 로그의 마지막 단계가 build일 때만 <strong>빌드 중단</strong>을
|
|
누르고 CANCELLED를 확인한 뒤 <strong>재시도</strong>하세요. migration 또는 process 전환이 시작된
|
|
뒤에는 DB와 runtime 보호를 위해 중단할 수 없습니다.
|
|
</div>
|
|
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<h3 class="text-lg font-semibold">Gateway 릴리스</h3>
|
|
<p class="text-xs text-zinc-400">
|
|
외부 release-controller가 Gateway API·frontend·orchestrator를 전환합니다.
|
|
</p>
|
|
</div>
|
|
<div class="text-xs text-zinc-400">
|
|
현재
|
|
<span class="font-mono text-zinc-200">{{
|
|
shortSha(gatewayReleaseState?.activeCommitSha)
|
|
}}</span>
|
|
· 이전
|
|
<span class="font-mono text-zinc-200">{{
|
|
shortSha(gatewayReleaseState?.previousCommitSha)
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
<div class="grid gap-3 md:grid-cols-[auto_1fr_1fr]">
|
|
<select
|
|
v-model="gatewayForm.sourceMode"
|
|
class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm"
|
|
>
|
|
<option value="BRANCH">브랜치</option>
|
|
<option value="COMMIT">커밋</option>
|
|
</select>
|
|
<input
|
|
v-model="gatewayForm.sourceRef"
|
|
class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 font-mono text-sm"
|
|
placeholder="main 또는 full commit SHA"
|
|
data-testid="gateway-source-ref"
|
|
/>
|
|
<input
|
|
v-model="gatewayForm.reason"
|
|
class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm"
|
|
placeholder="배포 사유"
|
|
/>
|
|
</div>
|
|
<div class="grid gap-3 md:grid-cols-2">
|
|
<button
|
|
class="rounded bg-violet-700 px-4 py-2 font-semibold hover:bg-violet-600 disabled:opacity-40"
|
|
:disabled="
|
|
submitting ||
|
|
gatewayReleaseOperations.some((item) => ['QUEUED', 'RUNNING'].includes(item.status))
|
|
"
|
|
data-testid="request-gateway-deploy"
|
|
@click="requestGatewayDeploy"
|
|
>
|
|
Gateway 배포
|
|
</button>
|
|
<button
|
|
class="rounded border border-violet-700 px-4 py-2 font-semibold hover:bg-violet-950 disabled:opacity-40"
|
|
:disabled="
|
|
submitting ||
|
|
!gatewayReleaseState?.previousCommitSha ||
|
|
gatewayReleaseOperations.some((item) => ['QUEUED', 'RUNNING'].includes(item.status))
|
|
"
|
|
data-testid="request-gateway-rollback"
|
|
@click="requestGatewayRollback"
|
|
>
|
|
이전 Gateway로 rollback
|
|
</button>
|
|
</div>
|
|
<div v-if="gatewayReleaseState?.lastError" class="text-sm text-red-300">
|
|
{{ gatewayReleaseState.lastError }}
|
|
</div>
|
|
<section
|
|
v-if="selectedGatewayOperationId"
|
|
class="overflow-hidden rounded border border-zinc-700 bg-zinc-950"
|
|
data-testid="gateway-release-log-panel"
|
|
aria-live="polite"
|
|
>
|
|
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-zinc-800 px-4 py-3">
|
|
<div>
|
|
<h4 class="text-sm font-semibold text-zinc-100">실시간 릴리스 로그</h4>
|
|
<p class="mt-1 font-mono text-[11px] text-zinc-500">
|
|
{{ selectedGatewayOperationId }}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<span
|
|
class="h-2 w-2 rounded-full"
|
|
:class="
|
|
gatewayReleaseLogConnection === 'reconnecting'
|
|
? 'animate-pulse bg-amber-400'
|
|
: ['QUEUED', 'RUNNING'].includes(
|
|
gatewayReleaseLogStatus || selectedGatewayOperation?.status || ''
|
|
)
|
|
? 'animate-pulse bg-emerald-400'
|
|
: 'bg-zinc-500'
|
|
"
|
|
></span>
|
|
<span data-testid="gateway-release-log-status">
|
|
{{ gatewayReleaseLogStatus || selectedGatewayOperation?.status || '연결 중' }}
|
|
<template v-if="gatewayReleaseLogConnection === 'reconnecting'"> · 재연결 중</template>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref="gatewayReleaseLogViewport"
|
|
class="h-72 overflow-y-auto px-4 py-3 font-mono text-xs leading-5"
|
|
data-testid="gateway-release-log"
|
|
>
|
|
<div v-if="!gatewayReleaseLogs.length" class="text-zinc-500">
|
|
{{ gatewayReleaseLogEmptyMessage }}
|
|
</div>
|
|
<div
|
|
v-for="entry in gatewayReleaseLogs"
|
|
:key="entry.cursor"
|
|
:class="
|
|
entry.level === 'ERROR'
|
|
? 'text-red-300'
|
|
: entry.level === 'OUTPUT'
|
|
? 'text-zinc-300'
|
|
: 'text-cyan-300'
|
|
"
|
|
>
|
|
<span class="text-zinc-600">{{ formatLogTime(entry.createdAt) }}</span>
|
|
<span class="ml-2 text-violet-300">[{{ entry.phase }}]</span>
|
|
<span class="ml-2 whitespace-pre-wrap break-all">{{ entry.message }}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<div class="overflow-x-auto">
|
|
<table
|
|
class="w-full table-fixed text-left text-xs sm:min-w-[680px]"
|
|
data-testid="gateway-release-table"
|
|
>
|
|
<colgroup>
|
|
<col class="w-[32%] sm:w-[144px]" />
|
|
<col class="w-[17%] sm:w-[68px]" />
|
|
<col class="w-[20%] sm:w-[88px]" />
|
|
<col class="hidden sm:table-column sm:w-[128px]" />
|
|
<col class="hidden sm:table-column sm:w-[112px]" />
|
|
<col class="w-[31%] sm:w-[140px]" />
|
|
</colgroup>
|
|
<thead class="border-b border-zinc-700 text-zinc-500">
|
|
<tr>
|
|
<th class="p-2">시각</th>
|
|
<th class="p-2">작업</th>
|
|
<th class="p-2">상태</th>
|
|
<th class="hidden p-2 sm:table-cell">소스</th>
|
|
<th class="hidden p-2 sm:table-cell">해석 커밋</th>
|
|
<th class="p-2">상세</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="operation in gatewayReleaseOperations" :key="operation.id">
|
|
<tr class="border-b border-zinc-800 align-top">
|
|
<td class="p-2">{{ formatTime(operation.createdAt) }}</td>
|
|
<td class="p-2">
|
|
<div>{{ operation.type }}</div>
|
|
<div
|
|
class="mt-1 truncate font-mono text-[10px] text-zinc-500 sm:hidden"
|
|
:title="operation.sourceRef"
|
|
>
|
|
{{ operation.sourceRef ?? '-' }}
|
|
</div>
|
|
</td>
|
|
<td class="p-2 font-semibold">{{ operation.status }}</td>
|
|
<td class="hidden p-2 font-mono sm:table-cell">
|
|
<div class="truncate" :title="operation.sourceRef">
|
|
{{ operation.sourceRef }}
|
|
</div>
|
|
</td>
|
|
<td class="hidden p-2 font-mono sm:table-cell">
|
|
{{ shortSha(operation.resolvedCommitSha) }}
|
|
</td>
|
|
<td class="p-2">
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
class="rounded border border-zinc-700 px-2 py-1 text-zinc-300 hover:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-400"
|
|
:class="
|
|
operation.id === selectedGatewayOperationId
|
|
? 'border-violet-500 text-violet-200'
|
|
: ''
|
|
"
|
|
@click="selectGatewayReleaseOperation(operation.id)"
|
|
>
|
|
로그
|
|
</button>
|
|
<button
|
|
v-if="operation.error"
|
|
type="button"
|
|
class="rounded border border-red-800 px-2 py-1 text-red-300 hover:bg-red-950 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-400"
|
|
:aria-expanded="expandedGatewayErrorOperationId === operation.id"
|
|
:aria-controls="`gateway-release-error-${operation.id}`"
|
|
data-testid="gateway-release-error-toggle"
|
|
@click="toggleGatewayReleaseError(operation.id)"
|
|
>
|
|
{{
|
|
expandedGatewayErrorOperationId === operation.id
|
|
? '오류 닫기'
|
|
: '오류 보기'
|
|
}}
|
|
</button>
|
|
<button
|
|
v-if="operation.status === 'QUEUED' || operation.status === 'RUNNING'"
|
|
type="button"
|
|
class="rounded border border-red-800 px-2 py-1 text-red-300 hover:bg-red-950 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-400"
|
|
@click="cancelGatewayRelease(operation)"
|
|
>
|
|
{{ operation.status === 'RUNNING' ? '빌드 중단' : '취소' }}
|
|
</button>
|
|
<button
|
|
v-else-if="
|
|
operation.status === 'FAILED' || operation.status === 'CANCELLED'
|
|
"
|
|
type="button"
|
|
class="rounded border border-amber-700 px-2 py-1 text-amber-300 hover:bg-amber-950 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-400"
|
|
@click="retryGatewayRelease(operation)"
|
|
>
|
|
재시도
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr
|
|
v-if="operation.error"
|
|
v-show="expandedGatewayErrorOperationId === operation.id"
|
|
:id="`gateway-release-error-${operation.id}`"
|
|
class="border-b border-red-900/70 bg-red-950/30"
|
|
data-testid="gateway-release-error-detail"
|
|
>
|
|
<td colspan="6" class="p-4">
|
|
<div
|
|
class="rounded border border-red-900/70 bg-zinc-950 px-4 py-3"
|
|
role="region"
|
|
:aria-label="`${operation.type} 릴리스 오류 상세`"
|
|
>
|
|
<div class="mb-2 text-xs font-semibold text-red-300">오류 상세</div>
|
|
<pre
|
|
class="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-red-200"
|
|
>{{ operation.error }}</pre>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
v-if="mode !== 'gateway' && selectedProfileOperationId"
|
|
class="overflow-hidden rounded border border-zinc-700 bg-zinc-950"
|
|
data-testid="profile-operation-log-panel"
|
|
aria-live="polite"
|
|
>
|
|
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-zinc-800 px-4 py-3">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-zinc-100">빌드·작업 로그</h3>
|
|
<p class="mt-1 font-mono text-[11px] text-zinc-500">
|
|
{{ selectedProfileOperationId }}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<span
|
|
class="h-2 w-2 rounded-full"
|
|
:class="
|
|
profileOperationLogConnection === 'reconnecting'
|
|
? 'animate-pulse bg-amber-400'
|
|
: ['QUEUED', 'RUNNING'].includes(
|
|
profileOperationLogStatus || selectedProfileOperation?.status || ''
|
|
)
|
|
? 'animate-pulse bg-emerald-400'
|
|
: 'bg-zinc-500'
|
|
"
|
|
></span>
|
|
<span data-testid="profile-operation-log-status">
|
|
{{ profileOperationLogStatus || selectedProfileOperation?.status || '연결 중' }}
|
|
<template v-if="profileOperationLogConnection === 'reconnecting'"> · 재연결 중</template>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref="profileOperationLogViewport"
|
|
class="h-72 overflow-y-auto px-4 py-3 font-mono text-xs leading-5"
|
|
data-testid="profile-operation-log"
|
|
>
|
|
<div v-if="!profileOperationLogs.length" class="text-zinc-500">
|
|
{{ profileOperationLogEmptyMessage }}
|
|
</div>
|
|
<div
|
|
v-for="entry in profileOperationLogs"
|
|
:key="entry.cursor"
|
|
:class="
|
|
entry.level === 'ERROR'
|
|
? 'text-red-300'
|
|
: entry.level === 'OUTPUT'
|
|
? 'text-zinc-300'
|
|
: 'text-cyan-300'
|
|
"
|
|
>
|
|
<span class="text-zinc-600">{{ formatLogTime(entry.createdAt) }}</span>
|
|
<span class="ml-2 text-violet-300">[{{ entry.phase }}]</span>
|
|
<span class="ml-2 whitespace-pre-wrap break-all">{{ entry.message }}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="mode !== 'gateway'" class="rounded-lg border border-zinc-800 bg-zinc-900 p-5">
|
|
<div
|
|
class="mb-4 rounded border border-amber-800/80 bg-amber-950/30 px-4 py-3 text-sm text-amber-100"
|
|
data-testid="profile-build-recovery-guide"
|
|
>
|
|
<strong>DB 보존 업데이트 빌드가 멈춘 경우:</strong> <strong>빌드 중단</strong>을 누르고 CANCELLED를
|
|
확인한 뒤 <strong>재시도</strong>하세요. 기존 profile runtime과 게임 DB는 유지됩니다.
|
|
RESET·migration·process 전환 단계는 이 화면에서 강제 중단하지 않습니다.
|
|
</div>
|
|
<div class="mb-4 flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold">작업 이력</h3>
|
|
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full min-w-[1300px] table-fixed text-left text-sm" data-testid="operations-table">
|
|
<colgroup>
|
|
<col style="width: 160px" />
|
|
<col style="width: 264px" />
|
|
<col style="width: 72px" />
|
|
<col style="width: 64px" />
|
|
<col style="width: 88px" />
|
|
<col style="width: 112px" />
|
|
<col style="width: 104px" />
|
|
<col style="width: 176px" />
|
|
<col style="width: 176px" />
|
|
<col style="width: 84px" />
|
|
</colgroup>
|
|
<thead class="border-b border-zinc-700 text-xs text-zinc-500">
|
|
<tr>
|
|
<th class="p-2">요청/예약</th>
|
|
<th class="p-2">작업 ID</th>
|
|
<th class="p-2">프로필</th>
|
|
<th class="p-2">작업</th>
|
|
<th class="p-2">상태</th>
|
|
<th class="p-2">소스</th>
|
|
<th class="p-2">해석 커밋</th>
|
|
<th class="p-2">요청자/사유</th>
|
|
<th class="p-2">완료/오류</th>
|
|
<th class="p-2">동작</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="operation in operations"
|
|
:key="operation.id"
|
|
class="border-b border-zinc-800 align-top"
|
|
>
|
|
<td class="p-2 text-xs">
|
|
{{ formatTime(operation.createdAt) }}
|
|
<div v-if="operation.scheduledAt" class="mt-1 text-amber-300">
|
|
예약 {{ formatTime(operation.scheduledAt) }}
|
|
</div>
|
|
</td>
|
|
<td class="max-w-48 break-all p-2 font-mono text-xs">{{ operation.id }}</td>
|
|
<td class="p-2">{{ operation.profileName }}</td>
|
|
<td class="p-2">{{ operation.type }}</td>
|
|
<td class="p-2 font-semibold">{{ operation.status }}</td>
|
|
<td class="p-2 font-mono text-xs">
|
|
<div>{{ operation.sourceMode ?? '-' }}</div>
|
|
<div
|
|
v-if="operation.sourceRef"
|
|
class="truncate"
|
|
data-testid="operation-source-ref"
|
|
:title="operation.sourceRef"
|
|
>
|
|
{{ operation.sourceRef }}
|
|
</div>
|
|
</td>
|
|
<td class="p-2 font-mono text-xs">{{ shortSha(operation.resolvedCommitSha) }}</td>
|
|
<td class="max-w-xs p-2 text-xs">
|
|
<div class="font-mono">{{ operation.requestedBy }}</div>
|
|
<div v-if="operation.reason" class="mt-1 text-zinc-400">{{ operation.reason }}</div>
|
|
</td>
|
|
<td class="max-w-xs p-2 text-xs">
|
|
{{ formatTime(operation.completedAt) }}
|
|
<div
|
|
v-if="operation.error"
|
|
class="mt-1"
|
|
:class="operation.status === 'FAILED' ? 'text-red-400' : 'text-amber-300'"
|
|
>
|
|
{{ operation.error }}
|
|
</div>
|
|
</td>
|
|
<td class="p-2">
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
class="rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-300 hover:bg-zinc-800"
|
|
:class="
|
|
operation.id === selectedProfileOperationId
|
|
? 'border-violet-500 text-violet-200'
|
|
: ''
|
|
"
|
|
@click="selectProfileOperation(operation.id)"
|
|
>
|
|
로그
|
|
</button>
|
|
<button
|
|
v-if="
|
|
operation.status === 'QUEUED' ||
|
|
(operation.status === 'RUNNING' && operation.type === 'DEPLOY')
|
|
"
|
|
class="rounded border border-red-800 px-2 py-1 text-xs text-red-300 hover:bg-red-950"
|
|
@click="cancelOperation(operation)"
|
|
>
|
|
{{ operation.status === 'RUNNING' ? '빌드 중단' : '취소' }}
|
|
</button>
|
|
<button
|
|
v-else-if="
|
|
operation.status === 'FAILED' || operation.status === 'CANCELLED'
|
|
"
|
|
class="rounded border border-amber-700 px-2 py-1 text-xs text-amber-300 hover:bg-amber-950"
|
|
@click="retryOperation(operation)"
|
|
>
|
|
재시도
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="operations.length === 0">
|
|
<td colspan="10" class="p-6 text-center text-zinc-500">작업 이력이 없습니다.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</AdminConsoleLayout>
|
|
</template>
|