From 7031439364ac548956de25f4fff729559bded423 Mon Sep 17 00:00:00 2001 From: hyungi Date: Thu, 11 Jun 2026 14:26:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EB=8B=A8=EA=B3=84=EB=B3=84=20?= =?UTF-8?q?=ED=98=84=ED=99=A9=20=EC=9E=AC=EC=84=A4=EA=B3=84=20=E2=80=94=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EA=B0=80=EC=8B=9C=ED=99=94=20+=20?= =?UTF-8?q?=EB=B9=88=20=EB=8B=A8=EA=B3=84=20=EC=88=A8=EA=B9=80=20(?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=94=BC=EB=93=9C=EB=B0=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '대기만 보이고 성공은 안 보인다' 피드백 반영: - 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 --- app/api/queue_overview.py | 11 ++++ app/services/queue_overview.py | 36 +++++++++++- frontend/src/lib/types/queue.ts | 10 ++++ frontend/src/routes/+page.svelte | 98 ++++++++++++++++++++++---------- tests/test_queue_overview.py | 27 ++++++++- 5 files changed, 148 insertions(+), 34 deletions(-) diff --git a/app/api/queue_overview.py b/app/api/queue_overview.py index a1ea3be..d456dcb 100644 --- a/app/api/queue_overview.py +++ b/app/api/queue_overview.py @@ -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 diff --git a/app/services/queue_overview.py b/app/services/queue_overview.py index 63779b6..803c603 100644 --- a/app/services/queue_overview.py +++ b/app/services/queue_overview.py @@ -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 """ diff --git a/frontend/src/lib/types/queue.ts b/frontend/src/lib/types/queue.ts index f0b15e7..d0eccd4 100644 --- a/frontend/src/lib/types/queue.ts +++ b/frontend/src/lib/types/queue.ts @@ -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; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index ce57d71..d028892 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -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 @@ - 단계 상세 + 단계별 현황 - {#if totalFailed > 0}실패 {totalFailed}{/if} - {#if totalPending > 0}대기 {totalPending}{/if} - {#if totalFailed === 0 && totalPending === 0}처리 완료{/if} + {#if queue} + {#if stageDoneToday > 0}오늘 {stageDoneToday.toLocaleString()} 완료{/if} + {#if queue.totals.failed > 0}실패 {queue.totals.failed}{/if} + {#if queue.totals.pending > 0}대기 {queue.totals.pending.toLocaleString()}{/if} + {#if stageDoneToday === 0 && queue.totals.failed === 0 && queue.totals.pending === 0}모든 단계 한가함{/if} + {:else} + {#if totalFailed > 0}실패 {totalFailed}{/if} + {#if totalPending > 0}대기 {totalPending}{/if} + {/if}
-

최근 24시간

- {#if pipelineRows.length > 0} -
- {#each pipelineRows as row (row.stage)} -
-
- - {row.label} - {#if row.oldestPendingAgeSec && row.oldestPendingAgeSec > 600} - ({formatAge(row.oldestPendingAgeSec)}) - {/if} - - - 대기 {row.pending} · - 처리 {row.processing} · - 실패 0 ? 'text-error font-medium' : ''}>{row.failed} - + {#if queue} + {#if activeStageRows.length > 0} +
+ {#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} +
+
+ + {queueStageLabel(row.stage)} + {#if row.processing > 0} + + 처리 중 {row.processing} + {/if} + + + {#if row.done_today > 0}오늘 {row.done_today.toLocaleString()} 완료{/if} + {#if row.pending > 0}대기 {row.pending.toLocaleString()}{/if} + {#if row.failed > 0}실패 {row.failed}{/if} + +
+ +
+ {#if donePct > 0}
{/if} + {#if procPct > 0}
{/if} +
+ {#if row.pending > 0 && row.oldest_pending_age_sec && row.oldest_pending_age_sec > 600} +

+ 가장 오래 기다린 항목 {formatAge(row.oldest_pending_age_sec)} +

+ {/if}
-
- {#if row.pending > 0}
{/if} - {#if row.processing > 0}
{/if} - {#if row.failed > 0}
{/if} -
-
- {/each} -
+ {/each} +
+ {:else} +

대기·처리·실패 없음 — 모든 단계가 한가합니다

+ {/if} + {#if idleStageRows.length > 0} +

+ 비어 있음: {idleStageRows.map((r) => queueStageLabel(r.stage)).join(' · ')} +

+ {/if} {:else} -

처리 작업 없음

+

현황을 불러오지 못했습니다

{/if}
diff --git a/tests/test_queue_overview.py b/tests/test_queue_overview.py index 9db1e42..03fde17 100644 --- a/tests/test_queue_overview.py +++ b/tests/test_queue_overview.py @@ -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())