e8c348ab21
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>
573 lines
26 KiB
Svelte
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>
|