Files
hyungi_document_server/frontend/src/routes/+page.svelte
T
Hyungi Ahn e8c348ab21 feat(dashboard): Day 4 튜닝 — 임계치 재조정 + deep_summary 안정성 카드
3일 telemetry (599 triage / 555 deep) 기반 임계치 재평가:

1. 에스컬레이션 비율 — 임계치 의미 reframe
   - 기존: >20% 적색 (튜닝 필요) → 항상 적색 (운영 패턴 97%)
   - 신규: <80% 적색 (정책 매칭 실패 증가)
   - 메시지: "safety 정책상 95~100% 가 정상" 보조 표시
   - safety_reference 99.7%, generic 100% (fallback risk_flag), msds 46.2%
     → 운영 정상 패턴 확인

2. Deep summary 안정성 — 신규 카드 추가
   - mode='summary_deep' 의 error_code IS NOT NULL 비율
   - 현재 5.2% (call_failed 21 + parse:ValidationError 8)
   - >5% 적색 임계
   - MLX 호출 timeout / JSON 파싱 실패 모니터

3. triage JSON 건강도, Backlog Suppression — 임계치 유지
   - 현재 0%, 1% — 매우 안정. 보수적 임계 유효.

Backend: TierHealthStack 에 deep_total / deep_err_total 추가
Frontend: 카드 그리드 3열 → 4열 (lg), Day 4 신규 카드.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 08:29:53 +09:00

573 lines
26 KiB
Svelte

