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
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
{% set _theme = theme|default('dark') %}
|
||||
{% set _icon = '☀️' if _theme == 'light' else '🌙' %}
|
||||
<button class="theme-toggle" type="button" onclick="toggleTheme()" title="切换主题 (暗/亮)" aria-label="切换主题">
|
||||
<span id="theme-icon">{{ _icon }}</span>
|
||||
</button>
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
const current = document.body.classList.contains('theme-light') ? 'light' : 'dark';
|
||||
const next = current === 'light' ? 'dark' : 'light';
|
||||
document.cookie = `theme=${next}; path=/; max-age=${365*24*3600}; samesite=Lax`;
|
||||
document.body.classList.toggle('theme-light', next === 'light');
|
||||
const icon = document.getElementById('theme-icon');
|
||||
if (icon) icon.textContent = next === 'light' ? '☀️' : '🌙';
|
||||
}
|
||||
// initial state — keep icon in sync if cookie says light but the body class
|
||||
// hasn't been set yet (e.g. partial rendered before server emitted the class)
|
||||
(function() {
|
||||
const match = document.cookie.match(/theme=(\w+)/);
|
||||
if (match && match[1] === 'light') {
|
||||
document.body.classList.add('theme-light');
|
||||
const icon = document.getElementById('theme-icon');
|
||||
if (icon) icon.textContent = '☀️';
|
||||
} else if (match && match[1] === 'dark') {
|
||||
document.body.classList.remove('theme-light');
|
||||
const icon = document.getElementById('theme-icon');
|
||||
if (icon) icon.textContent = '🌙';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,223 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}告警规则 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🚨 告警规则</h1>
|
||||
<div class="page-actions">
|
||||
<form method="post" action="{{ url_for('alerts_evaluate') }}" style="display:inline;">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-primary btn-small">⚡ 立即评估</button>
|
||||
</form>
|
||||
<a href="{{ url_for('alerts_history') }}" class="btn btn-secondary btn-small">📜 触发历史</a>
|
||||
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small">➕ 添加规则</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="meta-text">
|
||||
基于 access.log 解析指标与磁盘使用情况,按阈值触发告警。
|
||||
同一规则在 cooldown 时间窗内只记录一次,避免告警风暴。
|
||||
严重度: <span class="badge badge-blue">info</span>
|
||||
<span class="badge badge-yellow">warning</span>
|
||||
<span class="badge badge-red">critical</span>
|
||||
</p>
|
||||
|
||||
{# active alerts banner #}
|
||||
{% set active = [] %}
|
||||
{% for status in current_status %}
|
||||
{% if status.triggered %}{% set _ = active.append(status) %}{% endif %}
|
||||
{% endfor %}
|
||||
{% if active %}
|
||||
<div class="card" style="border-left: 4px solid #ef4444;">
|
||||
<h3 class="card-title">⚠️ 当前活跃告警 ({{ active | length }})</h3>
|
||||
<ul class="rule-messages">
|
||||
{% for s in active %}
|
||||
<li>
|
||||
<span class="badge badge-{% if s.severity == 'critical' %}red{% elif s.severity == 'warning' %}yellow{% else %}blue{% endif %}">{{ s.severity }}</span>
|
||||
{{ s.message }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# add rule form #}
|
||||
<div class="card" id="add-form" style="display:none;">
|
||||
<h3 class="card-title">添加新规则</h3>
|
||||
<form method="post" action="{{ url_for('alerts_add') }}" class="inline-form">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label>规则名称 <span class="hint">(简短描述,便于审计追溯)</span></label>
|
||||
<input type="text" name="name" class="form-input" required placeholder="例如: 高 5xx 错误率">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>指标</label>
|
||||
<select name="metric" class="form-input" required>
|
||||
{% for m, desc in metrics %}
|
||||
<option value="{{ m }}">{{ m }} — {{ desc }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>条件</label>
|
||||
<select name="operator" class="form-input" required>
|
||||
{% for op, sym in operators %}
|
||||
<option value="{{ op }}">{{ sym }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>阈值</label>
|
||||
<input type="number" name="threshold" class="form-input" step="0.0001" required value="0.05">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>窗口 (分钟)</label>
|
||||
<input type="number" name="window_minutes" class="form-input" min="1" max="1440" value="5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>冷却 (分钟)</label>
|
||||
<input type="number" name="cooldown_minutes" class="form-input" min="1" max="10080" value="30">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>严重度</label>
|
||||
<select name="severity" class="form-input">
|
||||
<option value="info">info</option>
|
||||
<option value="warning" selected>warning</option>
|
||||
<option value="critical">critical</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>启用</label>
|
||||
<select name="enabled" class="form-input">
|
||||
<option value="1" selected>是</option>
|
||||
<option value="0">否</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label>Webhook URL <span class="hint">(可选。留空仅记审计,非空则 POST JSON)</span></label>
|
||||
<input type="text" name="notify_webhook" class="form-input" placeholder="https://hooks.example.com/squid">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">规则列表</h3>
|
||||
<p class="card-desc">共 {{ rules | length }} 条规则。当前值是最近一次评估的快照。
|
||||
勾选/取消勾选"启用"列后点击"💾 保存所有规则"即可生效。</p>
|
||||
<form method="post" action="{{ url_for('alerts_save') }}">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px;">#</th>
|
||||
<th>名称</th>
|
||||
<th style="width:130px;">指标</th>
|
||||
<th style="width:80px;">条件</th>
|
||||
<th style="width:110px;">阈值</th>
|
||||
<th style="width:70px;">窗口</th>
|
||||
<th style="width:80px;">冷却</th>
|
||||
<th style="width:90px;">严重度</th>
|
||||
<th style="width:160px;">当前值</th>
|
||||
<th style="width:140px;">状态</th>
|
||||
<th style="width:80px;">启用</th>
|
||||
<th style="width:80px;">删除</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rules %}
|
||||
{% set s = status_map.get(loop.index0) %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}<input type="hidden" name="name[]" value="{{ r.name }}"></td>
|
||||
<td>
|
||||
{{ r.name }}
|
||||
{% if r.notify_webhook %}<br><span class="meta-text">🔗 webhook</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<select name="metric[]" class="form-input">
|
||||
{% for m, desc in metrics %}
|
||||
<option value="{{ m }}" {% if r.metric == m %}selected{% endif %}>{{ m }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select name="operator[]" class="form-input">
|
||||
{% for op, sym in operators %}
|
||||
<option value="{{ op }}" {% if r.operator == op %}selected{% endif %}>{{ sym }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="number" step="0.0001" name="threshold[]" value="{{ r.threshold }}" class="form-input"></td>
|
||||
<td><input type="number" name="window_minutes[]" value="{{ r.window_minutes }}" min="1" class="form-input"></td>
|
||||
<td><input type="number" name="cooldown_minutes[]" value="{{ r.cooldown_minutes }}" min="1" class="form-input"></td>
|
||||
<td>
|
||||
<select name="severity[]" class="form-input">
|
||||
{% for sv in severities %}
|
||||
<option value="{{ sv }}" {% if r.severity == sv %}selected{% endif %}>{{ sv }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{% if s %}
|
||||
<code>{{ '%.4f' % s.value }}</code>
|
||||
{% else %}
|
||||
<span class="meta-text">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not r.enabled %}
|
||||
<span class="badge badge-gray">已禁用</span>
|
||||
{% elif s %}
|
||||
{% if s.triggered %}
|
||||
<span class="badge badge-red">触发</span>
|
||||
{% elif s.in_cooldown %}
|
||||
<span class="badge badge-yellow">冷却</span>
|
||||
{% else %}
|
||||
<span class="badge badge-green">正常</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge badge-gray">未评估</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="enabled[]" value="1" {% if r.enabled %}checked{% endif %}>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not rules %}
|
||||
<tr><td colspan="12" class="empty-row">暂无规则。点击上方"添加规则"按钮开始,或点击 "立即评估" 先运行默认规则。</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 保存所有规则</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📖 规则说明</h3>
|
||||
<ul>
|
||||
<li><strong>触发逻辑</strong>: 计算当前指标值,与阈值按指定运算符 ( > < ≥ ≤ ) 比较,满足则记录并 (可选) POST webhook。</li>
|
||||
<li><strong>冷却 (cooldown)</strong>: 同一规则两次触发之间的最小间隔,避免告警风暴。</li>
|
||||
<li><strong>窗口 (window_minutes)</strong>: 用于按窗口归一化的指标 (如 <code>client_bytes_per_min</code>);其他指标仅用于备注。</li>
|
||||
<li><strong>评估方式</strong>: 点击"立即评估"使用当前 access.log 快照立即跑一遍;要持续监控,配合系统的 cron 调用 <code>/alerts/evaluate</code> 接口即可。</li>
|
||||
<li><strong>触发历史</strong>: 持久化记录会写入 <code>audit_log</code>,可在"<a href="{{ url_for('alerts_history') }}">触发历史</a>"查看 (取最近 200 条)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rule-messages { list-style: none; padding: 0; margin: 0; }
|
||||
.rule-messages li { padding: 6px 0; border-bottom: 1px dashed rgba(148, 163, 184, 0.18); }
|
||||
.rule-messages li:last-child { border-bottom: none; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}告警历史 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>📜 告警触发历史</h1>
|
||||
<div class="page-actions">
|
||||
<a href="{{ url_for('alerts_view') }}" class="btn btn-secondary btn-small">← 返回告警规则</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="card-desc">最近 {{ triggered | length }} 次实际触发 (来源 audit_log, action=<code>alert_triggered</code>),
|
||||
以及最近 {{ extras | length }} 条规则管理事件 (添加/保存/删除/评估)。</p>
|
||||
|
||||
<h3 class="card-title">🚨 触发记录 ({{ triggered | length }})</h3>
|
||||
{% if triggered %}
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:180px;">时间</th>
|
||||
<th style="width:120px;">用户</th>
|
||||
<th>详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in triggered %}
|
||||
<tr>
|
||||
<td class="time-cell">{{ a.timestamp_local }}</td>
|
||||
<td>{{ a.username }}</td>
|
||||
<td class="detail-cell">{{ a.detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="meta-text">还没有触发记录。在 <a href="{{ url_for('alerts_view') }}">告警规则</a> 页面点击 “立即评估” 即可触发评估。</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">⚙️ 规则管理事件 ({{ extras | length }})</h3>
|
||||
{% if extras %}
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:180px;">时间</th>
|
||||
<th style="width:120px;">用户</th>
|
||||
<th style="width:140px;">操作</th>
|
||||
<th>详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in extras %}
|
||||
<tr>
|
||||
<td class="time-cell">{{ a.timestamp_local }}</td>
|
||||
<td>{{ a.username }}</td>
|
||||
<td><code>{{ a.action }}</code></td>
|
||||
<td class="detail-cell">{{ a.detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="meta-text">暂无管理事件。</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,276 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}流量异常 - Squid Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>⚠️ 流量异常检测</h1>
|
||||
<div class="page-actions">
|
||||
<span class="meta-text">
|
||||
{% if summary.header_stats %}
|
||||
当前窗口: {{ summary.header_stats.current_window_count }} 条 ·
|
||||
{{ summary.header_stats.current_window_clients }} 个客户端
|
||||
{% else %}
|
||||
暂无日志数据
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="btn-group" style="display:inline-flex; gap:4px;">
|
||||
<a href="{{ url_for('export_anomalies_csv') }}"
|
||||
class="btn btn-secondary btn-small" title="导出异常列表为 CSV">⬇️ CSV</a>
|
||||
<a href="{{ url_for('export_anomalies_json') }}"
|
||||
class="btn btn-secondary btn-small" title="导出异常列表为 JSON">⬇️ JSON</a>
|
||||
</span>
|
||||
<button onclick="refreshAnomalies()" class="btn btn-small btn-secondary">🔄 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% 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> 或
|
||||
<a href="{{ url_for('config_paths') }}">路径设置</a> 配置 access.log。</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<!-- Thresholds (collapsible) -->
|
||||
<div class="card">
|
||||
<details>
|
||||
<summary class="card-title" style="cursor:pointer; margin:0;">
|
||||
⚙️ 阈值设置 (显示用, 修改后请回到此页面点击刷新)
|
||||
</summary>
|
||||
<form method="get" action="{{ url_for('anomalies_view') }}" class="filter-form"
|
||||
style="margin-top:12px; display:flex; gap:12px; flex-wrap:wrap; align-items:flex-end;">
|
||||
<div>
|
||||
<label class="form-label">突增比例 (ratio > X 标记异常)</label>
|
||||
<input type="number" name="spike_ratio" min="1" step="0.5"
|
||||
value="{{ thresholds.spike_warn_ratio }}" class="form-input" style="width:100px;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">大文件阈值 (MB)</label>
|
||||
<input type="number" name="large_mb" min="1" step="1"
|
||||
value="{{ thresholds.large_size_mb }}" class="form-input" style="width:100px;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">小包阈值 (KB)</label>
|
||||
<input type="number" name="small_kb" min="0.1" step="0.1"
|
||||
value="{{ thresholds.small_size_kb }}" class="form-input" style="width:100px;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">高频小包最小请求数</label>
|
||||
<input type="number" name="min_small_req" min="1" step="1"
|
||||
value="{{ thresholds.min_small_requests }}" class="form-input" style="width:120px;">
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-primary btn-small">应用</button>
|
||||
</div>
|
||||
<div class="meta-text" style="width:100%;">
|
||||
当前 critical 阈值 = {{ thresholds.spike_crit_ratio }}x (固定) ·
|
||||
基线窗口 = 前 80%, 当前窗口 = 后 20%
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Summary KPI -->
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card kpi-red">
|
||||
<div class="kpi-label">突增客户端</div>
|
||||
<div class="kpi-value">{{ summary.anomalous_clients | length }}</div>
|
||||
<div class="kpi-meta">请求速率突增 > {{ thresholds.spike_warn_ratio }}x</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-purple">
|
||||
<div class="kpi-label">大文件请求</div>
|
||||
<div class="kpi-value">{{ summary.large_requests | length }}</div>
|
||||
<div class="kpi-meta">单次响应 > {{ thresholds.large_size_mb }} MB</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-orange">
|
||||
<div class="kpi-label">高频小包客户端</div>
|
||||
<div class="kpi-value">{{ summary.high_freq_small | length }}</div>
|
||||
<div class="kpi-meta">响应 < {{ thresholds.small_size_kb }} KB 且请求数 ≥ {{ thresholds.min_small_requests }}</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-blue">
|
||||
<div class="kpi-label">首次出现客户端</div>
|
||||
<div class="kpi-value">{{ summary.new_clients | length }}</div>
|
||||
<div class="kpi-meta">本日志切片内首次见到的客户端</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. Spikes -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">🔥 突增客户端
|
||||
<span class="meta-text">({{ summary.anomalous_clients | length }} 条)</span>
|
||||
</h3>
|
||||
{% if summary.anomalous_clients %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户端</th>
|
||||
<th>基线请求/分</th>
|
||||
<th>当前请求/分</th>
|
||||
<th>基线总数</th>
|
||||
<th>当前总数</th>
|
||||
<th>倍数</th>
|
||||
<th>严重程度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in summary.anomalous_clients %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('logs_view', client=a.client) }}">{{ a.client }}</a></td>
|
||||
<td>{{ '%.2f' % a.baseline_rpm }}</td>
|
||||
<td>{{ '%.2f' % a.current_rpm }}</td>
|
||||
<td>{{ a.baseline_count }}</td>
|
||||
<td>{{ a.current_count }}</td>
|
||||
<td>
|
||||
{% if a.ratio == 'inf' or a.ratio == 'Infinity' or (a.ratio is number and a.ratio > 1e15) %}
|
||||
∞ (基线为 0)
|
||||
{% else %}
|
||||
{{ '%.2f' % a.ratio }}x
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-{% if a.severity == 'critical' %}red{% else %}yellow{% endif %}">
|
||||
{{ a.severity }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="meta-text">未检测到异常突增客户端。</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 2. Large requests -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">💾 异常大文件请求
|
||||
<span class="meta-text">({{ summary.large_requests | length }} 条)</span>
|
||||
</h3>
|
||||
{% if summary.large_requests %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>客户端</th>
|
||||
<th>大小</th>
|
||||
<th>方法</th>
|
||||
<th>状态</th>
|
||||
<th>URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in summary.large_requests %}
|
||||
<tr>
|
||||
<td><code>{{ r.time }}</code></td>
|
||||
<td><a href="{{ url_for('logs_view', client=r.client) }}">{{ r.client }}</a></td>
|
||||
<td>
|
||||
<strong>{{ '%.2f' % r.size_mb }} MB</strong>
|
||||
<span class="meta-text">({{ r.size_bytes | thousands }} B)</span>
|
||||
</td>
|
||||
<td><code>{{ r.method }}</code></td>
|
||||
<td><code>{{ r.result }}</code></td>
|
||||
<td class="url-cell" title="{{ r.url }}">
|
||||
<a href="{{ url_for('logs_view', url=r.url) }}">{{ r.url }}</a>
|
||||
{% if r.host %}<span class="meta-text">[{{ r.host }}]</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="meta-text">未检测到 > {{ thresholds.large_size_mb }} MB 的请求。</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 3. High-frequency small packets -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">📊 高频小包客户端
|
||||
<span class="meta-text">({{ summary.high_freq_small | length }} 个)</span>
|
||||
</h3>
|
||||
{% if summary.high_freq_small %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户端</th>
|
||||
<th>请求数</th>
|
||||
<th>总流量</th>
|
||||
<th>平均大小</th>
|
||||
<th>示例主机</th>
|
||||
<th>示例 URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in summary.high_freq_small %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('logs_view', client=c.client) }}">{{ c.client }}</a></td>
|
||||
<td><strong>{{ c.request_count | thousands }}</strong></td>
|
||||
<td>{{ c.total_bytes | thousands }} B</td>
|
||||
<td>{{ '%.0f' % c.avg_size }} B</td>
|
||||
<td>{{ c.sample_host or '-' }}</td>
|
||||
<td class="url-cell" title="{{ c.sample_url }}">
|
||||
<a href="{{ url_for('logs_view', url=c.sample_url) }}">{{ c.sample_url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="meta-text">
|
||||
未检测到符合条件 (< {{ thresholds.small_size_kb }} KB × ≥ {{ thresholds.min_small_requests }} 次) 的客户端。
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 4. New clients -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">👋 首次出现客户端
|
||||
<span class="meta-text">({{ summary.new_clients | length }} 个)</span>
|
||||
</h3>
|
||||
{% if summary.new_clients %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户端</th>
|
||||
<th>首次出现时间</th>
|
||||
<th>本日志段请求数</th>
|
||||
<th>示例 URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in summary.new_clients %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('logs_view', client=c.client) }}">{{ c.client }}</a></td>
|
||||
<td><code>{{ c.first_seen }}</code></td>
|
||||
<td>{{ c.request_count | thousands }}</td>
|
||||
<td class="url-cell" title="{{ c.sample_url }}">
|
||||
<a href="{{ url_for('logs_view', url=c.sample_url) }}">{{ c.sample_url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="meta-text">未检测到新出现的客户端。</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function refreshAnomalies() {
|
||||
fetch('{{ url_for("anomalies_refresh") }}', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) location.reload();
|
||||
else alert('刷新失败: ' + (d.message || ''));
|
||||
})
|
||||
.catch(e => alert('网络错误: ' + e));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}审计日志 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>📜 审计日志</h1>
|
||||
<div class="page-actions">
|
||||
<span class="meta-text">共 {{ total | thousands }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{% if items %}
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:180px;">时间</th>
|
||||
<th style="width:120px;">用户</th>
|
||||
<th style="width:180px;">操作</th>
|
||||
<th>详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in items %}
|
||||
<tr>
|
||||
<td class="time-cell">{{ a.timestamp_local }}</td>
|
||||
<td>{{ a.username }}</td>
|
||||
<td><code>{{ a.action }}</code></td>
|
||||
<td class="detail-cell">{{ a.detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}<a href="?page={{ page - 1 }}" class="btn btn-small">« 上一页</a>{% endif %}
|
||||
<span class="page-info">第 {{ page }} / {{ total_pages }} 页</span>
|
||||
{% if page < total_pages %}<a href="?page={{ page + 1 }}" class="btn btn-small">下一页 »</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="card-empty">
|
||||
<div class="empty-icon">📜</div>
|
||||
<p>暂无审计记录</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}配置备份 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>📦 配置备份</h1>
|
||||
<div class="page-actions">
|
||||
<span class="meta-text">共 {{ files | length }} 个备份</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{% if files %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名</th>
|
||||
<th>大小</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in files %}
|
||||
<tr>
|
||||
<td><code>{{ f.name }}</code></td>
|
||||
<td>{{ f.size }} 字节</td>
|
||||
<td class="time-cell">{{ f.mtime }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('backup_download', name=f.name) }}" class="btn btn-small btn-secondary">⬇ 下载</a>
|
||||
<form method="post" action="{{ url_for('backup_restore', name=f.name) }}" style="display:inline;"
|
||||
onsubmit="return confirm('确认从备份 {{ f.name }} 恢复? 当前配置将被覆盖。');">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-small btn-warning">♻ 恢复</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-empty">
|
||||
<div class="empty-icon">📦</div>
|
||||
<p>暂无备份。在保存配置时会自动创建备份。</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">ℹ️ 备份说明</h3>
|
||||
<ul>
|
||||
<li>每次通过 Web UI 保存配置时,会自动备份当前配置到 <code>backups/</code> 目录</li>
|
||||
<li>备份命名格式: <code>squid.conf.YYYYMMDD_HHMMSS.<reason></code></li>
|
||||
<li>恢复时会先将当前配置备份为 <code>before-restore</code></li>
|
||||
<li>恢复后需要 <a href="{{ url_for('service_view') }}">重载服务</a> 才能生效</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,220 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}修改密码 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🔒 修改密码</h1>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" class="form-narrow" id="change-pw-form">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label>旧密码</label>
|
||||
<input type="password" name="old_password" required class="form-input"
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新密码 <span class="hint">至少 8 位,含大小写+数字</span></label>
|
||||
<input type="password" name="new_password" id="new_password" required
|
||||
class="form-input" autocomplete="new-password" minlength="8">
|
||||
<div class="pw-strength" id="pw-strength">
|
||||
<div class="pw-strength-bar"><div class="pw-strength-fill" id="pw-fill"></div></div>
|
||||
<span class="pw-strength-label" id="pw-label"></span>
|
||||
<span class="pw-strength-hint" id="pw-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>确认新密码</label>
|
||||
<input type="password" name="confirm_password" id="confirm_password" required
|
||||
class="form-input" autocomplete="new-password">
|
||||
<div class="pw-match" id="pw-match"></div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 修改</button>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const newPw = document.getElementById('new_password');
|
||||
const confirmPw = document.getElementById('confirm_password');
|
||||
const fill = document.getElementById('pw-fill');
|
||||
const label = document.getElementById('pw-label');
|
||||
const hint = document.getElementById('pw-hint');
|
||||
const matchEl = document.getElementById('pw-match');
|
||||
|
||||
const COLORS = ['#ef4444', '#f59e0b', '#eab308', '#10b981', '#059669'];
|
||||
const LABELS = ['极弱', '弱', '中', '强', '很强'];
|
||||
|
||||
function score(pw) {
|
||||
if (!pw) return 0;
|
||||
let s = 0;
|
||||
if (pw.length >= 8) s++;
|
||||
if (pw.length >= 12) s++;
|
||||
if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) s++;
|
||||
if (/\d/.test(pw)) s++;
|
||||
if (/[^A-Za-z0-9]/.test(pw)) s++;
|
||||
if (pw.toLowerCase() === 'admin' || pw.toLowerCase() === 'password' ||
|
||||
pw.toLowerCase() === '123456' || pw.toLowerCase() === 'squid' ||
|
||||
pw.toLowerCase() === 'changeme') s = Math.max(0, s - 2);
|
||||
return Math.min(4, Math.max(0, s));
|
||||
}
|
||||
|
||||
newPw.addEventListener('input', () => {
|
||||
const s = score(newPw.value);
|
||||
fill.style.width = ((s + 1) * 20) + '%';
|
||||
fill.style.background = COLORS[s];
|
||||
label.textContent = '强度: ' + LABELS[s];
|
||||
label.style.color = COLORS[s];
|
||||
if (s < 2) hint.textContent = '建议 8+ 字符,含大小写+数字+特殊字符';
|
||||
else if (s < 3) hint.textContent = '可加入特殊字符进一步提升';
|
||||
else hint.textContent = '密码强度良好';
|
||||
if (newPw.value && confirmPw.value) checkMatch();
|
||||
});
|
||||
confirmPw.addEventListener('input', checkMatch);
|
||||
function checkMatch() {
|
||||
if (!confirmPw.value) { matchEl.textContent = ''; return; }
|
||||
if (newPw.value === confirmPw.value) {
|
||||
matchEl.textContent = '✓ 两次输入一致';
|
||||
matchEl.style.color = '#10b981';
|
||||
} else {
|
||||
matchEl.textContent = '✗ 两次输入不一致';
|
||||
matchEl.style.color = '#ef4444';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.pw-strength { margin-top: 8px; font-size: 12px; }
|
||||
.pw-strength-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: 4px; }
|
||||
.pw-strength-fill { height: 100%; width: 0; background: #ef4444; transition: width 0.2s, background 0.2s; }
|
||||
.pw-strength-label { font-weight: 600; }
|
||||
.pw-strength-hint { color: var(--text-muted); margin-left: 8px; }
|
||||
.pw-match { font-size: 12px; margin-top: 4px; min-height: 16px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,281 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,119 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}ACL 规则表 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🚦 ACL 规则表</h1>
|
||||
<div class="page-actions">
|
||||
<button onclick="document.getElementById('add-form').style.display='block'; this.scrollIntoView()" class="btn btn-primary btn-small">➕ 添加 ACL</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="add-form" style="display:none;">
|
||||
<h3 class="card-title">添加新 ACL</h3>
|
||||
<form method="post" class="inline-form">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>名称 <span class="hint">*</span></label>
|
||||
<input type="text" name="name" class="form-input" required placeholder="my_network">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>类型 <span class="hint">*</span></label>
|
||||
<select name="type" class="form-input" required>
|
||||
{% for code, desc in acl_types %}
|
||||
<option value="{{ code }}">{{ code }} - {{ desc }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label>值 <span class="hint">(空格分隔多个值)</span></label>
|
||||
<input type="text" name="values" class="form-input" required placeholder="192.168.1.0/24">
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label>备注</label>
|
||||
<input type="text" name="comment" class="form-input" placeholder="可选">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<p class="card-desc">共 {{ acls | length }} 条 ACL。修改后点击底部"保存"按钮。</p>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table acl-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:60px;">#</th>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>值</th>
|
||||
<th>备注</th>
|
||||
<th style="width:80px;">删除</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for acl in acls %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}
|
||||
<input type="hidden" name="id[]" value="{{ loop.index }}">
|
||||
</td>
|
||||
<td><input type="text" name="name[]" value="{{ acl.name }}" class="form-input"></td>
|
||||
<td>
|
||||
<select name="type[]" class="form-input">
|
||||
{% for code, desc in acl_types %}
|
||||
<option value="{{ code }}" {% if acl.type == code %}selected{% endif %}>{{ code }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" name="values[]" value="{{ acl.values | join(' ') }}" class="form-input"></td>
|
||||
<td><input type="text" name="comment[]" value="{{ acl.comment }}" class="form-input"></td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not acls %}
|
||||
<tr><td colspan="6" class="empty-row">暂无 ACL。点击上方"添加 ACL"按钮开始。</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if acls %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 保存所有 ACL</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📖 ACL 类型参考</h3>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>类型</th><th>说明</th><th>示例</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>src</code></td><td>源 IP / CIDR</td><td>192.168.1.0/24</td></tr>
|
||||
<tr><td><code>dst</code></td><td>目的 IP / CIDR</td><td>10.0.0.0/8</td></tr>
|
||||
<tr><td><code>dstdomain</code></td><td>目的域名 (含子域)</td><td>.example.com</td></tr>
|
||||
<tr><td><code>dstdom_regex</code></td><td>目的域名正则</td><td>^www\.</td></tr>
|
||||
<tr><td><code>port</code></td><td>目的端口</td><td>80 443</td></tr>
|
||||
<tr><td><code>method</code></td><td>HTTP 方法</td><td>GET POST</td></tr>
|
||||
<tr><td><code>proto</code></td><td>协议</td><td>http https ftp</td></tr>
|
||||
<tr><td><code>time</code></td><td>时间段 (周/时:分)</td><td>MTWHF 09:00-18:00</td></tr>
|
||||
<tr><td><code>url_regex</code></td><td>URL 正则</td><td>\.mp3$</td></tr>
|
||||
<tr><td><code>proxy_auth</code></td><td>认证用户</td><td>REQUIRED 或 user1 user2</td></tr>
|
||||
<tr><td><code>maxconn</code></td><td>每客户端最大连接</td><td>16</td></tr>
|
||||
<tr><td><code>browser</code></td><td>User-Agent 正则</td><td>Mozilla</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,121 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}认证配置 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🔑 认证配置 (auth_param)</h1>
|
||||
<div class="page-actions">
|
||||
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small">➕ 添加认证方案</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="add-form" style="display:none;">
|
||||
<h3 class="card-title">添加认证方案</h3>
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>方案 (scheme)</label>
|
||||
<select name="scheme" class="form-input">
|
||||
{% for s in ['basic','digest','ntlm','negotiate'] %}
|
||||
<option value="{{ s }}">{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label>认证程序路径</label>
|
||||
<input type="text" name="program" class="form-input" required
|
||||
value="/usr/lib/squid/basic_ncsa_auth /etc/squid/passwd">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>realm 提示</label>
|
||||
<input type="text" name="realm" class="form-input" value="Squid Proxy">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>children 进程数</label>
|
||||
<input type="number" name="children" class="form-input" value="5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>credentialsttl (小时)</label>
|
||||
<input type="number" name="credentialsttl" class="form-input" value="2">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<p class="card-desc">共 {{ auth_params | length }} 个认证方案。</p>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>方案</th>
|
||||
<th>程序</th>
|
||||
<th>realm</th>
|
||||
<th>children</th>
|
||||
<th>ttl</th>
|
||||
<th>删除</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ap in auth_params %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}
|
||||
<input type="hidden" name="id[]" value="{{ loop.index }}">
|
||||
</td>
|
||||
<td>
|
||||
<select name="scheme[]" class="form-input">
|
||||
{% for s in ['basic','digest','ntlm','negotiate'] %}
|
||||
<option value="{{ s }}" {% if ap.scheme == s %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" name="program[]" value="{{ ap.program }}" class="form-input"></td>
|
||||
<td><input type="text" name="realm[]" value="{{ ap.realm }}" class="form-input"></td>
|
||||
<td><input type="number" name="children[]" value="{{ ap.children }}" class="form-input"></td>
|
||||
<td><input type="number" name="credentialsttl[]" value="{{ ap.credentialsttl }}" class="form-input"></td>
|
||||
<td><button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not auth_params %}
|
||||
<tr><td colspan="7" class="empty-row">暂无认证方案。点击上方"添加"按钮。</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if auth_params %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 保存</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📖 常用认证程序</h3>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>方案</th><th>用途</th><th>示例程序</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>basic</code></td><td>基础认证 (用户名/密码明文)</td><td><code>/usr/lib/squid/basic_ncsa_auth /etc/squid/passwd</code></td></tr>
|
||||
<tr><td><code>basic</code></td><td>LDAP 认证</td><td><code>/usr/lib/squid/basic_ldap_auth -b "ou=users,dc=example,dc=com" ldap://ldap.example.com</code></td></tr>
|
||||
<tr><td><code>basic</code></td><td>PAM 认证</td><td><code>/usr/lib/squid/basic_pam_auth</code></td></tr>
|
||||
<tr><td><code>basic</code></td><td>Radius</td><td><code>/usr/lib/squid/basic_radius_auth -f /etc/squid/radius_config</code></td></tr>
|
||||
<tr><td><code>digest</code></td><td>摘要认证 (更安全)</td><td><code>/usr/lib/squid/digest_file_auth -c /etc/squid/digest_passwd</code></td></tr>
|
||||
<tr><td><code>negotiate</code></td><td>Kerberos / SPNEGO (AD)</td><td><code>/usr/lib/squid/negotiate_kerberos_auth</code></td></tr>
|
||||
<tr><td><code>ntlm</code></td><td>NTLM (旧版 Windows)</td><td><code>/usr/lib/squid/ntlm_smb_lm_auth</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="meta-text">⚠️ 添加认证后,还需在 <a href="{{ url_for('config_acls') }}">ACL 规则表</a> 中添加
|
||||
<code>acl auth_users proxy_auth REQUIRED</code>,并在
|
||||
<a href="{{ url_for('config_rules') }}">访问控制</a> 中添加
|
||||
<code>http_access allow auth_users</code>。</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,111 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}缓存目录 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>💾 缓存目录配置</h1>
|
||||
<div class="page-actions">
|
||||
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small">➕ 添加 cache_dir</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="add-form" style="display:none;">
|
||||
<h3 class="card-title">添加 cache_dir</h3>
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="add_dir">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>类型</label>
|
||||
<select name="type" class="form-input">
|
||||
{% for t in ['ufs','aufs','diskd','rock'] %}
|
||||
<option value="{{ t }}">{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>路径 <span class="hint">*</span></label>
|
||||
<input type="text" name="path" class="form-input" required value="/var/spool/squid">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>大小 (MB)</label>
|
||||
<input type="number" name="size_mb" class="form-input" value="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>L1 (一级目录数)</label>
|
||||
<input type="number" name="l1" class="form-input" value="16">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>L2 (二级目录数)</label>
|
||||
<input type="number" name="l2" class="form-input" value="256">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<p class="card-desc">共 {{ cache_dirs | length }} 个缓存目录。修改路径后需手动创建目录并执行 <code>squid -z</code> 初始化。</p>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>类型</th>
|
||||
<th>路径</th>
|
||||
<th>大小 (MB)</th>
|
||||
<th>L1</th>
|
||||
<th>L2</th>
|
||||
<th>删除</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cd in cache_dirs %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}
|
||||
<input type="hidden" name="id[]" value="{{ loop.index }}">
|
||||
</td>
|
||||
<td>
|
||||
<select name="type[]" class="form-input">
|
||||
{% for t in ['ufs','aufs','diskd','rock'] %}
|
||||
<option value="{{ t }}" {% if cd.type == t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" name="path[]" value="{{ cd.path }}" class="form-input"></td>
|
||||
<td><input type="number" name="size_mb[]" value="{{ cd.size_mb }}" class="form-input"></td>
|
||||
<td><input type="number" name="l1[]" value="{{ cd.l1 }}" class="form-input"></td>
|
||||
<td><input type="number" name="l2[]" value="{{ cd.l2 }}" class="form-input"></td>
|
||||
<td><button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not cache_dirs %}
|
||||
<tr><td colspan="7" class="empty-row">暂无 cache_dir。点击上方添加。</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if cache_dirs %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 保存</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📖 cache_dir 类型说明</h3>
|
||||
<ul>
|
||||
<li><code>ufs</code>: 默认同步写入</li>
|
||||
<li><code>aufs</code>: 异步线程写入 (推荐生产环境)</li>
|
||||
<li><code>diskd</code>: 独立进程写入</li>
|
||||
<li><code>rock</code>: 共享内存缓存 (适合多 worker)</li>
|
||||
</ul>
|
||||
<p class="meta-text">缓存大小、内存策略等参数在 <a href="{{ url_for('config_main') }}">主配置</a> 页面设置。</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}主配置 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>⚙️ 主配置</h1>
|
||||
<div class="page-actions">
|
||||
<span class="meta-text">配置文件: <code>{{ squid_conf_path }}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">🌐 网络 (Network)</h3>
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>http_port <span class="hint">HTTP 代理监听端口</span></label>
|
||||
<input type="text" name="http_port" value="{{ simple.http_port }}" class="form-input" placeholder="3128">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>https_port <span class="hint">HTTPS 监听 (用于 SSL Bump)</span></label>
|
||||
<input type="text" name="https_port" value="{{ simple.https_port }}" class="form-input" placeholder="留空则不启用">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>icp_port <span class="hint">ICP 协议端口 (邻居间)</span></label>
|
||||
<input type="text" name="icp_port" value="{{ simple.icp_port }}" class="form-input" placeholder="3130">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>snmp_port <span class="hint">SNMP 监控端口</span></label>
|
||||
<input type="text" name="snmp_port" value="{{ simple.snmp_port }}" class="form-input" placeholder="留空则不启用">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>visible_hostname <span class="hint">对外显示的主机名</span></label>
|
||||
<input type="text" name="visible_hostname" value="{{ simple.visible_hostname }}" class="form-input" placeholder="proxy.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>unique_hostname <span class="hint">多实例区分 (可选)</span></label>
|
||||
<input type="text" name="unique_hostname" value="{{ simple.unique_hostname }}" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">💾 缓存 (Cache)</h3>
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>cache_mem <span class="hint">内存缓存大小</span></label>
|
||||
<input type="text" name="cache_mem" value="{{ simple.cache_mem }}" class="form-input" placeholder="256 MB">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>maximum_object_size <span class="hint">最大对象大小</span></label>
|
||||
<input type="text" name="maximum_object_size" value="{{ simple.maximum_object_size }}" class="form-input" placeholder="4096 KB">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>minimum_object_size</label>
|
||||
<input type="text" name="minimum_object_size" value="{{ simple.minimum_object_size }}" class="form-input" placeholder="0 KB">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>maximum_object_size_in_memory</label>
|
||||
<input type="text" name="maximum_object_size_in_memory" value="{{ simple.maximum_object_size_in_memory }}" class="form-input" placeholder="512 KB">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>cache_replacement_policy</label>
|
||||
<select name="cache_replacement_policy" class="form-input">
|
||||
{% for p in ['lru','heap GDSF','heap LFUDA','heap LRU'] %}
|
||||
<option value="{{ p }}" {% if simple.cache_replacement_policy == p %}selected{% endif %}>{{ p }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>memory_replacement_policy</label>
|
||||
<select name="memory_replacement_policy" class="form-input">
|
||||
{% for p in ['lru','heap GDSF','heap LFUDA','heap LRU'] %}
|
||||
<option value="{{ p }}" {% if simple.memory_replacement_policy == p %}selected{% endif %}>{{ p }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>cache_swap_low <span class="hint">%</span></label>
|
||||
<input type="number" name="cache_swap_low" value="{{ simple.cache_swap_low }}" class="form-input" placeholder="90">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>cache_swap_high <span class="hint">%</span></label>
|
||||
<input type="number" name="cache_swap_high" value="{{ simple.cache_swap_high }}" class="form-input" placeholder="95">
|
||||
</div>
|
||||
</div>
|
||||
<p class="meta-text">磁盘缓存目录 (cache_dir) 在 <a href="{{ url_for('config_cache') }}">缓存目录</a> 页面配置</p>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">📝 日志 (Logging)</h3>
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>access_log <span class="hint">访问日志路径</span></label>
|
||||
<input type="text" name="access_log" value="{{ simple.access_log }}" class="form-input" placeholder="/var/log/squid/access.log">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>cache_log <span class="hint">缓存日志</span></label>
|
||||
<input type="text" name="cache_log" value="{{ simple.cache_log }}" class="form-input" placeholder="/var/log/squid/cache.log">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>cache_store_log</label>
|
||||
<input type="text" name="cache_store_log" value="{{ simple.cache_store_log }}" class="form-input" placeholder="/var/log/squid/store.log 或留空">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>pid_filename</label>
|
||||
<input type="text" name="pid_filename" value="{{ simple.pid_filename }}" class="form-input" placeholder="/var/run/squid.pid">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>logformat <span class="hint">自定义格式</span></label>
|
||||
<input type="text" name="logformat" value="{{ simple.logformat }}" class="form-input" placeholder="留空使用默认">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>debug_options <span class="hint">ALL,1 / section,level</span></label>
|
||||
<input type="text" name="debug_options" value="{{ simple.debug_options }}" class="form-input" placeholder="ALL,1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>log_ip_on_direct</label>
|
||||
<select name="log_ip_on_direct" class="form-input">
|
||||
<option value="on" {% if simple.log_ip_on_direct == 'on' %}selected{% endif %}>on</option>
|
||||
<option value="off" {% if simple.log_ip_on_direct == 'off' %}selected{% endif %}>off</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>buffered_logs</label>
|
||||
<select name="buffered_logs" class="form-input">
|
||||
<option value="on" {% if simple.buffered_logs == 'on' %}selected{% endif %}>on</option>
|
||||
<option value="off" {% if simple.buffered_logs == 'off' %}selected{% endif %}>off</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">🌍 DNS</h3>
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>dns_nameservers <span class="hint">DNS 服务器 (空格分隔)</span></label>
|
||||
<input type="text" name="dns_nameservers" value="{{ simple.dns_nameservers }}" class="form-input" placeholder="8.8.8.8 1.1.1.1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>dns_timeout</label>
|
||||
<input type="text" name="dns_timeout" value="{{ simple.dns_timeout }}" class="form-input" placeholder="30 seconds">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>hosts_file</label>
|
||||
<input type="text" name="hosts_file" value="{{ simple.hosts_file }}" class="form-input" placeholder="/etc/hosts">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>dns_defnames</label>
|
||||
<select name="dns_defnames" class="form-input">
|
||||
<option value="off" {% if simple.dns_defnames == 'off' %}selected{% endif %}>off</option>
|
||||
<option value="on" {% if simple.dns_defnames == 'on' %}selected{% endif %}>on</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">⏱️ 超时 (Timeouts)</h3>
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>connect_timeout</label>
|
||||
<input type="text" name="connect_timeout" value="{{ simple.connect_timeout }}" class="form-input" placeholder="60 seconds">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>read_timeout</label>
|
||||
<input type="text" name="read_timeout" value="{{ simple.read_timeout }}" class="form-input" placeholder="15 minutes">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>client_lifetime</label>
|
||||
<input type="text" name="client_lifetime" value="{{ simple.client_lifetime }}" class="form-input" placeholder="1 day">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>shutdown_lifetime</label>
|
||||
<input type="text" name="shutdown_lifetime" value="{{ simple.shutdown_lifetime }}" class="form-input" placeholder="30 seconds">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">🛠️ 管理 (Admin)</h3>
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>cache_mgr <span class="hint">管理员邮箱</span></label>
|
||||
<input type="text" name="cache_mgr" value="{{ simple.cache_mgr }}" class="form-input" placeholder="root">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>err_html_language</label>
|
||||
<input type="text" name="err_html_language" value="{{ simple.err_html_language }}" class="form-input" placeholder="en">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>ftp_user</label>
|
||||
<input type="text" name="ftp_user" value="{{ simple.ftp_user }}" class="form-input" placeholder="Squid@">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">📝 原始附加指令</h3>
|
||||
<p class="meta-text">此处填写本页未涵盖的额外指令 (每行一条,会原样写入 squid.conf 末尾)。</p>
|
||||
<div class="form-group">
|
||||
<textarea name="raw_extra" rows="5" class="form-input monospace" placeholder="# 例如 forward_timeout 4 minutes
|
||||
# maximum_single_addr_connections 16"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 保存并校验</button>
|
||||
<a href="{{ url_for('config_raw') }}" class="btn btn-secondary">📝 原始编辑</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,111 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}路径设置 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🗂️ 应用路径设置</h1>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="card-desc">配置本平台如何与 Squid 交互。这些设置仅保存在数据库,不影响 squid.conf。</p>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">🦑 Squid 路径</h3>
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>Squid 配置文件路径</label>
|
||||
<input type="text" name="squid_conf_path" value="{{ cfg.squid_conf_path }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Squid 二进制</label>
|
||||
<input type="text" name="squid_binary" value="{{ cfg.squid_binary }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>systemctl 路径</label>
|
||||
<input type="text" name="systemctl" value="{{ cfg.systemctl }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Systemd 服务名</label>
|
||||
<input type="text" name="squid_service" value="{{ cfg.squid_service }}" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">📄 日志路径</h3>
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>access.log 路径</label>
|
||||
<input type="text" name="access_log_path" value="{{ cfg.access_log_path }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>cache.log 路径</label>
|
||||
<input type="text" name="cache_log_path" value="{{ cfg.cache_log_path }}" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">⚙️ 日志分析设置</h3>
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>解析行数上限 <span class="hint">从 access.log 末尾读取的最大行数</span></label>
|
||||
<input type="number" name="log_parse_limit" value="{{ cfg.log_parse_limit }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>缓存秒数 <span class="hint">解析结果缓存 TTL</span></label>
|
||||
<input type="number" name="log_cache_seconds" value="{{ cfg.log_cache_seconds }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>实时日志显示行数</label>
|
||||
<input type="number" name="live_tail_lines" value="{{ cfg.live_tail_lines }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>自动刷新间隔 (秒)</label>
|
||||
<input type="number" name="auto_refresh_seconds" value="{{ cfg.auto_refresh_seconds }}" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">🗄️ 日志存储方式</h3>
|
||||
<p class="card-desc">P2-1 持久化模式会把 access.log 写入 SQLite,保留历史并支持时间范围查询;文件直读模式保持旧行为。</p>
|
||||
<div class="radio-group">
|
||||
<label class="radio-option">
|
||||
<input type="radio" name="log_use_persistent" value="1"
|
||||
{% if cfg.log_use_persistent != '0' %}checked{% endif %}>
|
||||
<span class="radio-title">✅ SQLite 持久化</span>
|
||||
<span class="radio-desc">日志行写入数据库,可分页/时间范围查询。同步通过 /logs/sync 触发,或设置下面自动同步间隔。</span>
|
||||
</label>
|
||||
<label class="radio-option">
|
||||
<input type="radio" name="log_use_persistent" value="0"
|
||||
{% if cfg.log_use_persistent == '0' %}checked{% endif %}>
|
||||
<span class="radio-title">📂 文件直读(旧模式)</span>
|
||||
<span class="radio-desc">仅读 access.log 末尾的指定行数。不保留历史,刷新即丢。</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>自动同步间隔 (秒) <span class="hint">仅在持久化模式下生效;0 表示仅手动同步</span></label>
|
||||
<input type="number" name="log_sync_interval" value="{{ cfg.log_sync_interval }}" class="form-input" min="0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">ℹ️ 说明</h3>
|
||||
<ul>
|
||||
<li>所有路径必须在本平台进程可访问的位置 (同一台主机或挂载的卷)</li>
|
||||
<li>如果 squid 安装在远程主机,可考虑通过 SSHFS 挂载配置和日志目录</li>
|
||||
<li>服务控制 (start/stop/reload) 通过本地 systemctl 执行 - 仅当 squid 与本平台同机时有效</li>
|
||||
<li>降低"解析行数上限"可加快仪表盘加载速度,但会丢失早期数据</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,115 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}缓存对等 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🌐 缓存对等 (cache_peer)</h1>
|
||||
<div class="page-actions">
|
||||
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small">➕ 添加 Peer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="add-form" style="display:none;">
|
||||
<h3 class="card-title">添加 cache_peer</h3>
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>主机 <span class="hint">*</span></label>
|
||||
<input type="text" name="host" class="form-input" required placeholder="proxy2.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>类型</label>
|
||||
<select name="type" class="form-input">
|
||||
{% for t in ['parent','sibling','multicast'] %}
|
||||
<option value="{{ t }}">{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>HTTP 端口</label>
|
||||
<input type="number" name="http_port" class="form-input" value="3128">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>ICP 端口 <span class="hint">0=禁用</span></label>
|
||||
<input type="number" name="icp_port" class="form-input" value="0">
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label>额外选项</label>
|
||||
<input type="text" name="options" class="form-input" placeholder="proxy-only no-query weight=10">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<p class="card-desc">共 {{ peers | length }} 个对等节点。</p>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>主机</th>
|
||||
<th>类型</th>
|
||||
<th>HTTP 端口</th>
|
||||
<th>ICP 端口</th>
|
||||
<th>选项</th>
|
||||
<th>删除</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in peers %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}
|
||||
<input type="hidden" name="id[]" value="{{ loop.index }}">
|
||||
</td>
|
||||
<td><input type="text" name="host[]" value="{{ p.host }}" class="form-input"></td>
|
||||
<td>
|
||||
<select name="type[]" class="form-input">
|
||||
{% for t in ['parent','sibling','multicast'] %}
|
||||
<option value="{{ t }}" {% if p.type == t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="number" name="http_port[]" value="{{ p.http_port }}" class="form-input"></td>
|
||||
<td><input type="number" name="icp_port[]" value="{{ p.icp_port }}" class="form-input"></td>
|
||||
<td><input type="text" name="options[]" value="{{ p.options | join(' ') }}" class="form-input"></td>
|
||||
<td><button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not peers %}
|
||||
<tr><td colspan="7" class="empty-row">暂无对等节点</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if peers %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 保存</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📖 cache_peer 类型与选项</h3>
|
||||
<ul>
|
||||
<li><strong>parent</strong>: 父代理 - 找不到缓存时转发到此节点</li>
|
||||
<li><strong>sibling</strong>: 兄弟节点 - 仅查询缓存,不转发请求</li>
|
||||
<li><strong>multicast</strong>: 多播组</li>
|
||||
</ul>
|
||||
<p class="meta-text">常用选项: <code>proxy-only</code> (不缓存到本地)、
|
||||
<code>no-query</code> (不发送 ICP 查询)、
|
||||
<code>weight=N</code> (权重)、
|
||||
<code>login=user:pass</code> (代理认证)、
|
||||
<code>ssl</code> (SSL 连接)、
|
||||
<code>round-robin</code> (轮询)</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}配置预览 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🔍 配置变更预览</h1>
|
||||
<div class="page-actions">
|
||||
<a href="{{ return_to }}" class="btn btn-secondary">← 取消</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📊 变更摘要</h3>
|
||||
<div class="diff-summary">
|
||||
<span class="diff-stat-add">+ 新增 {{ summary.adds }} 行</span>
|
||||
<span class="diff-stat-del">- 删除 {{ summary.dels }} 行</span>
|
||||
<span>共 {{ summary.total_changed }} 行变更</span>
|
||||
{% if summary.total_changed == 0 %}
|
||||
<span class="badge badge-blue">无变更</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if summary.total_changed > 0 %}
|
||||
<div class="diff-warning">
|
||||
⚠️ 仔细检查以下变更,确认无误后再点击 <strong>应用配置</strong>。
|
||||
配置写入后会自动备份当前文件到 <code>backups/</code>。
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ diff_html | safe }}
|
||||
|
||||
{% if summary.total_changed > 0 %}
|
||||
<form method="post" action="{{ url_for('config_confirm') }}" style="margin-top:20px;">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="pending" value="{{ pending_b64 }}">
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="validate" value="1" checked>
|
||||
写入前运行 <code>squid -k parse</code> 校验
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary"
|
||||
onclick="return confirm('确认应用此配置?\\n\\n会自动备份当前配置。')">
|
||||
✅ 应用配置
|
||||
</button>
|
||||
<a href="{{ return_to }}" class="btn btn-secondary">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="meta-text" style="margin-top:16px;">配置无变化,无需保存。</p>
|
||||
<a href="{{ return_to }}" class="btn btn-secondary">返回</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}原始编辑 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>📝 原始配置编辑</h1>
|
||||
<div class="page-actions">
|
||||
<span class="meta-text">配置文件: <code>{{ squid_conf_path }}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="flash flash-error">⚠️ {{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<p class="card-desc">直接编辑 squid.conf 原始内容。保存时可选启用语法校验。
|
||||
所有结构化编辑 (主配置、ACL、规则等) 在保存后会重新生成完整配置文件。</p>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<textarea name="content" rows="35" class="form-input monospace raw-editor">{{ content }}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="validate" checked> 保存前校验 (<code>squid -k parse</code>)
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">💾 保存</button>
|
||||
<button type="button" onclick="validateOnly()" class="btn btn-secondary">🔍 仅校验 (不保存)</button>
|
||||
<a href="{{ url_for('backups_view') }}" class="btn btn-secondary">📦 查看备份</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="validate-result" class="svc-result" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">⚠️ 注意事项</h3>
|
||||
<ul>
|
||||
<li>保存前会自动备份当前配置到 <code>backups/</code> 目录</li>
|
||||
<li>校验需要本机安装 squid 二进制 (否则仅做结构解析)</li>
|
||||
<li>保存成功后,需要 <a href="{{ url_for('service_view') }}">重载服务</a> 才能生效</li>
|
||||
<li>如果使用结构化编辑页面,原始编辑的修改可能被覆盖</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function validateOnly() {
|
||||
const content = document.querySelector('textarea[name="content"]').value;
|
||||
const box = document.getElementById('validate-result');
|
||||
box.style.display = 'block';
|
||||
box.className = 'svc-result svc-loading';
|
||||
box.textContent = '校验中...';
|
||||
fetch('/api/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'content=' + encodeURIComponent(content)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
box.className = 'svc-result ' + (d.ok ? 'svc-ok' : 'svc-err');
|
||||
box.innerHTML = '<strong>' + (d.ok ? '✅ 校验通过' : '❌ 校验失败') + '</strong><pre>' + (d.message || '') + '</pre>';
|
||||
})
|
||||
.catch(e => {
|
||||
box.className = 'svc-result svc-err';
|
||||
box.textContent = '请求失败: ' + e;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,107 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}刷新规则 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>♻️ Refresh Pattern 刷新规则</h1>
|
||||
<div class="page-actions">
|
||||
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small">➕ 添加规则</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="add-form" style="display:none;">
|
||||
<h3 class="card-title">添加 refresh_pattern</h3>
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>正则</label>
|
||||
<input type="text" name="regex" class="form-input" required value="." >
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>忽略大小写 (-i)</label>
|
||||
<select name="case_insensitive" class="form-input">
|
||||
<option value="off">off</option>
|
||||
<option value="on">on</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>min (分钟)</label>
|
||||
<input type="number" name="min_minutes" class="form-input" value="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>percent (%)</label>
|
||||
<input type="number" name="percent" class="form-input" value="20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>max (分钟)</label>
|
||||
<input type="number" name="max_minutes" class="form-input" value="4320">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<p class="card-desc">refresh_pattern regex min percent max - 控制对象过期判断</p>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>-i</th>
|
||||
<th>正则</th>
|
||||
<th>min (分钟)</th>
|
||||
<th>percent</th>
|
||||
<th>max (分钟)</th>
|
||||
<th>删除</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rp in patterns %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}
|
||||
<input type="hidden" name="id[]" value="{{ loop.index }}">
|
||||
<input type="hidden" name="case_insensitive_marker[]" value="{{ loop.index0 }}">
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="case_insensitive[]" value="{{ loop.index0 }}" {% if rp.case_insensitive %}checked{% endif %}>
|
||||
</td>
|
||||
<td><input type="text" name="regex[]" value="{{ rp.regex }}" class="form-input"></td>
|
||||
<td><input type="number" name="min_minutes[]" value="{{ rp.min_minutes }}" class="form-input"></td>
|
||||
<td><input type="number" name="percent[]" value="{{ rp.percent }}" class="form-input"></td>
|
||||
<td><input type="number" name="max_minutes[]" value="{{ rp.max_minutes }}" class="form-input"></td>
|
||||
<td><button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not patterns %}
|
||||
<tr><td colspan="7" class="empty-row">暂无刷新规则</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if patterns %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 保存</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📖 参数说明</h3>
|
||||
<ul>
|
||||
<li><strong>regex</strong>: 匹配 URL 的正则表达式</li>
|
||||
<li><strong>min</strong>: 最少缓存时间 (分钟)。即使 origin 返回更新也至少缓存这么久</li>
|
||||
<li><strong>percent</strong>: 对象年龄百分比。如果 (当前时间 - 修改时间) × percent% > 已缓存时间,则视为过期</li>
|
||||
<li><strong>max</strong>: 最大缓存时间 (分钟)。无论 origin 怎么说,超过此时间视为过期</li>
|
||||
<li><strong>-i</strong>: 忽略大小写</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,122 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}访问控制 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🔐 访问控制规则</h1>
|
||||
<div class="page-actions">
|
||||
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small">➕ 添加规则</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="add-form" style="display:none;">
|
||||
<h3 class="card-title">添加新规则</h3>
|
||||
<form method="post" class="inline-form">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>指令</label>
|
||||
<select name="directive" class="form-input">
|
||||
{% for d in rule_directives %}
|
||||
<option value="{{ d }}">{{ d }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>动作</label>
|
||||
<select name="action_type" class="form-input">
|
||||
<option value="allow">allow</option>
|
||||
<option value="deny">deny</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label>ACL 列表 <span class="hint">(空格分隔,! 表示取反,如 !localhost my_net)</span></label>
|
||||
<input type="text" name="acls" class="form-input" placeholder="!localhost allowed_net">
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label>备注</label>
|
||||
<input type="text" name="comment" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="card-desc">访问规则按顺序从上到下匹配,命中即停止。共 {{ rules | length }} 条规则。</p>
|
||||
<p class="meta-text">可用的 ACL:
|
||||
{% for name in acls_by_name.keys() %}
|
||||
<code>{{ name }}</code>{% if not loop.last %} · {% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:60px;">顺序</th>
|
||||
<th style="width:160px;">指令</th>
|
||||
<th style="width:100px;">动作</th>
|
||||
<th>ACL</th>
|
||||
<th>备注</th>
|
||||
<th style="width:80px;">删除</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rules %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}
|
||||
<input type="hidden" name="id[]" value="{{ loop.index }}">
|
||||
</td>
|
||||
<td>
|
||||
<select name="directive[]" class="form-input">
|
||||
{% for d in rule_directives %}
|
||||
<option value="{{ d }}" {% if r.directive == d %}selected{% endif %}>{{ d }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select name="action_type[]" class="form-input">
|
||||
<option value="allow" {% if r.action == 'allow' %}selected{% endif %}>allow</option>
|
||||
<option value="deny" {% if r.action == 'deny' %}selected{% endif %}>deny</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" name="acls[]" value="{{ r.acls | join(' ') }}" class="form-input"></td>
|
||||
<td><input type="text" name="comment[]" value="{{ r.comment }}" class="form-input"></td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not rules %}
|
||||
<tr><td colspan="6" class="empty-row">暂无规则。点击上方"添加规则"按钮开始。</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if rules %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 保存所有规则</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📖 规则说明</h3>
|
||||
<ul>
|
||||
<li><code>http_access</code>: 控制 HTTP 请求是否允许通过代理</li>
|
||||
<li><code>https_access</code>: 控制 CONNECT 隧道 (HTTPS) 请求</li>
|
||||
<li><code>icp_access</code>: 控制 ICP 查询 (邻居间)</li>
|
||||
<li><code>snmp_access</code>: 控制 SNMP 监控访问</li>
|
||||
<li><code>always_direct</code>: 强制直接访问 (不走 peer)</li>
|
||||
<li><code>never_direct</code>: 强制走父代理</li>
|
||||
<li>ACL 列表支持 <code>!</code> 取反,多个 ACL 之间为 AND 关系 (全部满足才匹配)</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,206 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}SSL Bump - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🔐 SSL Bump (HTTPS 透明代理)</h1>
|
||||
<div class="page-actions">
|
||||
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small">➕ 添加规则</button>
|
||||
<button onclick="document.getElementById('cert-form').style.display='block'" class="btn btn-secondary btn-small">🪪 生成自签 CA</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="border-left: 4px solid #d33; background: #fff8f8;">
|
||||
<h3 class="card-title" style="color:#c0392b;">⚠️ 风险与合规提示</h3>
|
||||
<ul>
|
||||
<li><b>中间人攻击:</b> 启用 <code>bump</code> 后代理会解密所有 HTTPS 流量,浏览器看到的是代理签名的证书而非真实证书。</li>
|
||||
<li><b>私钥泄露风险:</b> 必须在受信环境运行;一旦 CA 私钥泄露,所有用它签名的中间证书都可以伪造。</li>
|
||||
<li><b>用户信任:</b> 客户端必须安装并信任你生成的 CA;否则会看到证书错误。</li>
|
||||
<li><b>审计/合规:</b> 许多司法管辖区要求告知用户并获取同意;请提前与法务/合规团队沟通。</li>
|
||||
<li><b>替代方案:</b> 如果只是需要域名过滤,优先用 <code>splice</code> (不解密) + SNI 嗅探;只有需要看 payload 时再用 <code>bump</code>。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📋 默认规则示例</h3>
|
||||
<pre class="code-block"><code>{{ default_example|e }}</code></pre>
|
||||
<p class="meta-text">规则按顺序匹配,首条命中即停止。建议先用 <code>splice</code> 放通全部,排查没问题后再加 <code>peek</code>/<code>bump</code>。</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="add-form" style="display:none;">
|
||||
<h3 class="card-title">添加新 SSL Bump 规则</h3>
|
||||
<form method="post" class="inline-form">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>动作 (action)</label>
|
||||
<select name="action_type" class="form-input">
|
||||
{% for a in actions %}
|
||||
<option value="{{ a }}" {% if a == 'peek' %}selected{% endif %}>{{ a }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label>ACL 列表 <span class="hint">(空格分隔,支持 <code>!aclname</code> 取反,如 <code>!localhost trusted_net</code>)</span></label>
|
||||
<input type="text" name="acls" class="form-input" placeholder="step1 all">
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 3;">
|
||||
<label>备注</label>
|
||||
<input type="text" name="comment" class="form-input" placeholder="示例: 缺省放行">
|
||||
</div>
|
||||
</div>
|
||||
<details class="hint" style="margin-top: 0.5em;">
|
||||
<summary>📚 动作含义</summary>
|
||||
<ul style="margin-top: 0.5em;">
|
||||
<li><code>peek</code> - 在 SslBump1 解密 client hello,只读 SNI/ALPN,不解后续</li>
|
||||
<li><code>splice</code> - TCP 直通隧道,不解密 (性能最好,几乎无开销)</li>
|
||||
<li><code>bump</code> - 完整解密,代理签发证书,可看明文 (最慢/最重)</li>
|
||||
<li><code>terminate</code> - 主动关闭连接 (用于阻断)</li>
|
||||
<li><code>client-first</code> / <code>server-first</code> - 控制 TLS 握手方向</li>
|
||||
<li><code>none</code> - 显式禁用 (用于在内层 step 关闭默认行为)</li>
|
||||
</ul>
|
||||
</details>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="card-desc">当前共 <b>{{ rules | length }}</b> 条 SSL Bump 规则。按表从上到下顺序匹配,首条命中即终止。</p>
|
||||
<p class="meta-text">可用 ACL:
|
||||
{% for name in acls_by_name.keys() %}
|
||||
<code>{{ name }}</code>{% if not loop.last %} · {% endif %}
|
||||
{% else %}
|
||||
<em>暂无 ACL,请先在「ACL 规则表」页面创建。</em>
|
||||
{% endfor %}
|
||||
</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:60px;">#</th>
|
||||
<th style="width:160px;">动作</th>
|
||||
<th>ACL</th>
|
||||
<th>备注</th>
|
||||
<th style="width:80px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rules %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}
|
||||
<input type="hidden" name="id[]" value="{{ loop.index }}">
|
||||
</td>
|
||||
<td>
|
||||
<select name="action_type[]" class="form-input">
|
||||
{% for a in actions %}
|
||||
<option value="{{ a }}" {% if r.action == a %}selected{% endif %}>{{ a }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" name="acls[]" value="{{ r.acls | join(' ') }}" class="form-input" placeholder="!localhost trusted_net"></td>
|
||||
<td><input type="text" name="comment[]" value="{{ r.comment }}" class="form-input"></td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not rules %}
|
||||
<tr><td colspan="5" class="empty-row">暂无规则。点击"添加规则"开始,或先在下方配置 sslcrtd / sslproxy_ca_* 再添加 peek/splice 规则。</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if rules %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 保存所有规则与 SSL 设置</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">🗄️ SSL 数据库与 CA 证书设置</h3>
|
||||
<p class="meta-text">
|
||||
这些是让 SSL Bump 真正起作用的底层配置。
|
||||
<b>sslcrtd_program</b> + <b>sslcrtd_children</b> 控制 on-the-fly 自签证书生成器;
|
||||
<b>sslproxy_ca_certfile</b> + <b>sslproxy_ca_keyfile</b> 指向你用作 CA 的证书与私钥。
|
||||
</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group" style="grid-column: span 3;">
|
||||
<label>sslcrtd_program <span class="hint">(完整命令行: 可执行文件 + 参数,空格分隔)</span></label>
|
||||
<input type="text" name="sslcrtd_program" class="form-input"
|
||||
value="{{ simple.sslcrtd_program }}"
|
||||
placeholder="security_file_certgen -s /var/lib/squid/ssl_db -M 4 MB">
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 3;">
|
||||
<label>sslcrtd_children <span class="hint">(空格分隔,如 <code>5 startup=1 idle=1</code>)</span></label>
|
||||
<input type="text" name="sslcrtd_children" class="form-input"
|
||||
value="{{ simple.sslcrtd_children }}"
|
||||
placeholder="5 startup=1 idle=1">
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 3;">
|
||||
<label>sslproxy_ca_certfile <span class="hint">(CA 证书路径,绝对路径)</span></label>
|
||||
<input type="text" name="sslproxy_ca_certfile" class="form-input"
|
||||
value="{{ simple.sslproxy_ca_certfile }}"
|
||||
placeholder="/etc/squid/bump_ca.crt">
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 3;">
|
||||
<label>sslproxy_ca_keyfile <span class="hint">(CA 私钥路径,绝对路径)</span></label>
|
||||
<input type="text" name="sslproxy_ca_keyfile" class="form-input"
|
||||
value="{{ simple.sslproxy_ca_keyfile }}"
|
||||
placeholder="/etc/squid/bump_ca.key">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 保存 SSL 数据库与 CA 设置</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" id="cert-form" style="display:none;">
|
||||
<h3 class="card-title">🪪 生成 SSL Bump 自签 CA (便利工具)</h3>
|
||||
<p class="meta-text">
|
||||
一键生成自签 CA 证书到 <code>ssl_certs/</code>。生成后请将证书安装到所有需要被 SSL Bump 的客户端的"受信任的根证书颁发机构"。
|
||||
<b>注意:</b> 不要复用生产 CA 私钥。
|
||||
</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="generate-cert">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>Common Name (CN)</label>
|
||||
<input type="text" name="common_name" class="form-input" value="Squid Bump CA" placeholder="Squid Bump CA">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>有效期 (天)</label>
|
||||
<input type="number" name="days" class="form-input" value="3650" min="1" max="36500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">🪪 生成</button>
|
||||
<button type="button" onclick="document.getElementById('cert-form').style.display='none'" class="btn btn-secondary">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📖 参考</h3>
|
||||
<ul>
|
||||
<li><code>peek</code>: 解密 client hello 拿到 SNI/ALPN,然后视后续 step 再决定;不让代理完整解密流量。</li>
|
||||
<li><code>splice</code>: 直接把 TCP 流透传给 origin,不解密;性能最佳,适合大批流量。</li>
|
||||
<li><code>bump</code>: 完整解密 TLS,代理用 sslproxy_ca_certfile 签发 leaf cert;</li>
|
||||
<li><code>terminate</code>: 客户端 TCP 被代理关闭 (用于拒绝)。</li>
|
||||
<li>ACL 列表里用 <code>!</code> 取反,多个 ACL 之间为 AND (全部满足)。</li>
|
||||
<li>常见配套: <code>acl step1 at_step SslBump1</code> + <code>ssl_bump peek step1</code> 然后 <code>ssl_bump splice all</code>。</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,161 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}TLS 证书 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🔐 TLS 证书管理</h1>
|
||||
<div class="page-actions">
|
||||
<span class="meta-text">共 {{ certs | length }} 个证书 · 目录 <code>{{ cert_dir }}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="card-desc">
|
||||
HTTPS 反代需要 TLS 证书。这里可以一键生成<strong>自签名证书</strong>用于快速测试,
|
||||
也可以上传由 Let's Encrypt / 商业 CA 签发的正式证书(<code>.pem</code> / <code>.crt</code> + <code>.key</code>)。
|
||||
<br>
|
||||
<strong>⚠️ 生产环境强烈建议</strong>: 使用 <code>nginx</code> + <code>Let's Encrypt</code> 反向代理并强制 HTTPS,
|
||||
而不是把本 Web 平台直接暴露在公网。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">🪄 生成自签名证书</h3>
|
||||
<p class="card-desc">立即可用的测试证书,有效期可选 (1 ~ 3650 天)。建议仅用于内网 / 临时调试。</p>
|
||||
<form method="post" action="{{ url_for('config_tls_generate') }}">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>Common Name (CN) <span class="hint">主机名 / 域名, 例如 proxy.example.com</span></label>
|
||||
<input type="text" name="common_name" required minlength="1" maxlength="253"
|
||||
placeholder="proxy.example.com" class="form-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>有效期 (天) <span class="hint">默认 365, 最大 3650</span></label>
|
||||
<input type="number" name="days" value="365" min="1" max="3650" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">🔧 生成自签名证书</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">⬆ 上传证书</h3>
|
||||
<p class="card-desc">
|
||||
支持 <code>.pem</code> / <code>.crt</code> / <code>.key</code>。单个文件上限 <strong>1 MB</strong>。
|
||||
上传后请在表中核对<strong>主题 (CN)</strong>与<strong>到期时间</strong>。
|
||||
</p>
|
||||
<form method="post" action="{{ url_for('config_tls_upload') }}"
|
||||
enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label>证书 / 私钥文件 (.pem / .crt / .key)</label>
|
||||
<input type="file" name="certfile" required accept=".pem,.crt,.key" class="form-input">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">📤 上传</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📜 已有证书</h3>
|
||||
{% if certs %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名</th>
|
||||
<th>类型</th>
|
||||
<th>主题 (CN)</th>
|
||||
<th>到期</th>
|
||||
<th>状态</th>
|
||||
<th>SHA-256 指纹</th>
|
||||
<th>大小</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in certs %}
|
||||
<tr>
|
||||
<td><code>{{ c.name }}</code></td>
|
||||
<td>
|
||||
{% if c.is_key %}
|
||||
<span class="badge badge-blue">KEY</span>
|
||||
{% elif c.ext == '.pem' %}
|
||||
<span class="badge badge-purple">PEM</span>
|
||||
{% else %}
|
||||
<span class="badge badge-cyan">CRT</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if c.parsed_ok and c.cn %}
|
||||
{{ c.cn }}
|
||||
{% elif c.parsed_ok %}
|
||||
<span class="meta-text">—</span>
|
||||
{% else %}
|
||||
<span class="badge badge-gray">{{ c.parse_error[:30] }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if c.parsed_ok and c.not_after %}
|
||||
<span title="{{ c.expires_iso }}">{{ c.not_after }}</span>
|
||||
{% else %}
|
||||
<span class="meta-text">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if c.parsed_ok %}
|
||||
{% if c.status == 'valid' %}
|
||||
<span class="badge badge-green">有效 ({{ c.days_remaining }} 天)</span>
|
||||
{% elif c.status == 'expiring' %}
|
||||
<span class="badge badge-yellow">即将过期 ({{ c.days_remaining }} 天)</span>
|
||||
{% elif c.status == 'expired' %}
|
||||
<span class="badge badge-red">已过期</span>
|
||||
{% else %}
|
||||
<span class="badge badge-gray">未知</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge badge-gray">未识别</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if c.sha256 %}
|
||||
<code class="meta-text" title="{{ c.sha256 }}">{{ c.sha256[:16] }}…</code>
|
||||
{% else %}
|
||||
<span class="meta-text">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ c.size }} B</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('config_tls_delete') }}"
|
||||
style="display:inline;"
|
||||
onsubmit="return confirm('确认删除证书 {{ c.name }}? 此操作不可恢复。');">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="name" value="{{ c.name }}">
|
||||
<button type="submit" class="btn btn-small btn-warning">🗑 删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-empty">
|
||||
<div class="empty-icon">🔐</div>
|
||||
<p>暂无证书。先点击上方「生成自签名证书」或上传一个 .pem / .crt 文件。</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">ℹ️ 使用提示</h3>
|
||||
<ul>
|
||||
<li>所有证书都存储在 <code>{{ cert_dir }}/</code>,文件权限建议 <code>600</code></li>
|
||||
<li>自签名证书浏览器会提示<strong>不受信任</strong>,仅适合内网 / 临时测试</li>
|
||||
<li>推荐的正式方案见 README 中的 <strong>TLS / HTTPS 部署指南</strong></li>
|
||||
<li>删除私钥 (<code>.key</code>) 后,对应的证书将无法继续用于 HTTPS</li>
|
||||
<li>上传的 .pem 同时包含证书和私钥时,<strong>请确保只有 root 才能访问本目录</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,387 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}错误 {{ code }} - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-empty">
|
||||
<div class="empty-icon" style="font-size:64px;">{{ code }}</div>
|
||||
<h2>{{ message }}</h2>
|
||||
<p><a href="{{ url_for('dashboard') }}" class="btn btn-primary">返回仪表盘</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,306 @@
|
||||
{% 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: '© <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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
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 %}
|
||||
@@ -0,0 +1,195 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}多实例管理 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🖥️ 多实例管理</h1>
|
||||
<div class="page-actions">
|
||||
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small">➕ 添加实例</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="meta-text">
|
||||
在数据库中保存多个 Squid 实例。切换"当前活动实例"后,所有配置、日志、服务控制页面都会基于该实例的路径与服务名。<br>
|
||||
当前活动实例: <span class="badge badge-blue">{{ active_instance_name }}</span>
|
||||
{% if active %}<span class="meta-text">(ID #{{ active.id }})</span>{% endif %}
|
||||
</p>
|
||||
|
||||
{# -------- add new instance form (hidden by default) -------- #}
|
||||
<div class="card" id="add-form" style="display:none;">
|
||||
<h3 class="card-title">添加新实例</h3>
|
||||
<form method="post" action="{{ url_for('instances_add') }}" class="inline-form">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>名称 <span class="hint">(唯一)</span></label>
|
||||
<input type="text" name="name" class="form-input" required maxlength="80" placeholder="例如: prod-edge-01">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述</label>
|
||||
<input type="text" name="description" class="form-input" maxlength="200" placeholder="可选">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>本机实例</label>
|
||||
<select name="is_local" class="form-input">
|
||||
<option value="1" selected>是 (本地)</option>
|
||||
<option value="0">否 (远程 SSH)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">🦑 Squid 路径</h3>
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>squid.conf 路径</label>
|
||||
<input type="text" name="squid_conf_path" class="form-input" value="/etc/squid/squid.conf">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>access.log 路径</label>
|
||||
<input type="text" name="access_log_path" class="form-input" value="/var/log/squid/access.log">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>cache.log 路径</label>
|
||||
<input type="text" name="cache_log_path" class="form-input" value="/var/log/squid/cache.log">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>squid 二进制</label>
|
||||
<input type="text" name="squid_binary" class="form-input" value="squid">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>systemctl</label>
|
||||
<input type="text" name="systemctl" class="form-input" value="systemctl">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Systemd 服务名</label>
|
||||
<input type="text" name="squid_service" class="form-input" value="squid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">🔐 SSH (仅远程实例)</h3>
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>SSH 主机</label>
|
||||
<input type="text" name="ssh_host" class="form-input" placeholder="例如: 10.0.0.5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SSH 用户</label>
|
||||
<input type="text" name="ssh_user" class="form-input" placeholder="root">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SSH 端口</label>
|
||||
<input type="number" name="ssh_port" class="form-input" value="22" min="1" max="65535">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SSH 私钥路径</label>
|
||||
<input type="text" name="ssh_key_path" class="form-input" placeholder="/root/.ssh/id_rsa">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<label style="display:inline-flex; align-items:center; gap:6px; margin-right:auto;">
|
||||
<input type="checkbox" name="is_active" value="1">
|
||||
添加后立即激活
|
||||
</label>
|
||||
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary btn-small">取消</button>
|
||||
<button type="submit" class="btn btn-primary btn-small">💾 添加</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# -------- existing instances table -------- #}
|
||||
<div class="card">
|
||||
<h3 class="card-title">📋 已配置实例 ({{ instances|length }})</h3>
|
||||
{% if not instances %}
|
||||
<p class="card-empty">尚未配置任何实例。点击右上角"添加实例"开始。</p>
|
||||
{% else %}
|
||||
<div class="table-scroll">
|
||||
<form method="post" action="{{ url_for('instances_save') }}">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>描述</th>
|
||||
<th>主机</th>
|
||||
<th>配置文件</th>
|
||||
<th>access.log</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for inst in instances %}
|
||||
{% set is_current = (active and active.id == inst.id) %}
|
||||
<tr {% if is_current %}style="background: rgba(59,130,246,0.08);"{% endif %}>
|
||||
<td>
|
||||
<input type="text" name="name_{{ inst.id }}" value="{{ inst.name }}" class="form-input" required maxlength="80">
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="description_{{ inst.id }}" value="{{ inst.description or '' }}" class="form-input" maxlength="200" placeholder="(无)">
|
||||
</td>
|
||||
<td>
|
||||
{% if inst.is_local %}
|
||||
<span class="badge badge-blue">本地</span>
|
||||
{% else %}
|
||||
<code>{{ inst.ssh_host or '?' }}:{{ inst.ssh_port }}</code>
|
||||
<span class="meta-text">({{ inst.ssh_user or '?' }})</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><code class="meta-text">{{ inst.squid_conf_path }}</code></td>
|
||||
<td><code class="meta-text">{{ inst.access_log_path }}</code></td>
|
||||
<td>
|
||||
{% if is_current %}
|
||||
<span class="badge badge-green">★ 当前活动</span>
|
||||
{% elif inst.is_active %}
|
||||
<span class="badge badge-yellow">默认</span>
|
||||
{% else %}
|
||||
<span class="meta-text">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="white-space:nowrap;">
|
||||
{% if not is_current %}
|
||||
<form method="post" action="{{ url_for('instances_activate', inst_id=inst.id) }}" style="display:inline;">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ request.path }}">
|
||||
<button type="submit" class="btn btn-primary btn-small">▶ 激活</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('instances_test', inst_id=inst.id) }}" class="btn btn-secondary btn-small" target="_blank">🔍 测试</a>
|
||||
<form method="post" action="{{ url_for('instances_delete') }}" style="display:inline;" onsubmit="return confirm('确定要删除实例 {{ inst.name }} 吗?');">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="id" value="{{ inst.id }}">
|
||||
<button type="submit" class="btn btn-danger btn-small">🗑</button>
|
||||
</form>
|
||||
{# hidden checkbox used by /instances/save to set the active row #}
|
||||
{% if not is_current %}
|
||||
<input type="checkbox" name="is_active_{{ inst.id }}" value="1" style="display:none;">
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="form-actions" style="margin-top: 12px;">
|
||||
<span class="meta-text">提示: 表格中可编辑名称/描述/路径。修改后点击"保存所有修改"。</span>
|
||||
<button type="submit" class="btn btn-primary">💾 保存所有修改</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">ℹ️ 说明</h3>
|
||||
<ul>
|
||||
<li>当前活动实例的路径/服务名会覆盖 <code>/config/paths</code> 中的全局设置</li>
|
||||
<li>切换实例后,所有现有页面 (config/*、logs、service) 自动基于新实例的路径</li>
|
||||
<li>应用级参数 (日志解析上限、缓存秒数等) 仍然是全局的,不会因实例切换而变化</li>
|
||||
<li>远程实例的 SSH 凭据当前仅记录,配置读写仍需通过本地文件系统或 SSHFS 挂载</li>
|
||||
<li>"测试"按钮检查 conf_path 是否可读、access/cache log 是否存在</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}登录 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">🦑</div>
|
||||
<h1>Squid Web Manager</h1>
|
||||
<p class="login-sub">Squid 代理服务器管理平台</p>
|
||||
{% if throttle_remaining and throttle_remaining > 0 %}
|
||||
<div class="login-throttle">
|
||||
⏳ 登录已锁定,请等待 <strong id="throttle-countdown">{{ throttle_remaining }}</strong> 秒
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('login') }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" required autofocus
|
||||
autocomplete="username" placeholder="admin"
|
||||
class="form-input" {% if throttle_remaining and throttle_remaining > 0 %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" required
|
||||
autocomplete="current-password" class="form-input"
|
||||
{% if throttle_remaining and throttle_remaining > 0 %}disabled{% endif %}>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block"
|
||||
{% if throttle_remaining and throttle_remaining > 0 %}disabled{% endif %}>
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
<p class="login-hint">默认账号 admin / admin · 首次登录后请修改密码</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if throttle_remaining and throttle_remaining > 0 %}
|
||||
<script>
|
||||
let s = {{ throttle_remaining }};
|
||||
const el = document.getElementById('throttle-countdown');
|
||||
const t = setInterval(() => {
|
||||
s--;
|
||||
if (el) el.textContent = s;
|
||||
if (s <= 0) { clearInterval(t); location.reload(); }
|
||||
}, 1000);
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,165 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}日志查询 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>📋 日志查询</h1>
|
||||
<div class="page-actions">
|
||||
<span class="meta-text">共 {{ total | thousands }} 条匹配</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sync-bar">
|
||||
<div class="sync-info">
|
||||
{% if use_persistent %}
|
||||
{% if last_sync %}
|
||||
<span class="badge badge-green">📥 已启用持久化</span>
|
||||
<span class="meta-text">上次同步: {{ last_sync }}{% if last_sync_path %} · 源: <code>{{ last_sync_path }}</code>{% endif %}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-orange">⚠️ 尚未同步</span>
|
||||
<span class="meta-text">点击"立即同步"把当前 access.log 导入到数据库</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge badge-gray">📂 文件直读模式</span>
|
||||
<span class="meta-text">持久化已关闭 (在 <a href="{{ url_for('config_paths') }}">路径设置</a> 打开后可保留历史)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="sync-actions">
|
||||
<form method="post" action="{{ url_for('logs_sync') }}" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-primary btn-small">🔄 立即同步</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('logs_sync') }}?full=1" style="display:inline"
|
||||
onsubmit="return confirm('确定要从头扫描整个 access.log?耗时取决于文件大小.');">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="full" value="1">
|
||||
<button type="submit" class="btn btn-secondary btn-small">📥 全量导入历史</button>
|
||||
</form>
|
||||
<span class="btn-group" style="display:inline-flex; gap:4px; margin-left:8px;">
|
||||
{% set export_args = request.args.to_dict() %}
|
||||
{% set _ = export_args.pop('page', None) %}
|
||||
<a href="{{ url_for('export_logs_csv', **export_args) }}"
|
||||
class="btn btn-secondary btn-small" title="导出当前过滤结果为 CSV">⬇️ CSV</a>
|
||||
<a href="{{ url_for('export_logs_json', **export_args) }}"
|
||||
class="btn btn-secondary btn-small" title="导出当前过滤结果为 JSON">⬇️ JSON</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="filter-form">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>客户端 IP</label>
|
||||
<input type="text" name="client" value="{{ filters.client }}" class="form-input" placeholder="172.16.12.232">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>HTTP 方法</label>
|
||||
<select name="method" class="form-input">
|
||||
<option value="">全部</option>
|
||||
{% for m in ['GET','POST','PUT','DELETE','CONNECT','HEAD','OPTIONS','TRACE'] %}
|
||||
<option value="{{ m }}" {% if filters.method == m %}selected{% endif %}>{{ m }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>结果码</label>
|
||||
<input type="text" name="result_code" value="{{ filters.result_code }}" class="form-input" placeholder="TCP_MISS / TCP_TUNNEL / 200 / 404 ...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>目标主机</label>
|
||||
<input type="text" name="host" value="{{ filters.host }}" class="form-input" placeholder="chatgpt.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>URL 包含</label>
|
||||
<input type="text" name="url" value="{{ filters.url }}" class="form-input" placeholder="metadata">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>响应大小 (字节)</label>
|
||||
<div class="range-input">
|
||||
<input type="number" name="min_size" value="{{ filters.min_size }}" class="form-input" placeholder="最小">
|
||||
<span>~</span>
|
||||
<input type="number" name="max_size" value="{{ filters.max_size }}" class="form-input" placeholder="最大">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>响应时间 (ms)</label>
|
||||
<div class="range-input">
|
||||
<input type="number" name="min_elapsed" value="{{ filters.min_elapsed }}" class="form-input" placeholder="最小">
|
||||
<span>~</span>
|
||||
<input type="number" name="max_elapsed" value="{{ filters.max_elapsed }}" class="form-input" placeholder="最大">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>起始时间 <span class="hint">本地时区</span></label>
|
||||
<input type="datetime-local" name="start_time" value="{{ filters.start_time }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>结束时间 <span class="hint">本地时区</span></label>
|
||||
<input type="datetime-local" name="end_time" value="{{ filters.end_time }}" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">🔍 查询</button>
|
||||
<a href="{{ url_for('logs_view') }}" class="btn btn-secondary">清空</a>
|
||||
{% if filters.start_time or filters.end_time %}
|
||||
<span class="meta-text">⏱ 时间范围: {{ filters.start_time or '最早' }} → {{ filters.end_time or '最新' }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{% if entries %}
|
||||
<div class="table-scroll">
|
||||
<table class="data-table log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>客户端</th>
|
||||
<th>结果码</th>
|
||||
<th>HTTP</th>
|
||||
<th>方法</th>
|
||||
<th>大小</th>
|
||||
<th>耗时</th>
|
||||
<th>URL</th>
|
||||
<th>层级</th>
|
||||
<th>Content-Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td class="time-cell">{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S') if e.timestamp else e.time }}</td>
|
||||
<td><a href="{{ url_for('client_detail', ip=e.client) }}">{{ e.client }}</a></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) }}">{{ e.url }}</a></td>
|
||||
<td><code>{{ e.hier_code }}</code>{% if e.peer %}<div class="meta-text">{{ e.peer }}</div>{% endif %}</td>
|
||||
<td><code>{{ e.content_type }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% set args = request.args.to_dict() %}
|
||||
{% if page > 1 %}{% set _ = args.update({'page': page - 1}) %}<a href="?{{ args | urlencode }}" class="btn btn-small">« 上一页</a>{% endif %}
|
||||
<span class="page-info">第 {{ page }} / {{ total_pages }} 页</span>
|
||||
{% if page < total_pages %}{% set _ = args.update({'page': page + 1}) %}<a href="?{{ args | urlencode }}" class="btn btn-small">下一页 »</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="card-empty">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<p>未找到匹配的日志</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,148 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}日志导入 - Squid Manager{% endblock %}
|
||||
{% block head %}
|
||||
{% if imported %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>📥 日志导入分析</h1>
|
||||
</div>
|
||||
|
||||
{% if not imported %}
|
||||
<div class="card">
|
||||
<p class="card-desc">将 Squid access.log 内容粘贴到下方文本框,系统将解析并展示完整分析结果。
|
||||
适用于离线日志分析或一次性诊断。</p>
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label>日志内容</label>
|
||||
<textarea name="log_text" rows="20" class="form-input monospace"
|
||||
placeholder="1783841223.438 2488 172.16.12.232 TCP_MISS_ABORTED/000 0 GET http://169.254.169.254/metadata/instance/compute - HIER_DIRECT/169.254.169.254 -
|
||||
1783841225.683 4564 172.16.237.9 TCP_TUNNEL/200 10197 CONNECT default.exp-tas.com:443 - HIER_DIRECT/13.107.5.93 -
|
||||
..."></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">📊 开始分析</button>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="page-header">
|
||||
<h2>分析结果</h2>
|
||||
<div class="page-actions">
|
||||
<span class="meta-text">共解析 {{ entries | length }} 条 / {{ raw_lines | length }} 行</span>
|
||||
<a href="{{ url_for('logs_import') }}" class="btn btn-small btn-secondary">📥 重新导入</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="kpi-card kpi-green">
|
||||
<div class="kpi-label">缓存命中率</div>
|
||||
<div class="kpi-value">{{ '%.1f' % (stats.hit_ratio * 100) }}%</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-purple">
|
||||
<div class="kpi-label">总流量</div>
|
||||
<div class="kpi-value">{{ format_bytes(stats.total_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>
|
||||
<div class="kpi-card kpi-red">
|
||||
<div class="kpi-label">拒绝率</div>
|
||||
<div class="kpi-value">{{ '%.2f' % (stats.denied_rate * 100) }}%</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-yellow">
|
||||
<div class="kpi-label">中断率</div>
|
||||
<div class="kpi-value">{{ '%.2f' % (stats.aborted_rate * 100) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card chart-card">
|
||||
<h3 class="card-title">🏷️ 结果码分布</h3>
|
||||
<div class="chart-container"><canvas id="resultChart" height="200"></canvas></div>
|
||||
</div>
|
||||
<div class="card chart-card">
|
||||
<h3 class="card-title">🚀 方法分布</h3>
|
||||
<div class="chart-container"><canvas id="methodChart" height="200"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<h3 class="card-title">👥 Top 客户端</h3>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>#</th><th>IP</th><th>请求数</th><th>流量</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in stats.top_clients[:15] %}
|
||||
<tr><td>{{ loop.index }}</td><td>{{ c.client }}</td><td>{{ c.count }}</td><td>{{ format_bytes(c.bytes) }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 class="card-title">🌍 Top 目标主机</h3>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>#</th><th>主机</th><th>请求数</th><th>流量</th></tr></thead>
|
||||
<tbody>
|
||||
{% for h in stats.top_hosts[:15] %}
|
||||
<tr><td>{{ loop.index }}</td><td>{{ h.host }}</td><td>{{ h.count }}</td><td>{{ format_bytes(h.bytes) }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📋 原始日志 (前 200 条)</h3>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table log-table">
|
||||
<thead><tr><th>时间</th><th>客户端</th><th>结果</th><th>HTTP</th><th>方法</th><th>大小</th><th>URL</th><th>层级</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in entries[:200] %}
|
||||
<tr>
|
||||
<td class="time-cell">{{ e.timestamp.strftime('%H:%M:%S') if e.timestamp else e.time }}</td>
|
||||
<td>{{ e.client }}</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{% else %}gray{% endif %}">{{ e.result_code }}</span></td>
|
||||
<td>{{ e.http_code or '-' }}</td>
|
||||
<td>{{ e.method }}</td>
|
||||
<td>{{ format_bytes(e.size) }}</td>
|
||||
<td class="url-cell" title="{{ e.url }}">{{ e.url }}</td>
|
||||
<td><code>{{ e.hier_code }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if imported %}
|
||||
<script>
|
||||
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#06b6d4','#84cc16','#f97316','#6366f1','#14b8a6','#a855f7','#eab308','#22c55e','#64748b','#0ea5e9'];
|
||||
function makeDoughnut(id, data) {
|
||||
const ctx = document.getElementById(id).getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: { labels: data.map(d=>d[0]), datasets: [{ data: data.map(d=>d[1]), backgroundColor: COLORS, borderWidth:1, borderColor:'#1e293b' }] },
|
||||
options: { responsive:true, maintainAspectRatio:false, plugins: { legend: { position:'right', labels:{color:'#cbd5e1',font:{size:11}} } } }
|
||||
});
|
||||
}
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
makeDoughnut('resultChart', {{ stats.by_result | tojson }});
|
||||
makeDoughnut('methodChart', {{ stats.by_method | tojson }});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}实时日志 - Squid Manager{% endblock %}
|
||||
{% block head %}
|
||||
<meta http-equiv="refresh" content="{{ cfg.auto_refresh_seconds }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>⚡ 实时日志 (Live Tail)</h1>
|
||||
<div class="page-actions">
|
||||
<span class="meta-text">显示最近 {{ n }} 条 · 每 {{ cfg.auto_refresh_seconds }} 秒自动刷新</span>
|
||||
<a href="{{ url_for('config_paths') }}" class="btn btn-small btn-secondary">⚙️ 调整</a>
|
||||
<button onclick="location.reload()" class="btn btn-small btn-primary">🔄 立即刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{% if entries %}
|
||||
<div class="table-scroll">
|
||||
<table class="data-table log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>客户端</th>
|
||||
<th>结果码</th>
|
||||
<th>HTTP</th>
|
||||
<th>方法</th>
|
||||
<th>大小</th>
|
||||
<th>耗时</th>
|
||||
<th>URL</th>
|
||||
<th>层级</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td class="time-cell">{{ e.timestamp.strftime('%H:%M:%S') if e.timestamp else e.time }}</td>
|
||||
<td>{{ e.client }}</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 %}">{{ e.result_code }}</span>
|
||||
</td>
|
||||
<td><span class="badge badge-gray">{{ 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 }}">{{ e.url }}</td>
|
||||
<td><code>{{ e.hier_code }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-empty">
|
||||
<div class="empty-icon">📭</div>
|
||||
<p>暂无日志数据。请确认访问日志路径正确:
|
||||
<code>{{ cfg.access_log_path }}</code></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,148 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}日志轮转 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🔁 日志轮转 (logrotate)</h1>
|
||||
<div class="page-actions">
|
||||
<span class="meta-text">配置: <code>{{ logrotate_path }}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📘 logrotate 怎么配合 Squid</h3>
|
||||
<p class="card-desc">本页面会生成 <code>/etc/logrotate.d/squid</code>,内容由 Squid Manager 维护。流程:</p>
|
||||
<ol>
|
||||
<li>logrotate 检测到当前日志达到 <code>size</code> 阈值(或到 <code>daily</code> 时间)时,把当前文件改名为 <code>.1</code>;</li>
|
||||
<li>logrotate 用 <code>create 0640 proxy proxy</code> 创建新空文件(原属主为 Squid 用户);</li>
|
||||
<li>logrotate 执行 <code>postrotate</code> 块里的 <code>squid -k rotate</code>,让 Squid 关闭旧 fd 并重开新文件 — 这是核心联动;</li>
|
||||
<li>老日志按 <code>rotate N</code> 数量保留,多余的会被删除/压缩 (<code>compress</code>);</li>
|
||||
<li>当本页面「卸载」配置时,只删除 <code>/etc/logrotate.d/squid</code>,不会动 Squid 本身。</li>
|
||||
</ol>
|
||||
<p class="meta-text">
|
||||
{% if logrotate_available %}
|
||||
<span class="badge badge-green">● logrotate 可用</span>
|
||||
{% else %}
|
||||
<span class="badge badge-red">● logrotate 未安装</span> 请先 <code>apt install logrotate</code>。
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<h3 class="card-title">⚙️ 安装 / 重新生成配置</h3>
|
||||
<p class="card-desc">目标文件: <code>{{ logrotate_path }}</code></p>
|
||||
{% if not paths %}
|
||||
<div class="card-empty">
|
||||
<div class="empty-icon">📂</div>
|
||||
<p>未在「<a href="{{ url_for('config_paths') }}">路径设置</a>」里配置任何日志路径,无法生成。</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('ops_logrotate_install') }}">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<div class="filter-grid">
|
||||
<div class="form-group">
|
||||
<label>轮转阈值 (size) <span class="hint">单文件达到此大小立即轮转,如 <code>100M</code> / <code>500K</code></span></label>
|
||||
<input type="text" name="rotate_size" value="{{ install_size }}" class="form-input"
|
||||
placeholder="100M" pattern="^\d+[KMG]?$">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>保留份数 (rotate) <span class="hint">最多保留 N 份旧日志,超出自动删除</span></label>
|
||||
<input type="number" name="rotate_count" value="{{ install_count }}" class="form-input"
|
||||
min="1" max="365" step="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>压缩 (compress)</label>
|
||||
<select name="compress" class="form-input">
|
||||
<option value="1" {% if install_compress %}selected{% endif %}>启用 — 旧日志用 gzip 压缩</option>
|
||||
<option value="0" {% if not install_compress %}selected{% endif %}>禁用 — 保留为明文</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 写入配置</button>
|
||||
{% if is_installed %}
|
||||
<button type="button" class="btn btn-danger" id="btn-uninstall">🗑 卸载配置</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% if is_installed %}
|
||||
<form id="uninstall-form" method="post" action="{{ url_for('ops_logrotate_uninstall') }}" style="display:none;">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📋 当前已安装</h3>
|
||||
{% if is_installed %}
|
||||
<p class="meta-text">{{ current_msg }}</p>
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
<tr><th style="width: 160px;">状态</th>
|
||||
<td><span class="badge badge-green">● 已安装</span></td>
|
||||
</tr>
|
||||
<tr><th>路径</th><td><code>{{ logrotate_path }}</code></td></tr>
|
||||
<tr><th>管理日志</th>
|
||||
<td>
|
||||
{% for p in paths %}
|
||||
<div><code>{{ p }}</code></div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="meta-text">
|
||||
{% if not current_msg %}{% endif %}
|
||||
尚未安装 logrotate 配置 ({{ current_msg or '文件不存在' }}),点击左侧表单写入。
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📄 当前 {{ logrotate_path }} 内容</h3>
|
||||
{% if current_content %}
|
||||
<pre style="background: var(--bg-elevated); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 12px;"><code>{{ current_content }}</code></pre>
|
||||
{% else %}
|
||||
<p class="meta-text">暂无内容 (尚未安装)。</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if preview %}
|
||||
<div class="card">
|
||||
<h3 class="card-title">🧪 预览 (尚未写入磁盘)</h3>
|
||||
<p class="card-desc">下面是根据当前表单参数生成的预览,仅供检查。点「写入配置」才会落盘。</p>
|
||||
<pre style="background: var(--bg-elevated); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 12px;"><code>{{ preview }}</code></pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">ℹ️ 提示</h3>
|
||||
<ul>
|
||||
<li>权限不足时,写入会自动尝试 <code>sudo tee</code> — 需要本平台进程具备无密码 sudo,否则会回显失败原因。</li>
|
||||
<li><code>size</code> 接受 logrotate 单位后缀 <code>K</code> / <code>M</code> / <code>G</code>;可与 <code>daily</code> 共存(任一条件满足即触发)。</li>
|
||||
<li>卸载配置不会触碰日志文件本身 — 已存在的旧日志文件 <code>access.log.1.gz</code> 等仍保留。</li>
|
||||
<li>每次「写入配置」都会记录到 <a href="{{ url_for('audit_view') }}">审计日志</a>。</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if is_installed %}
|
||||
<script>
|
||||
(function() {
|
||||
const btn = document.getElementById('btn-uninstall');
|
||||
const form = document.getElementById('uninstall-form');
|
||||
if (!btn || !form) return;
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('确认卸载 logrotate 配置?\\n文件 {{ logrotate_path }} 将被删除,日志本身不会被影响。')) {
|
||||
return;
|
||||
}
|
||||
form.submit();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,118 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}日志状态 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>📂 日志状态</h1>
|
||||
<div class="page-actions">
|
||||
<span class="meta-text">合计: <strong>{{ total_human }}</strong> · {{ entries | length }} 个文件</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📘 日志轮转原理</h3>
|
||||
<p class="card-desc">Squid 默认会把请求/缓存/存储事件持续写入 <code>access.log</code> / <code>cache.log</code> / <code>store.log</code>,不会自动截断或归档,长期运行会让磁盘塞满。
|
||||
常见的做法是结合 <strong>logrotate</strong> 与 <code>squid -k rotate</code> 信号:</p>
|
||||
<ol>
|
||||
<li><strong>logrotate</strong> 周期性地把当前日志改名 (<code>access.log.1</code>, <code>access.log.2.gz</code> …),并创建新的空日志文件;</li>
|
||||
<li>logrotate 在 <code>postrotate</code> 钩子里执行 <code>squid -k rotate</code>,让 Squid 关闭旧 fd 并重新打开日志,避免日志写到被改名的旧文件里;</li>
|
||||
<li>老化的日志按 <code>rotate N</code> 自动清理/压缩,既能保留审计又不会撑爆磁盘。</li>
|
||||
</ol>
|
||||
<p class="meta-text">下表实时读取每个日志文件的元信息;颜色标识来自阈值:<{{ (warn_bytes / 1024 / 1024) | int }} MB 正常,≥{{ (warn_bytes / 1024 / 1024) | int }} MB 黄色,≥{{ (crit_bytes / 1024 / 1024 / 1024) | round(1) }} GB 红色。</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{% if entries %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日志文件</th>
|
||||
<th>大小</th>
|
||||
<th>状态</th>
|
||||
<th>最后修改</th>
|
||||
<th>年龄</th>
|
||||
<th>可写</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ e.path }}</code>
|
||||
{% if e.error %}<div class="meta-text">⚠ {{ e.error }}</div>{% endif %}
|
||||
</td>
|
||||
<td>{{ e.size_human }} <span class="meta-text">({{ '{:,}'.format(e.size_bytes) }} B)</span></td>
|
||||
<td>
|
||||
{% if not e.exists %}
|
||||
<span class="badge badge-gray">● 不存在</span>
|
||||
{% elif e.size_bytes >= crit_bytes %}
|
||||
<span class="badge badge-red">● 过大 (≥ {{ (crit_bytes / 1024 / 1024 / 1024) | round(1) }} GB)</span>
|
||||
{% elif e.size_bytes >= warn_bytes %}
|
||||
<span class="badge badge-yellow">● 偏大 (≥ {{ (warn_bytes / 1024 / 1024) | int }} MB)</span>
|
||||
{% else %}
|
||||
<span class="badge badge-green">● 正常</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="time-cell">{{ e.mtime_human or '-' }}</td>
|
||||
<td>{% if e.age_days is not none %}{{ e.age_days }} 天{% else %}-{% endif %}</td>
|
||||
<td>
|
||||
{% if e.is_writable %}
|
||||
<span class="badge badge-green">是</span>
|
||||
{% else %}
|
||||
<span class="badge badge-red">否</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('ops_logs_rotate') }}" style="display:inline;"
|
||||
onsubmit="return confirm('确认立即轮转日志?\\n将向 Squid 发送 rotate 信号并由 logrotate 接管新文件。');">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-small btn-primary"
|
||||
{% if not logrotate_available %}disabled title="logrotate 不可用"{% endif %}>
|
||||
🔁 立即轮转
|
||||
</button>
|
||||
</form>
|
||||
{% set parent_dir = e.path.rsplit('/', 1)[0] if '/' in e.path else '.' %}
|
||||
<span class="meta-text">📁 {{ parent_dir }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-empty">
|
||||
<div class="empty-icon">📂</div>
|
||||
<p>未在「<a href="{{ url_for('config_paths') }}">路径设置</a>」里配置任何日志路径。</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">🔧 工具状态</h3>
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
<tr><th style="width: 200px;">logrotate 可执行</th>
|
||||
<td>{% if logrotate_available %}<span class="badge badge-green">可用</span>
|
||||
{% else %}<span class="badge badge-red">未找到</span>
|
||||
<span class="meta-text">请安装 logrotate 包 (apt: logrotate)</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th>Squid 二进制</th><td><code>{{ binary }}</code></td></tr>
|
||||
<tr><th>日志轮转配置</th>
|
||||
<td>
|
||||
<a href="{{ url_for('ops_logrotate') }}" class="btn btn-small btn-secondary">🔁 前往日志轮转</a>
|
||||
<span class="meta-text">安装 logrotate.d/squid 后会按计划自动轮转</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">ℹ️ 提示</h3>
|
||||
<ul>
|
||||
<li><strong>立即轮转</strong>按钮只发送 <code>squid -k rotate</code> 信号,本身不重命名文件 — 需要 logrotate 配置协同工作。</li>
|
||||
<li>如果日志路径是网络挂载 (NFS/SMB),「可写」列反映的是当前进程对文件的写权限,可能与 Squid 实际权限不同。</li>
|
||||
<li>建议把 <code>access.log</code> 的轮转阈值设为单文件大约 100–500 MB,避免后续 <code>logrotate</code> 重命名瞬间触发磁盘峰值。</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,248 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}实时性能 - Squid Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>📊 实时性能监控</h1>
|
||||
<div class="page-actions">
|
||||
<span class="meta-text">
|
||||
采集于 {{ summary.fetched_at }} · 来源 {{ conn.host }}:{{ conn.port }}
|
||||
</span>
|
||||
<button id="refresh-btn" class="btn btn-small btn-secondary">🔄 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not summary.fetched_ok %}
|
||||
<div class="card" style="border-left: 4px solid #ef4444;">
|
||||
<h3 class="card-title">⚠️ 无法获取 Squid 性能数据</h3>
|
||||
<p class="meta-text">
|
||||
<strong>错误信息:</strong> {{ summary.error or "未知错误" }}
|
||||
</p>
|
||||
<p class="meta-text">
|
||||
请检查:
|
||||
<ul style="margin: 6px 0 0 18px;">
|
||||
<li><code>squidclient</code> 工具是否已安装且在 <code>PATH</code> 中 (运行 <code>which squidclient</code> 验证)</li>
|
||||
<li>Squid <code>cache_mgr</code> 管理接口是否启用 (默认端口 {{ conn.port }})</li>
|
||||
<li>主机 <code>{{ conn.host }}:{{ conn.port }}</code> 是否可以从 Web 服务器访问</li>
|
||||
<li>如启用了 <code>cachemgr_passwd</code>, 请在 <a href="{{ url_for('config_paths') }}">路径设置</a> 配置 <code>squid_mgr_password</code></li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Top metric grid: 8 dense cards (4 cols × 2 rows on desktop) -->
|
||||
<div class="kpi-grid" id="metric-grid">
|
||||
<div class="kpi-card kpi-blue">
|
||||
<div class="kpi-label">运行时长</div>
|
||||
<div class="kpi-value" data-field="squid_uptime_human">{{ summary.squid_uptime_human or "—" }}</div>
|
||||
<div class="kpi-meta">Squid 版本 {{ summary.squid_version or "?" }}</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-purple">
|
||||
<div class="kpi-label">CPU 时间</div>
|
||||
<div class="kpi-value" data-field="cpu_time_human">{{ summary.cpu_time_human or "—" }}</div>
|
||||
<div class="kpi-meta">累计用户态+内核态</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-cyan">
|
||||
<div class="kpi-label">最大内存占用</div>
|
||||
<div class="kpi-value" data-field="max_rss_human">{{ summary.max_rss_human or "—" }}</div>
|
||||
<div class="kpi-meta">RSS 峰值</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-orange">
|
||||
<div class="kpi-label">活跃客户端</div>
|
||||
<div class="kpi-value" data-field="current_clients">{{ summary.current_clients if summary.current_clients is not none else "—" }}</div>
|
||||
<div class="kpi-meta">正在访问缓存的 IP 数</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-green">
|
||||
<div class="kpi-label">缓存命中率</div>
|
||||
<div class="kpi-value" data-field="cache_hits_pct">
|
||||
{% if summary.cache_hits_pct is not none %}{{ '%.1f' % summary.cache_hits_pct }}%{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="kpi-meta">Hits as % of bytes sent</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-yellow">
|
||||
<div class="kpi-label">磁盘缓存</div>
|
||||
<div class="kpi-value" data-field="storage_swap_human">{{ summary.storage_swap_human or "—" }}</div>
|
||||
<div class="kpi-meta">Storage Swap size</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-pink">
|
||||
<div class="kpi-label">内存缓存</div>
|
||||
<div class="kpi-value" data-field="storage_mem_human">{{ summary.storage_mem_human or "—" }}</div>
|
||||
<div class="kpi-meta">Storage Mem size</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-red">
|
||||
<div class="kpi-label">5min 请求率</div>
|
||||
<div class="kpi-value" data-field="request_rate_5min">
|
||||
{% if summary.request_rate_5min is not none %}{{ '%.1f' % summary.request_rate_5min }} req/s{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="kpi-meta">
|
||||
流量:
|
||||
<span data-field="byte_rate_5min_human">
|
||||
{% if summary.byte_rate_5min is not none %}{{ format_bytes(summary.byte_rate_5min) }}/s{% else %}—{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<div class="card" style="margin-top: 16px;">
|
||||
<h3 class="card-title">🛰️ Squid 状态</h3>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<table class="data-table small">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>启动时间</th>
|
||||
<td data-field="start_time">{{ summary.start_time or "—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>当前时间</th>
|
||||
<td data-field="current_time">{{ summary.current_time or "—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>作者信息</th>
|
||||
<td>{{ summary.squid_author or "—" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<table class="data-table small">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>采集状态</th>
|
||||
<td>
|
||||
{% if summary.fetched_ok %}
|
||||
<span class="badge badge-green">✅ 成功</span>
|
||||
{% else %}
|
||||
<span class="badge badge-red">❌ 失败</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>采集时间</th>
|
||||
<td data-field="fetched_at">{{ summary.fetched_at }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>采集源</th>
|
||||
<td><code>{{ conn.host }}:{{ conn.port }}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fold-out raw output -->
|
||||
<div class="card" style="margin-top: 16px;">
|
||||
<h3 class="card-title">🔍 原始输出 (可折叠)</h3>
|
||||
<details>
|
||||
<summary>查看 mgr:info 完整文本</summary>
|
||||
<pre id="raw-info" style="max-height: 400px; overflow: auto; background: var(--bg); padding: 12px; border-radius: 6px; font-size: 12px;">{{ summary.raw_info or summary.error or "无数据" }}</pre>
|
||||
</details>
|
||||
<details style="margin-top: 12px;">
|
||||
<summary>查看 mgr:5min 完整文本 (懒加载)</summary>
|
||||
<div id="raw-5min" data-loaded="false" class="meta-text" style="padding: 8px 0;">点击展开后按需加载...</div>
|
||||
</details>
|
||||
<details style="margin-top: 12px;">
|
||||
<summary>查看 mgr:counters 完整文本 (懒加载)</summary>
|
||||
<div id="raw-counters" data-loaded="false" class="meta-text" style="padding: 8px 0;">点击展开后按需加载...</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function() {
|
||||
const btn = document.getElementById('refresh-btn');
|
||||
const grid = document.getElementById('metric-grid');
|
||||
if (!btn || !grid) return;
|
||||
|
||||
// ---- Auto-refresh: 30s if user hasn't disabled it ----
|
||||
let autoTimer = null;
|
||||
function scheduleAuto() {
|
||||
if (autoTimer) clearInterval(autoTimer);
|
||||
autoTimer = setInterval(refreshNow, 30000);
|
||||
}
|
||||
|
||||
function fmtBytes(b) {
|
||||
if (b === null || b === undefined) return '—';
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
|
||||
return b.toFixed(b >= 100 ? 0 : (b >= 10 ? 1 : 2)) + ' ' + u[i];
|
||||
}
|
||||
|
||||
function applySummary(s) {
|
||||
// Update each card by data-field
|
||||
const fields = {
|
||||
squid_uptime_human: s.squid_uptime_human || '—',
|
||||
cpu_time_human: s.cpu_time_human || '—',
|
||||
max_rss_human: s.max_rss_human || '—',
|
||||
current_clients: (s.current_clients === null || s.current_clients === undefined) ? '—' : s.current_clients,
|
||||
cache_hits_pct: (s.cache_hits_pct === null || s.cache_hits_pct === undefined) ? '—' : s.cache_hits_pct.toFixed(1) + '%',
|
||||
storage_swap_human: s.storage_swap_human || '—',
|
||||
storage_mem_human: s.storage_mem_human || '—',
|
||||
request_rate_5min: (s.request_rate_5min === null || s.request_rate_5min === undefined) ? '—' : s.request_rate_5min.toFixed(1) + ' req/s',
|
||||
byte_rate_5min_human: (s.byte_rate_5min === null || s.byte_rate_5min === undefined) ? '—' : fmtBytes(s.byte_rate_5min) + '/s',
|
||||
start_time: s.start_time || '—',
|
||||
current_time: s.current_time || '—',
|
||||
fetched_at: s.fetched_at || '—',
|
||||
};
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
const el = document.querySelector('[data-field="' + k + '"]');
|
||||
if (el) el.textContent = v;
|
||||
}
|
||||
// Visual feedback on success
|
||||
btn.textContent = '✅ ' + (s.fetched_ok ? '已刷新' : '刷新失败');
|
||||
btn.classList.toggle('btn-primary', !!s.fetched_ok);
|
||||
setTimeout(() => { btn.textContent = '🔄 刷新'; btn.classList.remove('btn-primary'); }, 1500);
|
||||
}
|
||||
|
||||
async function refreshNow() {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 刷新中…';
|
||||
try {
|
||||
const resp = await fetch('{{ url_for("performance_refresh") }}', {
|
||||
method: 'POST',
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const data = await resp.json();
|
||||
applySummary(data);
|
||||
} catch (err) {
|
||||
btn.textContent = '❌ ' + (err.message || '失败');
|
||||
setTimeout(() => { btn.textContent = '🔄 刷新'; }, 2000);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', refreshNow);
|
||||
scheduleAuto();
|
||||
|
||||
// ---- Lazy load raw sections when their <details> is opened ----
|
||||
document.querySelectorAll('details').forEach(d => {
|
||||
const target = d.querySelector('[data-loaded="false"]');
|
||||
if (!target) return;
|
||||
d.addEventListener('toggle', async () => {
|
||||
if (!d.open || target.dataset.loaded === 'true') return;
|
||||
target.dataset.loaded = 'loading';
|
||||
target.textContent = '加载中...';
|
||||
const section = target.id.replace('raw-', '');
|
||||
try {
|
||||
const resp = await fetch('{{ url_for("performance_raw", section="PLACEHOLDER") }}'.replace('PLACEHOLDER', section));
|
||||
const data = await resp.json();
|
||||
target.dataset.loaded = 'true';
|
||||
target.textContent = data.raw || data.error || '无数据';
|
||||
target.style.whiteSpace = 'pre-wrap';
|
||||
target.style.fontFamily = 'monospace';
|
||||
target.style.fontSize = '12px';
|
||||
} catch (err) {
|
||||
target.dataset.loaded = 'true';
|
||||
target.textContent = '加载失败: ' + err.message;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,174 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}服务控制 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
{% set tokens = confirm_tokens() %}
|
||||
<div class="page-header">
|
||||
<h1>🛠️ 服务控制</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<h3 class="card-title">📋 服务状态</h3>
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>状态</th>
|
||||
<td>
|
||||
{% if status.active %}
|
||||
<span class="badge badge-green">● 运行中</span>
|
||||
{% elif status.state == 'stopped' %}
|
||||
<span class="badge badge-gray">● 已停止</span>
|
||||
{% else %}
|
||||
<span class="badge badge-orange">● {{ status.state }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th>服务名</th><td><code>{{ cfg.squid_service if cfg is defined else 'squid' }}</code></td></tr>
|
||||
<tr><th>主进程 PID</th><td>{{ status.pid or '-' }}</td></tr>
|
||||
<tr><th>启动时间</th><td>{{ status.since or '-' }}</td></tr>
|
||||
<tr><th>Squid 版本</th><td>{{ status.version or '-' }}</td></tr>
|
||||
<tr><th>Unit 文件</th><td><code>{{ status.unit_file or '-' }}</code></td></tr>
|
||||
<tr><th>二进制可用</th><td>{% if status.binary_available %}<span class="badge badge-green">是</span>{% else %}<span class="badge badge-red">否</span> (无法执行 squid 命令){% endif %}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">🎛️ 操作</h3>
|
||||
<p class="card-desc">以下操作会通过 systemctl 调用 squid 服务。reload 会优先尝试平滑重载,失败则回退到 restart。</p>
|
||||
<div class="btn-group-vertical">
|
||||
<button onclick="svc('start')" class="btn btn-primary">▶ 启动</button>
|
||||
<button onclick="showConfirm('stop')" class="btn btn-danger">⏹ 停止</button>
|
||||
<button onclick="showConfirm('restart')" class="btn btn-warning">🔄 重启</button>
|
||||
<button onclick="svc('reload')" class="btn btn-secondary">♻️ 平滑重载 (reload)</button>
|
||||
</div>
|
||||
<div id="svc-result" class="svc-result" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📁 关键路径</h3>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>项目</th><th>路径</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>主配置文件</td>
|
||||
<td><code>{{ conf }}</code></td>
|
||||
<td><a href="{{ url_for('config_raw') }}" class="btn btn-small">编辑</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>访问日志</td>
|
||||
<td><code>{{ access_log }}</code></td>
|
||||
<td><a href="{{ url_for('logs_view') }}" class="btn btn-small">查看</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>缓存日志</td>
|
||||
<td><code>{{ cache_log }}</code></td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- P0-5: confirm-modal for dangerous service operations -->
|
||||
<div id="confirm-modal" class="modal" style="display:none;"
|
||||
data-token-start="{{ tokens.start }}"
|
||||
data-token-stop="{{ tokens.stop }}"
|
||||
data-token-restart="{{ tokens.restart }}"
|
||||
data-token-reload="{{ tokens.reload }}">
|
||||
<div class="modal-card">
|
||||
<h3 id="confirm-title">⚠️ 确认操作</h3>
|
||||
<p>这是一个危险操作。Squid 服务的所有连接会立即中断。</p>
|
||||
<p>请输入: <code id="confirm-phrase"></code> 来确认。</p>
|
||||
<input type="text" id="confirm-input" class="form-input" autocomplete="off">
|
||||
<input type="hidden" id="confirm-action" value="">
|
||||
<input type="hidden" id="confirm-token" value="">
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" onclick="submitConfirm()">确认执行</button>
|
||||
<button class="btn btn-secondary" onclick="hideConfirm()">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
/* P0-5: read tokens from data-* attributes to avoid Jinja set scope limits */
|
||||
const TOKENS = {
|
||||
start: document.getElementById('confirm-modal').dataset.tokenStart,
|
||||
stop: document.getElementById('confirm-modal').dataset.tokenStop,
|
||||
restart:document.getElementById('confirm-modal').dataset.tokenRestart,
|
||||
reload: document.getElementById('confirm-modal').dataset.tokenReload,
|
||||
};
|
||||
let locked = false;
|
||||
function showConfirm(action) {
|
||||
if (locked) return;
|
||||
/* Phrase format: STOP-SQUID / RESTART-SQUID — see tests */
|
||||
const phrase = action.toUpperCase() + '-SQUID';
|
||||
document.getElementById('confirm-phrase').textContent = phrase;
|
||||
document.getElementById('confirm-action').value = action;
|
||||
document.getElementById('confirm-token').value = TOKENS[action];
|
||||
document.getElementById('confirm-input').value = '';
|
||||
document.getElementById('confirm-modal').style.display = 'flex';
|
||||
document.getElementById('confirm-input').focus();
|
||||
}
|
||||
function hideConfirm() {
|
||||
document.getElementById('confirm-modal').style.display = 'none';
|
||||
}
|
||||
function submitConfirm() {
|
||||
const action = document.getElementById('confirm-action').value;
|
||||
const token = document.getElementById('confirm-token').value;
|
||||
const phrase = action.toUpperCase() + '-SQUID';
|
||||
const input = document.getElementById('confirm-input').value.trim();
|
||||
if (input !== phrase) {
|
||||
alert('输入的确认码不正确');
|
||||
return;
|
||||
}
|
||||
hideConfirm();
|
||||
svc(action, token);
|
||||
}
|
||||
function svc(action, token) {
|
||||
if (locked) return;
|
||||
if (!confirm(`最后确认: 执行 ${action}?`)) return;
|
||||
locked = true;
|
||||
/* lock all buttons */
|
||||
document.querySelectorAll('.btn-group-vertical .btn').forEach(b => b.disabled = true);
|
||||
// show countdown
|
||||
const box = document.getElementById('svc-result');
|
||||
box.style.display = 'block';
|
||||
box.className = 'svc-result svc-loading';
|
||||
let countdown = 30;
|
||||
box.innerHTML = `执行中: ${action} ... 按钮锁定 ${countdown}s`;
|
||||
const t = setInterval(() => {
|
||||
countdown--;
|
||||
if (countdown > 0) {
|
||||
box.innerHTML = `执行中: ${action} ... 按钮锁定 ${countdown}s`;
|
||||
} else {
|
||||
clearInterval(t);
|
||||
}
|
||||
}, 1000);
|
||||
const form = new FormData();
|
||||
form.append('confirm_token', token || '');
|
||||
fetch(`/api/service/${action}`, { method: 'POST', body: form })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
clearInterval(t);
|
||||
box.className = 'svc-result ' + (d.ok ? 'svc-ok' : 'svc-err');
|
||||
box.innerHTML = `<strong>${d.ok ? '✅ 成功' : '❌ 失败'}</strong> (耗时 ${d.elapsed || '?'}s)<br><pre>${d.message || ''}</pre>`;
|
||||
setTimeout(() => {
|
||||
locked = false;
|
||||
document.querySelectorAll('.btn-group-vertical .btn').forEach(b => b.disabled = false);
|
||||
location.reload();
|
||||
}, 2000);
|
||||
})
|
||||
.catch(e => {
|
||||
clearInterval(t);
|
||||
box.className = 'svc-result svc-err';
|
||||
box.textContent = '请求失败: ' + e;
|
||||
locked = false;
|
||||
document.querySelectorAll('.btn-group-vertical .btn').forEach(b => b.disabled = false);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,303 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Web 终端 - Squid Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>💻 Web SSH 终端</h1>
|
||||
<p class="page-desc">直接在浏览器中打开远程 Squid 主机的 SSH 会话,所有输入输出都会被审计记录。</p>
|
||||
</div>
|
||||
|
||||
{% if not ssh_target %}
|
||||
<div class="banner banner-warn">
|
||||
⚠ 当前未配置 SSH 目标 (没有活跃实例或活跃实例未启用 SSH)。
|
||||
请先在
|
||||
<a href="{{ url_for('instances_view') }}">多实例管理</a>
|
||||
中为实例填写 <code>ssh_host</code> / <code>ssh_user</code> / <code>ssh_key_path</code>。
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="banner banner-danger">
|
||||
<strong>🔒 审计提示:</strong>
|
||||
所有 SSH 会话的开启、关闭、resize 事件以及输入数据
|
||||
(经过编辑保留关键字符) 都会写入 <code>audit_log</code> 表,
|
||||
操作记录可在
|
||||
<a href="{{ url_for('audit_view') }}">审计日志</a>
|
||||
中查询。非法操作将被追责。
|
||||
</div>
|
||||
|
||||
{% if paramiko_missing %}
|
||||
<div class="banner banner-error" id="paramiko-error-banner">
|
||||
<strong>❌ 后端依赖缺失:</strong>
|
||||
检测到当前 Python 环境未安装 <code>paramiko</code>,WebSSH 功能不可用。
|
||||
请在服务器上执行
|
||||
<code>pip install paramiko flask-sock</code>
|
||||
后重启服务。当前页面仅供布局预览。
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="terminal-config">
|
||||
<div class="terminal-config-row">
|
||||
<span class="meta-text">SSH 目标:</span>
|
||||
{% if ssh_target %}
|
||||
<code class="terminal-ssh-host">{{ ssh_target }}</code>
|
||||
{% else %}
|
||||
<code class="meta-text">(未配置)</code>
|
||||
{% endif %}
|
||||
<span class="meta-text">| 用户:</span>
|
||||
<code>{{ ssh_user if ssh_user else '-' }}</code>
|
||||
<span class="meta-text">| 端口:</span>
|
||||
<code>{{ ssh_port }}</code>
|
||||
<span class="meta-text">| 密钥:</span>
|
||||
<code title="{{ ssh_key_path or '' }}">{{ ssh_key_short or '-' }}</code>
|
||||
<span class="meta-text">| 实例:</span>
|
||||
<code>{{ instance_name }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card terminal-card">
|
||||
<h3 class="card-title">🖥️ 终端</h3>
|
||||
<div id="terminal-error" class="banner banner-error" style="display:none;"></div>
|
||||
<div id="terminal" class="terminal-host" data-testid="terminal-host"></div>
|
||||
<div class="terminal-statusbar">
|
||||
<span>状态:</span>
|
||||
<span id="terminal-status" class="badge badge-gray">未连接</span>
|
||||
<span class="meta-text">|</span>
|
||||
<span>目标:</span>
|
||||
<code id="terminal-target">{% if ssh_target %}{{ ssh_target }}{% else %}(未配置){% endif %}</code>
|
||||
<span class="terminal-statusbar-spacer"></span>
|
||||
<span id="terminal-bytes" class="meta-text">0 B in / 0 B out</span>
|
||||
<button id="terminal-exit" type="button" class="btn btn-danger btn-small">⏹ 断开/退出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">📘 使用说明</h3>
|
||||
<ul class="card-list">
|
||||
<li>终端会在页面加载时自动建立 WebSocket → paramiko SSH 通道,默认 80×24。</li>
|
||||
<li>支持 <kbd>Ctrl+C</kbd> / <kbd>Ctrl+D</kbd> / <kbd>Ctrl+L</kbd> 等组合键,窗口大小变化会自动 resize。</li>
|
||||
<li>顶部"断开/退出"按钮会主动关闭 SSH 会话并写入审计记录。</li>
|
||||
<li>为安全起见,本功能仅 <code>admin</code> 角色可见可用,其他用户不会显示此入口。</li>
|
||||
{% if ssh_key_path %}
|
||||
<li>实例配置的密钥文件:
|
||||
<code>{{ ssh_key_path }}</code>
|
||||
<span class="meta-text">(如服务端读不到,UI 会显示错误)</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{# xterm.js + addon-fit from CDN. We pin to a known-good version; the
|
||||
addon-fit script is shipped as part of xterm-addon-fit. #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js" crossorigin="anonymous"></script>
|
||||
|
||||
<style>
|
||||
/* --- terminal layout --- */
|
||||
.terminal-config {
|
||||
margin: 12px 0;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0,0,0,0.18);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.terminal-config-row > * { margin-right: 6px; }
|
||||
.terminal-ssh-host { color: #4ec9b0; font-weight: 600; }
|
||||
.terminal-card { padding: 12px; }
|
||||
.terminal-host {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
}
|
||||
.terminal-host .xterm,
|
||||
.terminal-host .xterm-viewport,
|
||||
.terminal-host .xterm-screen { height: 100% !important; }
|
||||
.terminal-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 6px;
|
||||
background: rgba(0,0,0,0.12);
|
||||
font-size: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.terminal-statusbar-spacer { flex: 1; }
|
||||
.banner-danger {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
border-left: 4px solid #dc3545;
|
||||
padding: 10px 14px;
|
||||
margin: 12px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.banner-error {
|
||||
background: rgba(255, 80, 80, 0.18);
|
||||
border: 1px solid #ff5252;
|
||||
color: #ffb3b3;
|
||||
padding: 10px 14px;
|
||||
margin: 8px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.banner-warn {
|
||||
background: rgba(255, 193, 7, 0.10);
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 10px 14px;
|
||||
margin: 12px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.card-list { margin-left: 20px; }
|
||||
.card-list li { margin: 4px 0; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
const ERR_BANNER = document.getElementById('terminal-error');
|
||||
const ERR_BANNER_PARAMIKO = document.getElementById('paramiko-error-banner');
|
||||
const STATUS_EL = document.getElementById('terminal-status');
|
||||
const BYTES_EL = document.getElementById('terminal-bytes');
|
||||
const TARGET_EL = document.getElementById('terminal-target');
|
||||
const EXIT_BTN = document.getElementById('terminal-exit');
|
||||
|
||||
function setStatus(text, cls) {
|
||||
STATUS_EL.textContent = text;
|
||||
STATUS_EL.className = 'badge ' + (cls || 'badge-gray');
|
||||
}
|
||||
function showError(msg) {
|
||||
ERR_BANNER.style.display = 'block';
|
||||
ERR_BANNER.textContent = '❌ ' + msg;
|
||||
}
|
||||
function clearError() {
|
||||
ERR_BANNER.style.display = 'none';
|
||||
}
|
||||
|
||||
/* Initialise xterm.js */
|
||||
let term;
|
||||
try {
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Menlo, Monaco, Consolas, "DejaVu Sans Mono", monospace',
|
||||
scrollback: 5000,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#ffffff',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
showError('xterm.js 加载失败,请检查网络 (CDN 被拦截): ' + e);
|
||||
setStatus('加载失败', 'badge-red');
|
||||
return;
|
||||
}
|
||||
const fitAddon = (window.FitAddon && window.FitAddon.FitAddon)
|
||||
? new window.FitAddon.FitAddon()
|
||||
: null;
|
||||
term.open(document.getElementById('terminal'));
|
||||
if (fitAddon) { try { fitAddon.fit(); } catch (_) {} }
|
||||
term.writeln('\x1b[33m正在连接 SSH 服务,请稍候...\x1b[0m');
|
||||
|
||||
/* WebSocket */
|
||||
const wsScheme = (window.location.protocol === 'https:') ? 'wss' : 'ws';
|
||||
const wsUrl = wsScheme + '://' + window.location.host + '/ws/terminal';
|
||||
let ws = null;
|
||||
let bytesIn = 0, bytesOut = 0;
|
||||
let closing = false;
|
||||
|
||||
function updateBytes() {
|
||||
BYTES_EL.textContent = `${bytesIn} B in / ${bytesOut} B out`;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (closing) return;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch (e) {
|
||||
showError('无法创建 WebSocket: ' + e);
|
||||
setStatus('异常', 'badge-red');
|
||||
return;
|
||||
}
|
||||
setStatus('连接中...', 'badge-orange');
|
||||
ws.onopen = function() {
|
||||
setStatus('已连接', 'badge-green');
|
||||
clearError();
|
||||
// send resize
|
||||
const cols = term.cols || 80;
|
||||
const rows = term.rows || 24;
|
||||
ws.send(JSON.stringify({type: 'resize', cols: cols, rows: rows}));
|
||||
};
|
||||
ws.onmessage = function(ev) {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); }
|
||||
catch (_) { term.write(ev.data); return; }
|
||||
if (msg.type === 'output') {
|
||||
term.write(msg.data);
|
||||
bytesIn += (msg.data && msg.data.length) || 0;
|
||||
updateBytes();
|
||||
} else if (msg.type === 'status') {
|
||||
term.writeln('\r\n\x1b[36m[状态] ' + (msg.msg || '') + '\x1b[0m');
|
||||
} else if (msg.type === 'error') {
|
||||
showError(msg.msg || 'unknown error');
|
||||
setStatus('错误', 'badge-red');
|
||||
term.writeln('\r\n\x1b[31m[错误] ' + (msg.msg || '') + '\x1b[0m');
|
||||
// server forced close; close ws
|
||||
try { ws.close(); } catch (_) {}
|
||||
}
|
||||
};
|
||||
ws.onerror = function() {
|
||||
setStatus('连接失败', 'badge-red');
|
||||
showError('WebSocket 出现错误,请检查服务器日志。');
|
||||
};
|
||||
ws.onclose = function() {
|
||||
setStatus('已断开', 'badge-gray');
|
||||
term.writeln('\r\n\x1b[33m[提示] WebSocket 已关闭\x1b[0m');
|
||||
};
|
||||
}
|
||||
|
||||
/* input -> server */
|
||||
term.onData(function(data) {
|
||||
if (!ws || ws.readyState !== 1) return;
|
||||
try {
|
||||
ws.send(JSON.stringify({type: 'input', data: data}));
|
||||
bytesOut += data.length;
|
||||
updateBytes();
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
/* resize */
|
||||
function sendResize() {
|
||||
if (!ws || ws.readyState !== 1) return;
|
||||
try { ws.send(JSON.stringify({type: 'resize', cols: term.cols, rows: term.rows})); }
|
||||
catch (_) {}
|
||||
}
|
||||
if (fitAddon) {
|
||||
window.addEventListener('resize', function() {
|
||||
try { fitAddon.fit(); sendResize(); } catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
/* exit button */
|
||||
EXIT_BTN.addEventListener('click', function() {
|
||||
if (ws && ws.readyState === 1) {
|
||||
try { ws.close(1000, 'user-exit'); } catch (_) {}
|
||||
}
|
||||
closing = true;
|
||||
setStatus('已主动断开', 'badge-gray');
|
||||
term.writeln('\r\n\x1b[33m[用户操作] 已请求断开 SSH 会话\x1b[0m');
|
||||
});
|
||||
|
||||
/* kick off */
|
||||
connect();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user