feat: expose profile reset operation progress
This commit is contained in:
@@ -33,12 +33,15 @@ const installFixture = async (
|
|||||||
page: Page,
|
page: Page,
|
||||||
options: {
|
options: {
|
||||||
deferRequest?: boolean;
|
deferRequest?: boolean;
|
||||||
|
deferInstall?: boolean;
|
||||||
initialActions?: RuntimeAction[];
|
initialActions?: RuntimeAction[];
|
||||||
afterRequestActions?: RuntimeAction[];
|
afterRequestActions?: RuntimeAction[];
|
||||||
pendingProfileReads?: number;
|
pendingProfileReads?: number;
|
||||||
} = {}
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
let requested = false;
|
let requested = false;
|
||||||
|
let installRequested = false;
|
||||||
|
let installActive = false;
|
||||||
let postRequestProfileReads = 0;
|
let postRequestProfileReads = 0;
|
||||||
const requestBodies: unknown[] = [];
|
const requestBodies: unknown[] = [];
|
||||||
let releaseRequest = (): void => {};
|
let releaseRequest = (): void => {};
|
||||||
@@ -47,6 +50,12 @@ const installFixture = async (
|
|||||||
releaseRequest = resolve;
|
releaseRequest = resolve;
|
||||||
})
|
})
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
let releaseInstall = (): void => {};
|
||||||
|
const installGate = options.deferInstall
|
||||||
|
? new Promise<void>((resolve) => {
|
||||||
|
releaseInstall = resolve;
|
||||||
|
})
|
||||||
|
: Promise.resolve();
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
window.localStorage.setItem('sammo-session-token', 'playwright-admin-session');
|
window.localStorage.setItem('sammo-session-token', 'playwright-admin-session');
|
||||||
});
|
});
|
||||||
@@ -58,6 +67,12 @@ const installFixture = async (
|
|||||||
requestBodies.push(body);
|
requestBodies.push(body);
|
||||||
await requestGate;
|
await requestGate;
|
||||||
}
|
}
|
||||||
|
if (operations.includes('admin.profiles.install')) {
|
||||||
|
installRequested = true;
|
||||||
|
requestBodies.push(body);
|
||||||
|
await installGate;
|
||||||
|
installActive = true;
|
||||||
|
}
|
||||||
const results = operations.map((operation) => {
|
const results = operations.map((operation) => {
|
||||||
if (operation === 'me') {
|
if (operation === 'me') {
|
||||||
return response({
|
return response({
|
||||||
@@ -75,7 +90,17 @@ const installFixture = async (
|
|||||||
return response({ enabled: true });
|
return response({ enabled: true });
|
||||||
}
|
}
|
||||||
if (operation === 'admin.profiles.listScenarios') {
|
if (operation === 'admin.profiles.listScenarios') {
|
||||||
return response([]);
|
return response([
|
||||||
|
{
|
||||||
|
id: 1010,
|
||||||
|
title: '【테스트】황건의 난',
|
||||||
|
year: 184,
|
||||||
|
npcCount: 42,
|
||||||
|
npcExCount: 0,
|
||||||
|
npcNeutralCount: 0,
|
||||||
|
nations: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
if (operation === 'admin.profiles.list') {
|
if (operation === 'admin.profiles.list') {
|
||||||
const keepPending = requested && postRequestProfileReads++ < (options.pendingProfileReads ?? 0);
|
const keepPending = requested && postRequestProfileReads++ < (options.pendingProfileReads ?? 0);
|
||||||
@@ -83,11 +108,17 @@ const installFixture = async (
|
|||||||
{
|
{
|
||||||
profileName: 'hwe:default',
|
profileName: 'hwe:default',
|
||||||
profile: 'hwe',
|
profile: 'hwe',
|
||||||
scenario: 'default',
|
scenario: '1010',
|
||||||
apiPort: 15015,
|
apiPort: 15015,
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
buildStatus: 'SUCCEEDED',
|
buildStatus: 'SUCCEEDED',
|
||||||
meta: {},
|
meta: {},
|
||||||
|
activeOperation: installActive
|
||||||
|
? {
|
||||||
|
id: '77777777-7777-4777-8777-777777777777',
|
||||||
|
status: 'QUEUED',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
runtime: {
|
runtime: {
|
||||||
profileName: 'hwe:default',
|
profileName: 'hwe:default',
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
@@ -123,6 +154,32 @@ const installFixture = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (operation === 'admin.profiles.install') {
|
||||||
|
return response({
|
||||||
|
ok: true,
|
||||||
|
operationId: '77777777-7777-4777-8777-777777777777',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'admin.operations.list') {
|
||||||
|
return response(
|
||||||
|
installRequested
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: '77777777-7777-4777-8777-777777777777',
|
||||||
|
profileName: 'hwe:default',
|
||||||
|
type: 'RESET',
|
||||||
|
status: installActive ? 'QUEUED' : 'RUNNING',
|
||||||
|
sourceMode: 'COMMIT',
|
||||||
|
sourceRef: '0123456789abcdef0123456789abcdef01234567',
|
||||||
|
payload: {},
|
||||||
|
requestedBy: 'admin-user',
|
||||||
|
createdAt: '2026-07-30T02:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-30T02:00:00.000Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
}
|
||||||
throw new Error(`Unhandled tRPC operation: ${operation}`);
|
throw new Error(`Unhandled tRPC operation: ${operation}`);
|
||||||
});
|
});
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@@ -131,7 +188,7 @@ const installFixture = async (
|
|||||||
body: JSON.stringify(results),
|
body: JSON.stringify(results),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return { releaseRequest, requestBodies };
|
return { releaseRequest, releaseInstall, requestBodies };
|
||||||
};
|
};
|
||||||
|
|
||||||
test('reports clock-shift acceptance separately from actual application', async ({ page }) => {
|
test('reports clock-shift acceptance separately from actual application', async ({ page }) => {
|
||||||
@@ -215,3 +272,33 @@ test('renders an ignored terminal outcome without calling it applied', async ({
|
|||||||
await expect(page.getByText('지원하지 않는 요청')).toBeVisible();
|
await expect(page.getByText('지원하지 않는 요청')).toBeVisible();
|
||||||
await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0);
|
await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps profile installation disabled while its queued operation is active', async ({ page }, testInfo) => {
|
||||||
|
const fixture = await installFixture(page, { deferInstall: true });
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
await page.goto('admin');
|
||||||
|
|
||||||
|
const installButton = page.getByRole('button', { name: '설치 적용' });
|
||||||
|
const click = installButton.click();
|
||||||
|
await expect.poll(() => fixture.requestBodies.length).toBe(1);
|
||||||
|
await expect(page.getByRole('button', { name: '등록 중…' })).toBeDisabled();
|
||||||
|
fixture.releaseInstall();
|
||||||
|
await click;
|
||||||
|
|
||||||
|
const operationLink = page.getByRole('link', { name: /77777777-7777-4777-8777-777777777777 상태 보기/ });
|
||||||
|
await expect(operationLink).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '설치 작업 진행 중' })).toBeDisabled();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const linkGeometry = await operationLink.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { left: rect.left, right: rect.right, viewportWidth: window.innerWidth };
|
||||||
|
});
|
||||||
|
expect(linkGeometry.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(linkGeometry.right).toBeLessThanOrEqual(linkGeometry.viewportWidth);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('admin-install-active-mobile.png'), fullPage: true });
|
||||||
|
|
||||||
|
await operationLink.click();
|
||||||
|
await expect(page).toHaveURL(/\/gateway\/admin\/server-operations\?operationId=77777777/);
|
||||||
|
await expect(page.getByTestId('operations-table')).toContainText('77777777-7777-4777-8777-777777777777');
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ type Operation = {
|
|||||||
sourceMode?: 'BRANCH' | 'COMMIT';
|
sourceMode?: 'BRANCH' | 'COMMIT';
|
||||||
sourceRef?: string;
|
sourceRef?: string;
|
||||||
resolvedCommitSha?: string;
|
resolvedCommitSha?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
error?: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
requestedBy: string;
|
requestedBy: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -127,6 +129,22 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
state.operations = [operation];
|
state.operations = [operation];
|
||||||
return response(operation);
|
return response(operation);
|
||||||
}
|
}
|
||||||
|
if (name === 'admin.operations.retry') {
|
||||||
|
const operation: Operation = {
|
||||||
|
id: '44444444-4444-4444-8444-444444444444',
|
||||||
|
profileName: 'che:2',
|
||||||
|
type: 'RESET',
|
||||||
|
status: 'QUEUED',
|
||||||
|
sourceMode: 'COMMIT',
|
||||||
|
sourceRef: 'fedcba9876543210fedcba9876543210fedcba98',
|
||||||
|
payload: { installOperationId: 'failed-generation' },
|
||||||
|
requestedBy: 'admin',
|
||||||
|
createdAt: '2026-07-25T04:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-25T04:00:00.000Z',
|
||||||
|
};
|
||||||
|
state.operations = [operation, ...state.operations];
|
||||||
|
return response(operation);
|
||||||
|
}
|
||||||
throw new Error(`Unhandled tRPC operation: ${name}`);
|
throw new Error(`Unhandled tRPC operation: ${name}`);
|
||||||
});
|
});
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@@ -190,7 +208,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
await page.getByTestId('request-reset').hover();
|
await page.getByTestId('request-reset').hover();
|
||||||
await page.getByTestId('request-reset').click();
|
await page.getByTestId('request-reset').click();
|
||||||
|
|
||||||
await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible();
|
await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible();
|
||||||
await expect(page.getByTestId('operations-table')).toContainText('RESET');
|
await expect(page.getByTestId('operations-table')).toContainText('RESET');
|
||||||
const resetRequest = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset');
|
const resetRequest = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
|
||||||
@@ -232,3 +250,66 @@ test('starts and stops all runtime roles through the operation controls', async
|
|||||||
expect(serializedRequests).toContain('"action":"START"');
|
expect(serializedRequests).toContain('"action":"START"');
|
||||||
expect(serializedRequests).toContain('"action":"STOP"');
|
expect(serializedRequests).toContain('"action":"STOP"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renders a failed reset, retries it as a new operation, and reaches success', async ({ page }, testInfo) => {
|
||||||
|
const longError =
|
||||||
|
'선택한 커밋의 프로필 프로세스를 시작하지 못했습니다. 실패 원인을 확인한 뒤 동일 generation으로 재시도해 주세요.';
|
||||||
|
const state: FixtureState = {
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
id: '55555555-5555-4555-8555-555555555555',
|
||||||
|
profileName: 'che:2',
|
||||||
|
type: 'RESET',
|
||||||
|
status: 'FAILED',
|
||||||
|
sourceMode: 'COMMIT',
|
||||||
|
sourceRef: 'fedcba9876543210fedcba9876543210fedcba98',
|
||||||
|
resolvedCommitSha: 'fedcba9876543210fedcba9876543210fedcba98',
|
||||||
|
payload: { installOperationId: 'failed-generation' },
|
||||||
|
requestedBy: 'admin',
|
||||||
|
completedAt: '2026-07-25T03:30:00.000Z',
|
||||||
|
error: longError,
|
||||||
|
createdAt: '2026-07-25T03:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-25T03:30:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runtimeRunning: false,
|
||||||
|
requestBodies: [],
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
|
await page.goto('admin/server-operations');
|
||||||
|
await expect(page.getByText('FAILED', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
|
||||||
|
const failure = page.getByText(longError);
|
||||||
|
await expect(failure).toBeVisible();
|
||||||
|
expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '재시도' }).click();
|
||||||
|
await expect(page.getByText('재시도 작업을 등록했습니다.')).toBeVisible();
|
||||||
|
await expect(page.getByText('FAILED', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('QUEUED', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2);
|
||||||
|
|
||||||
|
state.operations[0] = {
|
||||||
|
...state.operations[0]!,
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
resolvedCommitSha: 'fedcba9876543210fedcba9876543210fedcba98',
|
||||||
|
completedAt: '2026-07-25T04:05:00.000Z',
|
||||||
|
updatedAt: '2026-07-25T04:05:00.000Z',
|
||||||
|
};
|
||||||
|
state.runtimeRunning = true;
|
||||||
|
await page.getByTestId('refresh-operations').click();
|
||||||
|
await expect(page.getByText('SUCCEEDED', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true });
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const tableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
|
||||||
|
const tableRect = table.getBoundingClientRect();
|
||||||
|
const scrollerRect = table.parentElement!.getBoundingClientRect();
|
||||||
|
return { tableWidth: tableRect.width, scrollerWidth: scrollerRect.width };
|
||||||
|
});
|
||||||
|
expect(tableGeometry.tableWidth).toBeGreaterThan(tableGeometry.scrollerWidth);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-mobile.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ type AdminProfile = {
|
|||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
};
|
};
|
||||||
buildCommitSha?: string;
|
buildCommitSha?: string;
|
||||||
|
activeOperation?: {
|
||||||
|
id: string;
|
||||||
|
status: 'QUEUED' | 'RUNNING';
|
||||||
|
} | null;
|
||||||
meta: Record<string, unknown>;
|
meta: Record<string, unknown>;
|
||||||
runtimeActions: Array<{
|
runtimeActions: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -238,7 +242,7 @@ type AdminClient = {
|
|||||||
gitRef?: string;
|
gitRef?: string;
|
||||||
};
|
};
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}) => Promise<{ ok: boolean; action?: unknown }>;
|
}) => Promise<{ ok: boolean; operationId: string; action?: unknown }>;
|
||||||
};
|
};
|
||||||
requestAction: {
|
requestAction: {
|
||||||
mutate: (input: {
|
mutate: (input: {
|
||||||
@@ -306,6 +310,8 @@ const profileActionSubmitting = ref<Record<string, boolean>>({});
|
|||||||
const scenarioCatalogs = ref<Record<string, ScenarioCatalogState>>({});
|
const scenarioCatalogs = ref<Record<string, ScenarioCatalogState>>({});
|
||||||
const profileInstalls = ref<Record<string, InstallFormState>>({});
|
const profileInstalls = ref<Record<string, InstallFormState>>({});
|
||||||
const profileInstallStatus = ref<Record<string, string>>({});
|
const profileInstallStatus = ref<Record<string, string>>({});
|
||||||
|
const profileInstallSubmitting = ref<Record<string, boolean>>({});
|
||||||
|
const profileInstallOperationId = ref<Record<string, string>>({});
|
||||||
|
|
||||||
const runtimeActionPending = (profile: AdminProfile): boolean => {
|
const runtimeActionPending = (profile: AdminProfile): boolean => {
|
||||||
return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL');
|
return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL');
|
||||||
@@ -685,6 +691,9 @@ const loadScenariosForProfile = async (profileName: string) => {
|
|||||||
if (!install) {
|
if (!install) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (profileInstallSubmitting.value[profileName]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await loadScenarioCatalog(install.gitRef);
|
await loadScenarioCatalog(install.gitRef);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -817,8 +826,9 @@ const requestInstall = async (profileName: string) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
profileInstallSubmitting.value = { ...profileInstallSubmitting.value, [profileName]: true };
|
||||||
const gitRef = normalizeGitRefInput(install.gitRef);
|
const gitRef = normalizeGitRefInput(install.gitRef);
|
||||||
await adminClient.profiles.install.mutate({
|
const result = await adminClient.profiles.install.mutate({
|
||||||
profileName,
|
profileName,
|
||||||
install: {
|
install: {
|
||||||
scenarioId: install.scenarioId,
|
scenarioId: install.scenarioId,
|
||||||
@@ -840,14 +850,20 @@ const requestInstall = async (profileName: string) => {
|
|||||||
});
|
});
|
||||||
profileInstallStatus.value = {
|
profileInstallStatus.value = {
|
||||||
...profileInstallStatus.value,
|
...profileInstallStatus.value,
|
||||||
[profileName]: openAt ? '설치 예약 완료' : '설치 요청 완료',
|
[profileName]: openAt ? '설치 작업을 예약했습니다.' : '설치 작업을 등록했습니다.',
|
||||||
|
};
|
||||||
|
profileInstallOperationId.value = {
|
||||||
|
...profileInstallOperationId.value,
|
||||||
|
[profileName]: result.operationId,
|
||||||
};
|
};
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
profileInstallStatus.value = {
|
profileInstallStatus.value = {
|
||||||
...profileInstallStatus.value,
|
...profileInstallStatus.value,
|
||||||
[profileName]: '설치 요청 실패',
|
[profileName]: error instanceof Error ? `설치 요청 실패: ${error.message}` : '설치 요청 실패',
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
profileInstallSubmitting.value = { ...profileInstallSubmitting.value, [profileName]: false };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1664,9 +1680,19 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h4 class="text-sm font-semibold">설치/리셋</h4>
|
<h4 class="text-sm font-semibold">설치/리셋</h4>
|
||||||
<span class="text-xs text-zinc-500">{{
|
<div class="text-right text-xs text-zinc-500">
|
||||||
profileInstallStatus[profile.profileName]
|
<div>{{ profileInstallStatus[profile.profileName] }}</div>
|
||||||
}}</span>
|
<RouterLink
|
||||||
|
v-if="profileInstallOperationId[profile.profileName]"
|
||||||
|
:to="{
|
||||||
|
path: '/admin/server-operations',
|
||||||
|
query: { operationId: profileInstallOperationId[profile.profileName] },
|
||||||
|
}"
|
||||||
|
class="block break-all text-amber-400 underline hover:text-amber-300"
|
||||||
|
>
|
||||||
|
작업 {{ profileInstallOperationId[profile.profileName] }} 상태 보기
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid lg:grid-cols-2 gap-4">
|
<div class="grid lg:grid-cols-2 gap-4">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -2064,10 +2090,20 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded w-full"
|
class="bg-emerald-600 hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50 text-black font-semibold px-4 py-2 rounded w-full"
|
||||||
|
:disabled="
|
||||||
|
profileInstallSubmitting[profile.profileName] ||
|
||||||
|
Boolean(profile.activeOperation)
|
||||||
|
"
|
||||||
@click="requestInstall(profile.profileName)"
|
@click="requestInstall(profile.profileName)"
|
||||||
>
|
>
|
||||||
설치 적용
|
{{
|
||||||
|
profileInstallSubmitting[profile.profileName]
|
||||||
|
? '등록 중…'
|
||||||
|
: profile.activeOperation
|
||||||
|
? '설치 작업 진행 중'
|
||||||
|
: '설치 적용'
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ const requestReset = async () => {
|
|||||||
preopenAt: toIso(form.preopenAt),
|
preopenAt: toIso(form.preopenAt),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
message.value = form.scheduledAt ? '예약 초기화 작업을 등록했습니다.' : '초기화 작업을 시작했습니다.';
|
message.value = form.scheduledAt ? '예약 초기화 작업을 등록했습니다.' : '초기화 작업을 등록했습니다.';
|
||||||
await loadState(true);
|
await loadState(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error ? error.message : '초기화 요청에 실패했습니다.';
|
errorMessage.value = error instanceof Error ? error.message : '초기화 요청에 실패했습니다.';
|
||||||
@@ -668,6 +668,7 @@ onBeforeUnmount(() => {
|
|||||||
<thead class="border-b border-zinc-700 text-xs text-zinc-500">
|
<thead class="border-b border-zinc-700 text-xs text-zinc-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="p-2">요청/예약</th>
|
<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>
|
||||||
@@ -690,6 +691,7 @@ onBeforeUnmount(() => {
|
|||||||
예약 {{ formatTime(operation.scheduledAt) }}
|
예약 {{ formatTime(operation.scheduledAt) }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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.profileName }}</td>
|
||||||
<td class="p-2">{{ operation.type }}</td>
|
<td class="p-2">{{ operation.type }}</td>
|
||||||
<td class="p-2 font-semibold">{{ operation.status }}</td>
|
<td class="p-2 font-semibold">{{ operation.status }}</td>
|
||||||
@@ -703,7 +705,13 @@ onBeforeUnmount(() => {
|
|||||||
</td>
|
</td>
|
||||||
<td class="max-w-xs p-2 text-xs">
|
<td class="max-w-xs p-2 text-xs">
|
||||||
{{ formatTime(operation.completedAt) }}
|
{{ formatTime(operation.completedAt) }}
|
||||||
<div v-if="operation.error" class="mt-1 text-red-400">{{ operation.error }}</div>
|
<div
|
||||||
|
v-if="operation.error"
|
||||||
|
class="mt-1"
|
||||||
|
:class="operation.status === 'FAILED' ? 'text-red-400' : 'text-amber-300'"
|
||||||
|
>
|
||||||
|
{{ operation.error }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-2">
|
<td class="p-2">
|
||||||
<button
|
<button
|
||||||
@@ -723,7 +731,7 @@ onBeforeUnmount(() => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="operations.length === 0">
|
<tr v-if="operations.length === 0">
|
||||||
<td colspan="9" class="p-6 text-center text-zinc-500">작업 이력이 없습니다.</td>
|
<td colspan="10" class="p-6 text-center text-zinc-500">작업 이력이 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user