fix(gateway): 서버 작업 이력을 상태 중심으로 간결화한다

서버별 화면의 작업 이력을 세 열 요약과 접근 가능한 상세 행으로 재구성한다. 500px Chromium에서 가로 넘침 없이 상태, 로그, 상세와 재시도 동작을 확인한다.
This commit is contained in:
2026-08-24 06:05:03 +00:00
parent 334ed09620
commit 2f4440f737
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 expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible();
await page.getByTestId('scenario-select').selectOption(scenarioId); 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(); const previousLatestOperation = await latestOperation.textContent();
await page.getByTestId('request-reset').click(); await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible(); await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible();
await expect.poll(() => latestOperation.textContent(), { timeout: 15_000 }).not.toBe(previousLatestOperation); await expect.poll(() => latestOperation.textContent(), { timeout: 15_000 }).not.toBe(previousLatestOperation);
await expect(latestOperation).toContainText(sourceCommit, { timeout: 15_000 }); await latestOperation.getByTestId('operation-details-toggle').click();
await expect(latestOperation.locator('td').nth(3)).toHaveText('SUCCEEDED', { await expect(page.getByTestId('operation-detail').first()).toContainText(sourceCommit, { timeout: 15_000 });
timeout: 300_000, await expect(latestOperation.locator('[data-operation-status]')).toHaveAttribute(
}); 'data-operation-status',
'SUCCEEDED',
{
timeout: 300_000,
}
);
const profileStatus = page.getByTestId('selected-profile-status'); const profileStatus = page.getByTestId('selected-profile-status');
await expect(profileStatus.locator(':scope > div').nth(0)).toContainText('RUNNING', { await expect(profileStatus.locator(':scope > div').nth(0)).toContainText('RUNNING', {
timeout: 30_000, 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 page.getByTestId('load-scenarios').click();
await expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible(); await expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible();
await page.getByTestId('scenario-select').selectOption(scenarioId); 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(); const previousLatestOperation = await latestOperation.textContent();
await page.getByTestId('request-reset').click(); await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); 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, timeout: 15_000,
}) })
.not.toBe(previousLatestOperation); .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, timeout: 15_000,
}); });
await expect(latestOperation.locator('td').nth(4)).toHaveText('SUCCEEDED', { await expect(latestOperation.locator('[data-operation-status]')).toHaveAttribute(
timeout: 300_000, 'data-operation-status',
}); 'SUCCEEDED',
{
timeout: 300_000,
}
);
} }
await expect(profileStatus).toContainText('RUNNING', { timeout: 30_000 }); await expect(profileStatus).toContainText('RUNNING', { timeout: 30_000 });
await expect(profileStatus).toContainText('SUCCEEDED'); 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 page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); 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-panel')).toBeVisible();
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.'); 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')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.');
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); 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 operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
const columnWidths = Array.from(table.querySelectorAll('thead th')).map( const columnWidths = Array.from(table.querySelectorAll('thead th')).map(
(heading) => heading.getBoundingClientRect().width (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 { return {
columnWidths, columnWidths,
rowHeight, summaryRowHeight,
tableWidth: table.getBoundingClientRect().width, tableWidth: table.getBoundingClientRect().width,
scrollerWidth: table.parentElement?.getBoundingClientRect().width ?? 0, scrollerWidth: table.parentElement?.getBoundingClientRect().width ?? 0,
scrollerScrollWidth: table.parentElement?.scrollWidth ?? 0,
tableLayout: getComputedStyle(table).tableLayout, tableLayout: getComputedStyle(table).tableLayout,
}; };
}); });
const sourceRefGeometry = await page.getByTestId('operation-source-ref').evaluate((element) => { const sourceRefGeometry = await page.getByTestId('operation-source-ref').evaluate((element) => {
const style = getComputedStyle(element); const style = getComputedStyle(element);
return { return {
title: element.getAttribute('title'), text: element.textContent?.trim(),
overflow: style.overflow, overflow: style.overflow,
textOverflow: style.textOverflow,
whiteSpace: style.whiteSpace, whiteSpace: style.whiteSpace,
}; };
}); });
expect(operationTableGeometry.tableLayout).toBe('fixed'); expect(operationTableGeometry.tableLayout).toBe('fixed');
expect(operationTableGeometry.tableWidth).toBeGreaterThanOrEqual(1_300); expect(operationTableGeometry.columnWidths).toHaveLength(3);
expect(operationTableGeometry.tableWidth).toBeGreaterThan(operationTableGeometry.scrollerWidth); expect(operationTableGeometry.tableWidth).toBeLessThanOrEqual(operationTableGeometry.scrollerWidth + 1);
expect(operationTableGeometry.columnWidths[0]).toBeGreaterThanOrEqual(159); expect(operationTableGeometry.scrollerScrollWidth).toBeLessThanOrEqual(operationTableGeometry.scrollerWidth + 1);
expect(operationTableGeometry.columnWidths[1]).toBeGreaterThanOrEqual(263); expect(operationTableGeometry.columnWidths[0]).toBeGreaterThan(operationTableGeometry.columnWidths[1]!);
expect(operationTableGeometry.columnWidths[5]).toBeLessThanOrEqual(113); expect(operationTableGeometry.columnWidths[2]).toBeGreaterThan(operationTableGeometry.columnWidths[1]!);
expect(operationTableGeometry.columnWidths[7]).toBeGreaterThanOrEqual(175); expect(operationTableGeometry.summaryRowHeight).toBeLessThanOrEqual(90);
expect(operationTableGeometry.columnWidths[1]).toBeGreaterThan(operationTableGeometry.columnWidths[5]! * 2);
expect(operationTableGeometry.rowHeight).toBeLessThanOrEqual(50);
expect(sourceRefGeometry).toEqual({ expect(sourceRefGeometry).toEqual({
title: '0123456789abcdef0123456789abcdef01234567', text: 'COMMIT 0123456789abcdef0123456789abcdef01234567',
overflow: 'hidden', overflow: 'visible',
textOverflow: 'ellipsis', whiteSpace: 'normal',
whiteSpace: 'nowrap',
}); });
await writeFile( await writeFile(
testInfo.outputPath('operation-table-metrics.json'), 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"'); 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: 500, height: 844 });
const mobileGeometry = await page const mobileGeometry = await page
.getByTestId('server-operations-page') .getByTestId('server-operations-page')
.locator('section') .locator('section')
@@ -705,7 +717,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
}); });
return children; return children;
}); });
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390); expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(500);
const mobilePublishGeometry = await publishSchedule.evaluate((element) => { const mobilePublishGeometry = await publishSchedule.evaluate((element) => {
const label = element.closest('label'); const label = element.closest('label');
if (!label) throw new Error('expected publish schedule 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 mobileOperationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
const scroller = table.parentElement!; const scroller = table.parentElement!;
const scrollerRect = scroller.getBoundingClientRect(); const scrollerRect = scroller.getBoundingClientRect();
const detailRect = table.querySelector('[data-testid="operation-detail"]')?.getBoundingClientRect();
return { return {
tableWidth: table.getBoundingClientRect().width, tableWidth: table.getBoundingClientRect().width,
scrollerX: scrollerRect.x, scrollerX: scrollerRect.x,
scrollerWidth: scrollerRect.width, scrollerWidth: scrollerRect.width,
scrollerScrollWidth: scroller.scrollWidth, scrollerScrollWidth: scroller.scrollWidth,
detailX: detailRect?.x,
detailRight: detailRect?.right,
viewportWidth: document.documentElement.clientWidth, viewportWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth, documentScrollWidth: document.documentElement.scrollWidth,
}; };
}); });
expect(mobileOperationTableGeometry.tableWidth).toBeGreaterThanOrEqual(1_300); expect(mobileOperationTableGeometry.tableWidth).toBeLessThanOrEqual(mobileOperationTableGeometry.scrollerWidth + 1);
expect(mobileOperationTableGeometry.scrollerScrollWidth).toBeGreaterThan( expect(mobileOperationTableGeometry.scrollerScrollWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.scrollerWidth mobileOperationTableGeometry.scrollerWidth + 1
); );
expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0); expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0);
expect(mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth).toBeLessThanOrEqual( 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( expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.viewportWidth 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( await writeFile(
testInfo.outputPath('operation-table-mobile-metrics.json'), testInfo.outputPath('operation-table-mobile-metrics.json'),
JSON.stringify(mobileOperationTableGeometry, null, 2) 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 page.getByTestId('request-deploy').click();
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible(); 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-panel')).toBeVisible();
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.');
await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete'); 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 cancelButton.click();
await expect(page.getByText('게임 취소 작업을 등록했습니다.').first()).toBeVisible(); 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).toHaveLength(1);
expect(confirmations[0]).toContain('기수 행 물리 삭제'); expect(confirmations[0]).toContain('기수 행 물리 삭제');
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 page.getByRole('button', { name: '빌드 중단' }).click();
await expect(page.getByText('프로필 빌드를 중단했습니다.').first()).toBeVisible(); 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); 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()); page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/servers/che%3Adefault/scenario'); await page.goto('admin/servers/che%3Adefault/scenario');
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible(); const operationTable = page.getByTestId('operations-table');
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible(); 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); const failure = page.getByTestId('operations-table').getByText(longError);
await expect(failure).toBeVisible(); 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 page.getByRole('button', { name: '재시도' }).click();
await expect(page.getByText('재시도 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByText('재시도 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible(); await expect(page.getByTestId('operations-table').getByText('실패', { exact: true })).toBeVisible();
await expect(page.getByTestId('operations-table').getByText('QUEUED', { exact: true })).toBeVisible(); await expect(page.getByTestId('operations-table').getByText('대기 중', { exact: true })).toBeVisible();
await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2); await expect(page.getByTestId('operation-summary-row')).toHaveCount(2);
state.operations[0] = { state.operations[0] = {
...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; state.runtimeRunning = true;
await page.getByTestId('refresh-operations').click(); 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 expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true }); 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 tableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
const tableRect = table.getBoundingClientRect(); const tableRect = table.getBoundingClientRect();
const scrollerRect = table.parentElement!.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 }); await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-mobile.png'), fullPage: true });
}); });
@@ -86,6 +86,7 @@ type GatewayReleaseLog = {
const scenarios = ref<Scenario[]>([]); const scenarios = ref<Scenario[]>([]);
const operations = ref<Operation[]>([]); const operations = ref<Operation[]>([]);
const selectedProfileOperationId = ref(''); const selectedProfileOperationId = ref('');
const expandedProfileOperationId = ref('');
const profileOperationLogs = ref<GatewayReleaseLog[]>([]); const profileOperationLogs = ref<GatewayReleaseLog[]>([]);
const profileOperationLogCursor = ref<string>(); const profileOperationLogCursor = ref<string>();
const profileOperationLogStatus = ref(''); 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 formatLogTime = (value: string): string => formatServerDateTime(value, { format: 'timeSeconds' });
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-'); 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 = () => { const clearStatus = () => {
message.value = ''; message.value = '';
errorMessage.value = ''; errorMessage.value = '';
@@ -1753,7 +1788,7 @@ onBeforeUnmount(() => {
</div> </div>
</section> </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 <div
class="mb-4 rounded border border-amber-800/80 bg-amber-950/30 px-4 py-3 text-sm text-amber-100" 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" data-testid="profile-build-recovery-guide"
@@ -1764,116 +1799,170 @@ onBeforeUnmount(() => {
</div> </div>
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold">작업 이력</h3> <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>
<div class="overflow-x-auto"> <div>
<table class="w-full min-w-[1300px] table-fixed text-left text-sm" data-testid="operations-table"> <table class="w-full table-fixed text-left text-xs sm:text-sm" data-testid="operations-table">
<colgroup> <colgroup>
<col style="width: 160px" /> <col class="w-[42%]" />
<col style="width: 264px" /> <col class="w-[24%]" />
<col style="width: 72px" /> <col class="w-[34%]" />
<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" />
</colgroup> </colgroup>
<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>
<th class="p-2">해석 커밋</th>
<th class="p-2">요청자/사유</th>
<th class="p-2">완료/오류</th>
<th class="p-2">동작</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <template v-for="operation in operations" :key="operation.id">
v-for="operation in operations" <tr class="border-b border-zinc-800 align-top" data-testid="operation-summary-row">
:key="operation.id" <td class="p-2">
class="border-b border-zinc-800 align-top" <div class="font-semibold text-zinc-100">
> {{ operationTypeLabel(operation.type) }}
<td class="p-2 text-xs"> </div>
{{ formatTime(operation.createdAt) }} <div class="mt-1 text-[11px] leading-4 text-zinc-400">
<div v-if="operation.scheduledAt" class="mt-1 text-amber-300"> {{ formatTime(operation.createdAt) }}
예약 {{ formatTime(operation.scheduledAt) }} </div>
</div> <div
</td> v-if="operation.scheduledAt"
<td class="max-w-48 break-all p-2 font-mono text-xs">{{ operation.id }}</td> class="mt-1 text-[11px] leading-4 text-amber-300"
<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)"
> >
로그 예약 {{ formatTime(operation.scheduledAt) }}
</button> </div>
<button </td>
v-if=" <td class="p-2">
operation.status === 'QUEUED' || <span
(operation.status === 'RUNNING' && operation.type === 'DEPLOY') 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)"
class="rounded border border-red-800 px-2 py-1 text-xs text-red-300 hover:bg-red-950" :data-operation-status="operation.status"
@click="cancelOperation(operation)"
> >
{{ operation.status === 'RUNNING' ? '빌드 중단' : '취소' }} {{ operationStatusLabel(operation.status) }}
</button> </span>
<button </td>
v-else-if=" <td class="p-2">
operation.status === 'FAILED' || operation.status === 'CANCELLED' <div class="flex flex-wrap gap-1.5">
" <button
class="rounded border border-amber-700 px-2 py-1 text-xs text-amber-300 hover:bg-amber-950" type="button"
@click="retryOperation(operation)" 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)} 상세`"
> >
재시도 <dl class="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
</button> <div>
</div> <dt class="text-[11px] font-semibold text-zinc-500">서버 ID</dt>
</td> <dd class="mt-1 break-all font-mono text-xs text-zinc-300">
</tr> {{ 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"> <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> </tr>
</tbody> </tbody>
</table> </table>
+5
View File
@@ -40,6 +40,11 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를 이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세 기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
runtime·빌드 상태는 상태 설정 탭에서 확인합니다. runtime·빌드 상태는 상태 설정 탭에서 확인합니다.
- 버전 업데이트·시나리오 초기화·게임 취소 화면의 작업 이력은 요청·작업, 상태,
보기의 3열 요약으로 표시합니다. `진행 중`, `완료`, `실패` 등 현재 상태와 로그를
먼저 확인하고, 서버 ID·작업 ID·소스·커밋·요청자·사유·완료 시각·오류 원문은
각 작업의 `상세` 행에서 확인합니다. 상세 버튼은 `aria-expanded`
`aria-controls`로 연결되며 500px 화면에서도 페이지 가로 스크롤 없이 열립니다.
- 공통 좌측 메뉴는 `admin.profiles.listNavigation`으로 접근 가능한 profile의 이름과 - 공통 좌측 메뉴는 `admin.profiles.listNavigation`으로 접근 가능한 profile의 이름과
표시명만 읽습니다. 이 요청은 PM2 runtime 상태를 포함하는 본문용 표시명만 읽습니다. 이 요청은 PM2 runtime 상태를 포함하는 본문용
`admin.profiles.list`와 별도의 non-batch 요청으로 전송하므로, 상태 조회가 늦거나 `admin.profiles.list`와 별도의 non-batch 요청으로 전송하므로, 상태 조회가 늦거나