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:
415
app/templates/diagnostics.html
Normal file
415
app/templates/diagnostics.html
Normal 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 }}
|
||||
· {{ docker_status_msg }}
|
||||
{% if started_at %} · 诊断启用: {{ 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>
|
||||
{{ 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>
|
||||
Reference in New Issue
Block a user