Files
squid-manager/templates/dashboard.html
T
Hermes Agent 4a1d949e41 feat: 完整 Squid Web Manager 平台 - P0-P3 全功能
完整 Web 管理平台,涵盖:

P0 生产加固:
- 会话超时(空闲+绝对) + CSRF 防护 + 登录失败限流
- HTTPS/TLS 证书管理(自签生成/上传/删除/过期提醒)
- SSL Bump (HTTPS 透明代理) 可视化配置
- 配置文件 diff 预览 + 二次确认才写入
- 服务控制二次确认 + 操作期间按钮锁定

P1 运维能力:
- 日志轮转管理(logrotate) + 日志状态监控
- 告警规则(5xx/命中率/磁盘/客户端流量)
- 多 Squid 实例管理 + 一键切换
- squidclient mgr 实时性能指标
- 流量异常检测(突增/大文件/高频小包/新客户端)

P2 日志分析增强:
- 日志持久化到 SQLite + 历史时间范围查询
- 导出 CSV/JSON(日志/KPI/异常/审计)
- GeoIP 地图(Leaflet + ip-api.com 离线可选 GeoLite2)
- 每客户端 24h 趋势图
- 自定义 Squid logformat 解析器

P3 体验锦上添花:
- 暗/亮主题切换(cookie 持久化)
- WebSSH 终端(xterm.js + paramiko)
- 完整审计日志 + 用户管理

技术栈: Flask 3.0 + SQLAlchemy 2.0 + SQLite + Chart.js + Leaflet
总代码量: ~16500 行 (15 Python 模块 + 34 模板 + CSS)
路由数: 73
2026-07-15 10:19:21 +08:00

388 lines
15 KiB
HTML

