merge: Gateway 초기화 예약 수명주기 정합화
This commit is contained in:
@@ -158,6 +158,20 @@ export const planProfileReconcile = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resolveResetLifecycleStatus = (
|
||||||
|
now: Date,
|
||||||
|
preopenAt: Date | null,
|
||||||
|
openAt: Date | null
|
||||||
|
): Extract<GatewayProfileStatus, 'RESERVED' | 'PREOPEN' | 'RUNNING'> => {
|
||||||
|
if (preopenAt && preopenAt.getTime() > now.getTime()) {
|
||||||
|
return 'RESERVED';
|
||||||
|
}
|
||||||
|
if (openAt && openAt.getTime() > now.getTime()) {
|
||||||
|
return 'PREOPEN';
|
||||||
|
}
|
||||||
|
return 'RUNNING';
|
||||||
|
};
|
||||||
|
|
||||||
type GatewayAdminActionStatus = 'REQUESTED' | 'APPLIED' | 'FAILED' | 'IGNORED';
|
type GatewayAdminActionStatus = 'REQUESTED' | 'APPLIED' | 'FAILED' | 'IGNORED';
|
||||||
|
|
||||||
interface GatewayAdminActionRecord {
|
interface GatewayAdminActionRecord {
|
||||||
@@ -880,7 +894,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
const now = this.now();
|
const now = this.now();
|
||||||
const due = await this.repository.listReservedToStart(now);
|
const due = await this.repository.listReservedToStart(now);
|
||||||
for (const profile of due) {
|
for (const profile of due) {
|
||||||
if (!profile.preopenAt || !profile.openAt) {
|
const preopenAt = parseDateTime(profile.preopenAt);
|
||||||
|
const openAt = parseDateTime(profile.openAt);
|
||||||
|
if (!preopenAt || !openAt) {
|
||||||
await this.repository.updateLastError(
|
await this.repository.updateLastError(
|
||||||
profile.profileName,
|
profile.profileName,
|
||||||
'Reserved profile is missing preopen/open schedule.'
|
'Reserved profile is missing preopen/open schedule.'
|
||||||
@@ -894,6 +910,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (profile.currentScenario !== null && profile.buildStatus === 'SUCCEEDED' && profile.buildWorkspace) {
|
||||||
|
await this.repository.updateStatus(
|
||||||
|
profile.profileName,
|
||||||
|
resolveResetLifecycleStatus(now, preopenAt, openAt),
|
||||||
|
{
|
||||||
|
preopenAt: profile.preopenAt,
|
||||||
|
openAt: profile.openAt,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await this.repository.updateLastError(profile.profileName, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const queued = profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING';
|
const queued = profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING';
|
||||||
if (!queued) {
|
if (!queued) {
|
||||||
await this.repository.updateBuildStatus(profile.profileName, 'QUEUED', {
|
await this.repository.updateBuildStatus(profile.profileName, 'QUEUED', {
|
||||||
@@ -1884,8 +1912,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
await assertLease?.();
|
await assertLease?.();
|
||||||
const completedAt = this.now().toISOString();
|
const completedAt = this.now().toISOString();
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
const shouldPreopen = openAt ? openAt.getTime() > now.getTime() : false;
|
const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, openAt);
|
||||||
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
|
|
||||||
const publishedProfile = await updateClaimedProfile(
|
const publishedProfile = await updateClaimedProfile(
|
||||||
{
|
{
|
||||||
currentScenario: String(scenarioId),
|
currentScenario: String(scenarioId),
|
||||||
@@ -1917,6 +1944,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
releasePrepared = true;
|
releasePrepared = true;
|
||||||
|
if (desiredStatus === 'RESERVED') {
|
||||||
|
await appendLog(
|
||||||
|
'schedule',
|
||||||
|
`${preopenAt?.toISOString() ?? '가오픈 시각'}까지 RESERVED 상태로 접속을 차단합니다.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
const builtProfile = publishedProfile ?? {
|
const builtProfile = publishedProfile ?? {
|
||||||
...profile,
|
...profile,
|
||||||
currentScenario: String(scenarioId),
|
currentScenario: String(scenarioId),
|
||||||
@@ -1941,6 +1974,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
return { status: 'FAILED', detail };
|
return { status: 'FAILED', detail };
|
||||||
}
|
}
|
||||||
await appendLog('readiness', 'profile readiness 확인을 통과했습니다.');
|
await appendLog('readiness', 'profile readiness 확인을 통과했습니다.');
|
||||||
|
}
|
||||||
await updateClaimedProfile({ lastError: null }, async () => {
|
await updateClaimedProfile({ lastError: null }, async () => {
|
||||||
await this.repository.updateLastError(profile.profileName, null);
|
await this.repository.updateLastError(profile.profileName, null);
|
||||||
return this.repository.getProfile(profile.profileName);
|
return this.repository.getProfile(profile.profileName);
|
||||||
|
|||||||
@@ -700,6 +700,63 @@ describe('admin operation API', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps reset start, preopen, and formal open as an ordered lifecycle', async () => {
|
||||||
|
const harness = await buildCaller(async (input) => ({
|
||||||
|
id: '77777777-7777-4777-8777-777777777777',
|
||||||
|
profileName: input.profileName,
|
||||||
|
type: 'RESET',
|
||||||
|
status: 'QUEUED',
|
||||||
|
sourceMode: input.sourceMode,
|
||||||
|
sourceRef: input.sourceRef,
|
||||||
|
payload: input.payload ?? {},
|
||||||
|
requestedBy: input.requestedBy,
|
||||||
|
scheduledAt: input.scheduledAt,
|
||||||
|
createdAt: '2026-08-08T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-08T00:00:00.000Z',
|
||||||
|
}));
|
||||||
|
const install = {
|
||||||
|
scenarioId: 1010,
|
||||||
|
turnTermMinutes: 60,
|
||||||
|
sync: false,
|
||||||
|
fiction: 1 as const,
|
||||||
|
extend: false,
|
||||||
|
blockGeneralCreate: 0 as const,
|
||||||
|
npcMode: 0 as const,
|
||||||
|
showImgLevel: 0 as const,
|
||||||
|
tournamentTrig: false,
|
||||||
|
joinMode: 'full' as const,
|
||||||
|
preopenAt: '2099-01-01T01:00:00.000Z',
|
||||||
|
openAt: '2099-01-01T02:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
await harness.caller.admin.operations.requestReset({
|
||||||
|
profileName: 'che:2',
|
||||||
|
sourceMode: 'COMMIT',
|
||||||
|
sourceRef: 'HEAD',
|
||||||
|
scheduledAt: '2099-01-01T00:00:00.000Z',
|
||||||
|
install,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(harness.createdInputs[0]).toMatchObject({
|
||||||
|
type: 'RESET',
|
||||||
|
scheduledAt: '2099-01-01T00:00:00.000Z',
|
||||||
|
payload: { install },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
harness.caller.admin.operations.requestReset({
|
||||||
|
profileName: 'che:2',
|
||||||
|
sourceMode: 'COMMIT',
|
||||||
|
sourceRef: 'HEAD',
|
||||||
|
scheduledAt: '2099-01-01T01:30:00.000Z',
|
||||||
|
install,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'preopenAt cannot be earlier than scheduledAt.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('returns validated profile reset defaults to a scenario-only operator', async () => {
|
it('returns validated profile reset defaults to a scenario-only operator', async () => {
|
||||||
const harness = await buildCaller(
|
const harness = await buildCaller(
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ const createHarness = (
|
|||||||
startGate?: Promise<void>,
|
startGate?: Promise<void>,
|
||||||
options: {
|
options: {
|
||||||
profile?: GatewayProfileRecord;
|
profile?: GatewayProfileRecord;
|
||||||
|
profiles?: GatewayProfileRecord[];
|
||||||
|
reservedToStart?: GatewayProfileRecord[];
|
||||||
|
now?: () => Date;
|
||||||
cancelGame?: GatewayOrchestratorOptions['cancelGame'];
|
cancelGame?: GatewayOrchestratorOptions['cancelGame'];
|
||||||
} = {}
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
@@ -59,10 +62,11 @@ const createHarness = (
|
|||||||
const started: ProcessDefinition[] = [];
|
const started: ProcessDefinition[] = [];
|
||||||
const stopped: string[] = [];
|
const stopped: string[] = [];
|
||||||
const deleted: string[] = [];
|
const deleted: string[] = [];
|
||||||
|
const buildStatuses: string[] = [];
|
||||||
const logs: Array<{ phase: string; message: string; level: string }> = [];
|
const logs: Array<{ phase: string; message: string; level: string }> = [];
|
||||||
|
|
||||||
const repository: GatewayProfileRepository = {
|
const repository: GatewayProfileRepository = {
|
||||||
listProfiles: async () => [harnessProfile],
|
listProfiles: async () => options.profiles ?? [harnessProfile],
|
||||||
getProfile: async () => harnessProfile,
|
getProfile: async () => harnessProfile,
|
||||||
upsertProfile: async () => harnessProfile,
|
upsertProfile: async () => harnessProfile,
|
||||||
updateCurrentScenario: async () => harnessProfile,
|
updateCurrentScenario: async () => harnessProfile,
|
||||||
@@ -70,9 +74,12 @@ const createHarness = (
|
|||||||
statuses.push(status);
|
statuses.push(status);
|
||||||
return { ...harnessProfile, status };
|
return { ...harnessProfile, status };
|
||||||
},
|
},
|
||||||
updateBuildStatus: async () => harnessProfile,
|
updateBuildStatus: async (_profileName, status) => {
|
||||||
|
buildStatuses.push(status);
|
||||||
|
return { ...harnessProfile, buildStatus: status };
|
||||||
|
},
|
||||||
updateMeta: async () => harnessProfile,
|
updateMeta: async () => harnessProfile,
|
||||||
listReservedToStart: async () => [],
|
listReservedToStart: async () => options.reservedToStart ?? [],
|
||||||
findQueuedBuild: async () => null,
|
findQueuedBuild: async () => null,
|
||||||
updateLastError: async () => {},
|
updateLastError: async () => {},
|
||||||
updateWorkspaceUsage: async () => {},
|
updateWorkspaceUsage: async () => {},
|
||||||
@@ -167,10 +174,11 @@ const createHarness = (
|
|||||||
scheduleIntervalMs: 60_000,
|
scheduleIntervalMs: 60_000,
|
||||||
buildIntervalMs: 60_000,
|
buildIntervalMs: 60_000,
|
||||||
adminActionIntervalMs: 60_000,
|
adminActionIntervalMs: 60_000,
|
||||||
|
now: options.now,
|
||||||
cancelGame: options.cancelGame,
|
cancelGame: options.cancelGame,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { orchestrator, statuses, completions, completionFields, started, stopped, deleted, logs };
|
return { orchestrator, statuses, buildStatuses, completions, completionFields, started, stopped, deleted, logs };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('GatewayOrchestrator first-class operations', () => {
|
describe('GatewayOrchestrator first-class operations', () => {
|
||||||
@@ -267,6 +275,81 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.deleted).toEqual([]);
|
expect(harness.deleted).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens a prepared reserved profile without rebuilding it again', async () => {
|
||||||
|
const now = new Date('2030-01-01T01:00:00.000Z');
|
||||||
|
const reservedProfile: GatewayProfileRecord = {
|
||||||
|
...profile,
|
||||||
|
status: 'RESERVED',
|
||||||
|
currentScenario: '1010',
|
||||||
|
scenario: '1010',
|
||||||
|
buildStatus: 'SUCCEEDED',
|
||||||
|
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef',
|
||||||
|
preopenAt: now.toISOString(),
|
||||||
|
openAt: '2030-01-01T02:00:00.000Z',
|
||||||
|
};
|
||||||
|
const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, {
|
||||||
|
profile: reservedProfile,
|
||||||
|
profiles: [],
|
||||||
|
reservedToStart: [reservedProfile],
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.orchestrator.runScheduleNow();
|
||||||
|
|
||||||
|
expect(harness.statuses).toEqual(['PREOPEN']);
|
||||||
|
expect(harness.buildStatuses).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts turns when a prepared reserved profile is handled after formal open', async () => {
|
||||||
|
const now = new Date('2030-01-01T02:00:00.000Z');
|
||||||
|
const reservedProfile: GatewayProfileRecord = {
|
||||||
|
...profile,
|
||||||
|
status: 'RESERVED',
|
||||||
|
currentScenario: '1010',
|
||||||
|
scenario: '1010',
|
||||||
|
buildStatus: 'SUCCEEDED',
|
||||||
|
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef',
|
||||||
|
preopenAt: '2030-01-01T01:00:00.000Z',
|
||||||
|
openAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, {
|
||||||
|
profile: reservedProfile,
|
||||||
|
profiles: [],
|
||||||
|
reservedToStart: [reservedProfile],
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.orchestrator.runScheduleNow();
|
||||||
|
|
||||||
|
expect(harness.statuses).toEqual(['RUNNING']);
|
||||||
|
expect(harness.buildStatuses).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains the legacy build queue for an unprepared reserved profile', async () => {
|
||||||
|
const now = new Date('2030-01-01T01:00:00.000Z');
|
||||||
|
const reservedProfile: GatewayProfileRecord = {
|
||||||
|
...profile,
|
||||||
|
status: 'RESERVED',
|
||||||
|
currentScenario: null,
|
||||||
|
scenario: 'default',
|
||||||
|
buildStatus: 'IDLE',
|
||||||
|
buildWorkspace: undefined,
|
||||||
|
preopenAt: now.toISOString(),
|
||||||
|
openAt: '2030-01-01T02:00:00.000Z',
|
||||||
|
};
|
||||||
|
const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, {
|
||||||
|
profile: reservedProfile,
|
||||||
|
profiles: [],
|
||||||
|
reservedToStart: [reservedProfile],
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.orchestrator.runScheduleNow();
|
||||||
|
|
||||||
|
expect(harness.statuses).toEqual([]);
|
||||||
|
expect(harness.buildStatuses).toEqual(['QUEUED']);
|
||||||
|
});
|
||||||
|
|
||||||
it('starts every profile process and records success', async () => {
|
it('starts every profile process and records success', async () => {
|
||||||
const harness = createHarness(buildOperation('START'));
|
const harness = createHarness(buildOperation('START'));
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
buildProcessDefinitions,
|
buildProcessDefinitions,
|
||||||
buildWorkspaceCommands,
|
buildWorkspaceCommands,
|
||||||
planProfileReconcile,
|
planProfileReconcile,
|
||||||
|
resolveResetLifecycleStatus,
|
||||||
} from '../src/orchestrator/gatewayOrchestrator.js';
|
} from '../src/orchestrator/gatewayOrchestrator.js';
|
||||||
import { sanitizeManagedProcessEnv } from '../src/orchestrator/processManager.js';
|
import { sanitizeManagedProcessEnv } from '../src/orchestrator/processManager.js';
|
||||||
import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js';
|
import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js';
|
||||||
@@ -108,6 +109,29 @@ describe('planProfileReconcile', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveResetLifecycleStatus', () => {
|
||||||
|
const now = new Date('2030-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
it('keeps an initialized profile reserved until the configured preopen time', () => {
|
||||||
|
expect(
|
||||||
|
resolveResetLifecycleStatus(now, new Date('2030-01-01T01:00:00.000Z'), new Date('2030-01-01T02:00:00.000Z'))
|
||||||
|
).toBe('RESERVED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves through preopen before the formal open time', () => {
|
||||||
|
expect(
|
||||||
|
resolveResetLifecycleStatus(now, new Date('2029-12-31T23:00:00.000Z'), new Date('2030-01-01T02:00:00.000Z'))
|
||||||
|
).toBe('PREOPEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs immediately when no future lifecycle boundary remains', () => {
|
||||||
|
expect(resolveResetLifecycleStatus(now, null, null)).toBe('RUNNING');
|
||||||
|
expect(
|
||||||
|
resolveResetLifecycleStatus(now, new Date('2029-12-31T22:00:00.000Z'), new Date('2029-12-31T23:00:00.000Z'))
|
||||||
|
).toBe('RUNNING');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildProcessDefinitions', () => {
|
describe('buildProcessDefinitions', () => {
|
||||||
const processConfig = {
|
const processConfig = {
|
||||||
workspaceRoot: '/srv/sammo/main',
|
workspaceRoot: '/srv/sammo/main',
|
||||||
|
|||||||
@@ -600,7 +600,15 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
|
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
|
||||||
await page.getByTestId('load-scenarios').click();
|
await page.getByTestId('load-scenarios').click();
|
||||||
await page.getByTestId('scenario-select').selectOption('5');
|
await page.getByTestId('scenario-select').selectOption('5');
|
||||||
await page.getByLabel('작업 예약 (서버 시간 UTC+9)').fill('2026-08-13T09:30');
|
await expect(page.getByText('초기화 시작 → 가오픈 시작 → 정식 오픈 순서입니다.')).toBeVisible();
|
||||||
|
await page.getByTestId('reset-scheduled-at').fill('2030-08-13T09:30');
|
||||||
|
await page.getByTestId('reset-preopen-at').fill('2030-08-13T10:00');
|
||||||
|
await page.getByTestId('reset-open-at').fill('2030-08-13T11:00');
|
||||||
|
const scheduledHelp = page.getByTestId('reset-help-scheduled-at');
|
||||||
|
await scheduledHelp.hover();
|
||||||
|
await expect(page.getByTestId('reset-help-scheduled-at-tooltip')).toContainText(
|
||||||
|
'완료되어도 가오픈 전에는 접속을 차단합니다.'
|
||||||
|
);
|
||||||
await page.getByTestId('request-reset').hover();
|
await page.getByTestId('request-reset').hover();
|
||||||
await page.getByTestId('request-reset').click();
|
await page.getByTestId('request-reset').click();
|
||||||
|
|
||||||
@@ -655,7 +663,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
|
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2026-08-13T00:30:00.000Z"');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2030-08-13T00:30:00.000Z"');
|
||||||
|
expect(JSON.stringify(resetRequest?.body)).toContain('"preopenAt":"2030-08-13T01:00:00.000Z"');
|
||||||
|
expect(JSON.stringify(resetRequest?.body)).toContain('"openAt":"2030-08-13T02:00:00.000Z"');
|
||||||
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
@@ -1046,7 +1056,7 @@ test('uses ref reset terms with compact hover, focus, and mobile help', async ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const helpButtons = page.getByRole('button', { name: /도움말$/ });
|
const helpButtons = page.getByRole('button', { name: /도움말$/ });
|
||||||
await expect(helpButtons).toHaveCount(10);
|
await expect(helpButtons).toHaveCount(13);
|
||||||
const fictionHelp = page.getByTestId('reset-help-fiction');
|
const fictionHelp = page.getByTestId('reset-help-fiction');
|
||||||
const fictionTooltip = page.getByTestId('reset-help-fiction-tooltip');
|
const fictionTooltip = page.getByTestId('reset-help-fiction-tooltip');
|
||||||
await expect(fictionTooltip).toBeHidden();
|
await expect(fictionTooltip).toBeHidden();
|
||||||
|
|||||||
@@ -162,6 +162,20 @@ const RESET_AUTORUN_FORM_KEYS = {
|
|||||||
battle: 'autorunBattle',
|
battle: 'autorunBattle',
|
||||||
chief: 'autorunChief',
|
chief: 'autorunChief',
|
||||||
} as const satisfies Record<ResetAutorunOption, keyof typeof form>;
|
} 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({
|
const gatewayForm = reactive({
|
||||||
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
|
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
|
||||||
sourceRef: 'main',
|
sourceRef: 'main',
|
||||||
@@ -1313,31 +1327,66 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
|
<div v-if="mode === 'scenario'" class="space-y-2 rounded border border-zinc-800 p-3">
|
||||||
<label class="text-xs text-zinc-400"
|
<p class="text-xs leading-5 text-zinc-400">
|
||||||
>작업 예약 (서버 시간 UTC+9)
|
초기화 시작 → 가오픈 시작 → 정식 오픈 순서입니다. 초기화 시작을 비우면 바로 작업합니다.
|
||||||
|
</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
|
<input
|
||||||
|
id="reset-scheduled-at"
|
||||||
v-model="form.scheduledAt"
|
v-model="form.scheduledAt"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
||||||
|
data-testid="reset-scheduled-at"
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<label class="text-xs text-zinc-400"
|
<div class="space-y-1">
|
||||||
>가오픈 (서버 시간 UTC+9)
|
<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
|
<input
|
||||||
|
id="reset-preopen-at"
|
||||||
v-model="form.preopenAt"
|
v-model="form.preopenAt"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
||||||
|
data-testid="reset-preopen-at"
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<label class="text-xs text-zinc-400"
|
<div class="space-y-1">
|
||||||
>정식 오픈 (서버 시간 UTC+9)
|
<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
|
<input
|
||||||
|
id="reset-open-at"
|
||||||
v-model="form.openAt"
|
v-model="form.openAt"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
||||||
|
data-testid="reset-open-at"
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user