<script lang="ts">
// 대시보드 — 상황판. 사용자 지시서 기반 재설계.
// 정보 위계: 헤더 → 핀 메모 → 카드 4개 → 최근 활동 → 파이프라인.
// 단일 흐름 레이아웃, 모바일 우선, 행동 유도는 승인 대기에만.
import { onMount } from 'svelte';
import {
dashboardSummary,
type DashboardSummary,
type PipelineStatus,
type QueueLag,
} from '$lib/stores/system';
import { domainBgClass, domainLabel } from '$lib/utils/domainSlug';
import { api } from '$lib/api';
import Card from '$lib/components/ui/Card.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import {
Inbox, Scale, FileText, Activity, StickyNote, Newspaper, Pin, ChevronRight, Pencil,
Library, Mic, Video, Sparkles,
} from 'lucide-svelte';
import { renderMemoHtml, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer';
import { addToast } from '$lib/stores/toast';
let summary = $derived<DashboardSummary | null>($dashboardSummary);
let loading = $derived(summary === null);
// ─── 핀 고정 메모 ───
let pinnedMemos = $state<any[]>([]);
// 메모별 "완료 항목 펼침" 토글 — key: memo.id, value: true 면 숨겨진 체크 항목 노출
let showHiddenByMemo = $state<Record<number, boolean>>({});
// 자동 숨김 tick. 1초 해상도로 충분 (hideAfter 10초라 오차 수용).
let nowTick = $state(new Date());
$effect(() => {
const id = setInterval(() => { nowTick = new Date(); }, 1000);
return () => clearInterval(id);
});
onMount(async () => {
try {
const res = await api<any>('/memos/?pinned=true&page_size=3&archived=false');
pinnedMemos = res.items || [];
} catch { /* 실패 시 빈 배열 유지 */ }
});
// ─── 핀 메모 체크박스 토글 ───
async function handlePinCheckbox(e: MouseEvent, memo: any) {
const target = e.target as HTMLElement;
if (target.tagName !== 'INPUT' || (target as HTMLInputElement).type !== 'checkbox') return;
e.preventDefault();
e.stopPropagation(); // details 토글 충돌 방지
const input = target as HTMLInputElement;
const taskIndex = parseInt(input.dataset.taskIndex || '', 10);
if (isNaN(taskIndex)) return;
const checked = input.checked;
try {
const updated = await api<any>(`/memos/${memo.id}/tasks/${taskIndex}`, {
method: 'PATCH',
body: JSON.stringify({ checked }),
});
pinnedMemos = pinnedMemos.map((m) => (m.id === memo.id ? updated : m));
} catch {
input.checked = !checked; // 롤백
addToast('error', '체크박스 변경 실패');
}
}
function toggleShowHidden(memoId: number) {
showHiddenByMemo = { ...showHiddenByMemo, [memoId]: !showHiddenByMemo[memoId] };
}
// ─── 파이프라인 ───
const STAGE_ORDER = ['extract', 'stt', 'classify', 'embed', 'preview', 'thumbnail'] as const;
const STAGE_LABEL: Record<string, string> = {
extract: '추출', stt: '전사', classify: '분류', embed: '임베딩',
preview: '미리보기', thumbnail: '썸네일',
};
interface PipelineRow {
stage: string; label: string;
pending: number; processing: number; failed: number; total: number;
// §4 — queue_lag 의 oldest_pending_age_sec (적체 신호용)
oldestPendingAgeSec: number | null;
}
function buildPipelineRows(items: PipelineStatus[], lag: QueueLag[]): PipelineRow[] {
// §4 — 24h 누적 (pipeline_status) + 현재 시점 lag (queue_lag) 두 소스 머지.
// queue_lag 가 있으면 stage 별 pending/processing/failed 는 그쪽 (정확) 사용.
const lagMap = new Map(lag.map((l) => [l.stage, l]));
const grouped = new Map<string, { pending: number; processing: number; failed: number; ageSec: number | null }>();
for (const it of items) {
const cur = grouped.get(it.stage) ?? { pending: 0, processing: 0, failed: 0, ageSec: null };
if (it.status === 'pending') cur.pending += it.count;
else if (it.status === 'processing') cur.processing += it.count;
else if (it.status === 'failed') cur.failed += it.count;
grouped.set(it.stage, cur);
}
// queue_lag 로 덮어쓰기 (현재 시점 신호가 우선)
for (const l of lag) {
grouped.set(l.stage, {
pending: l.pending, processing: l.processing, failed: l.failed,
ageSec: l.oldest_pending_age_sec,
});
}
// queue_lag 만 있는 stage 도 전부 포함
const allStages = new Set([...grouped.keys(), ...lagMap.keys()]);
const orderedStages = [
...STAGE_ORDER.filter((s) => allStages.has(s)),
...[...allStages].filter((s) => !STAGE_ORDER.includes(s as never)),
];
return orderedStages.map((stage) => {
const r = grouped.get(stage)!;
return {
stage, label: STAGE_LABEL[stage] ?? stage,
pending: r.pending, processing: r.processing, failed: r.failed,
total: r.pending + r.processing + r.failed,
oldestPendingAgeSec: r.ageSec,
};
});
}
let pipelineRows = $derived(
summary ? buildPipelineRows(summary.pipeline_status, summary.queue_lag ?? []) : []
);
let pipelineMax = $derived(Math.max(1, ...pipelineRows.map((r) => r.total)));
let totalFailed = $derived(summary?.failed_count ?? 0);
let totalPending = $derived(pipelineRows.reduce((s, r) => s + r.pending, 0));
// §4 — 카테고리 mini-card 데이터
const CATEGORY_CARDS: { key: string; label: string; href: string; icon: any }[] = [
{ key: 'library', label: '자료실', href: '/library', icon: Library },
{ key: 'audio', label: '오디오', href: '/audio', icon: Mic },
{ key: 'video', label: '비디오', href: '/video', icon: Video },
];
function formatAge(sec: number | null): string {
if (sec == null || sec <= 0) return '';
if (sec < 60) return `${sec}초 전`;
if (sec < 3600) return `${Math.floor(sec / 60)}분 전`;
if (sec < 86400) return `${Math.floor(sec / 3600)}시간 전`;
return `${Math.floor(sec / 86400)}일 전`;
}
// 파이프라인 접힘 상태
let pipelineManualClosed = $state(false);
let pipelineOpen = $derived(pipelineManualClosed ? false : totalFailed > 0);
// ─── 시스템 상태 ───
function pickSystemTone(s: DashboardSummary) {
if (s.failed_count > 0) return { label: `실패 ${s.failed_count}`, tone: 'error' as const };
const backlog = s.pipeline_status.some((p) => p.status === 'pending' && p.count > 10);
if (backlog) return { label: '대기열 적체', tone: 'warning' as const };
return { label: '정상', tone: 'success' as const };
}
const TONE_DOT: Record<string, string> = { success: 'bg-success', warning: 'bg-warning', error: 'bg-error' };
const TONE_TEXT: Record<string, string> = { success: 'text-success', warning: 'text-warning', error: 'text-error' };
let systemView = $derived(summary ? pickSystemTone(summary) : null);
function formatTime(dateStr: string) {
const d = new Date(dateStr);
const diff = Date.now() - d.getTime();
if (diff < 60000) return '방금';
if (diff < 3600000) return `${Math.floor(diff / 60000)}분 전`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}시간 전`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)}일 전`;
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
</script>
<div class="p-4 lg:p-6">
<div class="max-w-4xl mx-auto">
<!-- ═══ 1. 헤더 + 시스템 상태 ═══ -->
<div class="flex items-center justify-between mb-5">
<h2 class="text-xl font-bold text-text">대시보드</h2>
{#if systemView}
<span class="text-xs text-dim flex items-center gap-1.5 select-none">
{systemView.label}
<span class="w-2 h-2 rounded-full {TONE_DOT[systemView.tone]}"></span>
</span>
{/if}
</div>
{#if loading}
<!-- 스켈레톤 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-5">
{#each Array(4) as _}
<Card><Skeleton w="w-20" h="h-3" /><Skeleton w="w-16" h="h-8" class="mt-3" /><Skeleton w="w-24" h="h-3" class="mt-2" /></Card>
{/each}
</div>
<Card class="mb-4"><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-40" class="mt-3" /></Card>
<Card><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-20" class="mt-3" /></Card>
{:else if summary}
<!-- ═══ 2. 핀 고정 메모 (조건부, 펼침/접힘) ═══ -->
{#if pinnedMemos.length > 0}
<div class="mb-5 space-y-1.5">
{#each pinnedMemos as memo (memo.id)}
<details class="group/pin">
<summary class="flex items-center gap-2.5 px-3 py-2 bg-surface border border-default/50 rounded-lg
hover:bg-surface-hover transition-colors text-sm cursor-pointer select-none list-none">
<Pin size={13} class="text-accent shrink-0" />
<span class="text-text truncate flex-1">
{memo.title && memo.title !== memo.content?.split('\n')[0]?.replace(/^#+\s*/, '').slice(0, 80)
? memo.title
: memo.content?.split('\n')[0] || '메모'}
</span>
<ChevronRight size={13} class="text-dim shrink-0 transition-transform group-open/pin:rotate-90" />
</summary>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mt-1 px-3 py-2.5 bg-surface/50 border border-default/30 rounded-lg text-sm text-text"
onclick={(e) => handlePinCheckbox(e, memo)}
>
<div
class="prose prose-sm max-w-none memo-content-pin"
class:show-hidden={showHiddenByMemo[memo.id]}
>
{@html renderMemoHtml(memo.content || '', {
compact: true,
interactive: true,
taskStates: memo.memo_task_state ?? {},
now: nowTick,
})}
</div>
<div class="flex items-center gap-3 mt-2">
{#if countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS) > 0 || showHiddenByMemo[memo.id]}
<button
type="button"
class="text-[11px] text-dim hover:text-text underline-offset-2 hover:underline"
onclick={(e) => { e.stopPropagation(); toggleShowHidden(memo.id); }}
>
{#if showHiddenByMemo[memo.id]}
완료 항목 숨기기
{:else}
완료 {countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS)}개 보기
{/if}
</button>
{/if}
<a href="/memos" class="text-[11px] text-accent hover:underline">메모함에서 보기 →</a>
</div>
</div>
</details>
{/each}
{#if pinnedMemos.length >= 3}
<a href="/memos" class="text-[11px] text-accent hover:underline pl-8">더보기 →</a>
{/if}
</div>
{/if}
<!-- ═══ 3. 핵심 카드 4개 ═══ -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-5">
<!-- 문서함 -->
<a href="/documents" class="block">
<Card interactive class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">문서함</p>
<FileText size={18} class="text-faint" />
</div>
<p class="text-3xl font-bold mt-2 text-text">{(summary.documents_count ?? 0).toLocaleString()}</p>
<p class="text-xs text-dim mt-1">
{#if summary.today_added > 0}
<span class="text-accent">+{summary.today_added} 오늘</span>
{:else}
일반 문서
{/if}
</p>
</Card>
</a>
<!-- 메모 -->
<a href="/memos" class="block">
<Card interactive class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">메모</p>
<StickyNote size={18} class="text-faint" />
</div>
<p class="text-3xl font-bold mt-2 text-text">{(summary.memos_count ?? 0).toLocaleString()}</p>
<p class="text-xs text-dim mt-1">직접 작성</p>
</Card>
</a>
<!-- 뉴스 -->
<a href="/news" class="block">
<Card interactive class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">뉴스</p>
<Newspaper size={18} class="text-faint" />
</div>
<p class="text-3xl font-bold mt-2 text-text">{(summary.news_count ?? 0).toLocaleString()}</p>
<p class="text-xs text-dim mt-1">수집 기사</p>
</Card>
</a>
<!-- 승인 대기 (액션형) -->
<a href="/inbox" class="block">
<Card interactive class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">승인 대기</p>
<Inbox size={18} class="text-faint" />
</div>
<p class="text-3xl font-bold mt-2 {summary.inbox_count > 0 ? 'text-warning' : 'text-success'}">
{summary.inbox_count}
</p>
{#if summary.inbox_count > 0}
<p class="text-xs text-accent mt-1">검토하기 →</p>
{:else}
<p class="text-xs text-dim mt-1">미분류 없음</p>
{/if}
</Card>
</a>
</div>
<!-- ═══ 3.5. 카테고리 + 자료실 제안 (§4) ═══ -->
{#if summary.category_counts && (Object.keys(summary.category_counts).length > 0 || summary.library_pending_suggestions > 0)}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-5">
{#each CATEGORY_CARDS as cat}
{@const count = summary.category_counts?.[cat.key] ?? 0}
<a href={cat.href} class="block">
<Card interactive class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">{cat.label}</p>
<cat.icon size={18} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-2 text-text">{count.toLocaleString()}</p>
<p class="text-xs text-dim mt-1">카테고리</p>
</Card>
</a>
{/each}
<!-- 자료실 제안 (action card) -->
<a href="/library" class="block">
<Card interactive class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">자료실 제안</p>
<Sparkles size={18} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-2 {summary.library_pending_suggestions > 0 ? 'text-warning' : 'text-success'}">
{summary.library_pending_suggestions}
</p>
{#if summary.library_pending_suggestions > 0}
<p class="text-xs text-accent mt-1">검토하기 →</p>
{:else}
<p class="text-xs text-dim mt-1">대기 없음</p>
{/if}
</Card>
</a>
</div>
{/if}
<!-- ═══ 3.6. tier 관측성 3종 카드 (B-3) ═══ -->
{#if summary.tier_health && summary.tier_health.triage_total > 0}
{@const th = summary.tier_health}
{@const esc_rate = th.triage_total > 0 ? th.escalated_total / th.triage_total : 0}
{@const json_rate = th.triage_total > 0 ? th.triage_json_invalid / th.triage_total : 0}
{@const sup_rate = th.triage_total > 0 ? th.suppressed_total / th.triage_total : 0}
{@const deep_total = th.deep_total ?? 0}
{@const deep_err_rate = deep_total > 0 ? (th.deep_err_total ?? 0) / deep_total : 0}
<!-- Day 4 튜닝 (2026-04-27): 운영 패턴 실측 후 임계치 재조정.
3일 telemetry 기준 escalate 97% 가 정상 (safety 정책 의도) → <80% 가 진짜 신호. -->
{@const esc_tone = esc_rate < 0.80 ? 'text-error' : 'text-text'}
{@const json_tone = json_rate > 0.05 ? 'text-error' : 'text-text'}
{@const sup_tone = sup_rate > 0.10 ? 'text-warning' : 'text-text'}
{@const deep_tone = deep_err_rate > 0.05 ? 'text-error' : 'text-text'}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-5">
<!-- 에스컬레이션 비율 -->
<Card class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">에스컬레이션 비율 (24h)</p>
<Sparkles size={18} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-2 {esc_tone}">
{(esc_rate * 100).toFixed(1)}%
</p>
<p class="text-xs text-dim mt-1">
{th.escalated_total} / {th.triage_total}
{#if esc_rate < 0.80}<span class="text-error ml-1">(매칭 실패 증가)</span>{/if}
</p>
<p class="text-[10px] text-faint mt-1">safety 정책상 95~100% 가 정상</p>
{#if Object.keys(th.escalation_by_reason).length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each Object.entries(th.escalation_by_reason).slice(0, 4) as [reason, n]}
<span class="text-[10px] px-1.5 py-0.5 rounded bg-surface-muted text-dim">
{reason} {n}
</span>
{/each}
</div>
{/if}
</Card>
<!-- triage JSON 건강도 -->
<Card class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">triage JSON 건강도 (24h)</p>
<Sparkles size={18} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-2 {json_tone}">
{(json_rate * 100).toFixed(1)}%
</p>
<p class="text-xs text-dim mt-1">
깨짐 {th.triage_json_invalid}
{#if json_rate > 0.05}<span class="text-error ml-1">(프롬프트 이슈 의심)</span>{/if}
</p>
<p class="text-[10px] text-faint mt-1">5% 초과 시 4B 프롬프트·모델 재검토</p>
</Card>
<!-- Backlog Suppression -->
<Card class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">Backlog Suppression (24h)</p>
<Sparkles size={18} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-2 {sup_tone}">
{(sup_rate * 100).toFixed(1)}%
</p>
<p class="text-xs text-dim mt-1">
억제 {th.suppressed_total}
{#if sup_rate > 0.10}<span class="text-warning ml-1">(임계치 재조정 신호)</span>{/if}
</p>
<p class="text-[10px] text-faint mt-1">10% 초과 시 ratio/pending threshold 조정</p>
</Card>
<!-- Deep summary 안정성 (Day 4 신규) -->
<Card class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">Deep summary 안정성 (24h)</p>
<Sparkles size={18} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-2 {deep_tone}">
{(deep_err_rate * 100).toFixed(1)}%
</p>
<p class="text-xs text-dim mt-1">
실패 {th.deep_err_total ?? 0} / {deep_total}
{#if deep_err_rate > 0.05}<span class="text-error ml-1">(MLX 안정성 점검)</span>{/if}
</p>
<p class="text-[10px] text-faint mt-1">call_failed / parse:* 합계, 5% 초과 시 점검</p>
</Card>
</div>
{/if}
<!-- ═══ 4. 최근 활동 ═══ -->
<Card class="mb-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-text">최근 활동</h3>
<div class="flex items-center gap-3">
{#if summary.law_alerts > 0}
<a
href="/documents?source=law_monitor"
class="text-[11px] flex items-center gap-1 px-2.5 py-1 rounded-full
bg-warning/10 text-warning border border-warning/20
hover:bg-warning/20 transition-colors"
>
<Scale size={11} /> 법령 {summary.law_alerts}
</a>
{/if}
</div>
</div>
{#if summary.recent_documents.length > 0}
<div class="divide-y divide-default/50">
{#each summary.recent_documents as doc (doc.id)}
<a
href="/documents/{doc.id}"
class="block py-2.5 first:pt-0 last:pb-0 hover:bg-surface-hover -mx-5 px-5 transition-colors"
>
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-text truncate">{doc.title || '제목 없음'}</span>
<span class="text-[11px] text-dim shrink-0">{formatTime(doc.created_at)}</span>
</div>
<div class="flex items-center gap-1.5 mt-1">
<span class="w-1.5 h-1.5 rounded-full shrink-0 {domainBgClass(doc.ai_domain)}"></span>
<span class="text-[11px] text-dim truncate">{domainLabel(doc.ai_domain)}</span>
</div>
</a>
{/each}
</div>
{:else}
<EmptyState
icon={FileText}
title="아직 문서가 없습니다"
description="NAS PKM 폴더에 파일을 추가하면 자동으로 인덱싱됩니다."
/>
{/if}
</Card>
<!-- ═══ 5. 파이프라인 (접힘) ═══ -->
<details
open={pipelineOpen}
ontoggle={(e) => { if (!e.currentTarget.open) pipelineManualClosed = true; }}
>
<summary
class="flex items-center justify-between px-5 py-3.5 bg-surface border border-default rounded-lg
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}
</span>
</summary>
<div class="mt-2 px-5 py-4 bg-surface border border-default rounded-lg">
<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>
</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>
{:else}
<p class="text-xs text-dim text-center py-3">처리 작업 없음</p>
{/if}
</div>
</details>
{/if}
</div>
</div>
<style>
details[open] .details-chevron { transform: rotate(90deg); }
details summary::-webkit-details-marker { display: none; }
.memo-content-pin :global(p) { margin: 0.2em 0; }
.memo-content-pin :global(ul), .memo-content-pin :global(ol) { margin: 0.2em 0; padding-left: 1.5em; }
.memo-content-pin :global(code) { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.memo-content-pin :global(a) { color: var(--accent); }
.memo-content-pin :global(.memo-checkbox) { cursor: pointer; width: 14px; height: 14px; accent-color: var(--accent); vertical-align: middle; margin-right: 3px; }
.memo-content-pin :global(li:has(.memo-checkbox)) { list-style: none; margin-left: -1.5em; }
.memo-content-pin :global(.memo-task-done) { opacity: 0.5; text-decoration: line-through; }
/* 체크 후 10초 경과 항목 자동 숨김 (`show-hidden` 클래스로 토글 해제) */
.memo-content-pin :global(.memo-task-hidden) { display: none; }
.memo-content-pin.show-hidden :global(.memo-task-hidden) { display: list-item; }
.memo-content-pin :global(.due-badge) { display: inline-block; font-size: 10px; padding: 1px 5px; border-radius: 8px; margin-left: 3px; }
.memo-content-pin :global(.due-overdue) { background: rgba(245, 86, 78, 0.15); color: var(--error); }
.memo-content-pin :global(.due-soon) { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
.memo-content-pin :global(.due-normal) { background: var(--surface); color: var(--text-dim); }
.memo-content-pin :global(.due-done) { background: var(--surface); color: var(--text-dim); opacity: 0.6; }
</style>