1215 lines
82 KiB
HTML
1215 lines
82 KiB
HTML
|
|
<!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">×</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>
|