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

221 lines
10 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ csrf_meta()|safe }}
<title>{% block title %}Squid Web Manager{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head>
<body class="{% if theme == 'light' %}theme-light{% endif %}">
{% if current_user %}
<aside class="sidebar">
<div class="brand">
<div class="brand-logo">🦑</div>
<div class="brand-name">Squid Manager</div>
<div class="brand-actions">
{% include '_theme_toggle.html' %}
</div>
</div>
{# P1-3: instance switcher - shows the currently active Squid instance
and lets the user swap to a different one without leaving the page. #}
{% if instances %}
<div class="instance-switcher">
<label for="instance-select" class="instance-switcher-label">当前实例</label>
<select id="instance-select" class="instance-switcher-select"
onchange="if(this.value){window.location='{{ url_for('instances_activate', inst_id=0) }}'.replace('/0','/'+this.value);}">
{% for inst in instances %}
<option value="{{ inst.id }}" {% if active_instance_id == inst.id %}selected{% endif %}>
{% if active_instance_id == inst.id %}★ {% endif %}{{ inst.name }}{% if not inst.is_local %} (远程){% endif %}
</option>
{% endfor %}
</select>
{# Quick-switch buttons: each is its own POST form (HTML doesn't allow
nested forms, so we don't wrap them in another <form>). #}
<div class="instance-switcher-quick">
{% for inst in instances %}
{% if active_instance_id != inst.id %}
<form method="post" action="{{ url_for('instances_activate', inst_id=inst.id) }}" style="display:inline; margin:0;">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ request.full_path }}">
<button type="submit" class="btn btn-secondary btn-tiny" title="切换到 {{ inst.name }}">→ {{ inst.name }}</button>
</form>
{% endif %}
{% endfor %}
<a href="{{ url_for('instances_view') }}" class="btn btn-secondary btn-tiny">⚙ 管理</a>
</div>
</div>
{% else %}
<div class="instance-switcher">
<label class="instance-switcher-label">当前实例</label>
<div class="instance-switcher-empty">
<span class="meta-text">未配置实例 (使用全局设置)</span>
<a href="{{ url_for('instances_view') }}" class="btn btn-secondary btn-tiny" style="margin-top:6px;">⚙ 添加实例</a>
</div>
</div>
{% endif %}
<nav>
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint == 'dashboard' %}active{% endif %}">
📊 <span>仪表盘</span>
</a>
<a href="{{ url_for('anomalies_view') }}" class="{% if request.endpoint == 'anomalies_view' %}active{% endif %}">
⚠️ <span>流量异常</span>
</a>
<a href="{{ url_for('logs_view') }}" class="{% if request.endpoint in ('logs_view',) %}active{% endif %}">
📋 <span>日志查询</span>
</a>
<a href="{{ url_for('logs_live') }}" class="{% if request.endpoint == 'logs_live' %}active{% endif %}">
<span>实时日志</span>
</a>
<a href="{{ url_for('logs_import') }}" class="{% if request.endpoint == 'logs_import' %}active{% endif %}">
📥 <span>日志导入</span>
</a>
<a href="{{ url_for('service_view') }}" class="{% if request.endpoint == 'service_view' %}active{% endif %}">
🛠️ <span>服务控制</span>
</a>
<div class="nav-section">配置管理</div>
<a href="{{ url_for('config_main') }}" class="{% if request.endpoint == 'config_main' %}active{% endif %}">
⚙️ <span>主配置</span>
</a>
<a href="{{ url_for('config_acls') }}" class="{% if request.endpoint == 'config_acls' %}active{% endif %}">
🚦 <span>ACL 规则表</span>
</a>
<a href="{{ url_for('config_rules') }}" class="{% if request.endpoint == 'config_rules' %}active{% endif %}">
🔐 <span>访问控制</span>
</a>
<a href="{{ url_for('config_cache') }}" class="{% if request.endpoint == 'config_cache' %}active{% endif %}">
💾 <span>缓存目录</span>
</a>
<a href="{{ url_for('config_refresh') }}" class="{% if request.endpoint == 'config_refresh' %}active{% endif %}">
♻️ <span>刷新规则</span>
</a>
<a href="{{ url_for('config_auth') }}" class="{% if request.endpoint == 'config_auth' %}active{% endif %}">
🔑 <span>认证配置</span>
</a>
<a href="{{ url_for('config_ssl_bump') }}" class="{% if request.endpoint == 'config_ssl_bump' %}active{% endif %}">
🔐 <span>SSL Bump</span>
</a>
<a href="{{ url_for('config_peers') }}" class="{% if request.endpoint == 'config_peers' %}active{% endif %}">
🌐 <span>缓存对等</span>
</a>
<a href="{{ url_for('config_raw') }}" class="{% if request.endpoint == 'config_raw' %}active{% endif %}">
📝 <span>原始编辑</span>
</a>
<a href="{{ url_for('config_tls') }}" class="{% if request.endpoint == 'config_tls' %}active{% endif %}">
🔐 <span>TLS 证书</span>
</a>
<a href="{{ url_for('config_paths') }}" class="{% if request.endpoint == 'config_paths' %}active{% endif %}">
🗂️ <span>路径设置</span>
</a>
<div class="nav-section">运维</div>
<a href="{{ url_for('backups_view') }}" class="{% if request.endpoint == 'backups_view' %}active{% endif %}">
📦 <span>配置备份</span>
</a>
<a href="{{ url_for('audit_view') }}" class="{% if request.endpoint == 'audit_view' %}active{% endif %}">
📜 <span>审计日志</span>
</a>
<a href="{{ url_for('ops_logs') }}" class="{% if request.endpoint == 'ops_logs' %}active{% endif %}">
📂 <span>日志状态</span>
</a>
<a href="{{ url_for('ops_logrotate') }}" class="{% if request.endpoint == 'ops_logrotate' %}active{% endif %}">
🔁 <span>日志轮转</span>
</a>
<a href="{{ url_for('alerts_view') }}" class="{% if request.endpoint == 'alerts_view' %}active{% endif %}">
🚨 <span>告警规则</span>
</a>
<a href="{{ url_for('instances_view') }}" class="{% if request.endpoint == 'instances_view' %}active{% endif %}">
🖥️ <span>多实例管理</span>
</a>
<a href="{{ url_for('performance_view') }}" class="{% if request.endpoint == 'performance_view' %}active{% endif %}">
📊 <span>实时性能</span>
</a>
{% if current_user and current_user.role == 'admin' %}
<a href="{{ url_for('terminal_view') }}" class="{% if request.endpoint == 'terminal_view' %}active{% endif %}">
💻 <span>Web 终端</span>
</a>
{% endif %}
<a href="{{ url_for('change_password') }}" class="{% if request.endpoint == 'change_password' %}active{% endif %}">
🔒 <span>修改密码</span>
</a>
<a href="{{ url_for('logout') }}" class="nav-danger">
🚪 <span>退出登录</span>
</a>
</nav>
</aside>
{% endif %}
<main class="{% if not current_user %}content content-full{% else %}content{% endif %}">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-container">
{% for category, msg in messages %}
<div class="flash flash-{{ category }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% if current_user %}
<!-- Session idle warning toast -->
<div id="session-warn" class="session-warn" style="display:none;">
<div class="session-warn-inner">
<span>⏰ 会话即将过期 (<span id="session-warn-sec">60</span>s),点击任意位置续期</span>
<button onclick="event.stopPropagation(); document.body.click();" class="btn btn-small btn-primary">续期</button>
</div>
</div>
{% endif %}
{% block scripts %}{% endblock %}
<script>
// CSRF helper: auto-attach token to every fetch() with unsafe method
(function() {
const meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) return;
const token = meta.getAttribute('content');
const origFetch = window.fetch;
window.fetch = function(url, opts) {
opts = opts || {};
const method = (opts.method || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
opts.headers = opts.headers || {};
// Don't overwrite if user already set it
if (!opts.headers['X-CSRF-Token'] && !opts.headers['x-csrf-token']) {
opts.headers['X-CSRF-Token'] = token;
}
}
return origFetch(url, opts);
};
})();
// Session idle warning: warn user 60s before session expires
(function() {
const idleSec = {{ session_idle_to|int }};
if (!idleSec || idleSec < 120) return;
let lastActivity = Date.now();
['click','keydown','mousemove','scroll','touchstart'].forEach(ev =>
document.addEventListener(ev, () => { lastActivity = Date.now(); }, { passive: true })
);
const warn = document.getElementById('session-warn');
const secEl = document.getElementById('session-warn-sec');
if (!warn) return;
setInterval(() => {
const idle = (Date.now() - lastActivity) / 1000;
const remaining = idleSec - idle;
if (remaining <= 60 && remaining > 0) {
warn.style.display = 'block';
if (secEl) secEl.textContent = Math.ceil(remaining);
} else if (remaining <= 0) {
// hard reload to trigger session timeout redirect
location.reload();
} else {
warn.style.display = 'none';
}
}, 5000);
})();
</script>
</body>
</html>