merge: 서버 작업 이력 간결화를 main에 반영한다

This commit is contained in:
2026-08-24 06:05:37 +00:00
5 changed files with 277 additions and 142 deletions
@@ -34,15 +34,20 @@ const resetScenario = async (page: Page, scenarioId: string, sourceCommit: strin
await expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible();
await page.getByTestId('scenario-select').selectOption(scenarioId);
const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first();
const latestOperation = page.getByTestId('operation-summary-row').first();
const previousLatestOperation = await latestOperation.textContent();
await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible();
await expect.poll(() => latestOperation.textContent(), { timeout: 15_000 }).not.toBe(previousLatestOperation);
await expect(latestOperation).toContainText(sourceCommit, { timeout: 15_000 });
await expect(latestOperation.locator('td').nth(3)).toHaveText('SUCCEEDED', {
timeout: 300_000,
});
await latestOperation.getByTestId('operation-details-toggle').click();
await expect(page.getByTestId('operation-detail').first()).toContainText(sourceCommit, { timeout: 15_000 });
await expect(latestOperation.locator('[data-operation-status]')).toHaveAttribute(
'data-operation-status',
'SUCCEEDED',
{
timeout: 300_000,
}
);
const profileStatus = page.getByTestId('selected-profile-status');
await expect(profileStatus.locator(':scope > div').nth(0)).toContainText('RUNNING', {
timeout: 30_000,
+10 -5
View File
@@ -95,7 +95,7 @@ test('admin resets and opens hwe, then two users create generals and reach main'
await page.getByTestId('load-scenarios').click();
await expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible();
await page.getByTestId('scenario-select').selectOption(scenarioId);
const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first();
const latestOperation = page.getByTestId('operation-summary-row').first();
const previousLatestOperation = await latestOperation.textContent();
await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
@@ -105,12 +105,17 @@ test('admin resets and opens hwe, then two users create generals and reach main'
timeout: 15_000,
})
.not.toBe(previousLatestOperation);
await expect(latestOperation).toContainText(sourceCommit, {
await latestOperation.getByTestId('operation-details-toggle').click();
await expect(page.getByTestId('operation-detail').first()).toContainText(sourceCommit, {
timeout: 15_000,
});
await expect(latestOperation.locator('td').nth(4)).toHaveText('SUCCEEDED', {
timeout: 300_000,
});
await expect(latestOperation.locator('[data-operation-status]')).toHaveAttribute(
'data-operation-status',
'SUCCEEDED',
{
timeout: 300_000,
}
);
}
await expect(profileStatus).toContainText('RUNNING', { timeout: 30_000 });
await expect(profileStatus).toContainText('SUCCEEDED');
@@ -637,47 +637,59 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('RESET');
await expect(page.getByTestId('operations-table')).toContainText('시나리오 초기화');
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.');
await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.');
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
const operationTable = page.getByTestId('operations-table');
await expect(operationTable.getByRole('columnheader')).toHaveText(['요청 · 작업', '상태', '보기']);
await expect(operationTable.getByText('시나리오 초기화', { exact: true })).toBeVisible();
await expect(operationTable.getByText('완료', { exact: true })).toBeVisible();
await expect(operationTable.getByText('che:default', { exact: true })).toBeHidden();
const detailsToggle = operationTable.getByTestId('operation-details-toggle');
await expect(detailsToggle).toHaveAttribute('aria-expanded', 'false');
await detailsToggle.click();
await expect(detailsToggle).toHaveAttribute('aria-expanded', 'true');
const operationDetail = operationTable.getByTestId('operation-detail');
await expect(operationDetail).toContainText('che:default');
await expect(operationDetail).toContainText('0123456789abcdef0123456789abcdef01234567');
await expect(operationDetail).toContainText('admin');
const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
const columnWidths = Array.from(table.querySelectorAll('thead th')).map(
(heading) => heading.getBoundingClientRect().width
);
const rowHeight = table.querySelector('tbody tr')?.getBoundingClientRect().height ?? 0;
const summaryRowHeight = table
.querySelector('[data-testid="operation-summary-row"]')
?.getBoundingClientRect().height;
return {
columnWidths,
rowHeight,
summaryRowHeight,
tableWidth: table.getBoundingClientRect().width,
scrollerWidth: table.parentElement?.getBoundingClientRect().width ?? 0,
scrollerScrollWidth: table.parentElement?.scrollWidth ?? 0,
tableLayout: getComputedStyle(table).tableLayout,
};
});
const sourceRefGeometry = await page.getByTestId('operation-source-ref').evaluate((element) => {
const style = getComputedStyle(element);
return {
title: element.getAttribute('title'),
text: element.textContent?.trim(),
overflow: style.overflow,
textOverflow: style.textOverflow,
whiteSpace: style.whiteSpace,
};
});
expect(operationTableGeometry.tableLayout).toBe('fixed');
expect(operationTableGeometry.tableWidth).toBeGreaterThanOrEqual(1_300);
expect(operationTableGeometry.tableWidth).toBeGreaterThan(operationTableGeometry.scrollerWidth);
expect(operationTableGeometry.columnWidths[0]).toBeGreaterThanOrEqual(159);
expect(operationTableGeometry.columnWidths[1]).toBeGreaterThanOrEqual(263);
expect(operationTableGeometry.columnWidths[5]).toBeLessThanOrEqual(113);
expect(operationTableGeometry.columnWidths[7]).toBeGreaterThanOrEqual(175);
expect(operationTableGeometry.columnWidths[1]).toBeGreaterThan(operationTableGeometry.columnWidths[5]! * 2);
expect(operationTableGeometry.rowHeight).toBeLessThanOrEqual(50);
expect(operationTableGeometry.columnWidths).toHaveLength(3);
expect(operationTableGeometry.tableWidth).toBeLessThanOrEqual(operationTableGeometry.scrollerWidth + 1);
expect(operationTableGeometry.scrollerScrollWidth).toBeLessThanOrEqual(operationTableGeometry.scrollerWidth + 1);
expect(operationTableGeometry.columnWidths[0]).toBeGreaterThan(operationTableGeometry.columnWidths[1]!);
expect(operationTableGeometry.columnWidths[2]).toBeGreaterThan(operationTableGeometry.columnWidths[1]!);
expect(operationTableGeometry.summaryRowHeight).toBeLessThanOrEqual(90);
expect(sourceRefGeometry).toEqual({
title: '0123456789abcdef0123456789abcdef01234567',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
text: 'COMMIT 0123456789abcdef0123456789abcdef01234567',
overflow: 'visible',
whiteSpace: 'normal',
});
await writeFile(
testInfo.outputPath('operation-table-metrics.json'),
@@ -693,7 +705,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
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.setViewportSize({ width: 390, height: 844 });
await page.setViewportSize({ width: 500, height: 844 });
const mobileGeometry = await page
.getByTestId('server-operations-page')
.locator('section')
@@ -705,7 +717,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
});
return children;
});
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390);
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(500);
const mobilePublishGeometry = await publishSchedule.evaluate((element) => {
const label = element.closest('label');
if (!label) throw new Error('expected publish schedule label');
@@ -735,18 +747,21 @@ test('separates branch and commit semantics and submits a reset from the dedicat
const mobileOperationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
const scroller = table.parentElement!;
const scrollerRect = scroller.getBoundingClientRect();
const detailRect = table.querySelector('[data-testid="operation-detail"]')?.getBoundingClientRect();
return {
tableWidth: table.getBoundingClientRect().width,
scrollerX: scrollerRect.x,
scrollerWidth: scrollerRect.width,
scrollerScrollWidth: scroller.scrollWidth,
detailX: detailRect?.x,
detailRight: detailRect?.right,
viewportWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(mobileOperationTableGeometry.tableWidth).toBeGreaterThanOrEqual(1_300);
expect(mobileOperationTableGeometry.scrollerScrollWidth).toBeGreaterThan(
mobileOperationTableGeometry.scrollerWidth
expect(mobileOperationTableGeometry.tableWidth).toBeLessThanOrEqual(mobileOperationTableGeometry.scrollerWidth + 1);
expect(mobileOperationTableGeometry.scrollerScrollWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.scrollerWidth + 1
);
expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0);
expect(mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth).toBeLessThanOrEqual(
@@ -755,6 +770,13 @@ test('separates branch and commit semantics and submits a reset from the dedicat
expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.viewportWidth
);
expect(mobileOperationTableGeometry.detailX).toBeGreaterThanOrEqual(mobileOperationTableGeometry.scrollerX);
expect(mobileOperationTableGeometry.detailRight).toBeLessThanOrEqual(
mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth
);
await page.screenshot({ path: testInfo.outputPath('mobile-operation-detail.png'), fullPage: true });
await detailsToggle.click();
await expect(operationDetail).toBeHidden();
await writeFile(
testInfo.outputPath('operation-table-mobile-metrics.json'),
JSON.stringify(mobileOperationTableGeometry, null, 2)
@@ -783,7 +805,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
await page.getByTestId('request-deploy').click();
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
await expect(page.getByTestId('operations-table')).toContainText('버전 업데이트');
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.');
await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete');
@@ -848,7 +870,7 @@ test('submits a separately authorized destructive game cancellation on desktop a
await cancelButton.click();
await expect(page.getByText('게임 취소 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('CANCEL_GAME');
await expect(page.getByTestId('operations-table')).toContainText('게임 취소');
expect(confirmations).toHaveLength(1);
expect(confirmations[0]).toContain('기수 행 물리 삭제');
expect(confirmations[0]).toContain('장수 기록 보존');
@@ -1416,7 +1438,7 @@ test('stops a running profile build while keeping the existing runtime available
await page.getByRole('button', { name: '빌드 중단' }).click();
await expect(page.getByText('프로필 빌드를 중단했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table').getByText('CANCELLED', { exact: true })).toBeVisible();
await expect(page.getByTestId('operations-table').getByText('중단됨', { exact: true })).toBeVisible();
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.cancel')).toBe(true);
});
@@ -1688,17 +1710,20 @@ 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%3Adefault/scenario');
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
const operationTable = page.getByTestId('operations-table');
await expect(operationTable.getByText('실패', { exact: true })).toBeVisible();
await expect(operationTable.getByText(longError)).toBeHidden();
await operationTable.getByRole('button', { name: '오류 상세' }).click();
await expect(operationTable.getByText('fedcba9876543210fedcba9876543210fedcba98', { exact: true })).toBeVisible();
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)');
expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.808 0.114 19.571)');
await page.getByRole('button', { name: '재시도' }).click();
await expect(page.getByText('재시도 작업을 등록했습니다.').first()).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);
await expect(page.getByTestId('operations-table').getByText('실패', { exact: true })).toBeVisible();
await expect(page.getByTestId('operations-table').getByText('대기 중', { exact: true })).toBeVisible();
await expect(page.getByTestId('operation-summary-row')).toHaveCount(2);
state.operations[0] = {
...state.operations[0]!,
@@ -1709,7 +1734,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.getByTestId('operations-table').getByText('SUCCEEDED', { exact: true })).toBeVisible();
await expect(page.getByTestId('operations-table').getByText('완료', { exact: true })).toBeVisible();
await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true });
@@ -1717,8 +1742,14 @@ test('renders a failed reset, retries it as a new operation, and reaches success
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 };
return {
tableWidth: tableRect.width,
scrollerWidth: scrollerRect.width,
documentScrollWidth: document.documentElement.scrollWidth,
viewportWidth: document.documentElement.clientWidth,
};
});
expect(tableGeometry.tableWidth).toBeGreaterThan(tableGeometry.scrollerWidth);
expect(tableGeometry.tableWidth).toBeLessThanOrEqual(tableGeometry.scrollerWidth + 1);
expect(tableGeometry.documentScrollWidth).toBeLessThanOrEqual(tableGeometry.viewportWidth);
await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-mobile.png'), fullPage: true });
});
@@ -86,6 +86,7 @@ type GatewayReleaseLog = {
const scenarios = ref<Scenario[]>([]);
const operations = ref<Operation[]>([]);
const selectedProfileOperationId = ref('');
const expandedProfileOperationId = ref('');
const profileOperationLogs = ref<GatewayReleaseLog[]>([]);
const profileOperationLogCursor = ref<string>();
const profileOperationLogStatus = ref('');
@@ -269,6 +270,40 @@ const formatTime = (value?: string): string => formatServerDateTime(value, { fal
const formatLogTime = (value: string): string => formatServerDateTime(value, { format: 'timeSeconds' });
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
const operationTypeLabel = (type: Operation['type']): string => {
const labels: Record<Operation['type'], string> = {
DEPLOY: '버전 업데이트',
RESET: '시나리오 초기화',
CANCEL_GAME: '게임 취소',
START: '서버 시작',
STOP: '서버 중지',
};
return labels[type];
};
const operationStatusLabel = (status: Operation['status']): string => {
const labels: Record<Operation['status'], string> = {
QUEUED: '대기 중',
RUNNING: '진행 중',
SUCCEEDED: '완료',
FAILED: '실패',
CANCELLED: '중단됨',
};
return labels[status];
};
const operationStatusClass = (status: Operation['status']): string => {
if (status === 'RUNNING') return 'border-emerald-700 bg-emerald-950/70 text-emerald-200';
if (status === 'QUEUED') return 'border-amber-700 bg-amber-950/70 text-amber-200';
if (status === 'SUCCEEDED') return 'border-cyan-800 bg-cyan-950/60 text-cyan-200';
if (status === 'FAILED') return 'border-red-800 bg-red-950/70 text-red-200';
return 'border-zinc-700 bg-zinc-800 text-zinc-300';
};
const toggleProfileOperationDetails = (operationId: string) => {
expandedProfileOperationId.value = expandedProfileOperationId.value === operationId ? '' : operationId;
};
const clearStatus = () => {
message.value = '';
errorMessage.value = '';
@@ -1753,7 +1788,7 @@ onBeforeUnmount(() => {
</div>
</section>
<section v-if="mode !== 'gateway'" class="rounded-lg border border-zinc-800 bg-zinc-900 p-5">
<section v-if="mode !== 'gateway'" class="rounded-lg border border-zinc-800 bg-zinc-900 p-3 sm: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"
@@ -1764,116 +1799,170 @@ onBeforeUnmount(() => {
</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>
<span class="text-right 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">
<div>
<table class="w-full table-fixed text-left text-xs sm: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" />
<col class="w-[42%]" />
<col class="w-[24%]" />
<col class="w-[34%]" />
</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>
<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)"
<template v-for="operation in operations" :key="operation.id">
<tr class="border-b border-zinc-800 align-top" data-testid="operation-summary-row">
<td class="p-2">
<div class="font-semibold text-zinc-100">
{{ operationTypeLabel(operation.type) }}
</div>
<div class="mt-1 text-[11px] leading-4 text-zinc-400">
{{ formatTime(operation.createdAt) }}
</div>
<div
v-if="operation.scheduledAt"
class="mt-1 text-[11px] leading-4 text-amber-300"
>
로그
</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)"
예약 {{ formatTime(operation.scheduledAt) }}
</div>
</td>
<td class="p-2">
<span
class="inline-flex min-h-7 items-center rounded-full border px-2 py-1 text-[11px] font-semibold leading-none"
:class="operationStatusClass(operation.status)"
:data-operation-status="operation.status"
>
{{ 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)"
{{ operationStatusLabel(operation.status) }}
</span>
</td>
<td class="p-2">
<div class="flex flex-wrap gap-1.5">
<button
type="button"
class="rounded border border-zinc-700 px-2 py-1 text-xs 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 === selectedProfileOperationId
? 'border-violet-500 text-violet-200'
: ''
"
@click="selectProfileOperation(operation.id)"
>
로그
</button>
<button
type="button"
class="rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-300 hover:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-400"
:aria-expanded="expandedProfileOperationId === operation.id"
:aria-controls="`profile-operation-detail-${operation.id}`"
data-testid="operation-details-toggle"
@click="toggleProfileOperationDetails(operation.id)"
>
{{ operation.error ? '오류 상세' : '상세' }}
</button>
<button
v-if="
operation.status === 'QUEUED' ||
(operation.status === 'RUNNING' && operation.type === 'DEPLOY')
"
type="button"
class="rounded border border-red-800 px-2 py-1 text-xs 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="cancelOperation(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-xs 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="retryOperation(operation)"
>
재시도
</button>
</div>
</td>
</tr>
<tr
v-show="expandedProfileOperationId === operation.id"
:id="`profile-operation-detail-${operation.id}`"
class="border-b border-zinc-700 bg-zinc-950/60"
data-testid="operation-detail"
>
<td colspan="3" class="p-3 sm:p-4">
<div
class="rounded border border-zinc-800 bg-zinc-950 px-3 py-3"
role="region"
:aria-label="`${operationTypeLabel(operation.type)} 상세`"
>
재시도
</button>
</div>
</td>
</tr>
<dl class="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
<div>
<dt class="text-[11px] font-semibold text-zinc-500">서버 ID</dt>
<dd class="mt-1 break-all font-mono text-xs text-zinc-300">
{{ operation.profileName }}
</dd>
</div>
<div>
<dt class="text-[11px] font-semibold text-zinc-500">작업 ID</dt>
<dd class="mt-1 break-all font-mono text-xs text-zinc-300">
{{ operation.id }}
</dd>
</div>
<div>
<dt class="text-[11px] font-semibold text-zinc-500">소스</dt>
<dd
class="mt-1 break-all font-mono text-xs text-zinc-300"
data-testid="operation-source-ref"
>
{{ operation.sourceMode ?? '-' }}
{{ operation.sourceRef ?? '' }}
</dd>
</div>
<div>
<dt class="text-[11px] font-semibold text-zinc-500">해석 커밋</dt>
<dd class="mt-1 break-all font-mono text-xs text-zinc-300">
{{ operation.resolvedCommitSha ?? '-' }}
</dd>
</div>
<div>
<dt class="text-[11px] font-semibold text-zinc-500">요청자</dt>
<dd class="mt-1 break-all font-mono text-xs text-zinc-300">
{{ operation.requestedBy }}
</dd>
</div>
<div>
<dt class="text-[11px] font-semibold text-zinc-500">완료 시각</dt>
<dd class="mt-1 text-xs text-zinc-300">
{{ formatTime(operation.completedAt) }}
</dd>
</div>
<div v-if="operation.reason" class="sm:col-span-2">
<dt class="text-[11px] font-semibold text-zinc-500">사유</dt>
<dd
class="mt-1 whitespace-pre-wrap break-all text-xs text-zinc-300"
>
{{ operation.reason }}
</dd>
</div>
<div v-if="operation.error" class="sm:col-span-2">
<dt class="text-[11px] font-semibold text-red-400">오류</dt>
<dd class="mt-1 whitespace-pre-wrap break-all text-xs text-red-300">
{{ operation.error }}
</dd>
</div>
</dl>
</div>
</td>
</tr>
</template>
<tr v-if="operations.length === 0">
<td colspan="10" class="p-6 text-center text-zinc-500">작업 이력이 없습니다.</td>
<td colspan="3" class="p-6 text-center text-zinc-500">작업 이력이 없습니다.</td>
</tr>
</tbody>
</table>
+5
View File
@@ -40,6 +40,11 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
runtime·빌드 상태는 상태 설정 탭에서 확인합니다.
- 버전 업데이트·시나리오 초기화·게임 취소 화면의 작업 이력은 요청·작업, 상태,
보기의 3열 요약으로 표시합니다. `진행 중`, `완료`, `실패` 등 현재 상태와 로그를
먼저 확인하고, 서버 ID·작업 ID·소스·커밋·요청자·사유·완료 시각·오류 원문은
각 작업의 `상세` 행에서 확인합니다. 상세 버튼은 `aria-expanded`
`aria-controls`로 연결되며 500px 화면에서도 페이지 가로 스크롤 없이 열립니다.
- 공통 좌측 메뉴는 `admin.profiles.listNavigation`으로 접근 가능한 profile의 이름과
표시명만 읽습니다. 이 요청은 PM2 runtime 상태를 포함하는 본문용
`admin.profiles.list`와 별도의 non-batch 요청으로 전송하므로, 상태 조회가 늦거나