Files
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

281 lines
11 KiB
HTML

{% extends "base.html" %}
{% block title %}客户端 {{ client }} - 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>👤 客户端详情: <code>{{ client }}</code></h1>
<div class="page-actions">
<a href="{{ url_for('logs_view', client=client) }}" class="btn btn-small btn-secondary">📋 查看该客户端的原始日志</a>
<a href="{{ url_for('dashboard') }}" class="btn btn-small btn-secondary">← 返回仪表盘</a>
</div>
</div>
{# P2-4: per-client 24h trend. When the client has no rows in the parsed
log, render an empty-state card instead of empty charts so the page
is informative (and so we don't ship a 0-axis Chart.js graph). #}
{% if not has_data %}
<div class="card">
<div class="card-empty">
<div class="empty-icon">🔍</div>
<h2>未找到该客户端的日志</h2>
<p>已加载的日志范围内没有来自 <code>{{ client }}</code> 的请求记录。</p>
<p class="meta-text">可能原因: 客户端拼写错误、尚未产生流量、或日志已被归档。</p>
<p>
<a href="{{ url_for('logs_view') }}" class="btn btn-secondary">去日志查询</a>
<a href="{{ url_for('logs_sync') }}" class="btn btn-primary"
onclick="event.preventDefault(); fetch('{{ url_for('logs_sync') }}', {method:'POST'}).then(r=>location.reload());">
🔄 立即同步日志
</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">{{ trend.total_requests | thousands }}</div>
<div class="kpi-meta">该客户端的累计请求</div>
</div>
<div class="kpi-card kpi-purple">
<div class="kpi-label">总流量</div>
<div class="kpi-value">{{ format_bytes(trend.total_bytes) }}</div>
<div class="kpi-meta">原始字节数 {{ trend.total_bytes | thousands }}</div>
</div>
<div class="kpi-card kpi-green">
<div class="kpi-label">平均请求大小</div>
<div class="kpi-value">{{ format_bytes(trend.avg_size_bytes) }}</div>
<div class="kpi-meta">{{ '%.0f' % trend.avg_size_bytes }} 字节/请求</div>
</div>
<div class="kpi-card kpi-orange">
<div class="kpi-label">首次出现</div>
<div class="kpi-value" style="font-size:1.2em;">
{% if trend.time_start %}
{{ trend.time_start.strftime('%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</div>
<div class="kpi-meta">{% if trend.time_start %}{{ trend.time_start.strftime('%Y-%m-%d %H:%M:%S UTC') }}{% else %}-{% endif %}</div>
</div>
<div class="kpi-card kpi-red">
<div class="kpi-label">最后出现</div>
<div class="kpi-value" style="font-size:1.2em;">
{% if trend.time_end %}
{{ trend.time_end.strftime('%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</div>
<div class="kpi-meta">{% if trend.time_end %}{{ trend.time_end.strftime('%Y-%m-%d %H:%M:%S UTC') }}{% else %}-{% endif %}</div>
</div>
<div class="kpi-card kpi-cyan">
<div class="kpi-label">Top 主机数</div>
<div class="kpi-value">{{ trend.top_hosts | length }}</div>
<div class="kpi-meta">访问过的目标主机</div>
</div>
</div>
<!-- 24h hourly trend - dual axis: requests + bytes -->
<div class="card chart-card">
<h3 class="card-title">📈 24h 趋势 (按 UTC 小时聚合)</h3>
<div class="chart-container">
<canvas id="clientHourlyChart" height="100"></canvas>
</div>
<p class="meta-text">
横轴 = UTC 小时 (00-23) · 左轴 = 请求数 · 右轴 = 流量 (MB)。
与仪表盘的 24h 折线结构一致,方便横向对比整体流量。
</p>
</div>
<div class="grid-2">
<!-- Top hosts (click to drill into logs) -->
<div class="card">
<h3 class="card-title">🌍 Top 目标主机 (按请求数)</h3>
{% if trend.top_hosts %}
<table class="data-table">
<thead><tr><th>#</th><th>目标主机</th><th>请求数</th><th>流量</th></tr></thead>
<tbody>
{% for h in trend.top_hosts %}
<tr>
<td>{{ loop.index }}</td>
<td>
<a href="{{ url_for('logs_view', host=h.host, client=client) }}"
title="查看该客户端对该主机的全部日志">{{ h.host }}</a>
</td>
<td>{{ h.count | thousands }}</td>
<td>{{ format_bytes(h.bytes) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="meta-text">无目标主机数据。</p>
{% endif %}
</div>
<!-- Top URLs -->
<div class="card">
<h3 class="card-title">🔗 Top URL (访问最多的资源)</h3>
{% if trend.top_urls %}
<table class="data-table">
<thead><tr><th>#</th><th>URL</th><th>访问次数</th></tr></thead>
<tbody>
{% for u in trend.top_urls %}
<tr>
<td>{{ loop.index }}</td>
<td class="url-cell">
<a href="{{ url_for('logs_view', url=u.url, client=client) }}"
title="{{ u.url }}">{{ u.url }}</a>
</td>
<td>{{ u.count | thousands }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="meta-text">无 URL 数据。</p>
{% endif %}
</div>
</div>
<!-- 最近 50 条请求样本 -->
<div class="card">
<h3 class="card-title">📜 最近 50 条请求样本</h3>
{% if trend.samples %}
<div class="table-scroll">
<table class="data-table log-table">
<thead>
<tr>
<th>时间</th>
<th>结果码</th>
<th>HTTP</th>
<th>方法</th>
<th>大小</th>
<th>耗时</th>
<th>URL</th>
<th>层级</th>
</tr>
</thead>
<tbody>
{% for e in trend.samples %}
<tr>
<td class="time-cell">
{% if e.timestamp %}
{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
{{ e.time }}
{% endif %}
</td>
<td>
<span class="badge badge-{% if e.category == 'Hit' %}green{% elif e.category == 'Miss' %}blue{% elif e.category == 'Tunnel' %}cyan{% elif e.category == 'Denied' %}red{% elif e.category == 'Aborted' %}orange{% elif e.category == 'Error' %}red{% else %}gray{% endif %}"
title="{{ e.result_description }}">{{ e.result_code }}</span>
</td>
<td>
<span class="badge badge-{% if e.http_code and e.http_code|first == '2' %}green{% elif e.http_code and e.http_code|first == '3' %}blue{% elif e.http_code and e.http_code|first == '4' %}orange{% elif e.http_code and e.http_code|first == '5' %}red{% else %}gray{% endif %}">{{ e.http_code or '-' }}</span>
</td>
<td>{{ e.method }}</td>
<td>{{ format_bytes(e.size) }}</td>
<td>{{ format_duration(e.elapsed_ms) }}</td>
<td class="url-cell" title="{{ e.url }}">
<a href="{{ url_for('logs_view', url=e.url, client=client) }}">{{ e.url }}</a>
</td>
<td><code>{{ e.hier_code }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="meta-text">无样本数据。</p>
{% endif %}
</div>
<!-- Action bar: 封禁该 IP (WIP - 待 P3-2 实现) -->
<div class="card">
<h3 class="card-title">🚫 操作</h3>
<button type="button" class="btn btn-secondary" disabled
title="WIP: 待实现 P3-2"
style="opacity:0.55; cursor:not-allowed;">
🚫 封禁该 IP (WIP: 待实现 P3-2)
</button>
<span class="meta-text" style="margin-left:12px;">
封禁功能将由 P3-2 提供:写入 squid ACL + 重载配置 + 持久化黑名单。
</span>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
{# Drive the 24h hourly chart. Same dual-axis pattern as the dashboard
so an operator can mentally diff a single client's curve against the
global 24h envelope without learning a new visualisation. #}
<script>
const HOURLY = {{ trend.hourly | tojson }};
window.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('clientHourlyChart');
if (!el || !HOURLY || !HOURLY.length) return;
const ctx = el.getContext('2d');
new Chart(ctx, {
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' } },
title: {
display: true,
text: '客户端 {{ client }} · 24h 请求/流量分布',
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' }
}
}
}
});
});
</script>
{% endblock %}