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
281 lines
11 KiB
HTML
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 %} |