{% extends "base.html" %}
{% block title %}仪表盘 - Squid Manager{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% endblock %}
{% block content %}
<div class="page-header">
<h1>📊 日志分析仪表盘</h1>
<div class="page-actions">
<span class="meta-text">
{% if stats.time_start and stats.time_end %}
范围: {{ stats.time_start.strftime('%Y-%m-%d %H:%M:%S UTC') }} ~
{{ stats.time_end.strftime('%Y-%m-%d %H:%M:%S UTC') }}
{% else %}
暂无日志数据
{% endif %}
</span>
<span class="meta-text">共解析 {{ entries_count }} 条</span>
<button onclick="refreshLogs()" class="btn btn-small btn-secondary">🔄 刷新</button>
</div>
</div>
{% if active_alerts %}
<div class="card" style="border-left: 4px solid #ef4444;">
<h3 class="card-title">🚨 当前活跃告警 ({{ active_alerts | length }})</h3>
<ul class="active-alerts">
{% for a in active_alerts %}
<li>
<span class="badge badge-{% if a.severity == 'critical' %}red{% elif a.severity == 'warning' %}yellow{% else %}blue{% endif %}">{{ a.severity }}</span>
{{ a.message }}
<a href="{{ url_for('alerts_view') }}">管理规则</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if entries_count == 0 %}
<div class="card">
<div class="card-empty">
<div class="empty-icon">📭</div>
<h2>暂无日志数据</h2>
<p>请检查访问日志路径是否正确,或
<a href="{{ url_for('logs_import') }}">手动导入日志</a>进行分析。</p>
<p class="meta-text">当前路径: <code>{{ cfg.access_log_path }}</code></p>
<p class="meta-text">如需修改,请前往 <a href="{{ url_for('config_paths') }}">路径设置</a></p>
</div>
</div>
{% else %}
<!-- KPI cards -->
<div class="kpi-grid">
<div class="kpi-card kpi-blue">
<div class="kpi-label">总请求数</div>
<div class="kpi-value">{{ stats.total_requests | thousands }}</div>
<div class="kpi-meta">{{ stats.unique_clients }} 个客户端 · {{ stats.unique_hosts }} 个目标主机</div>
</div>
<div class="kpi-card kpi-green">
<div class="kpi-label">缓存命中率</div>
<div class="kpi-value">{{ '%.1f' % (stats.hit_ratio * 100) }}%</div>
<div class="kpi-meta">命中 {{ stats.hits }} · 未命中 {{ stats.misses }}</div>
</div>
<div class="kpi-card kpi-purple">
<div class="kpi-label">总流量</div>
<div class="kpi-value">{{ format_bytes(stats.total_bytes) }}</div>
<div class="kpi-meta">平均 {{ format_bytes(stats.avg_size_bytes) }}/请求</div>
</div>
<div class="kpi-card kpi-orange">
<div class="kpi-label">平均响应时间</div>
<div class="kpi-value">{{ format_duration(stats.avg_elapsed_ms) }}</div>
<div class="kpi-meta">总耗时 {{ format_duration(stats.total_elapsed_ms) }}</div>
</div>
<div class="kpi-card kpi-red">
<div class="kpi-label">拒绝率</div>
<div class="kpi-value">{{ '%.2f' % (stats.denied_rate * 100) }}%</div>
<div class="kpi-meta">{{ stats.denied }} 条被 ACL 拒绝</div>
</div>
<div class="kpi-card kpi-yellow">
<div class="kpi-label">中断率</div>
<div class="kpi-value">{{ '%.2f' % (stats.aborted_rate * 100) }}%</div>
<div class="kpi-meta">{{ stats.aborted }} 条客户端中断</div>
</div>
<div class="kpi-card kpi-cyan">
<div class="kpi-label">4xx 错误率</div>
<div class="kpi-value">{{ '%.2f' % (stats.errors_4xx_rate * 100) }}%</div>
<div class="kpi-meta">{{ stats.errors_4xx_count }} 条</div>
</div>
<div class="kpi-card kpi-pink">
<div class="kpi-label">5xx 错误率</div>
<div class="kpi-value">{{ '%.2f' % (stats.errors_5xx_rate * 100) }}%</div>
<div class="kpi-meta">{{ stats.errors_5xx_count }} 条</div>
</div>
</div>
<!-- Hourly traffic chart -->
<div class="card chart-card">
<h3 class="card-title">📈 每小时请求量与流量</h3>
<div class="chart-container">
<canvas id="hourlyChart" height="100"></canvas>
</div>
</div>
<div class="grid-2">
<!-- Result code distribution -->
<div class="card chart-card">
<h3 class="card-title">🏷️ Squid 结果码分布</h3>
<div class="chart-container">
<canvas id="resultChart" height="200"></canvas>
</div>
<details class="legend-details">
<summary>结果码含义</summary>
<table class="legend-table">
<thead><tr><th>代码</th><th>含义</th></tr></thead>
<tbody>
{% for code, desc in stats.result_code_legend.items() %}
<tr><td><code>{{ code }}</code></td><td>{{ desc }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
</div>
<!-- HTTP status distribution -->
<div class="card chart-card">
<h3 class="card-title">🌐 HTTP 状态码分布</h3>
<div class="chart-container">
<canvas id="httpChart" height="200"></canvas>
</div>
{% if stats.by_http %}
<table class="data-table small">
<thead><tr><th>状态码</th><th>次数</th><th>占比</th></tr></thead>
<tbody>
{% for code, n in stats.by_http[:10] %}
<tr>
<td><span class="badge badge-{% if code|first == '2' %}green{% elif code|first == '3' %}blue{% elif code|first == '4' %}orange{% elif code|first == '5' %}red{% else %}gray{% endif %}">{{ code }}</span></td>
<td>{{ n | thousands }}</td>
<td>{{ '%.1f' % (n / stats.total_requests * 100) }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
<div class="grid-2">
<!-- Method distribution -->
<div class="card chart-card">
<h3 class="card-title">🚀 HTTP 方法分布</h3>
<div class="chart-container">
<canvas id="methodChart" height="200"></canvas>
</div>
</div>
<!-- Category distribution -->
<div class="card chart-card">
<h3 class="card-title">📂 请求分类</h3>
<div class="chart-container">
<canvas id="categoryChart" height="200"></canvas>
</div>
<p class="meta-text">Hit=缓存命中, Miss=未命中, Tunnel=CONNECT隧道,
Denied=被拒, Aborted=中断, Error=错误</p>
</div>
</div>
<div class="grid-2">
<!-- Top clients -->
<div class="card">
<h3 class="card-title">👥 Top 客户端 (按请求数)</h3>
<table class="data-table">
<thead><tr><th>#</th><th>客户端 IP</th><th>请求数</th><th>流量</th><th>占比</th></tr></thead>
<tbody>
{% for c in stats.top_clients[:20] %}
<tr>
<td>{{ loop.index }}</td>
<td><a href="{{ url_for('client_detail', ip=c.client) }}">{{ c.client }}</a></td>
<td>{{ c.count | thousands }}</td>
<td>{{ format_bytes(c.bytes) }}</td>
<td>{{ '%.1f' % (c.count / stats.total_requests * 100) }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Top hosts -->
<div class="card">
<h3 class="card-title">🌍 Top 目标主机 (按请求数)</h3>
<table class="data-table">
<thead><tr><th>#</th><th>目标主机</th><th>请求数</th><th>流量</th><th>占比</th></tr></thead>
<tbody>
{% for h in stats.top_hosts[:20] %}
<tr>
<td>{{ loop.index }}</td>
<td><a href="{{ url_for('logs_view', host=h.host) }}">{{ h.host }}</a></td>
<td>{{ h.count | thousands }}</td>
<td>{{ format_bytes(h.bytes) }}</td>
<td>{{ '%.1f' % (h.count / stats.total_requests * 100) }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="grid-2">
<!-- Hierarchy distribution -->
<div class="card">
<h3 class="card-title">🔗 层级代码分布 (Hierarchy)</h3>
<table class="data-table">
<thead><tr><th>层级代码</th><th>次数</th><th>含义</th></tr></thead>
<tbody>
{% for hier, n in stats.by_hierarchy[:15] %}
<tr>
<td><code>{{ hier }}</code></td>
<td>{{ n | thousands }}</td>
<td>{{ stats.hierarchy_legend.get(hier, '-') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Content type -->
<div class="card">
<h3 class="card-title">📦 Content-Type 分布</h3>
<table class="data-table">
<thead><tr><th>Content-Type</th><th>次数</th><th>占比</th></tr></thead>
<tbody>
{% for ct, n in stats.by_ctype[:15] %}
<tr>
<td><code>{{ ct }}</code></td>
<td>{{ n | thousands }}</td>
<td>{{ '%.1f' % (n / stats.total_requests * 100) }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Top URLs -->
<div class="card">
<h3 class="card-title">🔗 Top URL (访问最多的资源)</h3>
<table class="data-table">
<thead><tr><th>#</th><th>URL</th><th>访问次数</th></tr></thead>
<tbody>
{% for u in stats.top_urls[:20] %}
<tr>
<td>{{ loop.index }}</td>
<td class="url-cell"><a href="{{ url_for('logs_view', url=u.url) }}" title="{{ u.url }}">{{ u.url }}</a></td>
<td>{{ u.count | thousands }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
const HOURLY = {{ stats.hourly | tojson }};
const BY_RESULT = {{ stats.by_result | tojson }};
const BY_HTTP = {{ stats.by_http | tojson }};
const BY_METHOD = {{ stats.by_method | tojson }};
const BY_CATEGORY = {{ stats.by_category | tojson }};
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#06b6d4','#84cc16','#f97316','#6366f1','#14b8a6','#a855f7','#eab308','#22c55e','#64748b','#0ea5e9','#d946ef','#f43f5e'];
function makeDoughnut(ctx, data, label) {
const labels = data.map(d => d[0]);
const values = data.map(d => d[1]);
return new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: values,
backgroundColor: COLORS.slice(0, labels.length),
borderWidth: 1,
borderColor: '#1e293b'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { color: '#cbd5e1', font: { size: 11 } } },
tooltip: {
callbacks: {
label: (ctx) => {
const total = ctx.dataset.data.reduce((a,b)=>a+b, 0);
const pct = total ? (ctx.parsed/total*100).toFixed(1) : 0;
return ` ${ctx.label}: ${ctx.parsed} (${pct}%)`;
}
}
}
}
}
});
}
function makeBar(ctx, data, label) {
const labels = data.map(d => d[0]);
const values = data.map(d => d[1]);
return new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: label,
data: values,
backgroundColor: '#3b82f6',
borderColor: '#1e40af',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: { legend: { display: false }, tooltip: { enabled: true } },
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
y: { ticks: { color: '#cbd5e1', font: { size: 11 } }, grid: { display: false } }
}
}
});
}
window.addEventListener('DOMContentLoaded', () => {
// hourly chart - dual axis (requests + bytes)
const hCtx = document.getElementById('hourlyChart').getContext('2d');
new Chart(hCtx, {
type: 'line',
data: {
labels: HOURLY.map(h => String(h.hour).padStart(2,'0') + ':00'),
datasets: [
{
label: '请求数',
data: HOURLY.map(h => h.requests),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,0.1)',
fill: true,
yAxisID: 'y',
tension: 0.3
},
{
label: '流量 (MB)',
data: HOURLY.map(h => h.bytes / (1024*1024)),
borderColor: '#10b981',
backgroundColor: 'rgba(16,185,129,0.1)',
fill: true,
yAxisID: 'y1',
tension: 0.3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { labels: { color: '#cbd5e1' } } },
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
y: { type: 'linear', position: 'left', ticks: { color: '#3b82f6' }, grid: { color: '#1e293b' }, title: { display: true, text: '请求数', color: '#3b82f6' } },
y1: { type: 'linear', position: 'right', ticks: { color: '#10b981' }, grid: { display: false }, title: { display: true, text: '流量 MB', color: '#10b981' } }
}
}
});
if (BY_RESULT.length) makeDoughnut(document.getElementById('resultChart').getContext('2d'), BY_RESULT, '结果码');
if (BY_HTTP.length) makeBar(document.getElementById('httpChart').getContext('2d'), BY_HTTP, 'HTTP状态');
if (BY_METHOD.length) makeDoughnut(document.getElementById('methodChart').getContext('2d'), BY_METHOD, '方法');
if (BY_CATEGORY.length) makeDoughnut(document.getElementById('categoryChart').getContext('2d'), BY_CATEGORY, '分类');
});
function refreshLogs() {
fetch('{{ url_for("api_logs_refresh") }}', { method: 'POST' })
.then(r => r.json())
.then(d => {
if (d.ok) location.reload();
else alert('刷新失败: ' + (d.message || ''));
});
}
</script>
{% endblock %}