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 @@
최근 24시간
- {#if pipelineRows.length > 0} -+ 가장 오래 기다린 항목 {formatAge(row.oldest_pending_age_sec)} +
+ {/if}대기·처리·실패 없음 — 모든 단계가 한가합니다
+ {/if} + {#if idleStageRows.length > 0} ++ 비어 있음: {idleStageRows.map((r) => queueStageLabel(r.stage)).join(' · ')} +
+ {/if} {:else} -처리 작업 없음
+현황을 불러오지 못했습니다
{/if}