bookworm-smart-assistant/scripts/dashboard.html

1215 lines
82 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Bookworm Smart Assistant v5.5 — 系统监控控制中心">
<meta name="theme-color" content="#0a0a14">
<link rel="preconnect" href="https://cdn.tailwindcss.com">
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<title>Bookworm 控制中心</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
panel: '#0f0f1a',
surface: 'rgba(30,30,50,0.65)',
'surface-solid': '#1e1e32',
accent: '#7c3aed',
'accent-glow': '#a78bfa',
ok: '#22c55e',
warn: '#eab308',
crit: '#ef4444',
info: '#3b82f6',
muted: '#6b7280',
dim: '#4b5563',
}
}
}
}
</script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style>
[x-cloak] { display: none !important; }
* { box-sizing: border-box; }
html, body { height: 100vh; height: 100dvh; margin: 0; overflow: hidden; font-family: 'Inter', system-ui, -apple-system, sans-serif; }
body {
background: #0a0a14;
background-image:
radial-gradient(ellipse 80% 60% at 20% 10%, rgba(124,58,237,0.18) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 80% 90%, rgba(59,130,246,0.14) 0%, transparent 60%),
radial-gradient(circle at 60% 50%, rgba(167,139,250,0.06) 0%, transparent 40%);
}
::-webkit-scrollbar { width: 3px; height: 3px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #3a3a5a; border-radius: 3px; }
/* 玻璃面板 */
.glass {
background: rgba(22,22,40,0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(124,58,237,0.12);
border-radius: 12px;
transition: border-color 0.25s ease, box-shadow 0.25s ease;
}
.glass:hover {
border-color: rgba(124,58,237,0.35);
box-shadow: 0 0 16px rgba(124,58,237,0.08);
}
/* KPI 卡片 */
.kpi {
background: rgba(124,58,237,0.06);
border: 1px solid rgba(124,58,237,0.15);
border-radius: 6px;
padding: 3px 10px;
text-align: center;
min-width: 0;
transition: background 0.2s ease;
}
.kpi:hover { background: rgba(124,58,237,0.12); }
.kpi-val { font-size: 1.05rem; font-weight: 700; line-height: 1.25; }
.kpi-label { font-size: 0.6rem; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; }
/* 面板标题 */
.ptitle {
font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
color: #a78bfa; margin-bottom: 4px; display: flex; align-items: center; gap: 5px;
flex-shrink: 0;
}
.ptitle svg { width: 13px; height: 13px; flex-shrink: 0; }
.status-badge { padding: 1px 6px; border-radius: 9999px; font-size: 0.55rem; font-weight: 600; text-transform: uppercase; }
/* 图表容器约束 */
canvas { display: block; max-width: 100%; max-height: 100%; }
.chart-wrap { position: relative; min-height: 0; min-width: 0; overflow: hidden; flex: 1 1 0; }
/* 心跳 */
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.hb { width: 6px; height: 6px; border-radius: 50%; animation: pulse-dot 2s ease-in-out infinite; }
.hb.active { background: #22c55e; }
.hb.idle { background: #eab308; animation-duration: 4s; }
.hb.dead { background: #ef4444; animation: none; }
/* 数据闪烁 — 微妙脉冲 */
@keyframes flash-update { 0% { box-shadow: 0 0 8px rgba(124,58,237,0.2); } 100% { box-shadow: none; } }
.data-updated { animation: flash-update 1s ease-out; }
/* 骨架屏 */
.skeleton { background: linear-gradient(90deg, rgba(30,30,50,0.5) 25%, rgba(50,50,74,0.5) 50%, rgba(30,30,50,0.5) 75%);
background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 8px; }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* 热力图 */
.hm-cell { border-radius: 2px; transition: background-color 0.3s; }
/* 标签云 */
.tag-cloud span {
display: inline-block; margin: 1px 3px; padding: 1px 6px; border-radius: 9999px;
background: rgba(124,58,237,0.12); color: #a78bfa; font-size: 0.6rem;
}
/* 大屏网格 */
.dash-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
gap: 6px;
height: 100%;
padding: 6px 8px;
min-width: 1100px;
}
/* 面板内部约束 — 防溢出 */
.dash-grid > section { min-height: 0; min-width: 0; }
/* 响应式断点 */
@media (max-width: 1400px) {
.dash-grid { grid-template-columns: 1fr 1fr 1fr; grid-template-rows: repeat(4, 1fr); }
}
@media (max-width: 1000px) {
.dash-grid { grid-template-columns: 1fr 1fr; grid-template-rows: auto; min-width: auto; }
html, body { overflow: auto; }
}
@media (max-width: 640px) {
.dash-grid { grid-template-columns: 1fr; }
.kpi { padding: 2px 6px; }
.kpi-val { font-size: 0.85rem; }
}
/* 连接中断 */
.conn-lost {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
background: rgba(239,68,68,0.9); color: #fff; text-align: center;
padding: 6px; font-size: 0.75rem; font-weight: 600;
}
/* 折叠按钮 */
.collapse-btn {
cursor: pointer; opacity: 0.4; transition: opacity 0.2s, transform 0.2s;
width: 12px; height: 12px; flex-shrink: 0;
}
.collapse-btn:hover { opacity: 0.8; }
.collapse-btn.collapsed { transform: rotate(-90deg); }
/* CSS 面板折叠 */
.glass.panel-off { align-self: start; }
.glass.panel-off > :not(.ptitle) { display: none !important; }
/* 钻取模态 */
.drill-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.7); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
}
.drill-card {
background: rgba(22,22,40,0.95); border: 1px solid rgba(124,58,237,0.3);
border-radius: 16px; padding: 20px; min-width: 420px; max-width: 90vw; max-height: 80vh;
overflow-y: auto; box-shadow: 0 0 40px rgba(124,58,237,0.15);
}
.drill-card h3 { color: #a78bfa; font-size: 0.9rem; font-weight: 700; margin-bottom: 10px; }
.drill-card table { width: 100%; font-size: 0.7rem; border-collapse: collapse; }
.drill-card th { text-align: left; color: #6b7280; padding: 4px 6px; border-bottom: 1px solid rgba(60,60,90,0.4); }
.drill-card td { padding: 4px 6px; border-bottom: 1px solid rgba(60,60,90,0.2); }
.drill-close {
position: absolute; top: 12px; right: 16px; cursor: pointer;
color: #6b7280; font-size: 1.2rem; transition: color 0.2s;
}
.drill-close:hover { color: #a78bfa; }
/* 亮色主题 */
html.light body { background: #f0f2f5; background-image: none; }
html.light .glass { background: rgba(255,255,255,0.85); border-color: rgba(124,58,237,0.12); }
html.light .glass:hover { border-color: rgba(124,58,237,0.4); box-shadow: 0 0 16px rgba(124,58,237,0.1); }
html.light .text-gray-300 { color: #1f2937; }
html.light .text-gray-400 { color: #4b5563; }
html.light .text-muted, html.light .text-dim { color: #6b7280; }
html.light .ptitle { color: #7c3aed; }
html.light .kpi { background: rgba(124,58,237,0.06); border-color: rgba(124,58,237,0.18); }
html.light .kpi-label { color: #6b7280; }
html.light .conn-lost { background: rgba(239,68,68,0.95); }
html.light .drill-card { background: rgba(255,255,255,0.97); border-color: rgba(124,58,237,0.2); }
html.light ::-webkit-scrollbar-thumb { background: #c4c4d0; }
/* SSE 状态指示 */
.sse-dot { width: 5px; height: 5px; border-radius: 50%; display: inline-block; }
.sse-dot.connected { background: #22c55e; }
.sse-dot.disconnected { background: #ef4444; }
</style>
</head>
<body class="text-gray-300" x-data="dashboard" x-init="init()" x-cloak role="application" aria-label="Bookworm 控制中心">
<!-- 连接中断 -->
<div x-show="connectionLost" x-transition class="conn-lost" role="alert" aria-live="assertive">
连接中断 — 自动刷新已暂停
<button @click="reconnect()" class="ml-3 underline" aria-label="重试连接">重试</button>
</div>
<!-- 全屏容器 -->
<div class="flex flex-col h-screen">
<!-- ═══ Header: KPI 条 ═══ -->
<header class="flex items-center gap-2 px-3 py-1 flex-shrink-0" style="min-height:44px">
<!-- 品牌 -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="w-7 h-7 rounded-lg bg-accent/20 flex items-center justify-center">
<svg class="w-4 h-4 text-accent-glow" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
</div>
<div>
<div class="text-sm font-bold text-accent-glow leading-none">Bookworm</div>
<div class="text-[0.5rem] text-muted leading-none mt-0.5">v5.5 Smart Assistant</div>
</div>
</div>
<div class="w-px h-6 bg-gray-700/50"></div>
<!-- KPI 卡片组 -->
<div class="flex gap-1.5 flex-1 overflow-x-auto" style="scrollbar-width:none">
<div class="kpi">
<div class="kpi-val" :class="scoreColor(health.overallScore)" x-text="health.overallScore || '--'"></div>
<div class="kpi-label">健康</div>
</div>
<div class="kpi">
<div class="kpi-val text-accent-glow" x-text="skills.list.length || '--'"></div>
<div class="kpi-label">技能</div>
</div>
<div class="kpi">
<div class="kpi-val" :class="compliance.rate >= 80 ? 'text-ok' : compliance.rate >= 60 ? 'text-warn' : 'text-crit'" x-text="compliance.rate ? compliance.rate + '%' : '--'"></div>
<div class="kpi-label">合规</div>
</div>
<div class="kpi">
<div class="kpi-val text-info" x-text="activity.events.length || '0'"></div>
<div class="kpi-label">事件</div>
</div>
<div class="kpi">
<div class="kpi-val" :class="security.summary.deny > 0 ? 'text-crit' : 'text-ok'" x-text="security.summary.deny + '/' + security.summary.ask"></div>
<div class="kpi-label">安全</div>
</div>
<div class="kpi">
<div class="kpi-val" :class="(disk.totalMB/1024) > 8 ? 'text-warn' : 'text-ok'" x-text="disk.totalMB ? (disk.totalMB/1024).toFixed(1) + 'G' : '--'"></div>
<div class="kpi-label">磁盘</div>
</div>
<div class="kpi">
<div class="kpi-val" :class="phase3.detection.trend==='improving'?'text-ok':phase3.detection.trend==='worsening'?'text-crit':'text-warn'" x-text="phase3.detection.todayCount || '--'"></div>
<div class="kpi-label">检测</div>
</div>
<div class="kpi">
<div class="kpi-val" :class="phase3.builds.overallSuccessRate>=80?'text-ok':phase3.builds.overallSuccessRate>=60?'text-warn':'text-crit'" x-text="phase3.builds.overallSuccessRate ? phase3.builds.overallSuccessRate+'%' : '--'"></div>
<div class="kpi-label">构建</div>
</div>
</div>
<!-- CRITICAL 告警 -->
<template x-if="criticalDims.length">
<div class="flex items-center gap-1 bg-crit/10 border border-crit/30 rounded px-2 py-0.5 flex-shrink-0">
<svg class="w-3 h-3 text-crit" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
<template x-for="d in criticalDims" :key="d.id">
<span class="text-[0.6rem] text-crit font-bold" x-text="d.id"></span>
</template>
</div>
</template>
<!-- 控制区 -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="flex items-center gap-1" :title="heartbeatTitle">
<div class="hb" :class="heartbeatClass.split(' ').pop()"></div>
<span class="text-[0.6rem]" :class="heartbeatClass.includes('active') ? 'text-ok' : heartbeatClass.includes('idle') ? 'text-warn' : 'text-crit'" x-text="heartbeatLabel"></span>
</div>
<div class="sse-dot" :class="sseConnected ? 'connected' : 'disconnected'" :title="sseConnected ? 'SSE 实时连接' : 'SSE 断开 (轮询中)'"></div>
<span class="text-[0.6rem] text-dim" x-text="refreshCountdown + 's'"></span>
<button @click="fetchAll()" class="text-[0.6rem] bg-accent/15 hover:bg-accent/30 text-accent-glow px-2 py-0.5 rounded transition" aria-label="手动刷新数据">刷新</button>
<button @click="toggleTheme()" class="text-[0.6rem] text-dim hover:text-accent-glow transition" :title="theme === 'dark' ? '切换亮色' : '切换暗色'">
<svg x-show="theme==='dark'" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
<svg x-show="theme==='light'" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
</button>
<button @click="exportSnapshot()" class="text-[0.6rem] text-dim hover:text-accent-glow transition" title="导出快照">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</button>
<div class="text-[0.6rem] text-dim" x-text="currentTime"></div>
</div>
</header>
<!-- ═══ 主体: 4×3 大屏网格 ═══ -->
<main class="dash-grid flex-1 min-h-0" role="main" aria-label="仪表盘面板">
<!-- ① 系统健康 -->
<section class="glass p-2 flex flex-col min-h-0 overflow-hidden" :class="{'panel-off':isPanelCollapsed('health')}">
<div class="ptitle" @dblclick="openDrill('health')" style="cursor:pointer" title="双击查看详情">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
系统健康
<span class="status-badge ml-auto" :class="statusBadgeClass(health.overallStatus)" x-text="health.overallStatus || '...'"></span>
<svg @click.stop="togglePanel('health')" class="collapse-btn" :class="isPanelCollapsed('health')?'collapsed':''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<template x-if="loading && !health.overallScore">
<div class="flex-1 skeleton"></div>
</template>
<template x-if="health.overallScore">
<div class="flex-1 flex flex-col gap-1 min-h-0">
<div class="grid grid-cols-2 gap-1 flex-1 min-h-0">
<div class="chart-wrap"><canvas id="chartRadar"></canvas></div>
<div class="chart-wrap flex flex-col items-center justify-center">
<canvas id="chartGauge"></canvas>
<span class="text-lg font-bold" :class="scoreColor(health.overallScore)" x-text="health.overallScore"></span>
<span class="text-[0.45rem] text-muted">/100</span>
</div>
</div>
<div class="grid grid-cols-5 gap-0.5 flex-shrink-0">
<template x-for="dim in health.dimensions.slice(0,10)" :key="dim.id">
<div class="flex items-center gap-0.5 px-1 py-px rounded" style="background:rgba(30,30,50,0.5)">
<span class="text-[0.45rem] text-muted font-mono" x-text="dim.id"></span>
<div class="flex-1 bg-gray-700/30 rounded-full h-0.5">
<div class="h-0.5 rounded-full transition-all" :class="scoreBarColor(dim.score)" :style="`width:${dim.score}%`"></div>
</div>
<span class="text-[0.45rem] w-4 text-right" :class="scoreColor(dim.score)" x-text="dim.score"></span>
</div>
</template>
</div>
</div>
</template>
</section>
<!-- ② 技能路由 -->
<section class="glass p-2 flex flex-col min-h-0 overflow-hidden" :class="{'panel-off':isPanelCollapsed('skills')}">
<div class="ptitle" @dblclick="openDrill('skills')" style="cursor:pointer" title="双击查看详情">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
技能路由
<span class="text-[0.5rem] text-muted ml-auto" x-text="skills.list.length + ' skills'"></span>
<svg @click.stop="togglePanel('skills')" class="collapse-btn" :class="isPanelCollapsed('skills')?'collapsed':''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<template x-if="loading && !skills.list.length">
<div class="flex-1 skeleton"></div>
</template>
<template x-if="skills.list.length">
<div class="flex-1 flex flex-col gap-1 min-h-0">
<div class="flex-1 min-h-0 chart-wrap"><canvas id="chartSkillUsage"></canvas></div>
<div class="grid grid-cols-2 gap-1 flex-shrink-0" style="height:35%">
<div class="chart-wrap"><canvas id="chartAccuracy"></canvas></div>
<div class="chart-wrap"><canvas id="chartConfidence"></canvas></div>
</div>
</div>
</template>
</section>
<!-- ③ 活动趋势 -->
<section class="glass p-2 flex flex-col min-h-0 overflow-hidden" :class="{'panel-off':isPanelCollapsed('activity')}">
<div class="ptitle">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
活动趋势
<svg @click.stop="togglePanel('activity')" class="collapse-btn ml-1" :class="isPanelCollapsed('activity')?'collapsed':''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<div class="ml-auto flex gap-1">
<button @click="switchDayRange(7)" class="text-[0.5rem] px-1 py-px rounded transition" :class="activity.dayRange===7 ? 'bg-accent/30 text-accent-glow' : 'text-dim hover:text-white'">7d</button>
<button @click="switchDayRange(30)" class="text-[0.5rem] px-1 py-px rounded transition" :class="activity.dayRange===30 ? 'bg-accent/30 text-accent-glow' : 'text-dim hover:text-white'">30d</button>
</div>
</div>
<template x-if="loading && !activity.loaded">
<div class="flex-1 skeleton"></div>
</template>
<template x-if="activity.loaded">
<div class="flex-1 flex flex-col gap-1 min-h-0">
<div class="flex-1 min-h-0 chart-wrap"><canvas id="chartDailyEvents"></canvas></div>
<div class="flex-shrink-0">
<div class="grid gap-px" style="grid-template-columns: repeat(24, 1fr)">
<template x-for="row in activity.heatmap" :key="row.day">
<template x-for="cell in row.hours" :key="row.day+'-'+cell.h">
<div class="hm-cell aspect-square" :style="`background:rgba(124,58,237,${cell.intensity})`" :title="`${row.day} ${cell.h}h: ${cell.count}`"></div>
</template>
</template>
</div>
<div class="flex justify-between text-[0.4rem] text-dim mt-0.5 px-0.5">
<span>0h</span><span>6h</span><span>12h</span><span>18h</span><span>23h</span>
</div>
</div>
</div>
</template>
</section>
<!-- ④ 安全监控 -->
<section class="glass p-2 flex flex-col min-h-0 overflow-hidden" :class="{'panel-off':isPanelCollapsed('security')}">
<div class="ptitle" @dblclick="openDrill('security')" style="cursor:pointer" title="双击查看详情">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
安全监控
<span class="text-[0.5rem] text-muted ml-auto" x-text="security.events.length + ' events'"></span>
<svg @click.stop="togglePanel('security')" class="collapse-btn" :class="isPanelCollapsed('security')?'collapsed':''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<template x-if="loading && !security.loaded">
<div class="flex-1 skeleton"></div>
</template>
<template x-if="security.loaded">
<div class="flex-1 flex flex-col gap-1 min-h-0">
<div class="grid grid-cols-2 gap-1 flex-1 min-h-0">
<div class="chart-wrap"><canvas id="chartSecurityDist"></canvas></div>
<div class="chart-wrap"><canvas id="chartHookBar"></canvas></div>
</div>
<div class="flex-shrink-0 chart-wrap" style="height:28%"><canvas id="chartSecurityTrend"></canvas></div>
<div class="max-h-12 overflow-y-auto text-[0.5rem] flex-shrink-0">
<template x-for="e in sortedSecurity().slice(0,4)" :key="e.ts+e.detail">
<div class="flex gap-1 py-px border-b border-gray-700/10">
<span class="text-dim flex-shrink-0" x-text="formatTime(e.ts)?.slice(6)"></span>
<span class="flex-shrink-0" :class="e.decision==='deny'?'text-crit':'text-warn'" x-text="e.decision"></span>
<span class="text-gray-400 truncate" x-text="shortHook(e.hook)"></span>
</div>
</template>
<div x-show="!security.events.length" class="text-center text-dim py-1">-</div>
</div>
</div>
</template>
</section>
<!-- ⑤ 磁盘用量 -->
<section class="glass p-2 flex flex-col min-h-0 overflow-hidden" :class="{'panel-off':isPanelCollapsed('disk')}">
<div class="ptitle">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/></svg>
磁盘用量
<span class="status-badge ml-auto" :class="statusBadgeClass(disk.health)" x-text="disk.health || '...'"></span>
<svg @click.stop="togglePanel('disk')" class="collapse-btn" :class="isPanelCollapsed('disk')?'collapsed':''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<template x-if="loading && !disk.totalMB">
<div class="flex-1 skeleton"></div>
</template>
<template x-if="disk.totalMB">
<div class="flex-1 flex flex-col gap-1 min-h-0">
<div class="grid grid-cols-2 gap-1 flex-1 min-h-0">
<div class="chart-wrap"><canvas id="chartDiskDist"></canvas></div>
<div class="chart-wrap flex flex-col items-center justify-center">
<canvas id="chartDiskGauge"></canvas>
<span class="text-sm font-bold" :class="scoreColor(disk.score)" x-text="(disk.totalMB/1024).toFixed(1) + 'GB'"></span>
<div class="text-[0.45rem] text-dim">
<span class="text-warn">8G</span> / <span class="text-crit">16G</span>
</div>
</div>
</div>
<div class="text-[0.5rem] space-y-px flex-shrink-0 max-h-14 overflow-y-auto">
<template x-for="[dir, mb] in sortedBreakdown().slice(0,5)" :key="dir">
<div class="flex items-center gap-1">
<span class="w-16 truncate text-dim" x-text="dir"></span>
<div class="flex-1 bg-gray-700/20 rounded-full h-0.5">
<div class="h-0.5 rounded-full bg-accent/50" :style="`width:${Math.min(100,mb/disk.totalMB*100)}%`"></div>
</div>
<span class="w-10 text-right text-gray-400" x-text="mb>=1024?(mb/1024).toFixed(1)+'G':Math.round(mb)+'M'"></span>
</div>
</template>
</div>
</div>
</template>
</section>
<!-- ⑥ 进化日志 -->
<section class="glass p-2 flex flex-col min-h-0 overflow-hidden" :class="{'panel-off':isPanelCollapsed('evolution')}">
<div class="ptitle" @dblclick="openDrill('evolution')" style="cursor:pointer" title="双击查看详情">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
进化日志
<span class="text-[0.5rem] text-muted ml-auto" x-text="evolution.entries.length + ' entries'"></span>
<svg @click.stop="togglePanel('evolution')" class="collapse-btn" :class="isPanelCollapsed('evolution')?'collapsed':''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<template x-if="loading && !evolution.loaded">
<div class="flex-1 skeleton"></div>
</template>
<template x-if="evolution.loaded">
<div class="flex-1 flex flex-col gap-1 min-h-0">
<div class="flex-1 min-h-0 chart-wrap"><canvas id="chartEvoBar"></canvas></div>
<div class="tag-cloud flex-shrink-0" x-show="evolution.tagCloud.length">
<template x-for="tag in evolution.tagCloud.slice(0,10)" :key="tag.name">
<span :style="`font-size:${Math.max(0.5, Math.min(0.75, 0.5 + tag.count * 0.05))}rem`" x-text="tag.name"></span>
</template>
</div>
<div class="max-h-10 overflow-y-auto text-[0.5rem] flex-shrink-0">
<template x-for="e in sortedEvolution().slice(0,3)" :key="e.seq||e.ts">
<div class="flex gap-1 py-px border-b border-gray-700/10">
<span class="text-dim flex-shrink-0" x-text="e.ts?.slice(5,10)"></span>
<span class="text-accent-glow flex-shrink-0" x-text="e.version"></span>
<span class="text-gray-400 truncate" x-text="e.summary"></span>
<span class="text-dim ml-auto flex-shrink-0" x-text="'x'+e.fix_count"></span>
</div>
</template>
</div>
</div>
</template>
</section>
<!-- ⑦ 路由合规 -->
<section class="glass p-2 flex flex-col min-h-0 overflow-hidden" :class="{'panel-off':isPanelCollapsed('compliance')}">
<div class="ptitle">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
路由合规
<span class="text-[0.5rem] text-muted ml-auto" x-text="compliance.rate + '% rate'"></span>
<svg @click.stop="togglePanel('compliance')" class="collapse-btn" :class="isPanelCollapsed('compliance')?'collapsed':''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<template x-if="loading && !compliance.loaded">
<div class="flex-1 skeleton"></div>
</template>
<template x-if="compliance.loaded">
<div class="flex-1 flex flex-col gap-1 min-h-0">
<div class="grid grid-cols-2 gap-1 flex-1 min-h-0">
<div class="chart-wrap flex flex-col items-center justify-center">
<canvas id="chartComplianceGauge"></canvas>
<span class="text-lg font-bold" :class="scoreColor(compliance.rate)" x-text="compliance.rate+'%'"></span>
<div class="flex gap-1.5 text-[0.45rem]">
<span class="text-ok" x-text="compliance.summary.pass+'p'"></span>
<span class="text-warn" x-text="compliance.summary.warn+'w'"></span>
<span class="text-crit" x-text="compliance.summary.fail+'f'"></span>
</div>
</div>
<div class="chart-wrap"><canvas id="chartComplianceTrend"></canvas></div>
</div>
</div>
</template>
</section>
<!-- ⑧ 配置 & 工具 -->
<section class="glass p-2 flex flex-col min-h-0 overflow-hidden" :class="{'panel-off':isPanelCollapsed('config')}">
<div class="ptitle">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
配置 & 工具
<svg @click.stop="togglePanel('config')" class="collapse-btn ml-auto" :class="isPanelCollapsed('config')?'collapsed':''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<div class="flex-1 flex flex-col gap-1 min-h-0">
<div class="rounded p-1.5 flex-shrink-0" style="background:rgba(30,30,50,0.5)">
<div class="flex items-center gap-1.5">
<div class="w-1.5 h-1.5 rounded-full" :class="configValidate.status==='PASS'?'bg-ok':configValidate.status==='WARN'?'bg-warn':'bg-crit'"></div>
<span class="text-[0.6rem] font-semibold" x-text="'Config: ' + (configValidate.status || '...')"></span>
<template x-if="!configValidate.issues || !configValidate.issues.length">
<span class="text-[0.5rem] text-ok ml-auto">OK</span>
</template>
</div>
<template x-if="configValidate.issues && configValidate.issues.length">
<div class="text-[0.5rem] text-warn truncate mt-0.5" x-text="configValidate.issues[0]"></div>
</template>
</div>
<div class="flex-1 min-h-0 chart-wrap"><canvas id="chartToolStack"></canvas></div>
<div class="grid grid-cols-4 gap-1 text-[0.5rem] flex-shrink-0">
<div class="flex justify-between px-1 py-px rounded" style="background:rgba(30,30,50,0.4)">
<span class="text-dim">Sk</span><span class="text-accent-glow" x-text="skills.list.length"></span>
</div>
<div class="flex justify-between px-1 py-px rounded" style="background:rgba(30,30,50,0.4)">
<span class="text-dim">Ag</span><span class="text-info">10</span>
</div>
<div class="flex justify-between px-1 py-px rounded" style="background:rgba(30,30,50,0.4)">
<span class="text-dim">Hk</span><span class="text-gray-400">17</span>
</div>
<div class="flex justify-between px-1 py-px rounded" style="background:rgba(30,30,50,0.4)">
<span class="text-dim">MC</span><span class="text-gray-400">6</span>
</div>
</div>
</div>
</section>
<!-- ⑨ Detection Intelligence -->
<section class="glass p-2 flex flex-col min-h-0 overflow-hidden" :class="{'panel-off':isPanelCollapsed('detection')}">
<div class="ptitle" @dblclick="openDrill('detection')" style="cursor:pointer" title="双击查看详情">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
检测智能
<span class="text-sm font-bold ml-1" :class="phase3.detection.trend==='improving'?'text-ok':phase3.detection.trend==='worsening'?'text-crit':'text-warn'" x-text="phase3.detection.todayCount"></span>
<span class="status-badge ml-auto" :class="phase3.detection.trend==='improving'?'bg-ok/20 text-ok':phase3.detection.trend==='worsening'?'bg-crit/20 text-crit':'bg-warn/20 text-warn'" x-text="phase3.detection.trend || '...'"></span>
<svg @click.stop="togglePanel('detection')" class="collapse-btn" :class="isPanelCollapsed('detection')?'collapsed':''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<template x-if="!phase3.detection.loaded">
<div class="flex-1 skeleton"></div>
</template>
<template x-if="phase3.detection.loaded">
<div class="flex-1 grid grid-cols-2 gap-1 min-h-0">
<div class="chart-wrap"><canvas id="chartDetRules"></canvas></div>
<div class="chart-wrap"><canvas id="chartDetExt"></canvas></div>
</div>
</template>
</section>
<!-- ⑩ Skill Outcomes -->
<section class="glass p-2 flex flex-col min-h-0 overflow-hidden" :class="{'panel-off':isPanelCollapsed('skillOutcome')}">
<div class="ptitle" @dblclick="openDrill('skills')" style="cursor:pointer" title="双击查看详情">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
技能成效
<svg @click.stop="togglePanel('skillOutcome')" class="collapse-btn ml-1" :class="isPanelCollapsed('skillOutcome')?'collapsed':''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<div class="ml-auto flex gap-1.5 text-[0.45rem]">
<span class="text-ok" x-text="'B:'+(phase3.skills.bestSkill||'-')"></span>
<span class="text-crit" x-text="'W:'+(phase3.skills.worstSkill||'-')"></span>
</div>
</div>
<template x-if="!phase3.skills.loaded">
<div class="flex-1 skeleton"></div>
</template>
<template x-if="phase3.skills.loaded">
<div class="flex-1 min-h-0 chart-wrap"><canvas id="chartSkillOutcomes"></canvas></div>
</template>
</section>
<!-- ⑪ Build & Traces -->
<section class="glass p-2 flex flex-col min-h-0 overflow-hidden" :class="{'panel-off':isPanelCollapsed('buildTrace')}">
<div class="ptitle">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"/></svg>
构建 & 追踪
<span class="text-[0.5rem] text-muted ml-auto" x-text="phase3.traces.totalEvents + ' evt'"></span>
<svg @click.stop="togglePanel('buildTrace')" class="collapse-btn" :class="isPanelCollapsed('buildTrace')?'collapsed':''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<template x-if="!phase3.builds.loaded && !phase3.traces.loaded">
<div class="flex-1 skeleton"></div>
</template>
<template x-if="phase3.builds.loaded || phase3.traces.loaded">
<div class="flex-1 flex flex-col gap-1 min-h-0">
<div class="grid grid-cols-2 gap-1 flex-1 min-h-0">
<div class="chart-wrap flex flex-col items-center justify-center">
<canvas id="chartBuildGauge"></canvas>
<span class="text-lg font-bold" :class="phase3.builds.overallSuccessRate>=80?'text-ok':phase3.builds.overallSuccessRate>=60?'text-warn':'text-crit'" x-text="phase3.builds.overallSuccessRate+'%'"></span>
<span class="text-[0.45rem] text-muted">Build</span>
</div>
<div class="chart-wrap"><canvas id="chartTraceEvents"></canvas></div>
</div>
<div class="text-[0.5rem] text-dim flex-shrink-0 truncate" x-show="phase3.builds.topFailures.length">
<template x-for="f in phase3.builds.topFailures.slice(0,3)" :key="f.command">
<span class="text-crit mr-1" x-text="f.command+'('+f.failureRate+'%)'"></span>
</template>
</div>
</div>
</template>
</section>
<!-- ⑫ Auto-Remediation -->
<section class="glass p-2 flex flex-col min-h-0 overflow-hidden" :class="{'panel-off':isPanelCollapsed('remediation')}">
<div class="ptitle">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
自动修复
<span class="text-[0.5rem] ml-auto" :class="phase3.remediations.total>0?'text-ok':'text-muted'" x-text="phase3.remediations.successful+'/'+phase3.remediations.total+' ok'"></span>
<svg @click.stop="togglePanel('remediation')" class="collapse-btn" :class="isPanelCollapsed('remediation')?'collapsed':''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<template x-if="!phase3.remediations.loaded">
<div class="flex-1 skeleton"></div>
</template>
<template x-if="phase3.remediations.loaded">
<div class="flex-1 flex flex-col gap-1 min-h-0">
<div class="grid grid-cols-2 gap-2 flex-shrink-0">
<div class="rounded p-1.5 text-center" style="background:rgba(30,30,50,0.5)">
<div class="text-lg font-bold text-ok" x-text="phase3.remediations.successful"></div>
<div class="text-[0.45rem] text-muted">成功</div>
</div>
<div class="rounded p-1.5 text-center" style="background:rgba(30,30,50,0.5)">
<div class="text-lg font-bold" :class="(phase3.remediations.total-phase3.remediations.successful)>0?'text-crit':'text-ok'" x-text="phase3.remediations.total - phase3.remediations.successful"></div>
<div class="text-[0.45rem] text-muted">失败</div>
</div>
</div>
<div class="text-[0.5rem] space-y-0.5 flex-1 overflow-y-auto">
<template x-for="r in phase3.remediations.recent" :key="r.ts+r.dimensionId">
<div class="flex gap-1 py-px border-b border-gray-700/10">
<span class="flex-shrink-0" :class="r.success?'text-ok':'text-crit'" x-text="r.success?'[+]':'[X]'"></span>
<span class="text-accent-glow flex-shrink-0" x-text="r.dimensionId"></span>
<span class="text-gray-400 truncate" x-text="r.action"></span>
<span class="text-dim ml-auto flex-shrink-0" x-text="r.improved!=null?'('+r.improved+')':''"></span>
</div>
</template>
<div x-show="!phase3.remediations.recent.length" class="text-center text-dim py-1">无修复记录</div>
</div>
</div>
</template>
</section>
</main>
<!-- 钻取模态 -->
<div x-show="drillPanel" x-transition.opacity class="drill-overlay" @click.self="drillPanel=null" @keydown.escape.window="drillPanel=null" role="dialog" aria-modal="true" aria-label="详情面板">
<div class="drill-card relative">
<span class="drill-close" @click="drillPanel=null">&times;</span>
<!-- Health drill -->
<template x-if="drillPanel==='health'">
<div>
<h3>系统健康 — 维度详情</h3>
<table><thead><tr><th>ID</th><th>名称</th><th>分数</th><th>状态</th><th>详情</th></tr></thead>
<tbody><template x-for="d in health.dimensions" :key="d.id"><tr>
<td class="text-accent-glow font-bold" x-text="d.id"></td>
<td x-text="d.name"></td>
<td :class="scoreColor(d.score)" x-text="d.score"></td>
<td><span class="status-badge" :class="statusBadgeClass(d.status)" x-text="d.status"></span></td>
<td class="text-dim truncate max-w-[200px]" x-text="d.detail"></td>
</tr></template></tbody></table>
</div>
</template>
<!-- Security drill -->
<template x-if="drillPanel==='security'">
<div>
<h3>安全事件 — 最近 50 条</h3>
<table><thead><tr><th>时间</th><th>Hook</th><th>决策</th><th>目标</th></tr></thead>
<tbody><template x-for="e in sortedSecurity()" :key="e.ts+e.hook"><tr>
<td class="text-dim" x-text="formatTime(e.ts)"></td>
<td class="text-accent-glow" x-text="shortHook(e.hook)"></td>
<td :class="e.decision==='deny'?'text-crit':'text-warn'" x-text="e.decision"></td>
<td class="text-gray-400 truncate max-w-[200px]" x-text="e.target||e.command||''"></td>
</tr></template></tbody></table>
</div>
</template>
<!-- Skills drill -->
<template x-if="drillPanel==='skills'">
<div>
<h3>技能成效 — 全部技能</h3>
<table><thead><tr><th>技能</th><th>成功率</th><th>成功</th><th>失败</th><th>趋势</th></tr></thead>
<tbody><template x-for="s in phase3.skills.entries" :key="s.name"><tr>
<td class="text-accent-glow" x-text="s.name"></td>
<td :class="s.successRate>=80?'text-ok':s.successRate>=60?'text-warn':'text-crit'" x-text="s.successRate+'%'"></td>
<td class="text-ok" x-text="s.success||0"></td>
<td class="text-crit" x-text="s.failure||0"></td>
<td x-text="s.trend||'-'"></td>
</tr></template></tbody></table>
</div>
</template>
<!-- Detection drill -->
<template x-if="drillPanel==='detection'">
<div>
<h3>检测智能 — 规则 & 类型</h3>
<div class="grid grid-cols-2 gap-4">
<div><div class="text-[0.65rem] text-muted mb-1 font-bold">Top Rules</div>
<template x-for="r in phase3.detection.topRules" :key="r.id"><div class="flex justify-between text-[0.65rem] py-0.5">
<span class="text-accent-glow" x-text="r.id"></span>
<span :class="r.severity==='critical'?'text-crit':r.severity==='high'?'text-warn':'text-info'" x-text="r.total+' ('+r.severity+')'"></span>
</div></template>
</div>
<div><div class="text-[0.65rem] text-muted mb-1 font-bold">File Types</div>
<template x-for="e in phase3.detection.topExtensions" :key="e.ext"><div class="flex justify-between text-[0.65rem] py-0.5">
<span class="text-accent-glow" x-text="e.ext"></span>
<span class="text-gray-400" x-text="e.count"></span>
</div></template>
</div>
</div>
</div>
</template>
<!-- Evolution drill -->
<template x-if="drillPanel==='evolution'">
<div>
<h3>进化日志 — 最近 30 条</h3>
<table><thead><tr><th>#</th><th>版本</th><th>描述</th><th>类型</th></tr></thead>
<tbody><template x-for="e in sortedEvolution()" :key="e.seq"><tr>
<td class="text-dim" x-text="e.seq"></td>
<td class="text-accent-glow" x-text="e.version"></td>
<td class="text-gray-400 truncate max-w-[250px]" x-text="e.description||e.desc||''"></td>
<td x-text="e.type||''"></td>
</tr></template></tbody></table>
</div>
</template>
</div>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dashboard', () => ({
loading: true, lastUpdated: null, refreshCountdown: 30, connectionLost: false, failCount: 0,
currentTime: '', sseConnected: false, sseSource: null,
theme: localStorage.getItem('bw-theme') || 'dark',
collapsedPanels: JSON.parse(localStorage.getItem('bw-collapsed') || '{}'),
drillPanel: null,
health: { overallScore: 0, overallStatus: '', dimensions: [] }, healthHistory: [],
skills: { list: [], usageRanking: [], composable: [] }, routeFeedback: [], routeWeights: {},
security: { events: [], summary: { deny: 0, ask: 0 }, byHook: {}, dailyCounts: [], loaded: false },
securitySort: { key: 'ts', asc: false },
disk: { totalMB: 0, health: '', score: 0, breakdown: {} },
evolution: { entries: [], byVersion: {}, tagCloud: [], loaded: false },
evoSort: { key: 'ts', asc: false },
activity: { events: [], dailyCounts: [], byTool: {}, heatmap: [], dayRange: 7, loaded: false },
compliance: { events: [], summary: { pass: 0, warn: 0, fail: 0 }, rate: 0, dailyCounts: [], failures: [], loaded: false },
configValidate: { status: '...', issues: [] },
phase3: {
detection: { trend:'', todayCount:0, topRules:[], topExtensions:[], loaded:false },
skills: { entries:[], bestSkill:null, worstSkill:null, loaded:false },
builds: { totalCommands:0, overallSuccessRate:0, topFailures:[], loaded:false },
traces: { totalEvents:0, byHook:{}, byEventType:{}, loaded:false },
remediations: { total:0, successful:0, recent:[], loaded:false },
},
heartbeatClass: 'hb idle', heartbeatLabel: '...', heartbeatTitle: '',
prevDataHash: '', dataChanged: false,
charts: {},
init() {
this.applyTheme();
this.fetchAll();
this.checkHeartbeat();
this.updateClock();
this.connectSSE();
setInterval(() => { if (!this.connectionLost && !this.sseConnected) this.fetchAll(); }, 30000);
setInterval(() => this.checkHeartbeat(), 10000);
setInterval(() => {
if (!this.connectionLost) this.refreshCountdown = Math.max(0, this.refreshCountdown - 1);
this.updateClock();
}, 1000);
},
updateClock() {
const d = new Date();
this.currentTime = `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}`;
},
// ─── SSE 实时连接 ──────────────────────
connectSSE() {
try {
this.sseSource = new EventSource('/api/events');
this.sseSource.onopen = () => { this.sseConnected = true; };
this.sseSource.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'data-changed') {
this.fetchAll(); // 数据变更时自动刷新
}
} catch {}
};
this.sseSource.onerror = () => {
this.sseConnected = false;
this.sseSource.close();
// 5 秒后重连
setTimeout(() => this.connectSSE(), 5000);
};
} catch { this.sseConnected = false; }
},
// ─── 主题切换 ──────────────────────────
toggleTheme() {
this.theme = this.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('bw-theme', this.theme);
this.applyTheme();
},
applyTheme() {
document.documentElement.classList.toggle('light', this.theme === 'light');
document.documentElement.classList.toggle('dark', this.theme === 'dark');
},
// ─── 面板折叠 ──────────────────────────
togglePanel(id) {
this.collapsedPanels[id] = !this.collapsedPanels[id];
localStorage.setItem('bw-collapsed', JSON.stringify(this.collapsedPanels));
},
isPanelCollapsed(id) { return !!this.collapsedPanels[id]; },
// ─── 图表下钻 ──────────────────────────
openDrill(panel) { this.drillPanel = panel; },
async checkHeartbeat() {
try {
const status = await this.api('/api/status');
const sources = status.dataSources || {};
const now = Date.now();
let latestMtime = 0;
Object.values(sources).forEach(s => {
if (s.lastModified) { const t = new Date(s.lastModified).getTime(); if (t > latestMtime) latestMtime = t; }
});
const ageSec = latestMtime ? Math.round((now - latestMtime) / 1000) : Infinity;
if (ageSec < 120) { this.heartbeatClass = 'hb active'; this.heartbeatLabel = '活跃'; }
else if (ageSec < 3600) { this.heartbeatClass = 'hb idle'; this.heartbeatLabel = Math.round(ageSec/60) + 'm'; }
else { this.heartbeatClass = 'hb dead'; this.heartbeatLabel = Math.round(ageSec/3600) + 'h'; }
this.heartbeatTitle = `Last: ${latestMtime ? new Date(latestMtime).toLocaleTimeString('zh-CN') : '?'} | Up: ${Math.round(status.uptime)}s`;
} catch { this.heartbeatClass = 'hb dead'; this.heartbeatLabel = 'off'; }
},
async fetchAll() {
this.refreshCountdown = 30;
try {
const [health, disk, weekly, activity, security, evolution, skillsData,
feedback, weights, healthHistory, complianceData, configValidateData,
detectionStats, skillCorrelation, outcomeAgg, tracesData, remediationsData] = await Promise.allSettled([
this.api('/api/health'), this.api('/api/disk'), this.api('/api/weekly'),
this.api('/api/activity?days=' + this.activity.dayRange),
this.api('/api/security?days=30'), this.api('/api/evolution'), this.api('/api/skills'),
this.api('/api/route-feedback'), this.api('/api/weights'), this.api('/api/health-history'),
this.api('/api/compliance?days=30'), this.api('/api/config-validate'),
this.api('/api/detection-stats'), this.api('/api/skill-correlation'),
this.api('/api/outcome-aggregation'), this.api('/api/traces?days=7'),
this.api('/api/remediations'),
]);
if (health.status === 'fulfilled' && health.value && !health.value.error) {
this.health = { overallScore: health.value.overallScore || health.value.overall?.score || 0,
overallStatus: health.value.overallStatus || health.value.overall?.status || '',
dimensions: health.value.dimensions || [] };
}
if (healthHistory.status === 'fulfilled') this.healthHistory = Array.isArray(healthHistory.value) ? healthHistory.value : [];
if (skillsData.status === 'fulfilled' && skillsData.value) {
const sd = skillsData.value;
this.skills.list = sd.skills || [];
this.skills.composable = (sd.skills||[]).filter(s => s.composable && (s.composable.enhances||s.composable.requires)).map(s => ({name:s.name,...s.composable}));
}
if (weekly.status === 'fulfilled' && weekly.value) this.skills.usageRanking = weekly.value.skills || weekly.value.summary?.skills || [];
if (feedback.status === 'fulfilled') this.routeFeedback = Array.isArray(feedback.value) ? feedback.value : [];
if (weights.status === 'fulfilled') this.routeWeights = weights.value || {};
if (security.status === 'fulfilled') {
const events = Array.isArray(security.value) ? security.value : [];
const summary = { deny: 0, ask: 0 }, byHook = {}, dailyMap = {};
events.forEach(e => {
if (e.decision==='deny') summary.deny++; else if (e.decision==='ask') summary.ask++;
const hook = e.hook||'unknown'; byHook[hook] = (byHook[hook]||0)+1;
const day = (e.ts||'').slice(0,10); if (day) dailyMap[day] = (dailyMap[day]||0)+1;
});
this.security = { events, summary, byHook, dailyCounts: Object.entries(dailyMap).sort((a,b)=>a[0].localeCompare(b[0])), loaded: true };
}
if (disk.status === 'fulfilled' && disk.value) {
const d = disk.value.disk || disk.value;
this.disk = { totalMB: d.totalMB||0, health: d.health||'', score: d.score||0, breakdown: d.breakdown||{} };
}
if (evolution.status === 'fulfilled') {
const entries = Array.isArray(evolution.value) ? evolution.value : [];
const byVersion = {}, tagMap = {};
entries.forEach(e => {
const ver = e.version||'?'; if (!byVersion[ver]) byVersion[ver]={fixes:0,count:0};
byVersion[ver].fixes += (e.fix_count||0); byVersion[ver].count++;
(e.tags||[]).forEach(t => { tagMap[t] = (tagMap[t]||0)+1; });
});
const tagCloud = Object.entries(tagMap).map(([name,count])=>({name,count})).sort((a,b)=>b.count-a.count);
this.evolution = { entries, byVersion, tagCloud, loaded: true };
}
if (activity.status === 'fulfilled') {
const events = Array.isArray(activity.value) ? activity.value : [];
const dailyMap = {}, toolMap = {}, hourMap = {};
events.forEach(e => {
const day = (e.ts||'').slice(0,10); if (day) dailyMap[day] = (dailyMap[day]||0)+1;
const tool = e.event||e.tool||'other'; if (!toolMap[tool]) toolMap[tool]={};
if (day) toolMap[tool][day] = (toolMap[tool][day]||0)+1;
const hour = new Date(e.ts).getHours(); const key = `${day}-${hour}`;
if (day) hourMap[key] = (hourMap[key]||0)+1;
});
const dailyCounts = Object.entries(dailyMap).sort((a,b)=>a[0].localeCompare(b[0]));
const heatmap = []; const maxHeat = Math.max(1,...Object.values(hourMap));
for (let i=6;i>=0;i--) {
const d = new Date(); d.setDate(d.getDate()-i); const day = d.toISOString().slice(0,10);
const hours = []; for (let h=0;h<24;h++) { const count = hourMap[`${day}-${h}`]||0; hours.push({h,count,intensity:Math.min(1,count/maxHeat*1.5).toFixed(2)}); }
heatmap.push({day,hours});
}
this.activity = { events, dailyCounts, byTool: toolMap, heatmap, dayRange: this.activity.dayRange, loaded: true };
}
if (complianceData.status === 'fulfilled') {
const events = Array.isArray(complianceData.value) ? complianceData.value : [];
const summary = { pass: 0, warn: 0, fail: 0 }, dailyMap = {}, failures = [];
events.forEach(e => {
let result;
if (e.event==='gate-pass'||e.compliant===true) result='pass';
else if (e.event==='gate-fail'||e.compliant===false||e.gateBlocked===true) result='fail';
else if (e.compliant==='skipped'&&!e.gateBlocked) result='pass';
else { const raw=(e.result||e.status||'').toLowerCase(); result=raw==='pass'||raw==='compliant'?'pass':raw==='warn'||raw==='warning'?'warn':'fail'; }
if (result==='pass') summary.pass++; else if (result==='warn') summary.warn++; else { summary.fail++; failures.push(e); }
const day = (e.ts||'').slice(0,10);
if (day) { if (!dailyMap[day]) dailyMap[day]={pass:0,fail:0}; if (result==='pass') dailyMap[day].pass++; else dailyMap[day].fail++; }
});
const total = summary.pass+summary.warn+summary.fail;
this.compliance = { events, summary, rate: total>0?Math.round(summary.pass/total*100):100,
dailyCounts: Object.entries(dailyMap).sort((a,b)=>a[0].localeCompare(b[0])), failures, loaded: true };
}
if (configValidateData.status === 'fulfilled' && configValidateData.value) {
const cv = configValidateData.value;
if (cv.error) { this.configValidate = { status: 'PASS', issues: [] }; }
else { const issues = cv.errors||cv.warnings||cv.issues||[];
this.configValidate = { status: issues.length>0?'WARN':'PASS', issues: Array.isArray(issues)?issues.map(i=>typeof i==='string'?i:i.message||JSON.stringify(i)):[] }; }
}
// Phase 3: Detection Stats
if (detectionStats.status === 'fulfilled' && detectionStats.value && !detectionStats.value.error) {
const ds = detectionStats.value;
const topRules = Array.isArray(ds.byRule) ? ds.byRule.sort((a,b)=>(b.total||b.count||0)-(a.total||a.count||0)).slice(0,5).map(r=>({id:r.id||r.rule,total:r.total||r.count||0,severity:r.severity||'unknown'})) : [];
const topExtensions = Array.isArray(ds.byExtension) ? ds.byExtension.sort((a,b)=>(b.count||0)-(a.count||0)).slice(0,5).map(e=>({ext:e.ext||e.extension,count:e.count||0})) : [];
this.phase3.detection = { trend: ds.trend||'insufficient', todayCount: ds.todayCount||ds.total||0, topRules, topExtensions, loaded: true };
}
// Phase 3: Skill Correlation
if (skillCorrelation.status === 'fulfilled' && skillCorrelation.value && !skillCorrelation.value.error) {
const sc = skillCorrelation.value;
const entries = [];
const skills = sc.skills || sc.correlations || sc;
if (typeof skills === 'object' && !Array.isArray(skills)) {
for (const [name, data] of Object.entries(skills)) {
if (!data || typeof data !== 'object') continue;
const total = data.total || (data.success||0)+(data.failure||0) || 0;
const successRate = total > 0 ? Math.round((data.success||0)/total*100) : 0;
entries.push({ name, total, successRate, trend: data.trend||'stable' });
}
} else if (Array.isArray(skills)) {
for (const s of skills) {
const total = s.total || (s.success||0)+(s.failure||0) || 0;
const successRate = total > 0 ? Math.round((s.success||0)/total*100) : 0;
entries.push({ name: s.name||s.skill, total, successRate, trend: s.trend||'stable' });
}
}
entries.sort((a,b) => b.total - a.total);
const sorted = [...entries].filter(e=>e.total>0).sort((a,b)=>b.successRate-a.successRate);
this.phase3.skills = { entries, bestSkill: sorted[0]?.name||null, worstSkill: sorted[sorted.length-1]?.name||null, loaded: true };
}
// Phase 3: Outcome Aggregation (builds)
if (outcomeAgg.status === 'fulfilled' && outcomeAgg.value && !outcomeAgg.value.error) {
const oa = outcomeAgg.value;
const commands = oa.commands || oa.outcomes || oa;
let totalCmd=0, totalSuccess=0; const failures=[];
if (typeof commands === 'object' && !Array.isArray(commands)) {
for (const [cmd, data] of Object.entries(commands)) {
if (!data || typeof data !== 'object') continue;
const t = data.total || (data.success||0)+(data.failure||0) || 0;
const f = data.failure || 0;
totalCmd += t; totalSuccess += (data.success||0);
if (f>0 && t>0) failures.push({ command: cmd, failureRate: Math.round(f/t*100), total: t });
}
}
this.phase3.builds = { totalCommands: totalCmd, overallSuccessRate: totalCmd>0?Math.round(totalSuccess/totalCmd*100):0, topFailures: failures.sort((a,b)=>b.failureRate-a.failureRate).slice(0,5), loaded: true };
}
// Phase 3: Traces
if (tracesData.status === 'fulfilled') {
const traces = Array.isArray(tracesData.value) ? tracesData.value : [];
const byHook={}, byEventType={};
traces.forEach(t => {
const hook = t.hook||t.hookName||'unknown'; byHook[hook] = (byHook[hook]||0)+1;
const evType = t.eventType||t.event||t.type||'unknown'; byEventType[evType] = (byEventType[evType]||0)+1;
});
this.phase3.traces = { totalEvents: traces.length, byHook, byEventType, loaded: true };
}
// Phase 3: Remediations
if (remediationsData.status === 'fulfilled') {
const rems = Array.isArray(remediationsData.value) ? remediationsData.value : [];
const successful = rems.filter(r => r.success || r.result === 'success').length;
const recent = rems.slice(-5).reverse().map(r => ({
ts: r.ts||r.timestamp||'', dimensionId: r.dimensionId||r.dimension||r.id||'',
action: r.action||r.description||'', success: !!(r.success||r.result==='success'),
improved: r.improved!=null ? r.improved : null,
}));
this.phase3.remediations = { total: rems.length, successful, recent, loaded: true };
}
this.loading = false; this.lastUpdated = new Date().toISOString(); this.failCount = 0; this.connectionLost = false;
const newHash = [this.health.overallScore,this.security.events.length,this.activity.events.length,this.evolution.entries.length].join('|');
this.dataChanged = this.prevDataHash!==''&&this.prevDataHash!==newHash; this.prevDataHash = newHash;
document.title = `Bookworm [${this.health.overallScore}]`;
if (this.dataChanged) document.querySelectorAll('.glass').forEach(el => { el.classList.remove('data-updated'); void el.offsetWidth; el.classList.add('data-updated'); });
this.$nextTick(() => this.renderCharts());
} catch (e) { this.failCount++; if (this.failCount >= 3) this.connectionLost = true; }
},
async api(path) { const r = await fetch(path); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); },
reconnect() { this.failCount=0; this.connectionLost=false; this.fetchAll(); },
switchDayRange(days) {
this.activity.dayRange = days;
this.api('/api/activity?days='+days).then(events => {
if (!Array.isArray(events)) return;
const dailyMap={},toolMap={},hourMap={};
events.forEach(e => {
const day=(e.ts||'').slice(0,10); if(day) dailyMap[day]=(dailyMap[day]||0)+1;
const tool=e.event||e.tool||'other'; if(!toolMap[tool]) toolMap[tool]={};
if(day) toolMap[tool][day]=(toolMap[tool][day]||0)+1;
const hour=new Date(e.ts).getHours(); if(day) hourMap[`${day}-${hour}`]=(hourMap[`${day}-${hour}`]||0)+1;
});
const dailyCounts=Object.entries(dailyMap).sort((a,b)=>a[0].localeCompare(b[0]));
const heatmap=[]; const maxHeat=Math.max(1,...Object.values(hourMap));
for(let i=6;i>=0;i--) { const d=new Date();d.setDate(d.getDate()-i);const day=d.toISOString().slice(0,10);
const hours=[]; for(let h=0;h<24;h++){const c=hourMap[`${day}-${h}`]||0;hours.push({h,count:c,intensity:Math.min(1,c/maxHeat*1.5).toFixed(2)});}
heatmap.push({day,hours}); }
this.activity={events,dailyCounts,byTool:toolMap,heatmap,dayRange:days,loaded:true};
this.$nextTick(()=>{this.renderActivityCharts();});
});
},
// ─── Charts ────────────────────────────────────
destroyChart(id) { if(this.charts[id]){this.charts[id].destroy();delete this.charts[id];} },
getCtx(id) { const el=document.getElementById(id); return el?el.getContext('2d'):null; },
renderCharts() {
Chart.defaults.color='#6b7280'; Chart.defaults.borderColor='rgba(60,60,90,0.25)';
Chart.defaults.font.size=10; Chart.defaults.font.family='system-ui,sans-serif';
this.renderHealthCharts(); this.renderSkillCharts(); this.renderSecurityCharts();
this.renderDiskCharts(); this.renderEvoCharts(); this.renderActivityCharts(); this.renderComplianceCharts();
this.renderDetectionCharts(); this.renderSkillOutcomeCharts(); this.renderBuildTraceCharts();
},
renderHealthCharts() {
const dims=this.health.dimensions; if(!dims.length) return;
this.destroyChart('radar'); const ctxR=this.getCtx('chartRadar');
if(ctxR) { this.charts.radar=new Chart(ctxR,{type:'radar',data:{labels:dims.map(d=>d.id),datasets:[{data:dims.map(d=>d.score),backgroundColor:'rgba(124,58,237,0.15)',borderColor:'#7c3aed',borderWidth:1.5,pointBackgroundColor:dims.map(d=>this.statusColor(d.status)),pointRadius:3}]},options:{responsive:true,maintainAspectRatio:false,scales:{r:{beginAtZero:true,max:100,ticks:{stepSize:25,color:'#4b5563',backdropColor:'transparent',font:{size:8}},grid:{color:'rgba(60,60,90,0.25)'},pointLabels:{color:'#6b7280',font:{size:9}}}},plugins:{legend:{display:false}}}}); }
this.destroyChart('gauge'); const ctxG=this.getCtx('chartGauge');
if(ctxG) { const s=this.health.overallScore; this.charts.gauge=new Chart(ctxG,{type:'doughnut',data:{datasets:[{data:[s,100-s],backgroundColor:[this.scoreHex(s),'rgba(60,60,90,0.2)'],borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,circumference:180,rotation:-90,cutout:'78%',plugins:{legend:{display:false},tooltip:{enabled:false}}}}); }
},
renderSkillCharts() {
const ranking=this.skills.usageRanking.slice(0,10);
this.destroyChart('skillUsage'); const ctx1=this.getCtx('chartSkillUsage');
if(ctx1&&ranking.length) { this.charts.skillUsage=new Chart(ctx1,{type:'bar',data:{labels:ranking.map(s=>s.name||s[0]),datasets:[{data:ranking.map(s=>s.count||s[1]||0),backgroundColor:'rgba(124,58,237,0.5)',borderColor:'#7c3aed',borderWidth:1,borderRadius:2}]},options:{responsive:true,maintainAspectRatio:false,indexAxis:'y',scales:{x:{beginAtZero:true,ticks:{font:{size:8}}},y:{ticks:{font:{size:8}}}},plugins:{legend:{display:false}}}}); }
this.destroyChart('accuracy'); const ctx2=this.getCtx('chartAccuracy');
if(ctx2&&this.routeFeedback.length) { const dayAcc={}; this.routeFeedback.forEach(f=>{const day=(f.ts||'').slice(0,10);if(!day)return;if(!dayAcc[day])dayAcc[day]={c:0,t:0};dayAcc[day].t++;if(f.type==='confirm')dayAcc[day].c++;});
const sorted=Object.entries(dayAcc).sort((a,b)=>a[0].localeCompare(b[0]));
this.charts.accuracy=new Chart(ctx2,{type:'line',data:{labels:sorted.map(([d])=>d.slice(5)),datasets:[{data:sorted.map(([,v])=>v.t?Math.round(v.c/v.t*100):0),borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,0.08)',fill:true,tension:0.3,pointRadius:2}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{min:0,max:100,ticks:{callback:v=>v+'%',font:{size:8}}},x:{ticks:{font:{size:8}}}},plugins:{legend:{display:false},title:{display:true,text:'Accuracy',color:'#6b7280',font:{size:8}}}}}); }
this.destroyChart('confidence'); const ctx3=this.getCtx('chartConfidence');
if(ctx3&&this.routeFeedback.length) { const b=Array(10).fill(0); this.routeFeedback.forEach(f=>{const c=f.topConfidence||0;b[Math.min(9,Math.floor(c*10))]++;});
this.charts.confidence=new Chart(ctx3,{type:'bar',data:{labels:b.map((_,i)=>(i*10)+'%'),datasets:[{data:b,backgroundColor:'rgba(59,130,246,0.5)',borderRadius:1}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{beginAtZero:true,ticks:{font:{size:8}}},x:{ticks:{font:{size:8}}}},plugins:{legend:{display:false},title:{display:true,text:'Confidence',color:'#6b7280',font:{size:8}}}}}); }
},
renderSecurityCharts() {
this.destroyChart('securityDist'); const ctx1=this.getCtx('chartSecurityDist');
if(ctx1) { this.charts.securityDist=new Chart(ctx1,{type:'doughnut',data:{labels:['deny','ask'],datasets:[{data:[this.security.summary.deny,this.security.summary.ask],backgroundColor:['#ef4444','#eab308'],borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,cutout:'60%',plugins:{legend:{position:'bottom',labels:{boxWidth:8,padding:4,font:{size:8}}}}}}); }
this.destroyChart('hookBar'); const ctx2=this.getCtx('chartHookBar');
if(ctx2) { const hooks=Object.entries(this.security.byHook).sort((a,b)=>b[1]-a[1]).slice(0,6);
this.charts.hookBar=new Chart(ctx2,{type:'bar',data:{labels:hooks.map(([h])=>h.replace('block-','').slice(0,12)),datasets:[{data:hooks.map(([,c])=>c),backgroundColor:'rgba(239,68,68,0.4)',borderRadius:2}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{beginAtZero:true,ticks:{font:{size:8}}},x:{ticks:{font:{size:8},maxRotation:30}}},plugins:{legend:{display:false}}}}); }
this.destroyChart('securityTrend'); const ctx3=this.getCtx('chartSecurityTrend');
if(ctx3&&this.security.dailyCounts.length) { this.charts.securityTrend=new Chart(ctx3,{type:'line',data:{labels:this.security.dailyCounts.map(([d])=>d.slice(5)),datasets:[{data:this.security.dailyCounts.map(([,c])=>c),borderColor:'#ef4444',backgroundColor:'rgba(239,68,68,0.08)',fill:true,tension:0.3,pointRadius:1}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{beginAtZero:true,ticks:{font:{size:8}}},x:{ticks:{font:{size:8}}}},plugins:{legend:{display:false}}}}); }
},
renderDiskCharts() {
if(!this.disk.totalMB) return;
const entries=Object.entries(this.disk.breakdown).filter(([,v])=>v>0).sort((a,b)=>b[1]-a[1]);
this.destroyChart('diskDist'); const ctx1=this.getCtx('chartDiskDist');
if(ctx1&&entries.length) { const colors=['#7c3aed','#3b82f6','#22c55e','#eab308','#ef4444','#ec4899','#06b6d4','#f97316','#8b5cf6','#14b8a6'];
this.charts.diskDist=new Chart(ctx1,{type:'doughnut',data:{labels:entries.map(([d])=>d),datasets:[{data:entries.map(([,m])=>Math.round(m)),backgroundColor:entries.map((_,i)=>colors[i%colors.length]),borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,cutout:'55%',plugins:{legend:{position:'bottom',labels:{boxWidth:8,padding:4,font:{size:8}}},tooltip:{callbacks:{label:ctx=>`${ctx.label}: ${ctx.raw>=1024?(ctx.raw/1024).toFixed(1)+'G':ctx.raw+'M'}`}}}}}); }
this.destroyChart('diskGauge'); const ctx2=this.getCtx('chartDiskGauge');
if(ctx2) { const g=this.disk.totalMB/1024;const pct=Math.min(100,g/16*100);const c=g>16?'#ef4444':g>8?'#eab308':g>4?'#22c55e':'#3b82f6';
this.charts.diskGauge=new Chart(ctx2,{type:'doughnut',data:{datasets:[{data:[pct,100-pct],backgroundColor:[c,'rgba(60,60,90,0.2)'],borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,circumference:180,rotation:-90,cutout:'75%',plugins:{legend:{display:false},tooltip:{enabled:false}}}}); }
},
renderEvoCharts() {
this.destroyChart('evoBar'); const ctx=this.getCtx('chartEvoBar'); if(!ctx) return;
const vers=Object.entries(this.evolution.byVersion).sort((a,b)=>a[0].localeCompare(b[0])); if(!vers.length) return;
this.charts.evoBar=new Chart(ctx,{type:'bar',data:{labels:vers.map(([v])=>v),datasets:[{label:'Fixes',data:vers.map(([,d])=>d.fixes),backgroundColor:'rgba(124,58,237,0.5)',borderRadius:2},{label:'Entries',data:vers.map(([,d])=>d.count),backgroundColor:'rgba(59,130,246,0.3)',borderRadius:2}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{beginAtZero:true,ticks:{font:{size:8}}},x:{ticks:{font:{size:8}}}},plugins:{legend:{labels:{boxWidth:8,font:{size:8}}}}}});
},
renderActivityCharts() {
this.destroyChart('dailyEvents'); const ctx1=this.getCtx('chartDailyEvents');
if(ctx1&&this.activity.dailyCounts.length) { this.charts.dailyEvents=new Chart(ctx1,{type:'line',data:{labels:this.activity.dailyCounts.map(([d])=>d.slice(5)),datasets:[{data:this.activity.dailyCounts.map(([,c])=>c),borderColor:'#7c3aed',backgroundColor:'rgba(124,58,237,0.1)',fill:true,tension:0.3,pointRadius:1}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{beginAtZero:true,ticks:{font:{size:8}}},x:{ticks:{maxTicksLimit:8,font:{size:8}}}},plugins:{legend:{display:false}}}}); }
this.destroyChart('toolStack'); const ctx2=this.getCtx('chartToolStack');
if(ctx2) { const tc={bash:'#ef4444',write:'#22c55e',skill:'#7c3aed',agent:'#3b82f6',mcp:'#eab308',edit:'#ec4899',read:'#06b6d4',other:'#6b7280'};
const allDays=this.activity.dailyCounts.map(([d])=>d);
const ds=Object.entries(this.activity.byTool).map(([tool,dayMap])=>({label:tool,data:allDays.map(d=>dayMap[d]||0),borderColor:tc[tool]||'#6b7280',backgroundColor:(tc[tool]||'#6b7280')+'22',fill:true,tension:0.3,pointRadius:0}));
if(ds.length) { this.charts.toolStack=new Chart(ctx2,{type:'line',data:{labels:allDays.map(d=>d.slice(5)),datasets:ds},options:{responsive:true,maintainAspectRatio:false,scales:{y:{stacked:true,beginAtZero:true,ticks:{font:{size:8}}},x:{ticks:{maxTicksLimit:8,font:{size:8}}}},plugins:{legend:{position:'bottom',labels:{boxWidth:6,padding:4,font:{size:8}}}}}}); } }
},
renderComplianceCharts() {
this.destroyChart('complianceGauge'); const ctx1=this.getCtx('chartComplianceGauge');
if(ctx1) { const r=this.compliance.rate; this.charts.complianceGauge=new Chart(ctx1,{type:'doughnut',data:{datasets:[{data:[r,100-r],backgroundColor:[this.scoreHex(r),'rgba(60,60,90,0.2)'],borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,circumference:180,rotation:-90,cutout:'78%',plugins:{legend:{display:false},tooltip:{enabled:false}}}}); }
this.destroyChart('complianceTrend'); const ctx2=this.getCtx('chartComplianceTrend');
if(ctx2&&this.compliance.dailyCounts.length) { this.charts.complianceTrend=new Chart(ctx2,{type:'bar',data:{labels:this.compliance.dailyCounts.map(([d])=>d.slice(5)),datasets:[{label:'Pass',data:this.compliance.dailyCounts.map(([,v])=>v.pass),backgroundColor:'rgba(34,197,94,0.5)',borderRadius:1,stack:'a'},{label:'Fail',data:this.compliance.dailyCounts.map(([,v])=>v.fail),backgroundColor:'rgba(239,68,68,0.5)',borderRadius:1,stack:'a'}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{stacked:true,beginAtZero:true,ticks:{font:{size:8}}},x:{stacked:true,ticks:{maxTicksLimit:8,font:{size:8}}}},plugins:{legend:{position:'bottom',labels:{boxWidth:6,padding:4,font:{size:8}}}}}}); }
},
renderDetectionCharts() {
const rules = this.phase3.detection.topRules; const exts = this.phase3.detection.topExtensions;
this.destroyChart('detRules'); const ctx1 = this.getCtx('chartDetRules');
if (ctx1 && rules.length) {
const colors = rules.map(r => r.severity==='critical'?'#ef4444':r.severity==='high'?'#eab308':r.severity==='medium'?'#3b82f6':'#6b7280');
this.charts.detRules = new Chart(ctx1, { type:'bar', data:{ labels:rules.map(r=>r.id), datasets:[{ data:rules.map(r=>r.total), backgroundColor:colors, borderRadius:2 }] }, options:{ responsive:true, maintainAspectRatio:false, scales:{ y:{beginAtZero:true,ticks:{font:{size:8}}}, x:{ticks:{font:{size:8},maxRotation:30}} }, plugins:{ legend:{display:false} } } });
}
this.destroyChart('detExt'); const ctx2 = this.getCtx('chartDetExt');
if (ctx2 && exts.length) {
const extColors = ['#7c3aed','#3b82f6','#22c55e','#eab308','#ef4444'];
this.charts.detExt = new Chart(ctx2, { type:'doughnut', data:{ labels:exts.map(e=>e.ext), datasets:[{ data:exts.map(e=>e.count), backgroundColor:extColors.slice(0,exts.length), borderWidth:0 }] }, options:{ responsive:true, maintainAspectRatio:false, cutout:'55%', plugins:{ legend:{position:'bottom',labels:{boxWidth:8,padding:4,font:{size:8}}} } } });
}
},
renderSkillOutcomeCharts() {
const entries = this.phase3.skills.entries; if (!entries.length) return;
this.destroyChart('skillOutcomes'); const ctx = this.getCtx('chartSkillOutcomes'); if (!ctx) return;
const top = entries.slice(0,8);
const bgColors = top.map(s => s.successRate>=80?'rgba(34,197,94,0.5)':s.successRate>=60?'rgba(234,179,8,0.5)':'rgba(239,68,68,0.5)');
const borderColors = top.map(s => s.successRate>=80?'#22c55e':s.successRate>=60?'#eab308':'#ef4444');
this.charts.skillOutcomes = new Chart(ctx, { type:'bar', data:{ labels:top.map(s=>s.name), datasets:[{ data:top.map(s=>s.successRate), backgroundColor:bgColors, borderColor:borderColors, borderWidth:1, borderRadius:2 }] }, options:{ responsive:true, maintainAspectRatio:false, indexAxis:'y', scales:{ x:{beginAtZero:true,max:100,ticks:{callback:v=>v+'%',font:{size:8}}}, y:{ticks:{font:{size:8}}} }, plugins:{ legend:{display:false}, tooltip:{callbacks:{label:ctx2=>`${ctx2.raw}% (${top[ctx2.dataIndex].total} total)`}} } } });
},
renderBuildTraceCharts() {
// Build gauge
this.destroyChart('buildGauge'); const ctx1 = this.getCtx('chartBuildGauge');
if (ctx1 && this.phase3.builds.loaded) {
const r = this.phase3.builds.overallSuccessRate;
this.charts.buildGauge = new Chart(ctx1, { type:'doughnut', data:{ datasets:[{ data:[r,100-r], backgroundColor:[this.scoreHex(r),'rgba(60,60,90,0.2)'], borderWidth:0 }] }, options:{ responsive:true, maintainAspectRatio:false, circumference:180, rotation:-90, cutout:'75%', plugins:{ legend:{display:false}, tooltip:{enabled:false} } } });
}
// Trace events bar
this.destroyChart('traceEvents'); const ctx2 = this.getCtx('chartTraceEvents');
if (ctx2 && this.phase3.traces.loaded) {
const evts = Object.entries(this.phase3.traces.byEventType).sort((a,b)=>b[1]-a[1]);
if (evts.length) {
const evtColors = ['#7c3aed','#3b82f6','#22c55e','#eab308','#ef4444','#ec4899','#06b6d4','#f97316'];
this.charts.traceEvents = new Chart(ctx2, { type:'bar', data:{ labels:evts.map(([k])=>k), datasets:[{ data:evts.map(([,v])=>v), backgroundColor:evtColors.slice(0,evts.length), borderRadius:2 }] }, options:{ responsive:true, maintainAspectRatio:false, scales:{ y:{beginAtZero:true,ticks:{font:{size:8}}}, x:{ticks:{font:{size:8},maxRotation:30}} }, plugins:{ legend:{display:false} } } });
}
}
},
// ─── Sort & helpers ──────────────────────────
sortSecurity(key) { if(this.securitySort.key===key) this.securitySort.asc=!this.securitySort.asc; else{this.securitySort.key=key;this.securitySort.asc=false;} },
sortedSecurity() { const arr=[...this.security.events]; const{key,asc}=this.securitySort; arr.sort((a,b)=>{const va=a[key]||'',vb=b[key]||'';return asc?String(va).localeCompare(String(vb)):String(vb).localeCompare(String(va));}); return arr.slice(0,50); },
sortEvo(key) { if(this.evoSort.key===key) this.evoSort.asc=!this.evoSort.asc; else{this.evoSort.key=key;this.evoSort.asc=false;} },
sortedEvolution() { const arr=[...this.evolution.entries]; const{key,asc}=this.evoSort; arr.sort((a,b)=>{let va=a[key],vb=b[key];if(typeof va==='number')return asc?va-vb:vb-va;return asc?String(va||'').localeCompare(String(vb||'')):String(vb||'').localeCompare(String(va||''));}); return arr.slice(0,30); },
sortedBreakdown() { return Object.entries(this.disk.breakdown).filter(([,v])=>v>0).sort((a,b)=>b[1]-a[1]); },
formatTime(ts) { if(!ts)return''; try{const d=new Date(ts);return`${(d.getMonth()+1).toString().padStart(2,'0')}-${d.getDate().toString().padStart(2,'0')} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;}catch{return ts;} },
scoreColor(s) { return s>=80?'text-ok':s>=60?'text-warn':'text-crit'; },
scoreHex(s) { return s>=80?'#22c55e':s>=60?'#eab308':'#ef4444'; },
scoreBarColor(s) { return s>=80?'bg-ok':s>=60?'bg-warn':'bg-crit'; },
statusColor(st) { return{PASS:'#22c55e',HEALTHY:'#22c55e',EXCELLENT:'#22c55e',GOOD:'#22c55e',WARN:'#eab308',WARNING:'#eab308',DEGRADED:'#eab308',FAIL:'#ef4444',CRITICAL:'#ef4444',INFO:'#3b82f6'}[st]||'#6b7280'; },
statusBadgeClass(st) { return{PASS:'bg-ok/20 text-ok',HEALTHY:'bg-ok/20 text-ok',EXCELLENT:'bg-ok/20 text-ok',GOOD:'bg-ok/20 text-ok',WARN:'bg-warn/20 text-warn',WARNING:'bg-warn/20 text-warn',DEGRADED:'bg-warn/20 text-warn',FAIL:'bg-crit/20 text-crit',CRITICAL:'bg-crit/20 text-crit',INFO:'bg-info/20 text-info'}[st]||'bg-gray-600/20 text-muted'; },
shortHook(h) { return(h||'').replace('block-','').replace('sensitive-','s-'); },
get criticalDims() { return(this.health.dimensions||[]).filter(d=>d.status==='CRITICAL'||d.status==='FAIL'); },
exportSnapshot() {
const snap={time:new Date().toISOString(),health:this.health,skills:{count:this.skills.list.length},security:this.security.summary,disk:this.disk,compliance:{rate:this.compliance.rate,summary:this.compliance.summary},evolution:{count:this.evolution.entries.length},phase3:this.phase3};
const blob=new Blob([JSON.stringify(snap,null,2)],{type:'application/json'});
const url=URL.createObjectURL(blob); const a=document.createElement('a');
a.href=url; a.download=`bookworm-${new Date().toISOString().slice(0,10)}.json`; a.click(); URL.revokeObjectURL(url);
},
}));
});
</script>
</body>
</html>