feat: MeshSafe 文件摆渡安全卫士 v1.0 初始提交

核心功能:
- 6 层检测管线: L0 结构分析 → L1 ClamAV → L2 文档深度检测 → L3 关键词匹配 → L4 YARA → L5 PE/二进制
- FastAPI + PostgreSQL + Redis + Docker Compose 架构
- 客户端 HMAC-SHA256 签名鉴权 + 限流 + 幂等性
- 控制台 UI (登录/Dashboard/诊断中心/策略管理/任务列表/测试中心/产品介绍页)
- 三层诊断定时器 (error_scan 30s / db_aggregate 30s / container_check 5min)

并发与稳定性修复:
- 诊断定时器 Redis 时间槽位锁 (多 worker 同一槽位只有一个执行)
- JSONL 存储 fcntl 跨进程排他锁 + 容错解码 + 原子重写
- 移除 SQLite fallback, 统一 PostgreSQL
- API_CLIENTS 脱敏为环境变量, .env 已 .gitignore
This commit is contained in:
2026-10-28 19:07:11 +08:00
commit 16f134e328
113 changed files with 24604 additions and 0 deletions

16
app/templates/_nav.html Normal file
View File

@@ -0,0 +1,16 @@
{# ============================================================================
统一导航栏 —— 所有控制台页面 include 本文件,保证样式与选中态完全一致
用法:{% set nav_active = 'overview' %} {% include "_nav.html" %}
============================================================================ #}
<nav>
<span class="logo"><img src="/static/logo.png" class="logo-img" alt="MeshSafe"> MeshSafe</span>
<a href="/console/" class="{{ 'active' if nav_active == 'overview' else '' }}">概览</a>
<a href="/console/policy" class="{{ 'active' if nav_active == 'policy' else '' }}">策略配置</a>
<a href="/console/tasks" class="{{ 'active' if nav_active == 'tasks' else '' }}">任务历史</a>
<a href="/console/test" class="{{ 'active' if nav_active == 'test' else '' }}">测试靶场</a>
<a href="/console/diagnostics" class="{{ 'active' if nav_active == 'diagnostics' else '' }}">🔧 诊断</a>
<a href="/console/about" class="{{ 'active' if nav_active == 'about' else '' }}">关于 MeshSafe</a>
<div class="spacer"></div>
<span class="user">{{ user.username }} ({{ user.role }})</span>
<a href="/console/logout">退出</a>
</nav>

View File

@@ -0,0 +1,32 @@
{# ============================================================================
统一导航栏 + 徽章样式 —— 所有控制台页面 include 本文件
用法:<style>{% include "_nav_style.html" %}</style>
============================================================================ #}
nav { background: linear-gradient(135deg, #0f172a, #1e3a5f); color: white; padding: 0 24px; height: 54px;
display: flex; align-items: center; gap: 24px; box-shadow: 0 2px 12px rgba(15,23,42,.18); }
nav .logo { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 8px; }
nav .logo-img { height: 28px; width: auto; border-radius: 5px; }
nav a { color: rgba(255,255,255,.72); text-decoration: none; font-size: 14px;
transition: color .2s; position: relative; padding: 0 2px; }
nav a:hover { color: #fff; }
nav a.active { color: #fff; font-weight: 500; }
nav a.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -18px;
height: 2px; background: #38bdf8; border-radius: 2px; }
nav .spacer { flex: 1; }
nav .user { font-size: 13px; color: rgba(255,255,255,.6); }
nav .user a { color: rgba(255,255,255,.6); margin-left: 12px; }
/* ---------- 统一风险徽章体系 ---------- */
.tag { font-size: 11px; padding: 2.5px 9px; border-radius: 5px; font-weight: 500; white-space: nowrap; }
.tag.ok { background: #dcfce7; color: #15803d; }
.tag.warn { background: #fef3c7; color: #b45309; }
.tag.notice{ background: #fff7ed; color: #c2410c; }
.tag.bad { background: #fee2e2; color: #b91c1c; }
.tag.err { background: #ede9fe; color: #6d28d9; }
.tag.muted { background: #f1f5f9; color: #64748b; }
.tag.plain { background: #f1f5f9; color: #475569; }
.tag.risk-high { background: #fee2e2; color: #b91c1c; }
.tag.risk-notice { background: #fff7ed; color: #c2410c; }
.tag.risk-safe { background: #dcfce7; color: #15803d; }
.tag.risk-fault { background: #ede9fe; color: #6d28d9; }
.tag.risk-idle { background: #f1f5f9; color: #64748b; }

401
app/templates/about.html Normal file
View File

@@ -0,0 +1,401 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>关于 MeshSafe</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f4f6fa; color: #1e293b; }
{% include "_nav_style.html" %}
main { padding: 0 24px 56px; max-width: 1180px; margin: 0 auto; }
/* ===== Hero ===== */
.hero {
position: relative; margin: 0 -24px 0; padding: 64px 24px 56px;
background:
radial-gradient(1100px 420px at 12% -10%, rgba(59,130,246,.20), transparent 62%),
radial-gradient(900px 380px at 88% 0%, rgba(139,92,246,.16), transparent 60%),
linear-gradient(180deg, #f8fafc 0%, #eef2f8 100%);
border-bottom: 1px solid #e2e8f0;
overflow: hidden;
}
.hero-inner { max-width: 1180px; margin: 0 auto; position: relative; }
.hero .eyebrow {
display: inline-flex; align-items: center; gap: 7px;
font-size: 12px; letter-spacing: 1.4px; text-transform: uppercase;
color: #3b82f6; font-weight: 600; margin-bottom: 16px;
}
.hero .eyebrow::before {
content: ""; width: 22px; height: 2px; border-radius: 2px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
}
.hero h1 {
margin: 0 0 10px; font-size: 42px; line-height: 1.16; font-weight: 800;
letter-spacing: -1px; color: #0f172a;
}
.hero h1 .grad {
background: linear-gradient(100deg, #2563eb 0%, #7c3aed 55%, #0891b2 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.hero .lead {
margin: 0 0 22px; font-size: 16.5px; line-height: 1.85; color: #475569; max-width: 760px;
}
.hero .lead b { color: #1e293b; font-weight: 600; }
.hero .tags { display: flex; flex-wrap: wrap; gap: 9px; }
.hero .tags span {
font-size: 12.5px; color: #475569; background: rgba(255,255,255,.78);
border: 1px solid #e2e8f0; border-radius: 999px; padding: 5px 14px;
backdrop-filter: blur(6px);
}
/* ===== 章节通用 ===== */
section { margin-top: 52px; }
.sec-head { margin-bottom: 22px; }
.sec-head .kicker {
font-size: 11.5px; letter-spacing: 1.2px; text-transform: uppercase;
color: #94a3b8; font-weight: 600; margin-bottom: 7px;
}
.sec-head h2 {
margin: 0 0 8px; font-size: 23px; font-weight: 700; color: #0f172a; letter-spacing: -.3px;
}
.sec-head p { margin: 0; font-size: 14px; color: #64748b; line-height: 1.75; max-width: 820px; }
/* ===== 痛点叙事 ===== */
.story {
background: #fff; border: 1px solid #eef2f7; border-radius: 14px;
padding: 34px 38px; box-shadow: 0 1px 3px rgba(15,23,42,.05), 0 8px 26px rgba(15,23,42,.04);
}
.story p { margin: 0 0 15px; font-size: 14.5px; line-height: 1.95; color: #475569; }
.story p:last-child { margin-bottom: 0; }
.story .hl {
display: block; margin: 24px 0; padding: 20px 24px;
background: linear-gradient(100deg, #eff6ff 0%, #f5f3ff 100%);
border-left: 3px solid #3b82f6; border-radius: 8px;
font-size: 16px; font-weight: 650; color: #1e3a8a; line-height: 1.7;
}
.story .risk { color: #b91c1c; font-weight: 600; }
.story .fine { color: #64748b; font-weight: 400; }
/* ===== 能力大卡 ===== */
.cap-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px; }
.cap {
background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 26px 28px;
box-shadow: 0 1px 3px rgba(15,23,42,.05), 0 8px 26px rgba(15,23,42,.035);
position: relative; overflow: hidden; transition: transform .2s, box-shadow .2s, border-color .2s;
}
.cap::after {
content: ""; position: absolute; inset: 0 0 auto 0; height: 3px;
background: linear-gradient(90deg, var(--c1), var(--c2));
opacity: .85;
}
.cap:hover {
transform: translateY(-3px);
box-shadow: 0 6px 14px rgba(15,23,42,.08), 0 18px 40px rgba(15,23,42,.07);
border-color: #dbe3ee;
}
.cap .ic {
width: 44px; height: 44px; border-radius: 12px; margin-bottom: 15px;
display: flex; align-items: center; justify-content: center; font-size: 21px;
background: var(--bg); color: var(--c1);
box-shadow: inset 0 0 0 1px rgba(15,23,42,.045);
}
.cap h3 { margin: 0 0 4px; font-size: 16.5px; font-weight: 700; color: #0f172a; }
.cap .cap-sub { margin: 0 0 12px; font-size: 12.5px; color: #94a3b8; letter-spacing: .2px; }
.cap p { margin: 0 0 14px; font-size: 14px; line-height: 1.85; color: #475569; }
.cap .detail {
margin: 0; padding-top: 13px; border-top: 1px dashed #e2e8f0;
font-size: 12.5px; line-height: 1.8; color: #64748b;
}
.cap .detail b { color: #334155; font-weight: 600; }
.cap .detail code {
background: #f1f5f9; padding: 1px 5px; border-radius: 4px;
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11.5px; color: #b45309;
}
/* ===== 能力矩阵(引擎分层) ===== */
.layers { display: grid; gap: 10px; }
.layer {
display: flex; align-items: flex-start; gap: 16px;
background: #fff; border: 1px solid #eef2f7; border-radius: 11px; padding: 16px 20px;
box-shadow: 0 1px 2px rgba(15,23,42,.04);
}
.layer .lv {
flex-shrink: 0; width: 34px; height: 34px; border-radius: 9px;
display: flex; align-items: center; justify-content: center;
font-size: 12.5px; font-weight: 700; font-family: ui-monospace, Menlo, Consolas, monospace;
background: #f1f5f9; color: #475569;
}
.layer .lt { min-width: 118px; flex-shrink: 0; }
.layer .lt b { display: block; font-size: 14px; color: #1e293b; font-weight: 650; }
.layer .lt span { font-size: 11.5px; color: #94a3b8; }
.layer .ld { font-size: 13.5px; line-height: 1.75; color: #475569; }
.layer .ld b { color: #1e293b; font-weight: 600; }
/* ===== 架构小卡 ===== */
.arch-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.arch {
background: #fff; border: 1px solid #eef2f7; border-radius: 12px; padding: 22px 24px;
box-shadow: 0 1px 3px rgba(15,23,42,.05);
transition: transform .18s, box-shadow .18s;
}
.arch:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(15,23,42,.08); }
.arch .ai { font-size: 20px; margin-bottom: 10px; }
.arch h4 { margin: 0 0 7px; font-size: 14.5px; font-weight: 650; color: #0f172a; }
.arch p { margin: 0; font-size: 12.8px; line-height: 1.8; color: #64748b; }
.arch p b { color: #334155; font-weight: 600; }
/* ===== 底部信息条 ===== */
.foot {
margin-top: 52px; padding: 22px 28px; border-radius: 12px;
background: linear-gradient(100deg, #0f172a 0%, #1e3a5f 100%);
color: rgba(255,255,255,.78); font-size: 13px; line-height: 1.9;
display: flex; flex-wrap: wrap; gap: 10px 40px; align-items: center;
}
.foot .brand { color: #fff; font-weight: 650; font-size: 14px; }
.foot .dot { color: rgba(255,255,255,.32); }
.foot b { color: #7dd3fc; font-weight: 600; }
@media (max-width: 900px) {
.cap-grid, .arch-grid { grid-template-columns: 1fr; }
.hero h1 { font-size: 32px; }
.layer { flex-direction: column; gap: 10px; }
.layer .lt { min-width: 0; }
}
</style>
</head>
<body>
{% set nav_active = 'about' %}
{% include "_nav.html" %}
<div class="hero">
<div class="hero-inner">
<div class="eyebrow">文件摆渡安全卫士</div>
<h1>MeshSafe <span class="grad">· 文件摆渡安全卫士</span></h1>
<p class="lead">
为内外网文件摆渡提供<b>零信任检测</b>能力。每一份传入的文件,在进入内部网络之前
都要经过 6 层引擎的交叉验证 —— 从文件结构完整性到病毒特征、从文档行为到二进制指令,
任何一层发现威胁,整份文件即刻终止通行。
</p>
<div class="tags">
<span>6 层引擎交叉检测</span>
<span>零容忍规则优先响应</span>
<span>真实文件类型识别</span>
<span>全链路审计追溯</span>
<span>完全离线可用</span>
<span>毫秒级响应</span>
</div>
</div>
</div>
<main>
<!-- ===== 用户痛点 ===== -->
<section>
<div class="sec-head">
<div class="kicker">The Challenge</div>
<h2>文件摆渡时,你真正担心的是什么?</h2>
<p>不是"这个文件有没有被杀毒软件报过毒"这么简单。真正让安全负责人睡不着的,往往是下面这些场景。</p>
</div>
<div class="story">
<p>
一份 ISO 镜像刚传入内网,里面藏着的木马就开始在机器上监听端口、拖走数据;
一份看似无害的 Word 文档,员工的电脑双击之后,VBA 宏悄悄把内网信息外发到公网;
一份压缩包里套着另一个压缩包,解压到第 5 层才露出真正的恶意载荷。
</p>
<p>
传统杀毒软件查不到这些 —— 它只认<span class="risk">已被收录的病毒特征</span>。
而真正针对关键基础设施的攻击早已越过这条线:把恶意程序伪装成 <code style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;color:#b45309;">.txt.exe</code> 用双扩展名蒙混过关;
利用 USB 介质摆渡的 <code style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;color:#b45309;">.lnk</code> 快捷方式劫持来劫持凭证;
在 PDF 里嵌入 JavaScript 打开远程恶意链接;在 Office 宏里做字符混淆绕过关键字检测。
</p>
<p>
更棘手的是<span class="fine">它不一定会触发任何告警</span>。文件顺利躺在内网服务器的某个目录里,
直到有一天有人双击了它。
</p>
<span class="hl">
MeshSafe 把风险挡在网络边界这一层 —— 让每一个传入的文件,先过一遍安全审判。
</span>
<p>
每一份文件都要依次通过结构完整性检测、病毒特征扫描、文档深度行为分析、
关键词匹配、YARA 规则匹配、PE/二进制分析 —— <b>6 层引擎交叉验证</b>。
命中"零容忍"特征的,<b>直接终止,不打分、不商量</b>。
即使是完全离线的内网环境,病毒特征库也能通过文件摆渡的方式持续更新,保持检测能力不衰减。
</p>
</div>
</section>
<!-- ===== 核心能力 ===== -->
<section>
<div class="sec-head">
<div class="kicker">Core Capabilities</div>
<h2>四个核心能力</h2>
<p>不是把功能点罗列一遍,而是回答四个你在实际运维中真正会问的问题。</p>
</div>
<div class="cap-grid">
<div class="cap" style="--c1:#3b82f6; --c2:#06b6d4; --bg:#eff6ff;">
<div class="ic">🛡️</div>
<h3>6 层引擎交叉检测</h3>
<p class="cap-sub">多层独立引擎 · 单引擎故障不中断整体扫描</p>
<p>
六个引擎各管一摊,横向覆盖不同威胁面。任何一个引擎抛出异常都被安全捕获,
<b>不会因为病毒库没加载或某个解析器出错而让整份扫描失败</b>,更不会因此把文件误放行。
</p>
<p class="detail">
<b>关键设计:</b>引擎异常时任务结论标记为 <code>error</code> 并按<b>拒绝</b>处理 —— 失败即拦截,绝不失败即放行。
</p>
</div>
<div class="cap" style="--c1:#ef4444; --c2:#f97316; --bg:#fef2f2;">
<div class="ic">⚡</div>
<h3>零容忍优先响应</h3>
<p class="cap-sub">20+ 条硬威胁规则 · 命中即拒</p>
<p>
有些特征不需要再算风险分 —— PDF 内嵌可执行 JavaScript、VBA 宏自动执行、
Linux ELF 可执行文件、LNK 快捷方式劫持、ISO 镜像内含可执行文件、路径穿越攻击,
命中任何一条,文件<b>当场终止</b>。
</p>
<p class="detail">
<b>与普通检测的区别:</b>零容忍规则绕过评分机制,不存在"分数高一点就放行"的情况。
</p>
</div>
<div class="cap" style="--c1:#8b5cf6; --c2:#d946ef; --bg:#f5f3ff;">
<div class="ic">🔍</div>
<h3>真实文件类型识别</h3>
<p class="cap-sub">不认扩展名 · 只看文件头魔数</p>
<p>
攻击者最容易钻的空子就是文件名 —— 把 <code style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;color:#b45309;">payload.exe</code>
改名成 <code style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;color:#b45309;">readme.txt</code>。
MeshSafe 直接读取文件头字节判断真实类型,与扩展名不符、双扩展名伪装、
危险可执行扩展名,统统识别出来。
</p>
<p class="detail">
<b>还覆盖:</b>压缩包结构安全预检、嵌套深度限制、解压总量上限(zip bomb 防护),
藏在压缩包里的第二层、第三层载荷同样会被拆开检测。
</p>
</div>
<div class="cap" style="--c1:#10b981; --c2:#14b8a6; --bg:#ecfdf5;">
<div class="ic">📝</div>
<h3>全链路审计追溯</h3>
<p class="cap-sub">哈希链防篡改 · 合规审计可查</p>
<p>
每次扫描的输入文件哈希、判定结论、命中规则明细、时间戳、来源渠道全部落库,
形成完整审计链。审计记录采用<b>哈希链</b>结构串联,任何一条被事后修改,
后续链路的校验立刻失效。
</p>
<p class="detail">
<b>合规价值:</b>等保测评、审计问询时"某个文件当时为什么被拦截",
可以直接调出完整判定过程,而不是靠回忆。
</p>
</div>
</div>
</section>
<!-- ===== 引擎分层 ===== -->
<section>
<div class="sec-head">
<div class="kicker">Detection Pipeline</div>
<h2>6 层检测管线</h2>
<p>每一层解决一类问题,层层递进不重复。</p>
</div>
<div class="layers">
<div class="layer">
<div class="lv">L0</div>
<div class="lt"><b>结构与完整性</b><span>Structure</span></div>
<div class="ld">识别真实文件类型、检出扩展名伪装与双扩展名、判定危险可执行扩展名、对压缩包做结构安全预检,拦截路径穿越与 zip bomb。</div>
</div>
<div class="layer">
<div class="lv">L1</div>
<div class="lt"><b>病毒特征</b><span>ClamAV</span></div>
<div class="ld">对接 ClamAV 病毒引擎做特征匹配,命中已知恶意样本直接判定恶意。病毒库可离线更新,完全不依赖外网。</div>
</div>
<div class="layer">
<div class="lv">L2</div>
<div class="lt"><b>文档深度分析</b><span>Document</span></div>
<div class="ld">解析 Office 宏、PDF 动作、OLE 嵌入对象、邮件附件等结构化内容,判断文档是否包含自动执行或外联行为。</div>
</div>
<div class="layer">
<div class="lv">L3</div>
<div class="lt"><b>关键词匹配</b><span>Keywords</span></div>
<div class="ld">在文本与脚本内容中匹配反弹 Shell、远程下载执行、凭据外传等高危行为模式,识别"无病毒但有恶意意图"的内容。</div>
</div>
<div class="layer">
<div class="lv">L4</div>
<div class="lt"><b>YARA 规则</b><span>YARA</span></div>
<div class="ld">加载自定义规则集匹配已知家族与自定义特征,规则可由安全团队随业务需要持续扩充,无需改代码。</div>
</div>
<div class="layer">
<div class="lv">L5</div>
<div class="lt"><b>PE/二进制分析</b><span>Binary</span></div>
<div class="ld">对可执行文件做指令级特征分析,识别加壳、混淆、可疑导入组合等静态分析可疑点,覆盖无签名但行为异常的程序。</div>
</div>
</div>
</section>
<!-- ===== 架构 ===== -->
<section>
<div class="sec-head">
<div class="kicker">Security Architecture</div>
<h2>为生产环境而生的架构</h2>
<p>安全能力不是叠加出来的,是从接入层到存储层贯穿设计的。</p>
</div>
<div class="arch-grid">
<div class="arch">
<div class="ai">🔐</div>
<h4>签名认证接入</h4>
<p>
每个调用方分配独立 <b>client_id + client_secret</b>,请求必须携带
<b>HMAC-SHA256 签名</b>。服务端用 HKDF 派生请求/响应双向独立密钥,
时间戳窗口 + 一次性 nonce 双重防重放,伪造请求进不来。
</p>
</div>
<div class="arch">
<div class="ai">🚦</div>
<h4>分层限流保护</h4>
<p>
扫描接口与非扫描接口<b>分别限流</b>,按调用方维度在 Redis 中滑动窗口计数,
多实例部署下计数依然准确。超限返回 429 与重试提示,不拖垮后端。
</p>
</div>
<div class="arch">
<div class="ai">🏗️</div>
<h4>高可用部署</h4>
<p>
多 worker 进程 + 数据库连接池 + 常驻病毒扫描守护进程,
并发扫描不阻塞事件循环。相同文件重复提交直接命中缓存秒级返回,不重复消耗算力。
</p>
</div>
</div>
</section>
<!-- ===== 底部信息 ===== -->
<section>
<div class="foot">
<span class="brand">MeshSafe</span>
<span>文件摆渡安全卫士</span>
<span class="dot">|</span>
<span>版本 <b>v1.0</b></span>
<span class="dot">|</span>
<span>完全离线部署可用</span>
<span class="dot">|</span>
<span>扫描结论与响应全程可验签</span>
</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>MeshSafe 控制台</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f4f6fa; color: #1e293b; }
{% include "_nav_style.html" %}
main { padding: 24px; max-width: 1320px; margin: 0 auto; }
/* ===== 顶部标题栏 ===== */
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 20px; }
.page-head h1 { margin: 0 0 6px; font-size: 21px; font-weight: 700; letter-spacing: .3px; }
.page-head .sub { font-size: 13px; color: #64748b; }
.policy-chip {
display: inline-flex; align-items: center; gap: 8px;
background: #fff; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 8px 14px; font-size: 12.5px; color: #475569;
box-shadow: 0 1px 3px rgba(0,0,0,.04);
}
.policy-chip b { color: #0f172a; }
.policy-chip code { color: #64748b; font-size: 11.5px; }
/* ===== 统计卡片行 ===== */
.stat-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 22px; }
.stat {
background: #fff; border-radius: 12px; padding: 18px 20px;
box-shadow: 0 1px 3px rgba(15,23,42,.07), 0 6px 20px rgba(15,23,42,.04);
border: 1px solid #eef2f7; position: relative; overflow: hidden;
transition: transform .18s, box-shadow .18s;
}
.stat:hover { transform: translateY(-2px); box-shadow: 0 4px 10px rgba(15,23,42,.09), 0 12px 30px rgba(15,23,42,.07); }
.stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; }
.stat.s1::before { background: #3b82f6; } .stat.s2::before { background: #ef4444; }
.stat.s3::before { background: #f59e0b; } .stat.s4::before { background: #8b5cf6; }
.stat.s5::before { background: #10b981; }
.stat .s-label { font-size: 12.5px; color: #64748b; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.stat .s-value { font-size: 28px; font-weight: 700; color: #0f172a; line-height: 1.1; font-variant-numeric: tabular-nums; }
.stat .s-value .unit { font-size: 14px; font-weight: 500; color: #94a3b8; margin-left: 3px; }
.stat .s-foot { font-size: 11.5px; color: #94a3b8; margin-top: 7px; }
/* ===== 主内容两栏 ===== */
.cols { display: grid; grid-template-columns: 1fr 320px; gap: 18px; align-items: start; }
.panel {
background: #fff; border-radius: 12px; padding: 20px;
box-shadow: 0 1px 3px rgba(15,23,42,.07), 0 6px 20px rgba(15,23,42,.04);
border: 1px solid #eef2f7; margin-bottom: 18px;
}
.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.panel-head h2 { margin: 0; font-size: 15px; font-weight: 600; color: #0f172a; display: flex; align-items: center; gap: 8px; }
.panel-head .hint { font-size: 12px; color: #94a3b8; }
/* ===== 引擎卡片 ===== */
.engine-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.ecard {
border: 1px solid #e8edf4; border-radius: 10px; padding: 14px;
background: #fcfdff; transition: all .18s;
}
.ecard:hover { border-color: #c7d2fe; box-shadow: 0 4px 14px rgba(99,102,241,.1); }
.ecard .e-top { display: flex; align-items: center; gap: 9px; margin-bottom: 10px; }
.ecard .e-icon {
width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 15px;
background: #eff6ff; color: #3b82f6;
}
.ecard .e-name { font-size: 13.5px; font-weight: 600; color: #1e293b; line-height: 1.25; }
.ecard .e-ver { font-size: 11px; color: #94a3b8; font-family: monospace; }
.badge {
margin-left: auto; font-size: 11px; padding: 3px 9px; border-radius: 20px;
font-weight: 500; white-space: nowrap; flex-shrink: 0;
}
.badge.ok { background: #dcfce7; color: #15803d; }
.badge.error { background: #fee2e2; color: #b91c1c; }
.badge.degraded { background: #fef3c7; color: #b45309; }
.ecard .e-meta { font-size: 11.5px; color: #64748b; line-height: 1.7; }
.ecard .e-meta .k { color: #94a3b8; }
.ecard .e-meta .v { color: #334155; font-weight: 500; }
.ecard .e-err { margin-top: 8px; font-size: 11.5px; color: #b91c1c; background: #fef2f2;
padding: 6px 9px; border-radius: 6px; line-height: 1.5; }
.clamav-actions { margin-top: 11px; display: flex; gap: 7px; }
.clamav-actions button {
flex: 1; padding: 6px 8px; border: none; border-radius: 6px; cursor: pointer;
font-size: 11.5px; font-weight: 500; transition: all .18s;
}
.btn-online { background: #e0f2fe; color: #0369a1; }
.btn-online:hover { background: #bae6fd; }
.btn-upload { background: #dcfce7; color: #15803d; }
.btn-upload:hover { background: #bbf7d0; }
/* ===== 结论分布条 ===== */
.dist-bar { display: flex; height: 10px; border-radius: 6px; overflow: hidden; background: #f1f5f9; margin-bottom: 12px; }
.dist-bar span { display: block; height: 100%; }
.dist-bar .d-ok { background: #10b981; }
.dist-bar .d-warn { background: #f59e0b; }
.dist-bar .d-bad { background: #ef4444; }
.dist-bar .d-err { background: #8b5cf6; }
.dist-bar .d-muted { background: #94a3b8; }
.dist-legend { display: flex; flex-wrap: wrap; gap: 14px; }
.dist-legend .li { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #64748b; }
.dist-legend .li .dot { width: 8px; height: 8px; border-radius: 3px; }
.dist-legend .li b { color: #1e293b; font-variant-numeric: tabular-nums; }
.dist-legend .li .pct { color: #94a3b8; font-size: 11px; }
/* ===== 渠道排行 ===== */
.rank-item { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px dashed #eef2f7; }
.rank-item:last-child { border-bottom: none; }
.rank-item .r-no {
width: 20px; height: 20px; border-radius: 5px; background: #eff6ff; color: #3b82f6;
font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.rank-item .r-name { font-size: 12.5px; color: #1e293b; font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rank-item .r-name small { display: block; font-family: -apple-system, "PingFang SC", sans-serif; color: #94a3b8; font-size: 11px; }
.rank-item .r-count { margin-left: auto; font-size: 13px; font-weight: 600; color: #334155; font-variant-numeric: tabular-nums; }
/* ===== 任务表 ===== */
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; padding: 9px 12px; text-align: left; font-size: 11.5px;
color: #64748b; font-weight: 600; border-bottom: 1px solid #e8edf4; white-space: nowrap; }
td { padding: 10px 12px; font-size: 12.5px; border-bottom: 1px solid #f4f7fb; color: #334155; }
tr:last-child td { border-bottom: none; }
tbody tr:hover { background: #fafbfd; }
td.mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11.5px; color: #64748b; }
td.fname { font-weight: 500; color: #1e293b; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link { color: #3b82f6; text-decoration: none; font-size: 12px; }
.link:hover { text-decoration: underline; }
.empty { text-align: center; color: #94a3b8; padding: 32px 0; font-size: 13px; }
/* ===== 模态框 ===== */
.modal-mask { display: none; position: fixed; inset: 0; background: rgba(15,23,42,.5);
z-index: 100; align-items: center; justify-content: center; }
.modal-mask.show { display: flex; }
.modal { background: white; border-radius: 14px; width: 480px; max-width: 90vw;
box-shadow: 0 24px 64px rgba(0,0,0,.28); overflow: hidden; }
.modal-header { background: linear-gradient(135deg, #0f172a, #1e3a5f); color: white; padding: 15px 20px;
font-size: 15px; font-weight: 500; display: flex; justify-content: space-between; align-items: center; }
.modal-header .close { cursor: pointer; opacity: .7; font-size: 22px; line-height: 1; }
.modal-header .close:hover { opacity: 1; }
.modal-body { padding: 20px; font-size: 13px; }
.modal-body p { margin: 8px 0; color: #555; }
.modal-body .hint { background: #fffbeb; border-left: 3px solid #f59e0b; padding: 11px 14px;
border-radius: 0 6px 6px 0; margin: 13px 0; font-size: 12px; color: #78350f; line-height: 1.7; }
.modal-body label { display: block; margin: 13px 0 6px; font-weight: 500; color: #333; }
.modal-body input[type="file"] { width: 100%; padding: 9px; border: 1.5px dashed #cbd5e1; border-radius: 8px; background: #f8fafc; }
.modal-body .result-box { margin-top: 13px; padding: 11px 14px; border-radius: 8px; font-size: 12px; display: none; white-space: pre-wrap; line-height: 1.7; }
.modal-body .result-box.show { display: block; }
.result-box.ok { background: #f0fdf4; color: #15803d; border: 1px solid #bbf7d0; }
.result-box.err { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; }
.modal-footer { padding: 14px 20px; border-top: 1px solid #eef2f7; display: flex; justify-content: flex-end; gap: 8px; }
.modal-footer button { padding: 8px 20px; border: none; border-radius: 7px; cursor: pointer; font-size: 13px; font-weight: 500; }
.btn-primary { background: #1e3a5f; color: white; }
.btn-primary:hover { background: #152060; }
.btn-primary:disabled { background: #cbd5e1; cursor: not-allowed; }
.btn-secondary { background: #f1f5f9; color: #475569; }
.btn-secondary:hover { background: #e2e8f0; }
@media (max-width: 1150px) {
.stat-row { grid-template-columns: repeat(3, 1fr); }
.cols { grid-template-columns: 1fr; }
.engine-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 700px) {
.stat-row { grid-template-columns: repeat(2, 1fr); }
.engine-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
{% set nav_active = 'overview' %}{% include "_nav.html" %}
<main>
<!-- ===== 顶部标题 + 策略 ===== -->
<div class="page-head">
<div>
<h1>系统概览</h1>
<div class="sub">光闸摆渡文件安全检测 · 实时运行状态与统计</div>
</div>
<div class="policy-chip">
当前策略 <b>{{ policy_store.current().version }}</b>
<span>·</span>
<code>{{ policy_store.current().sha256[:16] }}</code>
</div>
</div>
<!-- ===== 统计卡片 ===== -->
<div class="stat-row">
<div class="stat s1">
<div class="s-label">📊 累计扫描</div>
<div class="s-value">{{ stats.total_scans }}<span class="unit">个文件</span></div>
<div class="s-foot">检测引擎处理总量</div>
</div>
<div class="stat s2">
<div class="s-label">🛑 拦截次数</div>
<div class="s-value">{{ stats.total_blocked }}<span class="unit">次</span></div>
<div class="s-foot">零容忍命中直接阻断</div>
</div>
<div class="stat s3">
<div class="s-label">⚠️ 检出问题</div>
<div class="s-value">{{ stats.total_findings }}<span class="unit">项</span></div>
<div class="s-foot">规则命中 findings 总数</div>
</div>
<div class="stat s4">
<div class="s-label">📋 规则总数</div>
<div class="s-value">{{ stats.total_rules }}<span class="unit">条</span></div>
<div class="s-foot">零容忍 {{ stats.zero_tolerance }} · YARA {{ stats.yara_rules }}</div>
</div>
<div class="stat s5">
<div class="s-label">⚡ 引擎在线</div>
<div class="s-value">{{ stats.engines_ok }}<span class="unit">/ {{ stats.engines_total }}</span></div>
<div class="s-foot">全部引擎可用则正常检测</div>
</div>
</div>
<!-- ===== 两栏主区 ===== -->
<div class="cols">
<div>
<!-- ===== 引擎状态 ===== -->
<div class="panel">
<div class="panel-head">
<h2>🧩 引擎状态</h2>
<span class="hint">{{ stats.engines_ok }} / {{ stats.engines_total }} 引擎在线</span>
</div>
<div class="engine-grid">
{% for h in engine_health %}
<div class="ecard">
<div class="e-top">
<div class="e-icon">{% if h.engine == 'structure' %}🗂️{% elif h.engine == 'clamav' %}🦠{% elif h.engine == 'document' %}📄{% elif h.engine == 'keywords' %}🔑{% elif h.engine == 'yara' %}🎯{% elif h.engine == 'binary' %}⚙️{% else %}🔧{% endif %}</div>
<div>
<div class="e-name">{{ h.display_name }}</div>
{% if h.engine_version_label %}<div class="e-ver">{{ h.engine_version_label }}</div>{% endif %}
</div>
<span class="badge {{ 'ok' if h.ok else ('error' if h.error else 'degraded') }}">{{ h.status_label or h.status }}</span>
</div>
<div class="e-meta">
{% if h.engine == 'clamav' %}
<div><span class="k">病毒库</span> <span class="v" id="clamav-sig-date">加载中...</span></div>
{% elif h.engine == 'yara' %}
<div><span class="k">规则库</span> <span class="v">{{ stats.yara_rules }} 条规则已加载</span></div>
{% elif h.engine == 'keywords' %}
<div><span class="k">特征词表</span> <span class="v">{{ policy_store.current().keyword_rules|length }} 条规则</span></div>
{% else %}
<div><span class="k">层级</span> <span class="v">{{ h.layer_label }}</span></div>
{% endif %}
</div>
{% if h.friendly_error %}
<div class="e-err">{{ h.friendly_error }}</div>
{% endif %}
{% if h.engine == 'clamav' %}
<div class="clamav-actions">
<button class="btn-online" onclick="clamavUpdate(this)">🔄 在线更新</button>
<button class="btn-upload" onclick="openUploadModal()">📤 上传签名库</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- ===== 检测结论分布 ===== -->
<div class="panel">
<div class="panel-head">
<h2>📈 检测结论分布</h2>
<span class="hint">共 {{ stats.total_scans }} 次扫描</span>
</div>
{% if verdict_items %}
<div class="dist-bar">
{% for v in verdict_items %}
<span class="d-{{ v.cls }}" style="width: {{ v.pct }}%" title="{{ v.label }} {{ v.pct }}%"></span>
{% endfor %}
</div>
<div class="dist-legend">
{% for v in verdict_items %}
<div class="li">
<span class="dot d-{{ v.cls }}"></span>
<span>{{ v.label }}</span>
<b>{{ v.count }}</b>
<span class="pct">{{ v.pct }}%</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">暂无扫描数据</div>
{% endif %}
</div>
<!-- ===== 最近扫描任务 ===== -->
<div class="panel">
<div class="panel-head">
<h2>🕐 最近扫描任务</h2>
<a class="link" href="/console/tasks">查看全部 →</a>
</div>
{% if recent_tasks %}
<table>
<thead>
<tr>
<th>时间</th><th>文件名</th><th>来源渠道</th><th>大小</th><th>结论</th><th>动作</th><th>分数</th><th></th>
</tr>
</thead>
<tbody>
{% for t in recent_tasks %}
<tr>
<td class="mono">{{ t.created_at|bj_clock if t.created_at else '' }}</td>
<td class="fname" title="{{ t.filename }}">{{ t.filename }}</td>
<td class="mono" title="{{ t.client_id }} / {{ t.channel }}">{{ t.channel|cn_channel or '-' }}</td>
<td class="mono">{{ (t.size / 1024)|round(1) if t.size else 0 }} KB</td>
<td><span class="tag {{ t.verdict|cn_verdict_badge }}" title="{{ t.verdict }}">{{ t.verdict|cn_verdict }}</span></td>
<td><span class="tag plain" title="{{ t.action }}">{{ t.action|cn_action }}</span></td>
<td class="mono">{{ t.score }}</td>
<td><a class="link" href="/console/tasks#task-{{ t.id }}">详情</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">暂无扫描记录,先去 <a class="link" href="/console/test">测试靶场</a> 跑一批样本吧</div>
{% endif %}
</div>
</div>
<!-- ===== 右栏 ===== -->
<div>
<!-- 策略要点 -->
<div class="panel">
<div class="panel-head"><h2>🛡️ 策略要点</h2></div>
<div class="e-meta" style="font-size:12.5px; line-height:2;">
<div><span class="k">零容忍规则</span> <span class="v">{{ stats.zero_tolerance }} 条</span></div>
<div><span class="k">YARA 规则</span> <span class="v">{{ stats.yara_rules }} 条</span></div>
<div><span class="k">规则覆盖</span> <span class="v">{{ policy_store.current().rule_overrides|length }} 条</span></div>
<div><span class="k">拦截阈值</span> <span class="v">≥ {{ policy_store.current().block_score }} 分</span></div>
<div><span class="k">文件大小上限</span> <span class="v">{{ (policy_store.current().max_file_size / 1024 / 1024 / 1024)|round(2) }} GB</span></div>
</div>
</div>
<!-- 渠道排行 -->
<div class="panel">
<div class="panel-head">
<h2>🔗 摆渡来源 TOP</h2>
<span class="hint">按任务数</span>
</div>
{% if channel_rank %}
{% for c in channel_rank %}
<div class="rank-item">
<div class="r-no">{{ loop.index }}</div>
<div class="r-name">{{ c.channel|cn_channel }}<small>{{ c.client_id }}</small></div>
<div class="r-count">{{ c.count }}</div>
</div>
{% endfor %}
{% else %}
<div class="empty">暂无渠道数据</div>
{% endif %}
</div>
</div>
</div>
</main>
<!-- ========== 上传签名库模态框 ========== -->
<div class="modal-mask" id="uploadModal">
<div class="modal">
<div class="modal-header">
📤 离线导入 ClamAV 签名库
<span class="close" onclick="closeUploadModal()">×</span>
</div>
<div class="modal-body">
<p>选择需要更新的 ClamAV 签名库文件(可多选):</p>
<div class="hint">
💡 光闸内网无法联网,请先在<strong>外网电脑</strong>下载最新签名库,通过 U 盘/摆渡带进内网后上传。<br>
下载地址:<code>https://database.clamav.net/main.cvd</code> / <code>daily.cvd</code> / <code>bytecode.cvd</code><br>
clamd 会自动热加载新签名,无需重启。
</div>
<label>选择文件(.cvd / .cld):</label>
<input type="file" id="uploadFiles" multiple accept=".cvd,.cld,.ign2">
<div class="result-box" id="uploadResult"></div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeUploadModal()">取消</button>
<button class="btn-primary" id="uploadSubmitBtn" onclick="submitUpload()">确认上传</button>
</div>
</div>
</div>
<script>
// ========== ClamAV 病毒库信息加载 ==========
async function loadClamavInfo() {
const el = document.getElementById('clamav-sig-date');
if (!el) return;
try {
const resp = await fetch('/console/clamav/info', { credentials: 'same-origin' });
const data = await resp.json();
if (!data.daemon_ok) {
el.textContent = '守护进程不可用';
el.style.color = '#b91c1c';
return;
}
// 只显示到日期,不显示时分秒
const d = (data.signature_date || '').slice(0, 10);
el.textContent = d || '未知';
el.style.color = '';
if (data.signature_days_old !== null && data.signature_days_old > 7) {
el.textContent = d + '(已 ' + data.signature_days_old + ' 天)';
el.style.color = '#b45309';
}
} catch (e) {
el.textContent = '加载失败';
el.style.color = '#b91c1c';
}
}
// ========== 在线更新 ==========
async function clamavUpdate(btn) {
if (!confirm('确认尝试在线更新病毒库?\n光闸内网环境大概率拉不到(断网隔离)。')) return;
const old = btn.textContent;
btn.disabled = true; btn.textContent = '更新中...';
try {
const resp = await fetch('/console/clamav/update', { method: 'POST', credentials: 'same-origin' });
const data = await resp.json();
if (data.ok) {
alert('✅ ' + data.message + '\n\n' + (data.output || ''));
} else {
alert('⚠️ ' + data.message + '\n\n请改用「上传签名库」离线导入。');
}
loadClamavInfo();
} catch (e) {
alert('❌ 请求失败: ' + e);
} finally {
btn.disabled = false; btn.textContent = old;
}
}
// ========== 上传模态框 ==========
function openUploadModal() {
document.getElementById('uploadModal').classList.add('show');
document.getElementById('uploadResult').className = 'result-box';
document.getElementById('uploadResult').textContent = '';
document.getElementById('uploadFiles').value = '';
}
function closeUploadModal() {
document.getElementById('uploadModal').classList.remove('show');
}
async function submitUpload() {
const input = document.getElementById('uploadFiles');
const result = document.getElementById('uploadResult');
const btn = document.getElementById('uploadSubmitBtn');
if (!input.files || input.files.length === 0) {
result.className = 'result-box err show';
result.textContent = '❌ 请先选择要上传的文件';
return;
}
const fd = new FormData();
for (let i = 0; i < input.files.length; i++) {
fd.append('files', input.files[i]);
}
btn.disabled = true; btn.textContent = '上传中...';
result.className = 'result-box';
result.textContent = '';
try {
const resp = await fetch('/console/clamav/upload', {
method: 'POST', body: fd, credentials: 'same-origin',
});
const data = await resp.json();
result.className = 'result-box ' + (data.ok ? 'ok' : 'err') + ' show';
let txt = '';
if (data.saved && data.saved.length > 0) {
txt += '✅ 成功导入 ' + data.saved.length + ' 个文件:\n';
data.saved.forEach(s => txt += ' · ' + s.name + ' (' + s.size_mb + ' MB)\n');
txt += '\n' + (data.reload_message || '');
}
if (data.errors && data.errors.length > 0) {
txt += '\n❌ 失败:\n';
data.errors.forEach(e => txt += ' · ' + e + '\n');
}
if (data.skipped && data.skipped.length > 0) {
txt += '\n⏭️ 跳过:\n';
data.skipped.forEach(s => txt += ' · ' + s + '\n');
}
result.textContent = txt || data.message || '完成';
loadClamavInfo();
} catch (e) {
result.className = 'result-box err show';
result.textContent = '❌ 请求失败: ' + e;
} finally {
btn.disabled = false; btn.textContent = '确认上传';
}
}
loadClamavInfo();
</script>
</body>
</html>

View File

@@ -0,0 +1,415 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>MeshSafe · 诊断面板</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f4f6fa; color: #1e293b; }
{% include "_nav_style.html" %}
/* 诊断面板专用样式 */
.diag-wrap { padding: 28px 40px; max-width: 1180px; margin: 0 auto; }
.diag-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 24px;
}
.diag-header h1 { font-size: 20px; font-weight: 700; color: #0f172a; }
.diag-header .meta { font-size: 12.5px; color: #64748b; }
/* 统计卡 */
.diag-stats {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px;
margin-bottom: 24px;
}
.diag-stat {
background: white; border: 1px solid #e2e8f0; border-radius: 12px;
padding: 18px 20px;
}
.diag-stat .label { font-size: 12px; color: #64748b; margin-bottom: 4px; }
.diag-stat .val { font-size: 26px; font-weight: 700; color: #0f172a; }
.diag-stat .val.crit { color: #b91c1c; }
.diag-stat .val.warn { color: #b45309; }
.diag-stat .val.ok { color: #15803d; }
/* 区块 */
.diag-block {
background: white; border: 1px solid #e2e8f0; border-radius: 12px;
margin-bottom: 20px;
}
.diag-block h2 {
font-size: 14px; font-weight: 600; color: #0f172a;
padding: 14px 20px; border-bottom: 1px solid #f1f5f9;
}
/* 规则列表 */
.rule-item {
padding: 12px 20px; border-bottom: 1px solid #f8fafc;
display: flex; align-items: flex-start; gap: 12px;
}
.rule-item:last-child { border-bottom: none; }
.rule-item .rule-id {
font-family: monospace; font-size: 12px; color: #94a3b8;
min-width: 44px; flex-shrink: 0;
}
.rule-item .rule-body { flex: 1; min-width: 0; }
.rule-item .rule-msg {
font-size: 13.5px; color: #0f172a; margin-bottom: 2px;
}
.rule-item .rule-hint {
font-size: 12px; color: #64748b; line-height: 1.5;
white-space: pre-line;
}
.rule-item .rule-evidence {
font-size: 11.5px; color: #94a3b8; font-family: monospace;
margin-top: 4px; padding: 6px 10px; background: #f8fafc; border-radius: 6px;
max-height: 120px; overflow-y: auto; white-space: pre-wrap;
}
.rule-item .rule-time {
font-size: 11px; color: #94a3b8; flex-shrink: 0;
}
.rule-item.skipped .rule-msg { color: #94a3b8; }
/* 容器/引擎表 */
.diag-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.diag-table th {
text-align: left; padding: 10px 20px; font-weight: 500;
color: #64748b; font-size: 12px; border-bottom: 1px solid #f1f5f9;
}
.diag-table td { padding: 10px 20px; border-bottom: 1px solid #f8fafc; color: #0f172a; }
.diag-table tr:last-child td { border-bottom: none; }
.engine-name, .container-name { font-weight: 500; }
.status-running { color: #15803d; font-weight: 500; }
.status-exited { color: #b91c1c; font-weight: 500; }
.status-paused { color: #b45309; font-weight: 500; }
/* 未启用横幅 */
.diag-disabled {
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
border: 1px solid #cbd5e1; border-radius: 12px;
padding: 28px 32px; text-align: center; margin-bottom: 24px;
}
.diag-disabled h2 { font-size: 18px; font-weight: 600; color: #475569; margin-bottom: 10px; }
.diag-disabled p { font-size: 13px; color: #64748b; line-height: 1.7; }
/* 空状态 */
.diag-empty {
padding: 20px; text-align: center; color: #94a3b8; font-size: 13px;
}
/* 按钮 */
.btn-export {
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 22px;
background: linear-gradient(135deg, #1e3a5f, #3b82f6);
color: white; border: none; border-radius: 8px;
font-size: 13.5px; font-weight: 500; cursor: pointer;
text-decoration: none; transition: all .2s;
}
.btn-export:hover { box-shadow: 0 4px 12px rgba(59,130,246,.3); transform: translateY(-1px); }
.diag-footer {
display: flex; justify-content: flex-end; align-items: center;
gap: 16px; margin-top: 20px;
}
/* 刷新按钮 */
.refresh-btn {
display: inline-flex; align-items: center; gap: 4px;
background: transparent; border: none; cursor: pointer;
color: #94a3b8; font-size: 12px; font-weight: 400;
margin-left: 8px; padding: 2px 6px; border-radius: 4px;
transition: color .2s, background .2s;
vertical-align: middle;
}
.refresh-btn:hover { color: #3b82f6; background: rgba(59,130,246,.08); }
.refresh-btn svg { transition: transform .6s cubic-bezier(.4,0,.2,1); }
.refresh-btn.spinning svg { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.refresh-time { font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
{% include "_nav.html" %}
<div class="diag-wrap">
<!-- 顶部 -->
<div class="diag-header">
<h1>🔧 系统诊断</h1>
<div class="meta">
🕐 刷新时间: {{ refresh_time }}
&nbsp;·&nbsp; {{ docker_status_msg }}
{% if started_at %}&nbsp;·&nbsp; 诊断启用: {{ started_at[:19].replace('T', ' ') }}{% endif %}
</div>
</div>
<!-- 未启用横幅 -->
{% if not diag_enabled %}
<div class="diag-disabled">
<h2>⛔ 诊断套件未启用</h2>
<p>diagnostics.yaml: diagnostics.enabled = false<br>
启用后将开启后台定时检测(三个定时器,CPU 占用可忽略)。<br>
当前仅保留手动导出功能,自动规则引擎已停止。</p>
<p style="margin-top:14px;"><a href="/api/v1/telemetry/dump" class="btn-export">📦 手动导出诊断包</a></p>
</div>
{% else %}
<!-- 统计卡 -->
<div class="diag-stats">
<div class="diag-stat">
<div class="label">规则总数</div>
<div class="val">{{ rule_count }}</div>
</div>
<div class="diag-stat">
<div class="label">严重</div>
<div class="val crit">{{ crit_count }}</div>
</div>
<div class="diag-stat">
<div class="label">警告</div>
<div class="val warn">{{ warn_count }}</div>
</div>
<div class="diag-stat">
<div class="label">通过</div>
<div class="val ok">{{ ok_count }}</div>
</div>
</div>
<!-- 异常检测 -->
<div class="diag-block">
<h2>⚠️ 规则检测</h2>
{% if all_rules %}
{% for r in all_rules %}
<div class="rule-item {% if r.skipped %}skipped{% endif %}">
<span class="rule-id">{{ r.id }}</span>
<div class="rule-body">
<div class="rule-msg">
<span class="{{ badge_for(r.severity) }}">{{ {
'high': '严重', 'medium': '警告', 'low': '提示',
'info': '信息', 'ok': '通过'
}.get(r.severity, '未知') }}</span>
&nbsp;{{ r.message }}
</div>
{% if r.hint %}<div class="rule-hint">💡 {{ r.hint }}</div>{% endif %}
{% if r.evidence %}
<div class="rule-evidence">{% for e in r.evidence %}{{ e }}{% if not loop.last %}\n{% endif %}{% endfor %}</div>
{% endif %}
{% if r.skipped and r.skip_reason %}
<div class="rule-hint" style="color:#94a3b8;">⏭️ 跳过原因: {{ r.skip_reason }}</div>
{% endif %}
</div>
<span class="rule-time">
{% if r.run_at %}{{ r.run_at }}{% endif %}
{% if r.run_by %}<br><span style="color:#cbd5e1;font-size:10.5px;">{{ r.run_by }}</span>{% endif %}
</span>
</div>
{% endfor %}
{% else %}
<div class="diag-empty">暂无规则结果(定时器还没跑起来?)</div>
{% endif %}
</div>
<!-- 引擎健康 -->
<div class="diag-block" id="engine-health-wrap">
<h2>🧪 引擎状态
<button id="engine-refresh-btn" class="refresh-btn" title="立即刷新" aria-label="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
<span id="engine-refresh-time" class="refresh-time">—</span>
</button>
</h2>
<div id="engine-health-body">
{% if engine_health %}
<table class="diag-table">
<thead><tr><th>引擎</th><th>状态</th><th>版本 / 详情</th></tr></thead>
<tbody>
{% for name, info in engine_health.items() %}
<tr>
<td class="engine-name">{{ name }}</td>
<td>
{% if info.get('ok') or info.get('available') %}
<span class="tag ok">在线</span>
{% else %}
<span class="tag bad">离线</span>
{% endif %}
</td>
<td style="color:#64748b;font-size:12px;">
{% if info.get('version') %}版本: {{ info.version }}{% endif %}
{% if info.get('signature_date') %}· 病毒库: {{ info.signature_date }}{% endif %}
{% if info.get('rules_loaded') is not none %}· 规则: {{ info.rules_loaded }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="diag-empty">引擎健康信息不可用</div>
{% endif %}
</div>
</div>
<!-- 容器状态(Docker 可用时显示)-->
{% if docker_status == 'full' %}
<div class="diag-block">
<h2>🐳 容器运行状态</h2>
{% if docker_containers %}
<table class="diag-table">
<thead><tr><th>容器</th><th>状态</th><th>健康</th><th>Docker重启</th><th>历史重启</th><th>运行时长</th></tr></thead>
<tbody>
{% for c in docker_containers %}
{% if 'meshsafe' in c.name %}
<tr>
<td class="container-name">{{ c.name }}</td>
<td class="status-{{ c.status }}">
{% if c.oom_killed %}<span class="tag bad">OOM</span> {% endif %}
{{ c.status }}
</td>
<td>
{% if c.health %}
{% if c.health == 'healthy' %}<span class="tag ok">✓ healthy</span>
{% elif c.health == 'unhealthy' %}<span class="tag bad">✗ unhealthy</span>
{% elif c.health == 'starting' %}<span class="tag warn">… starting</span>
{% else %}{{ c.health }}{% endif %}
{% else %}—{% endif %}
</td>
<td>
{% if c.restart_count and c.restart_count > 3 %}
<span class="tag bad">{{ c.restart_count }}</span>
{% else %}
{{ c.restart_count if c.restart_count is not none else '-' }}
{% endif %}
</td>
<td>
{% if c.historical_restart_count and c.historical_restart_count > 0 %}
<span class="tag warn">{{ c.historical_restart_count }}</span>
{% else %}
0
{% endif %}
</td>
<td>{{ c.uptime or '—' }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
<div class="diag-empty" style="font-size:11.5px;color:#94a3b8;margin-top:6px;padding:0;">
说明:Docker重启 = RestartCount(仅自动崩溃被拉起计数);
历史重启 = R015 检测到的 StartedAt/restart_count 变化(含手动 docker restart)
</div>
{% else %}
<div class="diag-empty">未发现 meshsafe 容器</div>
{% endif %}
</div>
{% elif docker_status == 'partial' %}
<div class="diag-block">
<h2>🐳 容器运行状态 <span class="tag warn">Docker 访问受限</span></h2>
<div class="diag-empty">部分容器信息不可用 — {{ docker_info.get('message', '') }}</div>
</div>
{% elif docker_status == 'none' %}
<div class="diag-block">
<h2>🐳 容器运行状态 <span class="tag muted">未启用</span></h2>
<div class="diag-empty">
Docker 日志检测未启用 — 如需启用,在 docker-compose.yml 的 api 服务中加<br>
<code>volumes: /var/run/docker.sock:/var/run/docker.sock:ro</code><br>
并确保 <code>group_add: ["999"]</code>
</div>
</div>
{% endif %}
<!-- 底部导出 -->
<div class="diag-footer">
<a href="/api/v1/telemetry/dump" class="btn-export" onclick="return confirm('将导出最近 500 条扫描记录、引擎状态、容器日志。\n不含客户内部文件名、IP、用户名等敏感信息。\n确定导出?')">📦 一键导出诊断包</a>
</div>
{% endif %} <!-- diag_enabled endif -->
</div>
<script>
(function(){
var REFRESH_INTERVAL = 10000; // 10 秒
var btn = document.getElementById('engine-refresh-btn');
var timeEl = document.getElementById('engine-refresh-time');
var bodyEl = document.getElementById('engine-health-body');
if (!btn || !bodyEl) return;
var timer = null;
function fmtTime(d){
var p = n => n.toString().padStart(2,'0');
return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds());
}
function buildHealthHTML(health){
if (!health || Object.keys(health).length === 0){
return '<div class="diag-empty">引擎健康信息不可用</div>';
}
var rows = Object.entries(health).map(function([name, info]){
var online = info.ok || info.available;
var tag = online
? '<span class="tag ok">在线</span>'
: '<span class="tag bad">离线</span>';
var parts = [];
if (info.version) parts.push('版本: ' + info.version);
if (info.signature_date) parts.push('· 病毒库: ' + info.signature_date);
if (info.rules_loaded != null) parts.push('· 规则: ' + info.rules_loaded);
var detail = parts.length
? '<td style="color:#64748b;font-size:12px;">' + parts.join('') + '</td>'
: '<td style="color:#64748b;font-size:12px;">—</td>';
return '<tr>' +
'<td class="engine-name">' + name + '</td>' +
'<td>' + tag + '</td>' +
detail +
'</tr>';
}).join('');
return '<table class="diag-table">' +
'<thead><tr><th>引擎</th><th>状态</th><th>版本 / 详情</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
}
function refresh(){
btn.classList.add('spinning');
fetch('/api/v1/telemetry/summary', {credentials: 'same-origin'})
.then(function(r){ return r.json(); })
.then(function(data){
if (data.engine_health){
bodyEl.innerHTML = buildHealthHTML(data.engine_health);
}
if (timeEl) timeEl.textContent = fmtTime(new Date());
})
.catch(function(){
if (timeEl) timeEl.textContent = '失败';
})
.finally(function(){
btn.classList.remove('spinning');
});
}
btn.addEventListener('click', function(e){
e.preventDefault();
refresh();
});
// 10 秒自动刷新
timer = setInterval(refresh, REFRESH_INTERVAL);
// 页面隐藏时暂停(省请求)
document.addEventListener('visibilitychange', function(){
if (document.hidden){
if (timer){ clearInterval(timer); timer = null; }
} else {
if (!timer){ timer = setInterval(refresh, REFRESH_INTERVAL); refresh(); }
}
});
// 首次记录时间
if (timeEl) timeEl.textContent = fmtTime(new Date());
})();
</script>
</body>
</html>

278
app/templates/login.html Normal file
View File

@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>MeshSafe · 文件摆渡安全卫士</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
min-height: 100vh;
display: flex;
background: #f8fafc;
}
/* ========= 左栏:品牌区 ========= */
.brand-side {
flex: 1.25;
background: linear-gradient(160deg, #0f172a 0%, #1e3a5f 40%, #0c4a6e 100%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 60px 72px;
position: relative;
overflow: hidden;
}
.brand-side::before {
content: "";
position: absolute; inset: 0;
background-image:
linear-gradient(rgba(56,189,248,.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(56,189,248,.08) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse at center, black 20%, transparent 70%);
}
.brand-side::after {
content: "";
position: absolute;
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(56,189,248,.15) 0%, transparent 65%);
top: 50%; left: 50%;
transform: translate(-50%, -50%);
animation: pulse 6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: .7; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.05); }
}
.brand-content { position: relative; z-index: 1; width: 100%; max-width: 620px; }
.brand-logo {
display: flex; align-items: center; gap: 14px;
margin-bottom: 52px;
}
.brand-logo img { height: 42px; width: 42px; border-radius: 9px; }
.brand-logo .brand-name {
font-size: 27px; font-weight: 700; color: white; letter-spacing: .5px;
line-height: 1.2;
}
.brand-logo .brand-sub {
font-size: 12.5px; color: rgba(148,163,184,.85); letter-spacing: 1.5px;
margin-top: 3px;
}
/* 主标语 — 一行 */
.tagline-cn {
font-size: 30px; font-weight: 700; color: white;
line-height: 1.4; margin-bottom: 14px;
white-space: nowrap;
}
.tagline-cn .highlight {
background: linear-gradient(90deg, #38bdf8, #818cf8);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.tagline-en {
font-size: 13.5px; color: rgba(148,163,184,.75); letter-spacing: .3px;
margin-bottom: 44px;
}
/* 能力标签 — 横向一行 */
.cap-row {
display: flex; flex-wrap: nowrap; gap: 8px;
}
.cap {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 13px;
background: rgba(56,189,248,.08);
border: 1px solid rgba(56,189,248,.22);
border-radius: 100px;
font-size: 12.5px; color: rgba(226,232,240,.92);
backdrop-filter: blur(4px);
transition: all .2s;
white-space: nowrap;
flex-shrink: 0;
}
.cap:hover {
background: rgba(56,189,248,.15);
border-color: rgba(56,189,248,.4);
}
.cap .dot {
width: 6px; height: 6px; border-radius: 50%;
background: #38bdf8; flex-shrink: 0;
box-shadow: 0 0 8px rgba(56,189,248,.8);
}
.cap.secure .dot { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.8); }
.cap.secure:hover { background: rgba(52,211,153,.13); border-color: rgba(52,211,153,.38); }
/* 引擎列表 */
.engine-line {
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid rgba(148,163,184,.15);
}
.engine-line .el-label {
font-size: 11.5px; color: rgba(148,163,184,.6);
letter-spacing: 2px; margin-bottom: 12px;
}
.engine-line .el-items {
display: flex; flex-wrap: wrap; gap: 8px;
}
.engine-line .el {
font-size: 12px; color: rgba(203,213,225,.75);
padding: 5px 11px;
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.08);
border-radius: 6px;
}
/* ========= 右栏:登录卡片 ========= */
.login-side {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 60px;
}
.login-card { width: 380px; }
.login-card .hello {
font-size: 15px; color: #64748b; margin-bottom: 8px;
}
.login-card .title {
font-size: 24px; font-weight: 700; color: #0f172a; margin-bottom: 36px;
}
label { display: block; font-size: 13px; color: #334155; margin-bottom: 8px; font-weight: 500; }
.input-wrap { position: relative; margin-bottom: 20px; }
.input-wrap input {
width: 100%; padding: 12px 14px 12px 40px;
border: 1.5px solid #e2e8f0; border-radius: 10px;
font-size: 14px; background: #f8fafc;
transition: all .2s;
}
.input-wrap input:focus {
border-color: #3b82f6; background: white;
box-shadow: 0 0 0 3px rgba(59,130,246,.12); outline: none;
}
.input-wrap .input-icon {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: #94a3b8; font-size: 16px;
}
button.login-btn {
width: 100%; padding: 13px;
background: linear-gradient(135deg, #1e3a5f, #3b82f6);
color: white; border: none; border-radius: 10px;
font-size: 15px; font-weight: 600; cursor: pointer;
transition: all .2s;
margin-top: 8px;
}
button.login-btn:hover {
background: linear-gradient(135deg, #244773, #2563eb);
box-shadow: 0 8px 24px rgba(59,130,246,.3);
transform: translateY(-1px);
}
button.login-btn:active { transform: translateY(0); }
.error {
background: #fef2f2; color: #dc2626;
padding: 12px 14px; border-radius: 10px;
font-size: 13px; margin-bottom: 20px;
border: 1px solid #fecaca;
display: flex; align-items: center; gap: 8px;
}
.footer {
margin-top: 40px; text-align: center;
color: #94a3b8; font-size: 12px;
}
.footer .divider {
display: inline-block; width: 40px; height: 1px; background: #e2e8f0;
vertical-align: middle; margin: 0 12px;
}
@media (max-width: 1000px) {
.tagline-cn { font-size: 24px; white-space: normal; }
}
@media (max-width: 900px) {
body { flex-direction: column; }
.brand-side { padding: 48px 32px; flex: none; min-height: 320px; }
.engine-line { display: none; }
.login-side { padding: 32px; }
}
</style>
</head>
<body>
<!-- ========== 左栏:品牌区 ========== -->
<div class="brand-side">
<div class="brand-content">
<div class="brand-logo">
<img src="/static/logo.png" alt="MeshSafe">
<div>
<div class="brand-name">MeshSafe</div>
<div class="brand-sub">文件摆渡安全卫士</div>
</div>
</div>
<div class="tagline-cn">
多层检测,<span class="highlight">双向管控</span>,文件摆渡更安心
</div>
<div class="tagline-en">Multi-layer inspection · Bidirectional control · Safer file exchange</div>
<div class="cap-row">
<div class="cap"><span class="dot"></span>6 层引擎交叉检测</div>
<div class="cap secure"><span class="dot"></span>故障默认拦截</div>
<div class="cap"><span class="dot"></span>零容忍规则优先响应</div>
<div class="cap secure"><span class="dot"></span>全链路审计追溯</div>
</div>
<div class="engine-line">
<div class="el-label">DETECTION ENGINES</div>
<div class="el-items">
<div class="el">文件结构分析</div>
<div class="el">ClamAV 病毒库</div>
<div class="el">文档深度检测</div>
<div class="el">关键词匹配</div>
<div class="el">YARA 规则匹配</div>
<div class="el">PE/二进制分析</div>
</div>
</div>
</div>
</div>
<!-- ========== 右栏:登录卡片 ========== -->
<div class="login-side">
<div class="login-card">
<div class="hello">欢迎回来</div>
<div class="title">登录 MeshSafe</div>
{% if error %}
<div class="error">
<span>⚠️</span>
<span>{{ error }}</span>
</div>
{% endif %}
<form method="post" action="/console/login">
<div class="input-wrap">
<span class="input-icon">👤</span>
<input type="text" name="username" placeholder="请输入用户名" autocomplete="username" autofocus required>
</div>
<div class="input-wrap">
<span class="input-icon">🔒</span>
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
</div>
<button type="submit" class="login-btn">登 录</button>
</form>
<div class="footer">
<span>© MeshSafe</span>
<span class="divider"></span>
<span>文件摆渡 · 内外网隔离</span>
</div>
</div>
</div>
</body>
</html>

223
app/templates/policy.html Normal file
View File

@@ -0,0 +1,223 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>策略配置 - MeshSafe 控制台</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f5f6fa; color: #333; }
{% include "_nav_style.html" %}
main { padding: 24px; max-width: 1400px; margin: 0 auto; }
h2 { margin: 0 0 16px; font-size: 18px; }
.tabs { display: flex; border-bottom: 2px solid #e0e0e0; margin-bottom: 16px; }
.tab { padding: 10px 20px; cursor: pointer; font-size: 14px; color: #666; border-bottom: 2px solid transparent; margin-bottom: -2px; }
.tab.active { color: #1a2a6c; border-bottom-color: #1a2a6c; font-weight: 500; }
.panel { display: none; }
.panel.active { display: block; }
.card { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,.06); margin-bottom: 16px; }
.card h3 { margin: 0 0 12px; font-size: 14px; color: #555; font-weight: 600; }
textarea { width: 100%; min-height: 480px; font-family: "JetBrains Mono", Consolas, monospace;
font-size: 13px; padding: 14px; border: 1px solid #ddd; border-radius: 6px;
resize: vertical; line-height: 1.5; }
textarea:focus { border-color: #1a2a6c; outline: none; }
.toolbar { display: flex; gap: 10px; margin-top: 12px; align-items: center; }
button { padding: 9px 20px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
.btn-primary { background: #1a2a6c; color: white; font-weight: 500; }
.btn-primary:hover { background: #243a8c; }
.btn-ghost { background: #f0f0f0; color: #555; }
.btn-ghost:hover { background: #e4e4e4; }
.btn-danger { background: #c62828; color: white; }
.btn-danger:hover { background: #d32f2f; }
.versions { width: 100%; border-collapse: collapse; font-size: 13px; }
.versions th { background: #f8f9fa; padding: 8px 12px; text-align: left; color: #666;
font-weight: 500; border-bottom: 1px solid #eee; }
.versions td { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; font-family: monospace; }
.chip { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px;
margin: 2px; }
.chip-zt { background: #ffebee; color: #c62828; }
.chip-warn { background: #fff3e0; color: #ef6c00; }
.chip-info { background: #e3f2fd; color: #1565c0; }
.chip-ok { background: #e8f5e9; color: #2e7d32; }
.form-row { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
.form-row label { width: 160px; font-size: 13px; color: #555; }
.form-row input[type=number], .form-row input[type=text] { padding: 8px 10px; border: 1px solid #ddd;
border-radius: 5px; font-size: 13px; width: 120px; }
</style>
</head>
<body>
{% set nav_active = 'policy' %}{% include "_nav.html" %}
<main>
<div class="tabs">
<div class="tab active" data-tab="yaml">YAML 模式(直接编辑)</div>
<div class="tab" data-tab="rules">规则清单(只读预览)</div>
<div class="tab" data-tab="yara">YARA 规则库({{ yara_loaded_count }} 条已加载)</div>
<div class="tab" data-tab="versions">版本历史</div>
</div>
<!-- ============ YAML 模式 ============ -->
<div id="panel-yaml" class="panel active">
<form method="post" action="/console/policy/save">
<input type="hidden" name="mode" value="yaml">
<div class="card">
<h3>策略内容(数据库为准,YAML 作为导出备份)</h3>
<textarea name="yaml_content" spellcheck="false">{{ yaml_text }}</textarea>
<div class="toolbar">
<button type="submit" class="btn-primary" {% if user.role != 'admin' %}disabled title="admin 权限才能保存"{% endif %}>保存并热重载</button>
<button type="button" class="btn-ghost" onclick="document.querySelector('#panel-yaml textarea').value = document.querySelector('#panel-yaml textarea').value;">刷新</button>
<span style="color:#888;font-size:12px;">当前版本:<b>{{ policy.version }}</b> · SHA256:{{ policy.sha256[:12] }}...</span>
</div>
</div>
</form>
</div>
<!-- ============ 规则清单 ============ -->
<div id="panel-rules" class="panel">
<div class="card">
<h3>零容忍规则集({{ policy.zero_tolerance_rules|length }} 条 · 命中即拒绝)</h3>
<div>
{% if zt_rules_cn %}
{% for r in zt_rules_cn %}
<span class="chip chip-zt" title="{{ r.rule }} — {{ r.cn_desc }}">{{ r.cn_name }}</span>
{% endfor %}
{% else %}
{% for r in policy.zero_tolerance_rules %}
<span class="chip chip-zt">{{ r }}</span>
{% endfor %}
{% endif %}
</div>
</div>
<div class="card">
<h3>策略全局参数</h3>
<table class="versions">
<tr><th>参数名</th><th>说明</th><th>当前值</th></tr>
<tr><td>block_score</td><td>单引擎累计得分 ≥ 此阈值 → 判定为 malicious 直接拒绝</td><td><b>{{ policy.block_score }}</b></td></tr>
<tr><td>review_score</td><td>累计得分落在这个区间 → 判定为 suspicious 转人工复核</td><td>{{ policy.review_score[0] }} ~ {{ policy.review_score[1] }}</td></tr>
<tr><td>engine_failure_mode</td><td>检测引擎异常时的处理策略(fail_closed=拒绝 fail_open=放行)</td><td><b style="color:#c62828;">{{ policy.engine_failure_mode }}</b></td></tr>
<tr><td>max_file_size</td><td>允许上传的单文件最大体积(超过直接拒绝)</td><td>{% set kb = policy.max_file_size / 1024 %}{% set mb = kb / 1024 %}{% if mb >= 1 %}{{ "%.1f"|format(mb) }} MB{% else %}{{ "%.0f"|format(kb) }} KB{% endif %}({{ policy.max_file_size }} 字节)</td></tr>
<tr><td>allow_encrypted_archive</td><td>是否允许带密码的加密 ZIP/RAR/7z 压缩包通过</td><td>{% if policy.allow_encrypted_archive %}<span class="chip chip-ok">允许</span>{% else %}<span class="chip chip-zt">禁止</span>{% endif %}</td></tr>
<tr><td>allow_encrypted_document</td><td>是否允许带密码的加密 Office 文档(.docx/.xlsx/.pptx)通过</td><td>{% if policy.allow_encrypted_document %}<span class="chip chip-ok">允许</span>{% else %}<span class="chip chip-zt">禁止</span>{% endif %}</td></tr>
<tr><td>白名单扩展名</td><td>允许上传的文件扩展名(不在此列表的直接拒绝)</td><td style="font-family:sans-serif;">
{% for e in policy.allowed_extensions %}
<span class="chip chip-info">{{ e }}</span>
{% endfor %}
</td></tr>
</table>
</div>
{% if policy.keyword_rules %}
<div class="card">
<h3>词表规则({{ policy.keyword_rules|length }} 条)</h3>
{% for kw in policy.keyword_rules %}
<div class="chip chip-info">{{ kw.name }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<!-- ============ YARA 规则库 ============ -->
<div id="panel-yara" class="panel">
<div class="card">
<h3>YARA 规则库({{ yara_files_count }} 个文件 · {{ yara_loaded_count }} 条已编译)</h3>
{% if yara_rules %}
<table class="versions">
<thead>
<tr>
<th>规则名</th>
<th>分类</th>
<th>风险等级</th>
<th>误报率</th>
<th>权重</th>
<th>触发次数</th>
<th>参考</th>
</tr>
</thead>
<tbody>
{% for r in yara_rules %}
<tr>
<td style="font-family:monospace;font-size:12px;" title="{{ r.name }}">{{ r.name }}</td>
<td>
<span class="chip chip-info" title="分类代码 {{ r.category }}">{{ r.category_cn }}</span>
</td>
<td>
{% if r.risk_level_cn %}<span class="chip {{ r.risk_level_chip }}">{{ r.risk_level_cn }}</span>
{% else %}<span class="chip chip-ok">{{ r.risk_level }}</span>{% endif %}
</td>
<td style="color:#888;font-size:12px;">{{ r.false_positive_rate }}</td>
<td><b>{{ r.weight }}</b></td>
<td>
{% if r.trigger_count > 0 %}<span class="chip chip-zt">{{ r.trigger_count }}</span>
{% else %}<span style="color:#bbb;">0</span>{% endif %}
</td>
<td style="font-size:11px;color:#888;">{{ r.reference[:50] }}{% if r.reference|length > 50 %}...{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="color:#999;text-align:center;padding:30px;">
{% if yara_rules_dir %}
YARA 规则库目录:<code>{{ yara_rules_dir }}</code><br>
目录内 {{ yara_files_count }} 个 .yar 文件
{% if yara_files_count == 0 %}—— 请检查规则文件是否存在{% endif %}
{% else %}
YARA 规则库未加载(可能 yara-python 未安装或规则目录不存在)
{% endif %}
</div>
{% endif %}
</div>
<div class="card" style="margin-top:12px;">
<h3>权重分档说明(来自 policy.yaml → yara_rule_weights)</h3>
<div style="font-size:13px;color:#555;line-height:1.8;">
<span class="chip chip-zt">critical → weight=85</span>
<span class="chip chip-warn">high → weight=65</span>
<span class="chip chip-info">medium → weight=45</span>
<span class="chip chip-ok">low → weight=25</span>
<span style="color:#888;margin-left:12px;">未配置的规则默认 weight=50</span>
</div>
</div>
</div>
<!-- ============ 版本历史 ============ -->
<div id="panel-versions" class="panel">
<div class="card">
<h3>策略版本历史({{ versions|length }} 条)</h3>
<table class="versions">
<thead><tr><th>版本</th><th>SHA256</th><th>创建时间</th><th>操作</th></tr></thead>
<tbody>
{% for v in versions %}
<tr>
<td>{{ v.version }}</td>
<td>{{ v.sha256 }}</td>
<td>{{ v.created_at|bj_time if v.created_at else '' }}</td>
<td>
{% if user.role == 'admin' %}
<form method="post" action="/console/policy/rollback/{{ v.version }}" style="display:inline;">
<button type="submit" class="btn-danger" style="padding:4px 12px;font-size:12px;">回滚到此版本</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="4" style="text-align:center;color:#999;">暂无版本记录(首次部署执行 scripts.init_db)</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</main>
<script>
document.querySelectorAll('.tab').forEach(tab => {
tab.onclick = () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById('panel-' + tab.dataset.tab).classList.add('active');
};
});
</script>
</body>
</html>

232
app/templates/tasks.html Normal file
View File

@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>任务历史 - MeshSafe 控制台</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f4f6fa; color: #1e293b; }
{% include "_nav_style.html" %}
main { padding: 24px; max-width: 1500px; margin: 0 auto; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 18px; }
.page-head h1 { margin: 0 0 6px; font-size: 21px; font-weight: 700; }
.page-head .sub { font-size: 13px; color: #64748b; }
.page-head .count-chip {
background: #fff; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 8px 14px; font-size: 12.5px; color: #475569;
}
.page-head .count-chip b { color: #0f172a; }
.panel { background: #fff; border-radius: 12px; padding: 18px 20px;
box-shadow: 0 1px 3px rgba(15,23,42,.07), 0 6px 20px rgba(15,23,42,.04);
border: 1px solid #eef2f7; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; padding: 10px 12px; text-align: left; font-size: 11.5px;
color: #64748b; font-weight: 600; border-bottom: 1px solid #e8edf4; white-space: nowrap; }
td { padding: 10px 12px; font-size: 12.5px; border-bottom: 1px solid #f4f7fb; color: #334155; }
tr:last-child td { border-bottom: none; }
tbody tr:hover { background: #fafbfd; }
td.mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11.5px; color: #64748b; }
td.fname { font-weight: 500; color: #1e293b; max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.verdict-cell { display: flex; align-items: center; gap: 6px; }
.mode-badge { display: inline-block; padding: 2px 8px; border-radius: 5px; font-size: 11px; }
.mode-sync { background: #e0f2fe; color: #0369a1; }
.mode-async { background: #f3e5f5; color: #7b1fa2; }
details > summary { cursor: pointer; color: #2563eb; user-select: none; font-size: 12px; }
details > summary:hover { text-decoration: underline; }
/* 详情列:限制宽度,内层内容横向滚动,不撑开外层表格 */
tbody td:last-child { min-width: 220px; max-width: 360px; vertical-align: top; }
details { display: block; width: 100%; }
details .findings { padding: 12px; font-size: 12px; background: #f8fafc;
border-radius: 8px; margin-top: 6px;
max-width: 100%; overflow-x: auto; overflow-y: visible; display: block; }
details .findings table { border: 1px solid #e8edf4; border-radius: 6px; overflow: hidden; min-width: 600px; }
details .findings th { font-size: 11px; padding: 6px 10px; white-space: nowrap; }
details .findings td { font-size: 11.5px; padding: 6px 10px; white-space: nowrap; }
.empty { text-align: center; padding: 50px; color: #94a3b8; font-size: 13px; }
/* ---------- 分页 ---------- */
.pager { display: flex; align-items: center; justify-content: space-between; margin-top: 16px;
padding-top: 14px; border-top: 1px solid #eef2f7; }
.pager .p-info { font-size: 12.5px; color: #64748b; }
.pager .p-info b { color: #0f172a; }
.pager .p-nav { display: flex; gap: 5px; align-items: center; }
.pager .p-nav a, .pager .p-nav span {
min-width: 32px; height: 30px; padding: 0 10px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 7px; font-size: 12.5px; text-decoration: none;
border: 1px solid #e2e8f0; color: #475569; background: #fff;
transition: all .15s;
}
.pager .p-nav a:hover { border-color: #93c5fd; color: #2563eb; background: #eff6ff; }
.pager .p-nav .cur { background: #1e3a5f; border-color: #1e3a5f; color: #fff; font-weight: 600; }
.pager .p-nav .disabled { opacity: .4; pointer-events: none; }
.pager .p-nav .dots { border: none; background: none; min-width: 20px; padding: 0; color: #94a3b8; }
/* ---------- 风险体系说明 ---------- */
.risk-legend { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 14px; padding-top: 14px;
border-top: 1px dashed #e8edf4; }
.risk-legend .rl { display: flex; align-items: flex-start; gap: 7px; font-size: 11.5px; color: #94a3b8; }
.risk-legend .rl .rl-name { color: #334155; font-weight: 600; font-size: 12px; white-space: nowrap; }
</style>
</head>
<body>
{% set nav_active = 'tasks' %}
{% include "_nav.html" %}
<main>
<div class="page-head">
<div>
<h1>任务历史</h1>
<div class="sub">全部扫描任务记录 · 每页 {{ page_size }} 条</div>
</div>
<div class="count-chip">
共 <b>{{ total }}</b> 条记录 · 第 <b>{{ page }}</b> / {{ total_pages }} 页
</div>
</div>
<div class="panel">
{% if tasks %}
<table>
<thead>
<tr>
<th>时间</th>
<th>任务 ID</th>
<th>文件名</th>
<th>来源渠道</th>
<th>类型</th>
<th>大小</th>
<th>模式</th>
<th>检测结论</th>
<th>处置动作</th>
<th>风险分</th>
<th>耗时</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in tasks %}
<tr id="task-{{ t.id }}">
<td class="mono">{{ t.created_at|bj_time("%m-%d %H:%M:%S") if t.created_at else '' }}</td>
<td class="mono" title="{{ t.id }}">{{ t.id[0:10] }}...</td>
<td class="fname" title="{{ t.filename }}">{{ t.filename }}</td>
<td class="mono" title="{{ t.client_id }}">{{ t.channel|cn_channel }}</td>
<td class="mono" title="{{ t.real_type }}">{{ t.real_type[:16] if t.real_type else t.declared_ext }}</td>
<td class="mono">
{% if t.size > 1048576 %}{{ (t.size / 1048576)|round(1) }} MB
{% elif t.size > 1024 %}{{ (t.size / 1024)|round(1) }} KB
{% else %}{{ t.size }} B{% endif %}
</td>
<td><span class="mode-badge mode-{{ t.mode }}">{{ t.mode|cn_mode }}</span></td>
<td>
<div class="verdict-cell">
<span class="tag {{ t.verdict|cn_verdict_badge }}">{{ t.verdict|cn_verdict }}</span>
</div>
</td>
<td><span class="tag plain">{{ t.action|cn_action }}</span></td>
<td class="mono">{{ "%.0f"|format(t.score) }}</td>
<td class="mono">{{ t.duration_ms }} ms</td>
<td>
<details>
<summary>详情 ({{ t.findings|length }})</summary>
<div class="findings">
{% if t.findings %}
<table>
<thead>
<tr><th>检测引擎</th><th>严重度</th><th>命中规则</th><th>权重</th><th>细节</th></tr>
</thead>
<tbody>
{% for f in t.findings %}
<tr>
<td><span class="tag plain">{{ f.engine|cn_engine }}</span></td>
<td><span class="tag {{ f.severity|cn_severity_badge }}">{{ f.severity|cn_severity }}</span></td>
<td class="mono">{{ f.rule }}</td>
<td class="mono">{{ f.weight }}</td>
<td>{{ f.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="color:#94a3b8;font-size:12px;">未检出任何风险信号</div>
{% endif %}
{% if t.sha256 %}
<div style="margin-top:10px;font-size:11px;color:#94a3b8;font-family:monospace;line-height:1.8;">
SHA256:{{ t.sha256 }}
{% if t.md5 %}<br>MD5:{{ t.md5 }}{% endif %}
<br>策略版本:{{ t.policy_version }}
{% if t.reason %}<br>裁决依据:{{ t.reason }}{% endif %}
</div>
{% endif %}
</div>
</details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- 风险体系说明 -->
<div class="risk-legend">
{% for r in RISK_MATRIX %}
<div class="rl">
<span class="tag risk-{{ r.key }}">{{ r.label }}</span>
<span>{{ r.desc }}</span>
</div>
{% endfor %}
</div>
<!-- 分页 -->
<div class="pager">
<div class="p-info">
显示第 <b>{{ (page - 1) * page_size + 1 }}</b> - <b>{{ [page * page_size, total]|min }}</b> 条,共 <b>{{ total }}</b> 条
</div>
<div class="p-nav">
{% if has_prev %}
<a href="/console/tasks?page={{ prev_page }}">上一页</a>
{% else %}
<span class="disabled">上一页</span>
{% endif %}
{% if page_nums and page_nums[0] > 1 %}
<a href="/console/tasks?page=1">1</a>
{% if page_nums[0] > 2 %}<span class="dots">···</span>{% endif %}
{% endif %}
{% for p in page_nums %}
{% if p == page %}
<span class="cur">{{ p }}</span>
{% else %}
<a href="/console/tasks?page={{ p }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if page_nums and page_nums[-1] < total_pages %}
{% if page_nums[-1] < total_pages - 1 %}<span class="dots">···</span>{% endif %}
<a href="/console/tasks?page={{ total_pages }}">{{ total_pages }}</a>
{% endif %}
{% if has_next %}
<a href="/console/tasks?page={{ next_page }}">下一页</a>
{% else %}
<span class="disabled">下一页</span>
{% endif %}
</div>
</div>
{% else %}
<div class="empty">
<div style="font-size:28px;margin-bottom:12px;">📭</div>
暂无扫描记录。调用 <code>/api/v1/scans</code> 提交文件后,结果会自动出现在这里。
</div>
{% endif %}
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>MeshSafe 控制台 — 测试靶场</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f5f6fa; color: #333; }
{% include "_nav_style.html" %}
main { padding: 24px; max-width: 1400px; margin: 0 auto; }
h2 { margin: 0 0 16px; font-size: 18px; }
.summary { display: flex; gap: 16px; margin-bottom: 20px; }
.summary .card { flex: 1; background: white; border-radius: 10px; padding: 16px 20px;
box-shadow: 0 2px 8px rgba(0,0,0,.06); text-align: center; }
.summary .num { font-size: 32px; font-weight: 700; }
.summary .lbl { font-size: 12px; color: #888; margin-top: 4px; }
.summary .total .num { color: #1a2a6c; }
.summary .pass .num { color: #2e7d32; }
.summary .fail .num { color: #c62828; }
table { width: 100%; border-collapse: collapse; background: white;
border-radius: 10px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.06); }
th, td { padding: 10px 14px; text-align: left; font-size: 13px; border-bottom: 1px solid #f0f0f0; }
th { background: #f8f9fd; font-weight: 500; color: #555; }
tr:hover { background: #fafbff; }
.chip { display: inline-block; padding: 1px 8px; border-radius: 10px;
font-size: 11px; font-weight: 500; white-space: nowrap; }
.chip-zt { background: #ffebee; color: #c62828; }
.chip-warn { background: #fff3e0; color: #ef6c00; }
.chip-ok { background: #e8f5e9; color: #2e7d32; }
.chip-info { background: #e3f2fd; color: #1565c0; }
.chip-gray { background: #f0f0f0; color: #666; }
.pass-mark { color: #2e7d32; font-size: 16px; }
.fail-mark { color: #c62828; font-size: 16px; }
details { background: #fafbff; }
details summary { cursor: pointer; padding: 6px 12px; font-size: 12px; color: #1565c0; }
details[open] summary { border-bottom: 1px solid #e3f2fd; }
.findings-table { margin: 8px 12px; }
.findings-table th { background: #eef2fb; font-size: 12px; padding: 6px 10px; }
.findings-table td { padding: 5px 10px; font-size: 12px; }
.refresh-btn { display: inline-block; padding: 8px 20px; background: #1a2a6c;
color: white; border-radius: 6px; text-decoration: none; font-size: 13px; }
.refresh-btn:hover { background: #0f1d52; }
.clamav-note { background: #fff8e1; border: 1px solid #ffe082; border-radius: 8px;
padding: 10px 16px; font-size: 13px; color: #5d4037; margin-bottom: 16px; }
</style>
</head>
<body>
{% set nav_active = 'test' %}{% include "_nav.html" %}
<main>
<input type="file" id="uploadInput" multiple style="display:none">
{% if results %}
<div class="summary">
<div class="card total"><div class="num">{{ total }}</div><div class="lbl">样本总数</div></div>
<div class="card pass"><div class="num">{{ pass_count }}</div><div class="lbl">检测通过 ✅</div></div>
<div class="card fail"><div class="num">{{ fail_count }}</div><div class="lbl">检测未通过 ❌</div></div>
<div class="card" style="text-align:center;">
<a class="refresh-btn" href="/console/test?rescan=1">🔄 重新扫描</a>
<button id="uploadBtn" class="refresh-btn" style="background:#2e7d32; margin-left:8px;">📤 上传样本</button>
</div>
</div>
<div style="display:flex; gap:16px; margin-bottom:14px; font-size:12px; color:#64748b; align-items:center;">
<span>📦 当前策略:<b style="color:#1e293b;">{{ policy_version or '-' }}</b></span>
{% if last_run_at %}
<span>🕐 上次运行:<b style="color:#1e293b;">{{ last_run_at|bj_time("%m-%d %H:%M:%S") }}</b></span>
{% else %}
<span style="color:#f59e0b;">⚠️ 从未运行过 —— 点「重新扫描」开始检测</span>
{% endif %}
{% if rescan %}<span style="color:#0369a1;">✅ 本次为实时全量扫描结果</span>{% endif %}
</div>
{% if results[0].clamav_error %}
<div class="clamav-note">
⚠️ ClamAV 引擎不可用(本地未起 clamd)—— 部分 verdict 显示为 <code>error (ClamAV 不可用)</code>,
但 YARA / structure / documents / keywords 引擎已正常工作。生产环境 docker compose 起 ClamAV 后会自动加载。
</div>
{% endif %}
<table>
<thead>
<tr>
<th style="width:40px;">#</th>
<th style="width:50px;">结果</th>
<th>样本文件</th>
<th style="width:80px;">分类</th>
<th style="width:180px;">期望裁决 · 规则</th>
<th style="width:200px;">检测结论 · 规则</th>
<th style="width:60px;">方向</th>
<th style="width:60px;">规则</th>
<th style="width:60px;">Score</th>
<th style="width:70px;">耗时</th>
<th style="width:80px;">Findings</th>
</tr>
</thead>
<tbody>
{% for r in results %}
<tr>
<td style="color:#999;font-size:12px;">{{ loop.index }}</td>
<td class="{% if r.overall_ok %}pass-mark{% else %}fail-mark{% endif %}">
{% if r.overall_ok %}✅{% else %}❌{% endif %}
</td>
<td>
<div style="font-family:monospace;font-size:12px;">{{ r.filename }}</div>
<div style="color:#888;font-size:11px;">{{ r.description }}</div>
</td>
<td><span class="chip chip-info">{{ r.category }}</span></td>
<td>
<!-- 期望裁决:完整显示 expected_verdict -->
{% set exp_v = r.expected_verdict or '' %}
{% if exp_v in ('reject', 'malicious') %}
<span class="tag bad">拦截 {{ exp_v }}</span>
{% elif exp_v == 'suspicious' %}
<span class="chip chip-warn">可疑</span>
{% elif exp_v == 'pass' or exp_v == 'clean' %}
<span class="tag ok">放行 {{ exp_v }}</span>
{% else %}
<span class="chip chip-gray">{{ exp_v or '未设' }}</span>
{% endif %}
<!-- 期望规则 -->
{% if r.expected_rules %}
<div style="margin-top:4px;">
{% for rule in r.expected_rules %}
<span class="chip chip-gray" style="font-family:monospace;font-size:10px;">{{ rule }}</span>
{% endfor %}
</div>
{% endif %}
</td>
<td>
<!-- 实际裁决 -->
<span class="tag {{ r.actual_verdict|cn_verdict_badge }}" title="{{ r.actual_verdict }}">{{ r.actual_verdict|cn_verdict }}</span>
{% if r.clamav_error %}<div style="font-size:10px;color:#ef6c00;">ClamAV 不可用</div>{% endif %}
<!-- 实际规则 -->
{% if r.actual_rules %}
<div style="margin-top:4px;">
{% for rule in r.actual_rules %}
<span class="chip chip-{% if rule in r.expected_rules %}ok{% else %}info{% endif %}" style="font-family:monospace;font-size:10px;">{{ rule }}</span>
{% endfor %}
</div>
{% elif r.actual_verdict in ('pass', 'clean') %}
<div style="margin-top:4px;color:#aaa;font-size:11px;">(无规则命中)</div>
{% else %}
<div style="margin-top:4px;color:#f59e0b;font-size:11px;">⚠️ 无规则</div>
{% endif %}
</td>
<td style="text-align:center;">
{% if r.verdict_match %}
<span style="color:#2e7d32;font-size:16px;" title="判定方向匹配">✅</span>
{% else %}
<span style="color:#c62828;font-size:16px;" title="判定方向偏离">❌</span>
{% endif %}
</td>
<td style="text-align:center;">
{% if r.rule_hit %}
<span style="color:#2e7d32;font-size:16px;" title="规则命中">✅</span>
{% else %}
<span style="color:#c62828;font-size:16px;" title="规则未命中">❌</span>
{% endif %}
</td>
<td><b>{{ "%.0f"|format(r.score) }}</b></td>
<td style="color:#888;">{{ r.duration_ms }}ms</td>
<td>
<details>
<summary>{{ r.findings_count }} 条</summary>
{% if r.findings %}
<table class="findings-table">
<thead><tr><th>引擎</th><th>规则</th><th>严重度</th><th>权重</th><th>细节</th></tr></thead>
<tbody>
{% for f in r.findings %}
<tr>
<td><span class="chip chip-info">{{ f.engine }}</span></td>
<td style="font-family:monospace;">{{ f.rule }}</td>
<td>{{ f.severity }}</td>
<td>{{ f.weight }}</td>
<td style="color:#888;font-size:11px;">{{ f.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="text-align:center;padding:60px;color:#999;">
测试样本目录不存在:<code>{{ samples_dir }}</code><br>
请运行:<code>python scripts/generate_test_samples.py</code>
</div>
{% endif %}
<!-- 用户自定义上传扫描结果区 -->
<div id="uploadResultArea" style="display:none; margin-top:24px;">
<h2 style="display:flex;align-items:center;gap:10px;">
📤 我的上传扫描结果
<span id="uploadSummary" style="font-size:13px;color:#888;font-weight:normal;"></span>
</h2>
<table id="uploadTable" style="margin-bottom:16px;">
<thead>
<tr>
<th style="width:40px;">#</th>
<th style="width:30px;">结果</th>
<th>文件</th>
<th style="width:90px;">大小</th>
<th style="width:110px;">verdict</th>
<th style="width:70px;">Score</th>
<th style="width:80px;">耗时</th>
<th>Findings</th>
</tr>
</thead>
<tbody id="uploadTableBody"></tbody>
</table>
</div>
</main>
<script>
(function() {
const btn = document.getElementById('uploadBtn');
const input = document.getElementById('uploadInput');
btn.addEventListener('click', function() { input.click(); });
input.addEventListener('change', async function() {
const files = Array.from(input.files);
if (!files.length) return;
btn.disabled = true;
btn.textContent = '⏳ 扫描中 ' + files.length + ' 个文件...';
input.value = ''; // reset 允许重复选同文件
const fd = new FormData();
files.forEach(f => fd.append('files', f));
try {
const resp = await fetch('/console/test/upload', {
method: 'POST',
body: fd,
credentials: 'same-origin',
});
const data = await resp.json();
renderUploadResults(data.results || []);
} catch (err) {
alert('上传扫描失败: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = '📤 上传样本';
}
});
function renderUploadResults(results) {
const area = document.getElementById('uploadResultArea');
const tbody = document.getElementById('uploadTableBody');
const summary = document.getElementById('uploadSummary');
area.style.display = 'block';
tbody.innerHTML = '';
let pass = 0, fail = 0;
results.forEach((r, i) => {
const icon = r.verdict === 'reject' || r.verdict === 'malicious' ? '❌' :
r.verdict === 'error' ? '⚠️' : '✅';
if (r.verdict === 'reject' || r.verdict === 'malicious' || r.verdict === 'error') fail++;
else pass++;
const sizeKB = r.size < 1024 ? r.size + ' B' : (r.size / 1024).toFixed(1) + ' KB';
const findingsHtml = r.findings && r.findings.length
? '<details><summary>' + r.findings.length + ' 条 findings</summary>' +
'<table class="findings-table"><thead><tr><th>引擎</th><th>规则</th><th>严重度</th><th>权重</th><th>细节</th></tr></thead><tbody>' +
r.findings.map(f =>
'<tr><td><span class="chip chip-info">' + escapeHtml(f.engine) + '</span></td>' +
'<td style="font-family:monospace;">' + escapeHtml(f.rule) + '</td>' +
'<td>' + escapeHtml(f.severity) + '</td>' +
'<td>' + escapeHtml(String(f.weight)) + '</td>' +
'<td style="color:#888;font-size:11px;">' + escapeHtml(f.detail) + '</td></tr>'
).join('') +
'</tbody></table></details>'
: '<span style="color:#aaa;">(无)</span>';
const clamavNote = r.clamav_error
? '<div style="font-size:10px;color:#ef6c00;">ClamAV 不可用</div>'
: '';
tbody.insertAdjacentHTML('beforeend',
'<tr>' +
'<td style="color:#999;font-size:12px;">' + (i + 1) + '</td>' +
'<td>' + icon + '</td>' +
'<td>' +
'<div style="font-family:monospace;font-size:12px;">' + escapeHtml(r.filename) + '</div>' +
(r.reason ? '<div style="color:#c62828;font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;">' + escapeHtml(r.reason) + '</div>' : '') +
'</td>' +
'<td style="color:#888;font-size:12px;">' + sizeKB + '</td>' +
'<td><span class="tag ' + (r.verdict_badge || 'muted') + '" title="' + escapeHtml(r.verdict) + '">' + escapeHtml(r.verdict_cn || r.verdict) + '</span>' + clamavNote + '</td>' +
'<td><b>' + (r.score || 0).toFixed(0) + '</b></td>' +
'<td style="color:#888;">' + (r.duration_ms || 0) + 'ms</td>' +
'<td>' + findingsHtml + '</td>' +
'</tr>'
);
});
summary.textContent = '✅ ' + pass + ' 通过 / ❌ ' + fail + ' 未通过 / 共 ' + results.length + ' 个文件';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
})();
</script>
</body>
</html>