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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user