Files
squid-manager/templates/geoip.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

307 lines
11 KiB
HTML

{% extends "base.html" %}
{% block title %}GeoIP 地图 - Squid Manager{% endblock %}
{% block head %}
<!-- Leaflet (open-source map library, CDN, no key) -->
<link rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<style>
/* map must have a fixed height for Leaflet */
#geoip-map { height: 460px; width: 100%; border-radius: 6px; }
.leaflet-popup-content { font-size: 12px; line-height: 1.5; }
.leaflet-popup-content code { font-size: 11px; }
.marker-flag {
display:inline-block;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
border-radius: 50%;
background: #1e293b;
color: #f1f5f9;
font-weight: 700;
font-size: 11px;
border: 2px solid #f1f5f9;
box-shadow: 0 1px 3px rgba(0,0,0,.5);
}
.geoip-source-inline {
font-family: monospace;
font-size: 12px;
padding: 2px 6px;
background: rgba(255,255,255,.05);
border-radius: 4px;
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<h1>🌍 GeoIP 客户端地图</h1>
<div class="page-actions">
<span class="meta-text">
{% if entries_count %}已解析 {{ entries_count }} 条日志{% else %}暂无日志{% endif %}
</span>
<button id="geoip-refresh" class="btn btn-small btn-secondary" type="button">🔄 刷新</button>
</div>
</div>
<!-- Source note -->
<div class="card" style="border-left: 4px solid #3b82f6;">
<h3 class="card-title">📡 数据来源</h3>
<p class="card-desc">
本页地理位置查询使用
<span class="geoip-source-inline">{{ data_source }}</span>
{% if use_online %}
默认走 <a href="https://ip-api.com" target="_blank" rel="noopener">ip-api.com</a> 免费公共 API (45 req/min,无需 key)。
如果你需要更稳定/更高速的查询,可以下载 MaxMind
<a href="https://www.maxmind.com/en/geolite2/signup" target="_blank" rel="noopener">GeoLite2-City</a>
<code>.mmdb</code> 文件并通过 AppConfig(<code>geoip_mmdb</code>) 配置离线路径。
{% else %}
已配置离线数据库 <code>{{ db_path }}</code>,优先使用本地查询,不再发送外网请求。
{% endif %}
</p>
<p class="meta-text">
私有 IP (10.x / 192.168.x / 172.16-31.x / 127.x) 不会发出查询请求,直接在表里标记为「私有 IP」,
不会出现在地图上。本页默认仅展示 Top 20 客户端;需要更广范围的数据请用
<code>/api/geoip/clients</code> (Top 50) 与 <code>/api/geoip/hosts</code> (目标主机) 接口。
</p>
</div>
<!-- KPI -->
<div class="kpi-grid">
<div class="kpi-card kpi-blue">
<div class="kpi-label">客户端总数</div>
<div class="kpi-value">{{ total_clients }}</div>
<div class="kpi-meta">Top {{ total_clients }} 客户端</div>
</div>
<div class="kpi-card kpi-green">
<div class="kpi-label">已解析位置</div>
<div class="kpi-value">{{ resolved_clients }}</div>
<div class="kpi-meta">
{{ '%.0f' % (resolved_clients / total_clients * 100) if total_clients else 0 }}% 命中率
</div>
</div>
<div class="kpi-card kpi-purple">
<div class="kpi-label">国家数</div>
<div class="kpi-value">{{ countries }}</div>
<div class="kpi-meta">按 ISO 国家码统计</div>
</div>
<div class="kpi-card kpi-orange">
<div class="kpi-label">城市数</div>
<div class="kpi-value">{{ cities }}</div>
<div class="kpi-meta">(国家, 城市) 去重</div>
</div>
</div>
<!-- Map -->
<div class="card">
<h3 class="card-title">🗺️ Top 客户端位置</h3>
{% if geo_rows %}
<div id="geoip-map"></div>
<p class="meta-text" style="margin-top:8px;">
地图标签基于 OpenStreetMap 瓦片;点位大小近似与该客户端的请求数成正比。
私有地址不会出现在地图上,可参考下方表格。
</p>
{% else %}
<div class="card-empty">
<div class="empty-icon">🗺️</div>
<h2>没有可显示的客户端位置</h2>
{% if total_clients == 0 %}
<p>当前解析日志中没有客户端 IP。请确认 access.log 路径与权限。</p>
{% else %}
<p>Top {{ total_clients }} 个客户端均为私有地址,或 ip-api.com 暂时不可达。
详见下方的状态表。</p>
{% endif %}
</div>
{% endif %}
</div>
<!-- Detail table -->
<div class="card">
<h3 class="card-title">📋 客户端明细</h3>
{% if rows %}
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>客户端 IP</th>
<th>国家</th>
<th>城市</th>
<th>ISP/ASN</th>
<th>请求数</th>
<th>流量</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ loop.index }}</td>
<td><code>{{ r.ip }}</code></td>
<td>
{% if r.country_name %}
{{ r.country_name }}
{% if r.country %}<span class="badge badge-blue">{{ r.country }}</span>{% endif %}
{% else %}
<span class="meta-text">-</span>
{% endif %}
</td>
<td>
{{ r.city or '-' }}
{% if r.region %}<span class="meta-text">{{ r.region }}</span>{% endif %}
</td>
<td class="meta-text">
{{ r.isp or '-' }}
{% if r.asn %}<br><code>{{ r.asn }}</code>{% endif %}
</td>
<td>{{ '{:,}'.format(r.count) }}</td>
<td>{{ format_bytes(r.bytes) }}</td>
<td>
{% if not r.error %}
<span class="badge badge-green">已解析</span>
{% elif r.error == 'private IP' %}
<span class="badge badge-gray">私有 IP</span>
{% else %}
<span class="badge badge-yellow" title="{{ r.error }}">⚠ 查询失败</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-empty">
<p>暂无客户端数据。</p>
</div>
{% endif %}
</div>
<!-- API endpoints table for reference -->
<div class="card">
<h3 class="card-title">🔌 JSON 接口</h3>
<p class="card-desc">同样数据以 JSON 形式提供,便于脚本与外部仪表盘接入:</p>
<table class="data-table small">
<thead><tr><th>路径</th><th>说明</th><th>返回</th></tr></thead>
<tbody>
<tr>
<td><code>GET /api/geoip/clients</code></td>
<td>Top 50 客户端 IP + GeoIP</td>
<td><code>{ok, source:"clients", backend, rows:[…]}</code></td>
</tr>
<tr>
<td><code>GET /api/geoip/hosts</code></td>
<td>Top 50 目标主机 (先解析为 IP 再查询)</td>
<td><code>{ok, source:"hosts", backend, rows, placed, skipped}</code></td>
</tr>
<tr>
<td><code>GET /geoip</code></td>
<td>本页 (Top 20)</td>
<td>HTML</td>
</tr>
</tbody>
</table>
</div>
{% endblock %}
{% block scripts %}
{% if geo_rows %}
<script>
(function() {
// GeoIP rows are safe-serialized in Jinja via tojson so quotes are escaped.
const ROWS = {{ geo_rows | tojson }};
const ALL = {{ rows | tojson }};
// start at a neutral world view
const map = L.map('geoip-map', {
worldCopyJump: true,
minZoom: 2,
maxZoom: 14,
}).setView([20, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// pick a marker radius that scales sub-linearly with request count
const counts = ROWS.map(r => r.count || 0);
const minR = 6, maxR = 22;
const cmin = Math.min.apply(null, counts);
const cmax = Math.max.apply(null, counts);
function radiusFor(n) {
if (cmax <= cmin) return (minR + maxR) / 2;
const t = (Math.log10(n + 1) - Math.log10(cmin + 1)) /
(Math.log10(cmax + 1) - Math.log10(cmin + 1));
return minR + t * (maxR - minR);
}
const bounds = [];
ROWS.forEach((r, idx) => {
if (!r.latitude || !r.longitude) return;
const lat = r.latitude, lon = r.longitude;
const rad = radiusFor(r.count);
const flag = (r.country || '??').slice(0, 2);
const icon = L.divIcon({
className: 'geoip-marker',
html: '<div class="marker-flag" title="' + r.ip + '">' + flag + '</div>',
iconSize: [24, 24],
iconAnchor: [12, 12],
});
const m = L.marker([lat, lon], { icon: icon }).addTo(map);
m.bindPopup(
'<strong>' + escapeHtml(r.ip) + '</strong><br>' +
'<span style="color:#94a3b8">#' + (idx + 1) + ' · ' +
escapeHtml(r.country_name || r.country || '-') + ' · ' +
escapeHtml(r.city || '-') + '</span><br>' +
'<table style="margin-top:6px; font-size:11px; color:#cbd5e1;">' +
'<tr><td>请求数</td><td style="text-align:right;"><strong>' +
(r.count || 0).toLocaleString() + '</strong></td></tr>' +
'<tr><td>流量</td><td style="text-align:right;">' +
escapeHtml(formatBytes(r.bytes || 0)) + '</td></tr>' +
(r.isp ? '<tr><td>ISP</td><td style="text-align:right;">' +
escapeHtml(r.isp) + '</td></tr>' : '') +
(r.asn ? '<tr><td>ASN</td><td style="text-align:right;"><code>' +
escapeHtml(r.asn) + '</code></td></tr>' : '') +
'</table>'
);
bounds.push([lat, lon]);
});
if (bounds.length === 1) {
map.setView(bounds[0], 6);
} else if (bounds.length > 1) {
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 6 });
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatBytes(n) {
if (n < 1024) return n + ' B';
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB';
if (n < 1099511627776) return (n / 1073741824).toFixed(2) + ' GB';
return (n / 1099511627776).toFixed(2) + ' TB';
}
// refresh button — just reload the page (cheap + obvious)
const btn = document.getElementById('geoip-refresh');
if (btn) btn.addEventListener('click', () => location.reload());
})();
</script>
{% else %}
<script>
// No map to render — still wire the refresh button.
document.getElementById('geoip-refresh').addEventListener('click',
() => location.reload());
</script>
{% endif %}
{% endblock %}