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:
Hermes Agent
2026-07-15 10:19:21 +08:00
commit 4a1d949e41
54 changed files with 16895 additions and 0 deletions
+29
View File
@@ -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>
+223
View File
@@ -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>: 计算当前指标值,与阈值按指定运算符 ( &gt; &lt; &ge; &le; ) 比较,满足则记录并 (可选) 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 %}
+71
View File
@@ -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 %}
+276
View File
@@ -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">响应 &lt; {{ thresholds.small_size_kb }} KB 且请求数 &ge; {{ 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">未检测到 &gt; {{ 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">
未检测到符合条件 (&lt; {{ thresholds.small_size_kb }} KB × &ge; {{ 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 %}
+49
View File
@@ -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 %}
+57
View File
@@ -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.&lt;reason&gt;</code></li>
<li>恢复时会先将当前配置备份为 <code>before-restore</code></li>
<li>恢复后需要 <a href="{{ url_for('service_view') }}">重载服务</a> 才能生效</li>
</ul>
</div>
{% endblock %}
+220
View File
@@ -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>
+97
View File
@@ -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 %}
+281
View File
@@ -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 %}
+119
View File
@@ -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 %}
+121
View File
@@ -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 %}
+111
View File
@@ -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 %}
+215
View File
@@ -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 %}
+111
View File
@@ -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 %}
+115
View File
@@ -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 %}
+55
View File
@@ -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 %}
+72
View File
@@ -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 %}
+107
View File
@@ -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% &gt; 已缓存时间,则视为过期</li>
<li><strong>max</strong>: 最大缓存时间 (分钟)。无论 origin 怎么说,超过此时间视为过期</li>
<li><strong>-i</strong>: 忽略大小写</li>
</ul>
</div>
{% endblock %}
+122
View File
@@ -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 %}
+206
View File
@@ -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 %}
+161
View File
@@ -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 %}
+387
View File
@@ -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 %}
+11
View File
@@ -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 %}
+306
View File
@@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// pick a marker radius that scales sub-linearly with request count
const counts = ROWS.map(r => r.count || 0);
const minR = 6, maxR = 22;
const cmin = Math.min.apply(null, counts);
const cmax = Math.max.apply(null, counts);
function radiusFor(n) {
if (cmax <= cmin) return (minR + maxR) / 2;
const t = (Math.log10(n + 1) - Math.log10(cmin + 1)) /
(Math.log10(cmax + 1) - Math.log10(cmin + 1));
return minR + t * (maxR - minR);
}
const bounds = [];
ROWS.forEach((r, idx) => {
if (!r.latitude || !r.longitude) return;
const lat = r.latitude, lon = r.longitude;
const rad = radiusFor(r.count);
const flag = (r.country || '??').slice(0, 2);
const icon = L.divIcon({
className: 'geoip-marker',
html: '<div class="marker-flag" title="' + r.ip + '">' + flag + '</div>',
iconSize: [24, 24],
iconAnchor: [12, 12],
});
const m = L.marker([lat, lon], { icon: icon }).addTo(map);
m.bindPopup(
'<strong>' + escapeHtml(r.ip) + '</strong><br>' +
'<span style="color:#94a3b8">#' + (idx + 1) + ' · ' +
escapeHtml(r.country_name || r.country || '-') + ' · ' +
escapeHtml(r.city || '-') + '</span><br>' +
'<table style="margin-top:6px; font-size:11px; color:#cbd5e1;">' +
'<tr><td>请求数</td><td style="text-align:right;"><strong>' +
(r.count || 0).toLocaleString() + '</strong></td></tr>' +
'<tr><td>流量</td><td style="text-align:right;">' +
escapeHtml(formatBytes(r.bytes || 0)) + '</td></tr>' +
(r.isp ? '<tr><td>ISP</td><td style="text-align:right;">' +
escapeHtml(r.isp) + '</td></tr>' : '') +
(r.asn ? '<tr><td>ASN</td><td style="text-align:right;"><code>' +
escapeHtml(r.asn) + '</code></td></tr>' : '') +
'</table>'
);
bounds.push([lat, lon]);
});
if (bounds.length === 1) {
map.setView(bounds[0], 6);
} else if (bounds.length > 1) {
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 6 });
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatBytes(n) {
if (n < 1024) return n + ' B';
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB';
if (n < 1099511627776) return (n / 1073741824).toFixed(2) + ' GB';
return (n / 1099511627776).toFixed(2) + ' TB';
}
// refresh button — just reload the page (cheap + obvious)
const btn = document.getElementById('geoip-refresh');
if (btn) btn.addEventListener('click', () => location.reload());
})();
</script>
{% else %}
<script>
// No map to render — still wire the refresh button.
document.getElementById('geoip-refresh').addEventListener('click',
() => location.reload());
</script>
{% endif %}
{% endblock %}
+195
View File
@@ -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 %}
+51
View File
@@ -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 &middot; 首次登录后请修改密码</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 %}
+165
View File
@@ -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 %}
+148
View File
@@ -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 %}
+60
View File
@@ -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 %}
+148
View File
@@ -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 %}
+118
View File
@@ -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">下表实时读取每个日志文件的元信息;颜色标识来自阈值:&lt;{{ (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 %}
+248
View File
@@ -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 %}
+174
View File
@@ -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 %}
+303
View File
@@ -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 %}