feat(ui): 처리 머신 보드 — 누가 일하나 (안2) + ETA·전 페이지 스트립/드로어 (안5/6 라이트)
plan ds-processing-ui-6an (시안 choice 채택: 안2 1차 + 안5/6 지원): - GET /api/queue/overview — 머신(GPU/맥미니/맥북) 귀속 라이브 집계 5쿼리, 마이그레이션 0. summarize 풀 완료 실적은 documents.ai_model_version 조인으로 맥북/맥미니 분리, 보류(deferred_until)=맥북 카드 귀속, state=active/deferred/idle. raw 모델명 비노출 - 홈: 처리 머신 보드(3열 카드 + 지금 처리 중 제목) + ETA 라인(유입 우세 시 null 명시), 기존 stage 테이블은 details 접힘으로 강등 (구조 개편) - 전 페이지: 상태 스트립(처리중·대기·실패·맥북 칩) + 우측 드로어(QueueDrawer, dialog a11y) — 공유 60s 폴링 store, 경량 fetch(401 강제 logout 부수효과 회피) - tests: 판정부 30건 (귀속/풀 분리/state 9케이스/ETA 경계/trend 버킷/계약 shape) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
// 처리 현황 드로어 (안6 라이트) — 전 페이지 상태 스트립 클릭 시 우측에서 열림.
|
||||
// 머신 미니카드 3 + ETA 한 줄 + 실패 합계 + 홈 링크 축약본. 상세는 홈 보드가 담당.
|
||||
// 데이터 = queueOverview store 공유 (60s 폴링, 실패 시 null → 안내문으로 degrade).
|
||||
// 열림 상태는 uiState 단일 drawer slot('queue') — 사이드바 드로어와 동시 오픈 차단.
|
||||
import { X } from 'lucide-svelte';
|
||||
import { ui } from '$lib/stores/uiState.svelte';
|
||||
import { queueOverview } from '$lib/stores/queueOverview';
|
||||
import {
|
||||
MACHINE_STATE_LABEL, machineChipClass, machineDotClass, formatRate, etaPhrase,
|
||||
} from '$lib/utils/queueDisplay';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
|
||||
let open = $derived(ui.isDrawerOpen('queue'));
|
||||
let data = $derived($queueOverview);
|
||||
|
||||
function close() {
|
||||
ui.closeDrawer();
|
||||
}
|
||||
|
||||
// ESC 닫기 — 레이아웃 전역 핸들러(ui.handleEscape)와 중복돼도 무해(멱등).
|
||||
// modal stack 이 열려 있으면 modal 우선 (전역 우선순위와 동일).
|
||||
function onWindowKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open && ui.modalStack.length === 0) close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-drawer">
|
||||
<!-- 스크림 — 클릭 시 닫기 -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={close}
|
||||
class="absolute inset-0 bg-scrim transition-opacity"
|
||||
aria-label="드로어 닫기"
|
||||
></button>
|
||||
|
||||
<!-- 패널 — div + role="dialog" (aside 는 interactive role 불가, a11y 경고) -->
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="처리 현황"
|
||||
class="absolute right-0 top-0 bottom-0 w-rail max-w-full bg-sidebar shadow-xl overflow-y-auto"
|
||||
>
|
||||
<div class="flex items-center justify-between px-4 h-12 border-b border-default">
|
||||
<span class="text-sm font-bold text-text">처리 현황</span>
|
||||
<IconButton icon={X} size="sm" aria-label="닫기" onclick={close} />
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-3">
|
||||
{#if data}
|
||||
<!-- 머신 미니카드 3 -->
|
||||
{#each data.machines as m (m.key)}
|
||||
<div class="bg-surface border border-default rounded-lg px-3.5 py-2.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="flex items-center gap-2 text-[13px] font-semibold text-text min-w-0">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {machineDotClass(m.state)}"></span>
|
||||
<span class="truncate">{m.label}</span>
|
||||
</span>
|
||||
<span class="text-[10px] font-bold rounded-full px-2 py-0.5 shrink-0 {machineChipClass(m.state)}">
|
||||
{MACHINE_STATE_LABEL[m.state]}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-dim mt-1 tabular-nums">
|
||||
대기 <strong class="text-text">{m.pending.toLocaleString()}</strong>
|
||||
· 오늘 <strong class="text-text">{m.done_today.toLocaleString()}</strong>건 처리
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- ETA 한 줄 (안5 라이트 — 추정치) -->
|
||||
<div
|
||||
class="text-[11px] text-dim leading-relaxed tabular-nums"
|
||||
title="현재 페이스 기반 추정치 — 유입 변동 시 달라질 수 있습니다"
|
||||
>
|
||||
요약 대기 <strong class="text-text">{data.summarize_eta.pending.toLocaleString()}건</strong>
|
||||
— 소화 {formatRate(data.summarize_eta.done_rate_1h)}/h
|
||||
· 유입 {formatRate(data.summarize_eta.inflow_rate_1h)}/h
|
||||
{#if data.summarize_eta.eta_minutes != null}
|
||||
· <span class="text-accent font-semibold">{etaPhrase(data.summarize_eta.eta_minutes)}</span>
|
||||
{:else}
|
||||
· 유입 우세(백필 중)
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 실패 합계 -->
|
||||
{#if data.totals.failed > 0}
|
||||
<div class="text-[11px] font-semibold text-error bg-error/10 rounded-md px-2.5 py-1.5 tabular-nums">
|
||||
실패 {data.totals.failed.toLocaleString()}건 — 확인 필요
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-xs text-dim">처리 현황을 불러오지 못했습니다.</p>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href="/"
|
||||
onclick={close}
|
||||
class="block text-xs text-accent font-semibold hover:underline pt-1"
|
||||
>홈에서 자세히 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,63 @@
|
||||
// 처리 큐 overview store — GET /api/queue/overview 를 60초 주기로 폴링.
|
||||
// system.ts 의 dashboardSummary 와 같은 구독 기반 패턴 (첫 subscribe 시 시작).
|
||||
//
|
||||
// 의도적으로 api() 헬퍼를 쓰지 않는다 — 폴링 경로의 401 이 refresh 실패 →
|
||||
// window.location='/login' 강제 logout 부수효과를 일으키면 안 됨 (eid 리뷰
|
||||
// finding 재발 방지). 백엔드 미배포(404)/401/네트워크 실패 전부 silent 하게
|
||||
// null 로 수렴하고, 소비자(스트립/보드/드로어)는 null 이면 스스로 숨는다.
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import { getAccessToken } from '$lib/api';
|
||||
import type { QueueOverview } from '$lib/types/queue';
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let subscriberCount = 0;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
|
||||
const internal = writable<QueueOverview | null>(null, (_set) => {
|
||||
subscriberCount += 1;
|
||||
if (subscriberCount === 1 && browser) {
|
||||
void refreshQueueOverview();
|
||||
pollHandle = setInterval(() => void refreshQueueOverview(), POLL_INTERVAL_MS);
|
||||
}
|
||||
return () => {
|
||||
subscriberCount -= 1;
|
||||
if (subscriberCount === 0 && pollHandle) {
|
||||
clearInterval(pollHandle);
|
||||
pollHandle = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const queueOverview = { subscribe: internal.subscribe };
|
||||
|
||||
/** 경량 fetch — 실패는 전부 null (silent 비차단, 강제 logout 경로 없음) */
|
||||
async function fetchOverview(): Promise<QueueOverview | null> {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = getAccessToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch('/api/queue/overview', { headers, credentials: 'include' });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as QueueOverview;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 수동/추가 폴링용 — 홈은 자체 30s interval 로 이 함수를 호출 (동시 fetch 합치기) */
|
||||
export async function refreshQueueOverview(): Promise<void> {
|
||||
if (!browser) return;
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
internal.set(await fetchOverview());
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
// (toast는 별도 store. drawer가 persistent inline panel(예: xl+ meta rail)일 때는
|
||||
// 여기 시스템 밖이다 — 그저 레이아웃의 일부.)
|
||||
|
||||
type Drawer = { id: 'sidebar' | 'meta' } | null;
|
||||
// 'queue' = 처리 현황 드로어 (상태 스트립 클릭 시 우측) — 단일 slot 규칙 동일
|
||||
export type DrawerId = 'sidebar' | 'meta' | 'queue';
|
||||
type Drawer = { id: DrawerId } | null;
|
||||
type Modal = { id: string };
|
||||
|
||||
class UIState {
|
||||
@@ -11,14 +13,14 @@ class UIState {
|
||||
modalStack = $state<Modal[]>([]);
|
||||
|
||||
// ── Drawer (단일 slot) ──────────────────────────────
|
||||
openDrawer(id: 'sidebar' | 'meta') {
|
||||
openDrawer(id: DrawerId) {
|
||||
// 새 drawer 열면 이전 drawer는 자동으로 사라진다 (단일 slot)
|
||||
this.drawer = { id };
|
||||
}
|
||||
closeDrawer() {
|
||||
this.drawer = null;
|
||||
}
|
||||
isDrawerOpen(id: 'sidebar' | 'meta') {
|
||||
isDrawerOpen(id: DrawerId) {
|
||||
return this.drawer?.id === id;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* GET /api/queue/overview 응답 타입.
|
||||
*
|
||||
* Backend 는 병렬 트랙에서 구현 중 — 계약 고정 (feat/ds-processing-board).
|
||||
* 필드 변경 시 양쪽 동시 수정 필수.
|
||||
*/
|
||||
|
||||
export type MachineKey = 'gpu' | 'macmini' | 'macbook';
|
||||
|
||||
/** 머신 상태 — active(가동) / deferred(보류) / idle(대기) */
|
||||
export type MachineState = 'active' | 'deferred' | 'idle';
|
||||
|
||||
/** 머신이 지금 처리 중인 문서 1건 */
|
||||
export interface MachineCurrentItem {
|
||||
document_id: number;
|
||||
title: string;
|
||||
stage: string;
|
||||
}
|
||||
|
||||
export interface MachineOverview {
|
||||
key: MachineKey;
|
||||
label: string;
|
||||
state: MachineState;
|
||||
/** 담당 단계 키 목록 (extract/classify/... — 홈 STAGE_LABEL 로 한글화) */
|
||||
stages: string[];
|
||||
pending: number;
|
||||
processing: number;
|
||||
failed: number;
|
||||
/** 최근 1시간 완료 건수 (처리율 N/h 표기) */
|
||||
done_1h: number;
|
||||
done_today: number;
|
||||
/** 보류 건수 — 맥북 sleep 등으로 자동 재개 대기 중 */
|
||||
deferred_pending: number;
|
||||
current: MachineCurrentItem[];
|
||||
}
|
||||
|
||||
/** 요약 백로그 ETA (안5 라이트) — 추정치, 유입 변동 시 오차 */
|
||||
export interface SummarizeEta {
|
||||
pending: number;
|
||||
done_rate_1h: number;
|
||||
inflow_rate_1h: number;
|
||||
/** null = 유입이 소화를 앞섬 (백필 중) — 소진 예상 불가 */
|
||||
eta_minutes: number | null;
|
||||
}
|
||||
|
||||
/** 시간당 유입 vs 소화 (이번 트랙 미렌더 — 후속 추세 위젯 슬롯) */
|
||||
export interface TrendPoint {
|
||||
hour: string;
|
||||
inflow: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
export interface QueueTotals {
|
||||
pending: number;
|
||||
processing: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface QueueOverview {
|
||||
machines: MachineOverview[];
|
||||
summarize_eta: SummarizeEta;
|
||||
trend_24h: TrendPoint[];
|
||||
totals: QueueTotals;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 처리 머신 보드 / 상태 스트립 / 드로어 공용 표시 헬퍼.
|
||||
// 상태 표현은 dot + 칩 (이모지 금지 원칙) — 토큰 클래스만 사용.
|
||||
|
||||
import type { MachineState } from '$lib/types/queue';
|
||||
|
||||
/** 머신 상태 한글 라벨 */
|
||||
export const MACHINE_STATE_LABEL: Record<MachineState, string> = {
|
||||
active: '가동',
|
||||
deferred: '보류',
|
||||
idle: '대기',
|
||||
};
|
||||
|
||||
/** 상태 dot 색 — 가동=success / 보류=warning / 대기=faint */
|
||||
export function machineDotClass(state: MachineState): string {
|
||||
if (state === 'active') return 'bg-success';
|
||||
if (state === 'deferred') return 'bg-warning';
|
||||
return 'bg-faint';
|
||||
}
|
||||
|
||||
/** 상태 칩 톤 — 가동=accent / 보류=warn / 대기=dim */
|
||||
export function machineChipClass(state: MachineState): string {
|
||||
if (state === 'active') return 'bg-accent/10 text-accent';
|
||||
if (state === 'deferred') return 'bg-warning/10 text-warning';
|
||||
return 'bg-surface-hover text-faint';
|
||||
}
|
||||
|
||||
/** 처리율 표기 — 정수는 그대로, 소수는 한 자리 */
|
||||
export function formatRate(n: number): string {
|
||||
return Number.isInteger(n) ? n.toLocaleString() : n.toFixed(1);
|
||||
}
|
||||
|
||||
/** ETA 분 → "약 N분/N시간 후 소진 예상" (추정치 — title 로 명시는 호출부 책임) */
|
||||
export function etaPhrase(minutes: number): string {
|
||||
if (minutes < 60) return `약 ${Math.max(1, Math.round(minutes))}분 후 소진 예상`;
|
||||
const hours = minutes / 60;
|
||||
const text = hours >= 10 ? String(Math.round(hours)) : String(Math.round(hours * 10) / 10);
|
||||
return `약 ${text}시간 후 소진 예상`;
|
||||
}
|
||||
@@ -8,8 +8,11 @@
|
||||
import { toasts, removeToast } from '$lib/stores/toast';
|
||||
import { refresh as refreshPublicConfig } from '$lib/stores/config';
|
||||
import { ui } from '$lib/stores/uiState.svelte';
|
||||
import { queueOverview } from '$lib/stores/queueOverview';
|
||||
import { MACHINE_STATE_LABEL, machineChipClass } from '$lib/utils/queueDisplay';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import SystemStatusDot from '$lib/components/SystemStatusDot.svelte';
|
||||
import QueueDrawer from '$lib/components/QueueDrawer.svelte';
|
||||
import QuickMemoButton from '$lib/components/QuickMemoButton.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
@@ -65,6 +68,15 @@
|
||||
let showChrome = $derived($isAuthenticated && !NO_CHROME_PATHS.some(p => $page.url.pathname.startsWith(p)));
|
||||
let showSidebar = $derived(showChrome && !NO_SIDEBAR_PATHS.some(p => $page.url.pathname.startsWith(p)));
|
||||
|
||||
// 처리 현황 스트립 (안6 라이트) — 60s 폴링 store 공유. fetch 실패/401 시
|
||||
// store 가 null → 스트립 자체를 숨김 (silent 비차단, 로그인 페이지 동일).
|
||||
let queue = $derived($queueOverview);
|
||||
let queueMacbook = $derived(queue?.machines?.find((m) => m.key === 'macbook') ?? null);
|
||||
function toggleQueueDrawer() {
|
||||
if (ui.isDrawerOpen('queue')) ui.closeDrawer();
|
||||
else ui.openDrawer('queue');
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
|
||||
e.preventDefault();
|
||||
@@ -162,6 +174,28 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 전 페이지 상태 스트립 (안6 라이트) — 클릭 시 우측 처리 현황 드로어 토글 -->
|
||||
{#if queue}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleQueueDrawer}
|
||||
aria-expanded={ui.isDrawerOpen('queue')}
|
||||
aria-label="처리 현황 자세히 보기"
|
||||
class="flex items-center gap-3 px-4 py-1.5 border-b border-default bg-surface text-[11px] text-dim shrink-0 text-left hover:bg-surface-hover transition-colors overflow-x-auto"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 shrink-0">
|
||||
<span class="w-2 h-2 rounded-full {queue.totals.processing > 0 ? 'bg-success' : 'bg-faint'}"></span>
|
||||
<strong class="text-text font-semibold tabular-nums">처리 중 {queue.totals.processing.toLocaleString()}</strong>
|
||||
</span>
|
||||
<span class="tabular-nums shrink-0">대기 <strong class="text-text">{queue.totals.pending.toLocaleString()}</strong></span>
|
||||
<span class="tabular-nums shrink-0 {queue.totals.failed > 0 ? 'text-error font-semibold' : ''}">실패 <strong class={queue.totals.failed > 0 ? '' : 'text-text'}>{queue.totals.failed.toLocaleString()}</strong></span>
|
||||
{#if queueMacbook}
|
||||
<span class="text-[10px] font-bold rounded-full px-2 py-0.5 shrink-0 {machineChipClass(queueMacbook.state)}">맥북 {MACHINE_STATE_LABEL[queueMacbook.state]}</span>
|
||||
{/if}
|
||||
<span class="ml-auto flex items-center gap-0.5 text-faint shrink-0">자세히 <ChevronDown size={11} /></span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- 메인: 데스크탑 상시 사이드바 + 콘텐츠 -->
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
{#if showSidebar}
|
||||
@@ -191,6 +225,9 @@
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
<!-- 처리 현황 드로어 (안6 라이트, 스트립 클릭 시 우측) -->
|
||||
<QueueDrawer />
|
||||
|
||||
<!-- 빠른 메모 FAB -->
|
||||
<QuickMemoButton />
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
import { domainBgClass, domainLabel } from '$lib/utils/domainSlug';
|
||||
import { user } from '$lib/stores/auth';
|
||||
import { api } from '$lib/api';
|
||||
import { queueOverview, refreshQueueOverview } from '$lib/stores/queueOverview';
|
||||
import {
|
||||
MACHINE_STATE_LABEL, machineChipClass, machineDotClass, formatRate, etaPhrase,
|
||||
} from '$lib/utils/queueDisplay';
|
||||
import type { QueueOverview } from '$lib/types/queue';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import {
|
||||
@@ -125,6 +130,28 @@
|
||||
preview: '미리보기', thumbnail: '썸네일',
|
||||
};
|
||||
|
||||
// ─── 처리 머신 보드 (안2) + ETA (안5 라이트) — GET /api/queue/overview ───
|
||||
// 홈은 30s 폴링 (store 기본 60s 위에 추가 — inFlight 합치기로 중복 호출 0).
|
||||
// 백엔드 미배포/실패 시 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);
|
||||
return () => clearInterval(handle);
|
||||
});
|
||||
|
||||
interface PipelineRow {
|
||||
stage: string; label: string;
|
||||
pending: number; processing: number; failed: number; total: number;
|
||||
@@ -420,7 +447,68 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 파이프라인 상세 (실패 있을 때 자동 펼침) ═══ -->
|
||||
<!-- ═══ 처리 머신 보드 (안2) + ETA 라인 (안5 라이트) ═══ -->
|
||||
{#if queue}
|
||||
<div class="mt-5">
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wider mb-3">처리 머신</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{#each queue.machines as m (m.key)}
|
||||
<div class="bg-surface border border-default rounded-card p-4">
|
||||
<!-- 헤더: 상태 dot + 라벨 + state 칩 -->
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<span class="flex items-center gap-2 text-[13px] font-bold text-text min-w-0">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {machineDotClass(m.state)}"></span>
|
||||
<span class="truncate">{m.label}</span>
|
||||
</span>
|
||||
<span class="text-[10px] font-bold rounded-full px-2 py-0.5 shrink-0 {machineChipClass(m.state)}">
|
||||
{MACHINE_STATE_LABEL[m.state]}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 담당 단계 칩 -->
|
||||
{#if m.stages.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mb-2.5">
|
||||
{#each m.stages as s (s)}
|
||||
<span class="text-[10px] font-semibold rounded-full px-2 py-0.5 bg-surface-hover text-dim">{queueStageLabel(s)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 대기 · 처리율 · 오늘 -->
|
||||
<div class="text-xs text-dim tabular-nums">
|
||||
대기 <strong class="text-text">{m.pending.toLocaleString()}</strong>
|
||||
· 처리율 <strong class="text-text">{formatRate(m.done_1h)}/h</strong>
|
||||
· 오늘 <strong class="text-text">{m.done_today.toLocaleString()}</strong>건
|
||||
</div>
|
||||
<!-- 맥북 보류 (sleep 등 자동 재개 대기) -->
|
||||
{#if m.key === 'macbook' && m.deferred_pending > 0}
|
||||
<div class="text-[11px] font-semibold text-warning mt-1.5 tabular-nums">보류 {m.deferred_pending.toLocaleString()}건 — 자동 재개 대기</div>
|
||||
{/if}
|
||||
<!-- 지금 처리 중인 문서 -->
|
||||
{#if m.current.length > 0}
|
||||
<div class="text-[11px] text-dim border-t border-dashed border-default mt-2.5 pt-2 truncate"
|
||||
title={m.current.map((c) => `${c.title} (${queueStageLabel(c.stage)})`).join(' · ')}>
|
||||
지금: {m.current[0].title} ({queueStageLabel(m.current[0].stage)}){m.current.length > 1 ? ` 외 ${m.current.length - 1}건` : ''}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- ETA 한 줄 (안5 라이트 — 추정치) -->
|
||||
<div class="text-xs text-dim mt-2.5 px-1 tabular-nums"
|
||||
title="현재 페이스 기반 추정치 — 유입 변동 시 달라질 수 있습니다">
|
||||
요약 대기 <strong class="text-text">{queue.summarize_eta.pending.toLocaleString()}건</strong>
|
||||
— 소화 {formatRate(queue.summarize_eta.done_rate_1h)}/h
|
||||
· 유입 {formatRate(queue.summarize_eta.inflow_rate_1h)}/h
|
||||
{#if queue.summarize_eta.eta_minutes != null}
|
||||
· <span class="text-accent font-semibold">{etaPhrase(queue.summarize_eta.eta_minutes)}</span>
|
||||
{:else}
|
||||
· 유입 우세(백필 중)
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ 단계 상세 (기존 stage 테이블 — 접힘 강등, 실패 있을 때 자동 펼침) ═══ -->
|
||||
<details
|
||||
class="mt-5"
|
||||
open={pipelineOpen}
|
||||
@@ -429,7 +517,7 @@
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user