4a1d949e41
完整 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
388 lines
15 KiB
HTML
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 %}
|