Files
MeshSate/app/templates/diagnostics.html

416 lines
15 KiB
HTML
Raw Normal View History

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