feat(gateway): persist profile operation build logs
This commit is contained in:
@@ -33,6 +33,9 @@ type FixtureState = {
|
||||
}>;
|
||||
runtimeRunning: boolean;
|
||||
requestBodies: Array<{ operation: string; body: unknown }>;
|
||||
profileLogPollCount?: number;
|
||||
profileLogProgress?: boolean;
|
||||
profileLogsEmpty?: boolean;
|
||||
gatewayLogPollCount?: number;
|
||||
gatewayLogsEmpty?: boolean;
|
||||
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
|
||||
@@ -136,6 +139,9 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
await route.abort('failed');
|
||||
return;
|
||||
}
|
||||
if (names.includes('admin.operations.logs') && !state.profileLogProgress) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
const results = names.map((name) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
state.requestBodies.push({ operation: name, body });
|
||||
@@ -167,6 +173,54 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (name === 'admin.operations.list') {
|
||||
return response(state.operations);
|
||||
}
|
||||
if (name === 'admin.operations.logs') {
|
||||
const operation = state.operations[0];
|
||||
if (!operation) throw new Error('Profile operation fixture is missing');
|
||||
state.profileLogPollCount = (state.profileLogPollCount ?? 0) + 1;
|
||||
if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) {
|
||||
return response({ operation, entries: [] });
|
||||
}
|
||||
if (!state.profileLogProgress) {
|
||||
return response({ operation, entries: [] });
|
||||
}
|
||||
if (state.profileLogsEmpty) {
|
||||
return response({ operation, entries: [] });
|
||||
}
|
||||
const completed = state.profileLogPollCount > 1;
|
||||
const nextOperation = {
|
||||
...operation,
|
||||
status: completed ? ('SUCCEEDED' as const) : ('RUNNING' as const),
|
||||
};
|
||||
if (completed) state.operations[0] = nextOperation;
|
||||
return response({
|
||||
operation: nextOperation,
|
||||
entries: completed
|
||||
? [
|
||||
{
|
||||
cursor: '2',
|
||||
operationId: operation.id,
|
||||
level: 'OUTPUT',
|
||||
phase: operation.type === 'RESET' ? 'seed' : 'build',
|
||||
message:
|
||||
operation.type === 'RESET'
|
||||
? '시나리오 초기 데이터 생성을 완료했습니다.'
|
||||
: 'game-frontend build complete',
|
||||
createdAt: '2026-08-01T01:00:02.000Z',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
cursor: '1',
|
||||
operationId: operation.id,
|
||||
level: 'INFO',
|
||||
phase: 'build',
|
||||
message: `${operation.profileName} 구성 요소를 빌드합니다.`,
|
||||
createdAt: '2026-08-01T01:00:01.000Z',
|
||||
},
|
||||
],
|
||||
nextCursor: completed ? '2' : '1',
|
||||
});
|
||||
}
|
||||
if (name === 'admin.releases.gatewayState') {
|
||||
return response({
|
||||
id: 'gateway',
|
||||
@@ -336,7 +390,13 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
test('separates branch and commit semantics and submits a reset from the dedicated page', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: false, requestBodies: [] };
|
||||
const state: FixtureState = {
|
||||
operations: [],
|
||||
gatewayOperations: [],
|
||||
runtimeRunning: false,
|
||||
requestBodies: [],
|
||||
profileLogProgress: true,
|
||||
};
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
@@ -415,10 +475,15 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
|
||||
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table')).toContainText('RESET');
|
||||
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.');
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.');
|
||||
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
|
||||
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('0123456789abcdef0123456789abcdef01234567');
|
||||
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
||||
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const mobileGeometry = await page
|
||||
@@ -449,7 +514,13 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
});
|
||||
|
||||
test('separates DB-preserving profile deployment from DB reset', async ({ page }) => {
|
||||
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
|
||||
const state: FixtureState = {
|
||||
operations: [],
|
||||
gatewayOperations: [],
|
||||
runtimeRunning: true,
|
||||
requestBodies: [],
|
||||
profileLogProgress: true,
|
||||
};
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
@@ -464,6 +535,10 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
||||
|
||||
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
|
||||
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.');
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete');
|
||||
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
|
||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true);
|
||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
|
||||
});
|
||||
@@ -766,16 +841,16 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await expect(page.getByText('FAILED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
|
||||
const failure = page.getByText(longError);
|
||||
const failure = page.getByTestId('operations-table').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('재시도 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByText('FAILED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('QUEUED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').getByText('QUEUED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2);
|
||||
|
||||
state.operations[0] = {
|
||||
@@ -787,7 +862,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
||||
};
|
||||
state.runtimeRunning = true;
|
||||
await page.getByTestId('refresh-operations').click();
|
||||
await expect(page.getByText('SUCCEEDED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').getByText('SUCCEEDED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true });
|
||||
|
||||
@@ -79,6 +79,12 @@ type GatewayReleaseLog = {
|
||||
};
|
||||
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('');
|
||||
@@ -104,6 +110,7 @@ const resetDefaultsSource = ref<'SYSTEM' | 'PROFILE'>('SYSTEM');
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let stateRequestInFlight = false;
|
||||
let releaseLogLoopGeneration = 0;
|
||||
let profileLogLoopGeneration = 0;
|
||||
let componentMounted = false;
|
||||
|
||||
const form = reactive({
|
||||
@@ -140,6 +147,20 @@ const gatewayForm = reactive({
|
||||
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;
|
||||
@@ -289,6 +310,15 @@ const loadState = async (quiet = false) => {
|
||||
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) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.';
|
||||
@@ -298,6 +328,61 @@ const loadState = async (quiet = false) => {
|
||||
}
|
||||
};
|
||||
|
||||
const scrollProfileOperationLogToEnd = async () => {
|
||||
await nextTick();
|
||||
const viewport = profileOperationLogViewport.value;
|
||||
if (viewport) 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 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();
|
||||
}
|
||||
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 () => {
|
||||
await nextTick();
|
||||
const viewport = gatewayReleaseLogViewport.value;
|
||||
@@ -372,12 +457,13 @@ const requestDeploy = async () => {
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await adminClient.operations.requestDeploy.mutate({
|
||||
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) {
|
||||
@@ -491,7 +577,7 @@ const requestReset = async () => {
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await adminClient.operations.requestReset.mutate({
|
||||
const operation = await adminClient.operations.requestReset.mutate({
|
||||
profileName: selectedProfileName.value,
|
||||
sourceMode: form.sourceMode,
|
||||
sourceRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(),
|
||||
@@ -518,6 +604,7 @@ const requestReset = async () => {
|
||||
preopenAt: toIso(form.preopenAt),
|
||||
},
|
||||
});
|
||||
selectedProfileOperationId.value = operation.id;
|
||||
message.value = form.scheduledAt ? '예약 초기화 작업을 등록했습니다.' : '초기화 작업을 등록했습니다.';
|
||||
await loadState(true);
|
||||
} catch (error) {
|
||||
@@ -534,6 +621,7 @@ const cancelOperation = async (operation: Operation) => {
|
||||
}
|
||||
try {
|
||||
await adminClient.operations.cancel.mutate({ id: operation.id });
|
||||
selectedProfileOperationId.value = operation.id;
|
||||
message.value = '작업을 취소했습니다.';
|
||||
await loadState(true);
|
||||
} catch (error) {
|
||||
@@ -547,7 +635,8 @@ const retryOperation = async (operation: Operation) => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adminClient.operations.retry.mutate({ id: operation.id });
|
||||
const retried = await adminClient.operations.retry.mutate({ id: operation.id });
|
||||
selectedProfileOperationId.value = retried.id;
|
||||
message.value = '재시도 작업을 등록했습니다.';
|
||||
await loadState(true);
|
||||
} catch (error) {
|
||||
@@ -555,6 +644,15 @@ const retryOperation = async (operation: Operation) => {
|
||||
}
|
||||
};
|
||||
|
||||
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 = [];
|
||||
@@ -589,6 +687,7 @@ onMounted(async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
componentMounted = false;
|
||||
profileLogLoopGeneration += 1;
|
||||
releaseLogLoopGeneration += 1;
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
@@ -1073,6 +1172,64 @@ onBeforeUnmount(() => {
|
||||
</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 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">작업 이력</h3>
|
||||
@@ -1091,7 +1248,7 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
@@ -1129,20 +1286,36 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<button
|
||||
v-if="operation.status === 'QUEUED'"
|
||||
class="rounded border border-red-800 px-2 py-1 text-xs text-red-300 hover:bg-red-950"
|
||||
@click="cancelOperation(operation)"
|
||||
>
|
||||
취소
|
||||
</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 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'"
|
||||
class="rounded border border-red-800 px-2 py-1 text-xs text-red-300 hover:bg-red-950"
|
||||
@click="cancelOperation(operation)"
|
||||
>
|
||||
취소
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user