fix(board): 실패 뱃지 잘림(스크롤 컨테이너 헤드룸) + 구 단계별 현황 섹션 제거 + ETA 48h+ 일 표기

- 흐름 컨테이너 pt/px 헤드룸 — -top/-right 돌출 뱃지가 overflow-x-auto 에 잘리던 문제
- 단계별 현황 details = 흐름 보드가 대체(R2 통합안 의도) — 전용 파생값/헬퍼/chevron 동반 제거
- etaShort: 48시간 이상은 일 단위 (약 131시간 → 약 5.5일)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-12 03:02:04 +00:00
parent 2bbdf63d86
commit d05e41128a
3 changed files with 8 additions and 118 deletions
@@ -246,8 +246,8 @@
{/each}
</div>
<!-- 흐름 노드 -->
<div class="flex items-stretch overflow-x-auto pb-1">
<!-- 흐름 노드 — pt/px 헤드룸 = 실패 뱃지(-top/-right 돌출)가 스크롤 컨테이너에 잘리지 않게 -->
<div class="flex items-stretch overflow-x-auto pt-2.5 pb-1 px-2 -mx-2">
{#each mainNodes as n, i (n.def.key)}
{#if i > 0}
<div class="flex items-center text-faint text-sm px-1.5 shrink-0" aria-hidden="true"></div>
+5 -1
View File
@@ -37,10 +37,14 @@ export function etaPhrase(minutes: number): string {
return `${text}시간 후 소진 예상`;
}
/** ETA 분 → 칩용 짧은 표기 ("약 4.6시간" / "약 12분") */
/** ETA 분 → 칩용 짧은 표기 ("약 12분" / "약 4.6시간" / 48h+ = "약 5.5일") */
export function etaShort(minutes: number): string {
if (minutes < 60) return `${Math.max(1, Math.round(minutes))}`;
const hours = minutes / 60;
if (hours >= 48) {
const days = hours / 24;
return `${days >= 10 ? Math.round(days) : Math.round(days * 10) / 10}`;
}
const text = hours >= 10 ? String(Math.round(hours)) : String(Math.round(hours * 10) / 10);
return `${text}시간`;
}
+1 -115
View File
@@ -19,7 +19,7 @@
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import {
Scale, FileText, Pin, ChevronRight, GraduationCap, Upload, Newspaper,
Scale, FileText, Pin, GraduationCap, Upload, Newspaper,
} from 'lucide-svelte';
import { addToast } from '$lib/stores/toast';
@@ -133,17 +133,6 @@
// 백엔드 미배포/실패 시 store=null → 보드 자체가 조용히 생략 (silent 비차단).
let queue = $derived<QueueOverview | null>($queueOverview);
// 머신 담당 단계 라벨 — STAGE_LABEL 재사용 + overview 전용 단계 보강
// (backend services/queue_overview.py _STAGE_ORDER 와 동기), 미지 키는 raw
const QUEUE_STAGE_LABEL: Record<string, string> = {
...STAGE_LABEL,
summarize: '요약', chunk: '청크', markdown: '마크다운',
fulltext: '전문', deep_summary: '심층분석',
};
function queueStageLabel(stage: string): string {
return QUEUE_STAGE_LABEL[stage] ?? stage;
}
onMount(() => {
void refreshQueueOverview();
const handle = setInterval(() => void refreshQueueOverview(), 30_000);
@@ -191,35 +180,10 @@
let pipelineRows = $derived(
summary ? buildPipelineRows(summary.pipeline_status, summary.queue_lag ?? []) : []
);
let pipelineMax = $derived(Math.max(1, ...pipelineRows.map((r) => r.total)));
let totalFailed = $derived(summary?.failed_count ?? 0);
let totalPending = $derived(pipelineRows.reduce((s, r) => s + r.pending, 0));
let totalProcessing = $derived(pipelineRows.reduce((s, r) => s + r.processing, 0));
let pipelineManualClosed = $state(false);
let pipelineOpen = $derived(
pipelineManualClosed ? false : (queue?.totals.failed ?? totalFailed) > 0
);
// 단계별 현황 (2026-06-11 피드백 재설계: 완료가 보여야 한다 — overview.stages 단일 소스)
// active = 오늘 움직임이 있는 단계만, idle = 전부 0 인 단계는 한 줄로 숨김.
let stageRows = $derived(queue?.stages ?? []);
let activeStageRows = $derived(
stageRows.filter((r) => r.pending + r.processing + r.failed + r.done_today > 0)
);
let idleStageRows = $derived(
stageRows.filter((r) => r.pending + r.processing + r.failed + r.done_today === 0)
);
let stageDoneToday = $derived(stageRows.reduce((s, r) => s + r.done_today, 0));
function formatAge(sec: number | null): string {
if (sec == null || sec <= 0) return '';
if (sec < 60) return `${sec} `;
if (sec < 3600) return `${Math.floor(sec / 60)} `;
if (sec < 86400) return `${Math.floor(sec / 3600)}시간 `;
return `${Math.floor(sec / 86400)}일 전`;
}
function formatTime(dateStr: string) {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return ''; // 빈 문자열/유효하지 않은 created_at → 'Invalid Date' 회피
@@ -463,80 +427,6 @@
<ProcessingFlowBoard overview={queue} />
{/if}
<!-- ═══ 단계 상세 (기존 stage 테이블 — 접힘 강등, 실패 있을 때 자동 펼침) ═══ -->
<details
class="mt-5"
open={pipelineOpen}
ontoggle={(e) => { if (!e.currentTarget.open) pipelineManualClosed = true; }}
>
<summary class="flex items-center justify-between px-5 py-3.5 bg-surface border border-default rounded-card cursor-pointer hover:bg-surface-hover transition-colors select-none list-none">
<span class="text-sm font-semibold text-text flex items-center gap-2">
<ChevronRight size={14} class="transition-transform details-chevron" />
단계별 현황
</span>
<span class="text-xs text-dim flex items-center gap-2.5">
{#if queue}
{#if stageDoneToday > 0}<span class="text-success">오늘 {stageDoneToday.toLocaleString()} 완료</span>{/if}
{#if queue.totals.failed > 0}<span class="text-error font-medium">실패 {queue.totals.failed}</span>{/if}
{#if queue.totals.pending > 0}<span>대기 {queue.totals.pending.toLocaleString()}</span>{/if}
{#if stageDoneToday === 0 && queue.totals.failed === 0 && queue.totals.pending === 0}<span>모든 단계 한가함</span>{/if}
{:else}
{#if totalFailed > 0}<span class="text-error font-medium">실패 {totalFailed}</span>{/if}
{#if totalPending > 0}<span>대기 {totalPending}</span>{/if}
{/if}
</span>
</summary>
<div class="mt-2 px-5 py-4 bg-surface border border-default rounded-card">
{#if queue}
{#if activeStageRows.length > 0}
<div class="space-y-3.5">
{#each activeStageRows as row (row.stage)}
{@const total = row.done_today + row.pending + row.processing}
{@const donePct = total > 0 ? (row.done_today / total) * 100 : 0}
{@const procPct = total > 0 ? (row.processing / total) * 100 : 0}
<div>
<div class="flex items-baseline justify-between text-xs mb-1.5 gap-2">
<span class="font-medium text-text flex items-center gap-1.5 whitespace-nowrap">
{queueStageLabel(row.stage)}
{#if row.processing > 0}
<span class="inline-block w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
<span class="text-accent font-normal">처리 중 {row.processing}</span>
{/if}
</span>
<span class="text-dim tabular-nums flex items-center gap-2.5 whitespace-nowrap">
{#if row.done_today > 0}<span class="text-success">오늘 {row.done_today.toLocaleString()} 완료</span>{/if}
{#if row.pending > 0}<span>대기 {row.pending.toLocaleString()}</span>{/if}
{#if row.failed > 0}<span class="text-error font-medium">실패 {row.failed}</span>{/if}
</span>
</div>
<!-- 게이지 = 이 단계의 오늘 진척 (완료 / 완료+대기) — 가득 찬 초록 = 다 끝남 -->
<div class="flex h-1.5 w-full overflow-hidden rounded-sm bg-bg" title="오늘 완료 {row.done_today.toLocaleString()} / 잔여 {row.pending.toLocaleString()}">
{#if donePct > 0}<div class="bg-success/70 h-full" style="width: {donePct}%"></div>{/if}
{#if procPct > 0}<div class="bg-accent h-full" style="width: {Math.max(procPct, 1)}%"></div>{/if}
</div>
{#if row.pending > 0 && row.oldest_pending_age_sec && row.oldest_pending_age_sec > 600}
<p class="text-[10px] mt-1 tabular-nums {row.oldest_pending_age_sec > 21600 ? 'text-warning' : 'text-faint'}">
가장 오래 기다린 항목 {formatAge(row.oldest_pending_age_sec)}
</p>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-xs text-dim text-center py-3">대기·처리·실패 없음 — 모든 단계가 한가합니다</p>
{/if}
{#if idleStageRows.length > 0}
<p class="text-[11px] text-faint mt-4 pt-3 border-t border-default">
비어 있음: {idleStageRows.map((r) => queueStageLabel(r.stage)).join(' · ')}
</p>
{/if}
{:else}
<p class="text-xs text-dim text-center py-3">현황을 불러오지 못했습니다</p>
{/if}
</div>
</details>
{/if}
</div>
</div>
@@ -548,7 +438,3 @@
</div>
{/snippet}
<style>
details[open] :global(.details-chevron) { transform: rotate(90deg); }
details summary::-webkit-details-marker { display: none; }
</style>