feat(ui): 단계별 현황 재설계 — 완료 가시화 + 빈 단계 숨김 (사용자 피드백)

'대기만 보이고 성공은 안 보인다' 피드백 반영:
- overview 에 stages[] 노출 (stage 별 done_today + oldest_pending_age, SQL 1필드 추가)
- 게이지 의미 전환: 단계 간 대기량 비교(amber) → 단계 내 오늘 진척(완료=green 비율,
  가득 찬 초록 = 다 끝남) + 처리 중 pulse dot
- 움직임 없는 단계는 행 제거, 하단 '비어 있음: ...' 한 줄로
- 라벨 누수 fix: details 가 구 STAGE_LABEL 을 쓰던 것 → queueStageLabel 통일
  (deep_summary/markdown/summarize/chunk/fulltext 한글화)
- 헤더: 오늘 N 완료(성공 가시화) · 실패(error) · 대기. 데이터 소스 = overview 단일화

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-11 14:26:27 +09:00
parent 468804494d
commit 7031439364
5 changed files with 148 additions and 34 deletions
+11
View File
@@ -63,8 +63,19 @@ class Totals(BaseModel):
failed: int
class StageRow(BaseModel):
"""단계별 현황 행 — '단계 상세' 패널용 (완료 가시화)."""
stage: str
pending: int
processing: int
failed: int
done_today: int
oldest_pending_age_sec: int | None
class QueueOverviewResponse(BaseModel):
machines: list[MachineCard]
stages: list[StageRow]
summarize_eta: SummarizeEta
trend_24h: list[TrendBucket]
totals: Totals
+34 -2
View File
@@ -68,7 +68,7 @@ def _zero_stage() -> dict:
return {
"pending": 0, "processing": 0, "failed": 0,
"done_1h": 0, "done_today": 0, "done_15m": 0,
"deferred_pending": 0, "created_1h": 0,
"deferred_pending": 0, "created_1h": 0, "oldest_pending_at": None,
}
@@ -85,6 +85,7 @@ def rows_to_stage_stats(rows) -> dict[str, dict]:
"done_15m": int(row[6] or 0),
"deferred_pending": int(row[7] or 0),
"created_1h": int(row[8] or 0),
"oldest_pending_at": row[9] if len(row) > 9 else None,
}
return stats
@@ -231,6 +232,35 @@ def build_trend(
return trend
def build_stages(stage_stats: dict[str, dict], now=None) -> list[dict]:
"""단계별 현황 행 — '단계 상세' 패널용 (2026-06-11 사용자 피드백: 완료가 보여야 한다).
파이프라인 순서 유지, 미지 stage 는 뒤에. 숨김/강조 판단은 FE 몫 — 여기선 사실만.
oldest_pending_age_sec = 가장 오래된 pending 의 경과 초 (pending 없으면 None).
"""
from datetime import datetime, timezone
now = now or datetime.now(timezone.utc)
extra = [s for s in stage_stats if s not in _STAGE_ORDER]
rows = []
for stage in [*_STAGE_ORDER, *extra]:
st = stage_stats.get(stage) or _zero_stage()
oldest = st.get("oldest_pending_at")
age = None
if oldest is not None:
if oldest.tzinfo is None:
oldest = oldest.replace(tzinfo=timezone.utc)
age = max(0, int((now - oldest).total_seconds()))
rows.append({
"stage": stage,
"pending": st["pending"],
"processing": st["processing"],
"failed": st["failed"],
"done_today": st["done_today"],
"oldest_pending_age_sec": age,
})
return rows
def build_totals(stage_stats: dict[str, dict]) -> dict:
"""전 stage 합계."""
return {
@@ -255,6 +285,7 @@ def compose_overview(
"machines": build_machines(
stage_stats, summarize_split, current_rows, deep_enabled=deep_enabled
),
"stages": build_stages(stage_stats),
"summarize_eta": build_summarize_eta(stage_stats),
"trend_24h": build_trend(inflow_buckets, done_buckets, now_kst),
"totals": build_totals(stage_stats),
@@ -280,7 +311,8 @@ _STAGE_STATS_SQL = """
AND payload ->> 'deferred_until' IS NOT NULL
AND (payload ->> 'deferred_until')::timestamptz > NOW())
AS deferred_pending,
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '1 hour') AS created_1h
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '1 hour') AS created_1h,
MIN(created_at) FILTER (WHERE status = 'pending') AS oldest_pending_at
FROM processing_queue
GROUP BY stage
"""
+10
View File
@@ -56,9 +56,19 @@ export interface QueueTotals {
failed: number;
}
export interface QueueStageRow {
stage: string;
pending: number;
processing: number;
failed: number;
done_today: number;
oldest_pending_age_sec: number | null;
}
export interface QueueOverview {
machines: MachineOverview[];
summarize_eta: SummarizeEta;
trend_24h: TrendPoint[];
stages: QueueStageRow[];
totals: QueueTotals;
}
+67 -31
View File
@@ -199,7 +199,20 @@
let totalProcessing = $derived(pipelineRows.reduce((s, r) => s + r.processing, 0));
let pipelineManualClosed = $state(false);
let pipelineOpen = $derived(pipelineManualClosed ? false : totalFailed > 0);
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 '';
@@ -517,44 +530,67 @@
<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 totalFailed > 0}<span class="text-error font-medium">실패 {totalFailed}</span>{/if}
{#if totalPending > 0}<span>대기 {totalPending}</span>{/if}
{#if totalFailed === 0 && totalPending === 0}<span>처리 완료</span>{/if}
{#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">
<p class="text-xs text-dim mb-3">최근 24시간</p>
{#if pipelineRows.length > 0}
<div class="space-y-3">
{#each pipelineRows as row (row.stage)}
<div>
<div class="flex items-center justify-between text-xs mb-1.5">
<span class="text-dim">
{row.label}
{#if row.oldestPendingAgeSec && row.oldestPendingAgeSec > 600}
<span class="ml-1 text-warning" title="가장 오래된 pending 의 경과 시간">({formatAge(row.oldestPendingAgeSec)})</span>
{/if}
</span>
<span class="text-dim tabular-nums">
대기 <span class="text-text">{row.pending}</span> ·
처리 <span class="text-text">{row.processing}</span> ·
실패 <span class={row.failed > 0 ? 'text-error font-medium' : ''}>{row.failed}</span>
</span>
{#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>
<div class="flex h-1.5 w-full overflow-hidden rounded-sm bg-bg">
{#if row.pending > 0}<div class="bg-warning h-full" style="width: {(row.pending / pipelineMax) * 100}%"></div>{/if}
{#if row.processing > 0}<div class="bg-accent h-full" style="width: {(row.processing / pipelineMax) * 100}%"></div>{/if}
{#if row.failed > 0}<div class="bg-error h-full" style="width: {(row.failed / pipelineMax) * 100}%"></div>{/if}
</div>
</div>
{/each}
</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>
<p class="text-xs text-dim text-center py-3">현황을 불러오지 못했습니다</p>
{/if}
</div>
</details>
+26 -1
View File
@@ -329,7 +329,7 @@ def test_compose_overview_contract_shape():
deep_enabled=True,
now_kst=datetime(2026, 6, 11, 14, 30, tzinfo=KST),
)
assert set(out.keys()) == {"machines", "summarize_eta", "trend_24h", "totals"}
assert set(out.keys()) == {"machines", "stages", "summarize_eta", "trend_24h", "totals"}
assert [m["key"] for m in out["machines"]] == ["gpu", "macmini", "macbook"]
for m in out["machines"]:
assert set(m.keys()) == {
@@ -343,3 +343,28 @@ def test_compose_overview_contract_shape():
assert set(out["totals"].keys()) == {"pending", "processing", "failed"}
# 머신 label 고정 (raw 모델명 노출 금지 — label 만)
assert [m["label"] for m in out["machines"]] == ["GPU 서버", "맥미니", "맥북 M5 Max"]
# ─── build_stages (단계별 현황 — 2026-06-11 사용자 피드백: 완료 가시화) ──────
def test_build_stages_order_fields_and_age():
from datetime import timedelta, timezone
from services.queue_overview import build_stages
now = datetime(2026, 6, 11, 14, 0, tzinfo=timezone.utc)
stats = {
"summarize": {**_stage(pending=5, done_today=12),
"oldest_pending_at": now - timedelta(hours=4)},
"extract": _stage(failed=2),
}
rows = build_stages(stats, now=now)
by = {r["stage"]: r for r in rows}
# 파이프라인 순서: extract 가 summarize 보다 앞
assert rows[0]["stage"] == "extract"
assert by["summarize"]["pending"] == 5
assert by["summarize"]["done_today"] == 12
assert by["summarize"]["oldest_pending_age_sec"] == 4 * 3600
assert by["extract"]["failed"] == 2
assert by["extract"]["oldest_pending_age_sec"] is None
# 전 stage 행 존재 (빈 단계 숨김은 FE 몫)
assert {"stage", "pending", "processing", "failed", "done_today",
"oldest_pending_age_sec"} == set(rows[0].keys())