bookworm-smart-assistant/scripts/dashboard.html

1215 lines
82 KiB
HTML
Raw Normal View History

<!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>