feat: 예약 초기화 중간 버전 업데이트를 허용한다
미래 예약 RESET을 보존하면서 DB 유지 DEPLOY 한 건을 별도 queue lane에 등록한다. 예약 시각까지 시작되지 않은 중간 배포는 RESET claim 전에 자동 취소하고 관리자 화면에 이 경계를 안내한다.
This commit is contained in:
@@ -10,6 +10,7 @@ type Operation = {
|
||||
sourceMode?: 'BRANCH' | 'COMMIT';
|
||||
sourceRef?: string;
|
||||
resolvedCommitSha?: string;
|
||||
scheduledAt?: string;
|
||||
completedAt?: string;
|
||||
error?: string;
|
||||
payload: Record<string, unknown>;
|
||||
@@ -382,7 +383,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
createdAt: '2026-07-25T02:00:00.000Z',
|
||||
updatedAt: '2026-07-25T02:00:00.000Z',
|
||||
};
|
||||
state.operations = [operation];
|
||||
state.operations = [operation, ...state.operations];
|
||||
return response(operation);
|
||||
}
|
||||
if (name === 'admin.operations.requestDeploy') {
|
||||
@@ -398,7 +399,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
createdAt: '2026-08-01T01:00:00.000Z',
|
||||
updatedAt: '2026-08-01T01:00:00.000Z',
|
||||
};
|
||||
state.operations = [operation];
|
||||
state.operations = [operation, ...state.operations];
|
||||
return response(operation);
|
||||
}
|
||||
if (name === 'admin.operations.requestGameCancellation') {
|
||||
@@ -817,6 +818,67 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps a future scenario reset reserved while submitting one interim DB-preserving deploy', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const scheduledReset: Operation = {
|
||||
id: '99999999-9999-4999-8999-999999999999',
|
||||
profileName: 'che:default',
|
||||
type: 'RESET',
|
||||
status: 'QUEUED',
|
||||
sourceMode: 'BRANCH',
|
||||
sourceRef: 'main',
|
||||
scheduledAt: '2099-08-27T05:00:00.000Z',
|
||||
payload: {},
|
||||
requestedBy: 'admin',
|
||||
createdAt: '2026-08-24T00:00:00.000Z',
|
||||
updatedAt: '2026-08-24T00:00:00.000Z',
|
||||
};
|
||||
const state: FixtureState = {
|
||||
operations: [scheduledReset],
|
||||
gatewayOperations: [],
|
||||
runtimeRunning: true,
|
||||
requestBodies: [],
|
||||
};
|
||||
await installFixture(page, state);
|
||||
const confirmations: string[] = [];
|
||||
page.on('dialog', async (dialog) => {
|
||||
confirmations.push(dialog.message());
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
await page.goto('admin/servers/che%3Adefault/version');
|
||||
const notice = page.getByTestId('interim-deploy-notice');
|
||||
await expect(notice).toContainText('예약 초기화 유지');
|
||||
await expect(notice).toContainText('시나리오 초기화 예약은 취소되지 않습니다.');
|
||||
await expect(page.getByTestId('request-deploy')).toBeEnabled();
|
||||
await page.getByTestId('request-deploy').click();
|
||||
|
||||
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
|
||||
expect(confirmations).toHaveLength(1);
|
||||
expect(confirmations[0]).toContain('예약된 시나리오 초기화');
|
||||
expect(confirmations[0]).toContain('자동 취소됩니다.');
|
||||
expect(state.operations).toHaveLength(2);
|
||||
expect(state.operations).toContainEqual(scheduledReset);
|
||||
expect(state.requestBodies.filter((entry) => entry.operation === 'admin.operations.requestDeploy')).toHaveLength(1);
|
||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const mobileGeometry = await notice.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
documentScrollWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
expect(mobileGeometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||||
expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||||
await page.screenshot({ path: testInfo.outputPath('interim-deploy-mobile.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('submits a separately authorized destructive game cancellation on desktop and mobile', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -268,15 +268,35 @@ const pageDescription = computed(() => {
|
||||
return '현재 게임 DB를 유지한 채 코드와 forward migration을 배포합니다.';
|
||||
});
|
||||
|
||||
const activeOperation = computed(
|
||||
const activeOperations = computed(() =>
|
||||
operations.value.filter(
|
||||
(operation) =>
|
||||
operation.profileName === selectedProfileName.value &&
|
||||
(operation.status === 'QUEUED' || operation.status === 'RUNNING')
|
||||
)
|
||||
);
|
||||
|
||||
const activeOperation = computed(() => activeOperations.value[0] ?? null);
|
||||
|
||||
const queuedFutureScheduledReset = computed(
|
||||
() =>
|
||||
operations.value.find(
|
||||
activeOperations.value.find(
|
||||
(operation) =>
|
||||
operation.profileName === selectedProfileName.value &&
|
||||
(operation.status === 'QUEUED' || operation.status === 'RUNNING')
|
||||
operation.type === 'RESET' &&
|
||||
operation.status === 'QUEUED' &&
|
||||
Boolean(operation.scheduledAt) &&
|
||||
new Date(operation.scheduledAt ?? '').getTime() > Date.now()
|
||||
) ?? null
|
||||
);
|
||||
|
||||
const deployBlockingOperation = computed(() => {
|
||||
if (activeOperations.value.length === 1 && queuedFutureScheduledReset.value) return null;
|
||||
return (
|
||||
activeOperations.value.find((operation) => operation.id !== queuedFutureScheduledReset.value?.id) ??
|
||||
activeOperation.value
|
||||
);
|
||||
});
|
||||
|
||||
const sourceHelp = computed(() =>
|
||||
form.sourceMode === 'CURRENT'
|
||||
? '서버가 브랜치를 추적하면 작업 시작 시 최신 커밋을 사용하고, 커밋 고정 상태면 그 버전을 유지합니다.'
|
||||
@@ -564,15 +584,18 @@ const requestDeploy = async () => {
|
||||
if (
|
||||
!selectedProfileName.value ||
|
||||
!selectedProfileIdentityReady.value ||
|
||||
activeOperation.value ||
|
||||
deployBlockingOperation.value ||
|
||||
!form.sourceRef.trim() ||
|
||||
form.sourceMode === 'CURRENT'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const reservedResetReminder = queuedFutureScheduledReset.value?.scheduledAt
|
||||
? `\n예약된 시나리오 초기화(${formatTime(queuedFutureScheduledReset.value.scheduledAt)})는 유지됩니다. 이 배포가 그 시각까지 시작되지 못하면 자동 취소됩니다.`
|
||||
: '';
|
||||
if (
|
||||
!window.confirm(
|
||||
`${selectedProfileDisplayName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?`
|
||||
`${selectedProfileDisplayName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?${reservedResetReminder}`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -1054,6 +1077,18 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="mode === 'version' && queuedFutureScheduledReset"
|
||||
class="rounded border border-cyan-800/80 bg-cyan-950/35 px-4 py-3 text-sm text-cyan-100"
|
||||
data-testid="interim-deploy-notice"
|
||||
>
|
||||
<strong>예약 초기화 유지</strong>
|
||||
<span class="ml-2">
|
||||
{{ formatTime(queuedFutureScheduledReset.scheduledAt) }} 시나리오 초기화 예약은 취소되지
|
||||
않습니다. 지금 DB 유지 배포가 그 시각까지 시작되지 못하면 자동 취소됩니다.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="text-xs text-zinc-400">소스 종류</legend>
|
||||
<div class="flex gap-5">
|
||||
@@ -1493,7 +1528,7 @@ onBeforeUnmount(() => {
|
||||
:disabled="
|
||||
submitting ||
|
||||
!selectedProfileIdentityReady ||
|
||||
Boolean(activeOperation) ||
|
||||
Boolean(deployBlockingOperation) ||
|
||||
!form.sourceRef.trim()
|
||||
"
|
||||
data-testid="request-deploy"
|
||||
|
||||
Reference in New Issue
Block a user