fix: 修复暗色主题UI对比度、端口占用死循环及多项Web表单bug

后端修复:
- engine/instances.py: Socks5Server.start() 端口占用时死循环, 增加超时和异常透传
- engine/instances.py: import time 移至顶层
- engine/instances.py: sync_instances/start_instance 容忍单实例启动失败
- run.py: 启动时 sync_instances 失败不退出进程
- web/routes.py: instances start/restart/edit 路由捕获 OSError, flash 友好提示
- api/v1.py: instances start/restart/update API 返回 JSON 错误而非 500 崩溃
- web/routes.py: toggle_user/ban_user 支持 hidden 字段语义, 缺字段时切换状态
- web/routes.py: inject_nav 增加独立 nav.dashboard 和 page_name, 修复双 active bug
- auth.py: login 路由添加 logging import, 修 NameError

前端重构:
- templates/base.html: 全局对比度提升(--text #f1f5f9, --accent #a5b4fc), 标题色显式声明, alert/modal/progress/table 完整暗色样式
- templates/base.html: main-content padding-top 修复 navbar 遮挡内容
- templates/base.html: 侧边栏 active 高亮 (白字 + elevated bg + 左边框)
- templates/base.html: 顶栏动态显示当前页面名
- templates/*.html: 统一 page-header 类, 所有图标按钮添加 title tooltip
- templates/users.html: toggle/ban 表单添加 hidden enabled/ban 字段
- templates/connections.html: fmtBytes 改为 Jinja macro, 时间格式化
- templates/dashboard.html: 时间格式化为 YYYY-MM-DD HH:MM:SS, chart canvas 高度调整
- templates/logs.html: 时间格式化为可读格式
- templates/stats.html: 提升 chart 颜色对比度
- templates/system.html: 使用 time.localtime() 显示本地时间
This commit is contained in:
Your Name
2026-07-16 23:01:39 +08:00
parent 6504726431
commit 0bd9bcac12
13 changed files with 268 additions and 103 deletions
+20 -9
View File
@@ -106,9 +106,12 @@ def update_instance(iid):
# 重启以应用配置
if inst.running:
current_app.instance_manager.restart_instance(iid)
try:
current_app.instance_manager.restart_instance(iid)
except OSError as e:
return {"error": f"重启失败(端口可能被占用): {e}"}, 500
return jsonify({"ok": True})
return {"ok": True}
@bp.route("/instances/<int:iid>", methods=["DELETE"])
@@ -126,25 +129,33 @@ def delete_instance(iid):
@bp.route("/instances/<int:iid>/start", methods=["POST"])
@login_required
def start_instance(iid):
if current_app.instance_manager.start_instance(iid):
return jsonify({"ok": True})
return {"error": "启动失败"}, 500
try:
ok = current_app.instance_manager.start_instance(iid)
if not ok:
return {"error": "启动失败"}, 500
return {"ok": True}
except OSError as e:
return {"error": f"启动失败: {e}"}, 500
@bp.route("/instances/<int:iid>/stop", methods=["POST"])
@login_required
def stop_instance(iid):
if current_app.instance_manager.stop_instance(iid):
return jsonify({"ok": True})
return {"ok": True}
return {"error": "停止失败"}, 500
@bp.route("/instances/<int:iid>/restart", methods=["POST"])
@login_required
def restart_instance(iid):
if current_app.instance_manager.restart_instance(iid):
return jsonify({"ok": True})
return {"error": "重启失败"}, 500
try:
ok = current_app.instance_manager.restart_instance(iid)
if not ok:
return {"error": "重启失败"}, 500
return {"ok": True}
except OSError as e:
return {"error": f"重启失败: {e}"}, 500
@bp.route("/instances/sync", methods=["POST"])
+2
View File
@@ -1,10 +1,12 @@
"""登录认证。"""
import os
import hashlib
import logging
import secrets
from functools import wraps
from flask import Blueprint, request, session, redirect, url_for, current_app
log = logging.getLogger("socks.auth")
bp = Blueprint("auth", __name__)
+42 -8
View File
@@ -2,6 +2,7 @@
import asyncio
import logging
import threading
import time
from dataclasses import dataclass, field
from engine.server import ConnectionHandler
@@ -51,20 +52,41 @@ class Socks5Server:
self._active_connections = 0
self.running = True
self.server = None
self._start_error = None # 用于在主线程透传子线程异常
def run_loop():
asyncio.set_event_loop(self.loop)
self.server = self.loop.run_until_complete(
self._create_server()
)
self.loop.run_forever()
try:
self.server = self.loop.run_until_complete(
self._create_server()
)
self.loop.run_forever()
except Exception as e:
# 端口占用等异常:保存异常并停止循环,避免主线程死等
self._start_error = e
log.error("[%s] SOCKS5 服务器启动失败: %s",
self.config.name, e)
try:
self.loop.stop()
except Exception:
pass
self.thread = threading.Thread(target=run_loop, daemon=True,
name=f"socks5-{self.config.name}")
self.thread.start()
# 等 server 创建完成
while self.server is None and self.running:
# 等 server 创建完成 (或失败) — 设了上限避免永久死等
deadline = time.time() + 5.0
while self.server is None and self.running and self._start_error is None:
time.sleep(0.05)
if time.time() > deadline:
log.error("[%s] SOCKS5 服务器启动超时", self.config.name)
self.running = False
break
# 如果子线程失败, 把异常抛回调用方
if self._start_error is not None:
self.running = False
raise self._start_error
if self.server:
log.info("[%s] SOCKS5 服务器已启动: %s:%d",
self.config.name, self.config.listen_host, self.config.listen_port)
@@ -157,7 +179,11 @@ class InstanceManager:
for name in current_names - running_names:
cfg = self._config_cache[name]
srv = Socks5Server(cfg, self.user_service, self.flask_app)
srv.start()
try:
srv.start()
except OSError as e:
log.error("[%s] 跳过启动(端口冲突?: %s", name, e)
continue
with self._lock:
self._instances[name] = srv
@@ -198,7 +224,15 @@ class InstanceManager:
)
self._config_cache[inst.name] = cfg
srv = Socks5Server(cfg, self.user_service, self.flask_app)
srv.start()
try:
srv.start()
except OSError as e:
# 端口占用等启动失败: 回滚状态, 清理缓存, 返回 False
log.error("[%s] 启动实例失败: %s", inst.name, e)
self._config_cache.pop(inst.name, None)
inst.running = False
db.session.commit()
raise
with self._lock:
self._instances[inst.name] = srv
inst.running = True
+4 -1
View File
@@ -12,7 +12,10 @@ if __name__ == "__main__":
debug = os.environ.get("SM_DEBUG", "false").lower() == "true"
# 启动时同步实例
app.instance_manager.sync_instances()
try:
app.instance_manager.sync_instances()
except OSError as e:
print(f"⚠️ 部分实例启动失败(端口冲突?): {e}", flush=True)
# 记录流量快照
from services.stats_service import record_traffic_snapshot
+75 -43
View File
@@ -11,21 +11,31 @@
/* ── 全局 ─────────────────────────────────────────────────────── */
:root {
--bg-primary:#0b0e14; --bg-secondary:#161922; --bg-tertiary:#1e2235;
--border:#374151; --text:#e2e8f0; --text-muted:#9ca3af;
--accent:#818cf8; --accent-hover:#6366f1;
--green:#10b981; --red:#ef4444; --yellow:#f59e0b; --gray:#6b7280;
--bg-elevated:#1f2433;
--border:#3a4254; --border-strong:#4b5563;
--text:#f1f5f9; /* 提高主文字亮度, 之前 #e2e8f0 在小字号下偏暗 */
--text-muted:#a3acbf; /* 之前 #9ca3af, 稍微提亮 */
--accent:#a5b4fc; /* 提亮主色, 之前 #818cf8 对比度太低 */
--accent-hover:#818cf8;
--accent-dim:rgba(165,180,252,.15);
--green:#34d399; --red:#f87171; --yellow:#fbbf24; --gray:#94a3b8;
--card-bg:#161922; --input-bg:#0b0e14;
}
* { box-sizing:border-box; }
body { background:var(--bg-primary); color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
font-size:16px; line-height:1.7; }
font-size:15px; line-height:1.6; }
a { color:var(--accent); text-decoration:none; }
a:hover { color:var(--accent-hover); }
/* 显式覆盖 Bootstrap 默认的标题色 (默认是 inherit, 但某些情况下浏览器渲染为黑) */
h1, h2, h3, h4, h5, h6 { color:var(--text); margin-top:0; }
.h1, .h2, .h3, .h4, .h5, .h6 { color:var(--text); }
.text-secondary, .text-body { color:var(--text) !important; }
/* ── 导航 ─────────────────────────────────────────────────────── */
.navbar { background:var(--bg-secondary)!important; border-bottom:1px solid var(--border)!important;
padding:.6rem 1rem; }
padding:.6rem 1rem; height:56px; }
.navbar-brand { color:var(--accent)!important; font-weight:700; font-size:1.1rem; }
.navbar-brand i { margin-right:6px; }
.nav-link { color:var(--text-muted)!important; font-size:14px; padding:.5rem 1rem!important; }
@@ -36,70 +46,78 @@ a:hover { color:var(--accent-hover); }
background:var(--bg-secondary); border-right:1px solid var(--border); padding:.5rem 0; z-index:100; }
.sidebar .nav-link { padding:.55rem 1.2rem; border-radius:0 .4rem .4rem 0; margin:.15rem .6rem; font-size:14px; }
.sidebar .nav-link:hover { background:var(--bg-tertiary); color:var(--accent)!important; }
.sidebar .nav-link.active { background:var(--bg-tertiary); color:var(--accent)!important;
border-left:3px solid var(--accent); }
.sidebar .nav-link.active { background:var(--bg-elevated); color:#fff!important;
border-left:3px solid var(--accent); font-weight:600; }
.sidebar .nav-link i { margin-right:10px; width:18px; text-align:center; }
.sidebar .sidebar-section { padding:.3rem 1.2rem .1rem; font-size:11px; color:var(--text-muted);
text-transform:uppercase; letter-spacing:.05em; margin-top:.8rem; }
/* ── 主内容 ───────────────────────────────────────────────────── */
.main-content { margin-left:220px; padding:1.2rem 1.5rem; min-height:calc(100vh - 56px); }
.main-content { margin-left:220px; padding:1.2rem 1.5rem; min-height:calc(100vh - 56px); padding-top:calc(56px + 1.2rem); }
.page-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem; }
.page-header h5 { margin:0; color:var(--text); font-size:1.1rem; font-weight:600; }
/* ── 卡片 ─────────────────────────────────────────────────────── */
.card { background:var(--card-bg); border:1px solid var(--border); border-radius:.6rem; margin-bottom:1rem; }
.card-header { background:transparent; border-bottom:1px solid var(--border); padding:.8rem 1rem; }
.card-body { padding:1rem; }
.card-header { background:transparent; border-bottom:1px solid var(--border); padding:.8rem 1rem;
color:var(--text); font-weight:600; }
.card-header i { color:var(--accent); margin-right:.4rem; }
.card-body { padding:1rem; color:var(--text); }
.card-footer { background:transparent; border-top:1px solid var(--border); }
/* ── 表格 ─────────────────────────────────────────────────────── */
.table { color:var(--text); margin-bottom:0; }
.table thead th { background:var(--bg-tertiary); color:var(--text-muted); font-weight:600;
.table thead th { background:var(--bg-tertiary); color:#cbd5e1; font-weight:600;
font-size:12px; text-transform:uppercase; letter-spacing:.04em; border-bottom:1px solid var(--border);
padding:.65rem .8rem; }
.table tbody td { padding:.6rem .8rem; border-bottom:1px solid var(--border); font-size:14px;
vertical-align:middle; }
padding:.7rem .8rem; }
.table tbody td { padding:.65rem .8rem; border-bottom:1px solid var(--border); font-size:14px;
vertical-align:middle; color:var(--text); }
.table tbody tr:hover { background:var(--bg-tertiary); }
.table-dark { background:var(--card-bg); }
.table-dark th, .table-dark td { color:var(--text); border-color:var(--border); }
.table code { background:var(--bg-tertiary); color:var(--accent); }
/* ── 表单 ─────────────────────────────────────────────────────── */
.form-control, .form-select { background:#1a1d2b; border:1px solid #4b5563;
.form-control, .form-select { background:#1a1d2b; border:1px solid var(--border-strong);
color:var(--text); border-radius:.4rem; font-size:14px; padding:.5rem .7rem; }
.form-control:focus, .form-select:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(129,140,248,.12);
.form-control:focus, .form-select:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(165,180,252,.15);
background:#1a1d2b; color:var(--text); }
.form-control::placeholder { color:#5a6178; }
.form-control::placeholder { color:#6b7592; }
.form-text { color:var(--text-muted); font-size:12px; }
.form-check-input { background-color:var(--input-bg); border-color:var(--border); }
.form-label { color:#d1d9e6 !important; font-size:13px; margin-bottom:.3rem; font-weight:500; }
.form-check-input { background-color:var(--input-bg); border-color:var(--border-strong); }
.form-check-input:checked { background-color:var(--accent); border-color:var(--accent); }
/* ── 按钮 ─────────────────────────────────────────────────────── */
.btn-primary { background:var(--accent); border-color:var(--accent); color:#fff; }
.btn-primary:hover { background:var(--accent-hover); border-color:var(--accent-hover); color:#fff; }
.btn-primary { background:var(--accent); border-color:var(--accent); color:#0b0e14; font-weight:600; }
.btn-primary:hover { background:var(--accent-hover); border-color:var(--accent-hover); color:#0b0e14; }
.btn-primary:active { background:var(--accent-hover); }
.btn-primary:focus { box-shadow:0 0 0 3px rgba(129,140,248,.3); }
.btn-primary:focus { box-shadow:0 0 0 3px rgba(165,180,252,.3); }
.btn-outline-primary { color:var(--accent); border-color:var(--accent); }
.btn-outline-primary:hover { background:var(--accent); border-color:var(--accent); }
.btn-outline-secondary { color:var(--text-muted); border-color:var(--border); }
.btn-outline-primary:hover { background:var(--accent); border-color:var(--accent); color:#0b0e14; }
.btn-outline-secondary { color:var(--text-muted); border-color:var(--border-strong); }
.btn-outline-secondary:hover { color:var(--text); border-color:var(--text-muted); background:var(--bg-tertiary); }
.btn-outline-danger { color:var(--red); border-color:var(--red); }
.btn-outline-danger:hover { background:var(--red); border-color:var(--red); color:#fff; }
.btn-outline-success { color:var(--green); border-color:var(--green); }
.btn-outline-success:hover { background:var(--green); border-color:var(--green); color:#fff; }
.btn-outline-success:hover { background:var(--green); border-color:var(--green); color:#0b0e14; }
.btn-outline-warning { color:var(--yellow); border-color:var(--yellow); }
.btn-outline-warning:hover { background:var(--yellow); border-color:var(--yellow); color:#000; }
.btn-outline-warning:hover { background:var(--yellow); border-color:var(--yellow); color:#0b0e14; }
.btn-sm { font-size:13px; padding:.3rem .6rem; }
.btn-group-sm > .btn { padding:.25rem .55rem; }
/* ── 徽章 ─────────────────────────────────────────────────────── */
.badge { font-size:11px; padding:.3em .6em; border-radius:.3rem; }
.badge-success { background:rgba(16,185,129,.15); color:var(--green); }
.badge-danger { background:rgba(239,68,68,.15); color:var(--red); }
.badge-warning { background:rgba(245,158,11,.15); color:var(--yellow); }
.badge-secondary { background:rgba(107,114,128,.15); color:var(--gray); }
.badge-info { background:rgba(129,140,248,.15); color:var(--accent); }
.badge { font-size:11px; padding:.3em .6em; border-radius:.3rem; font-weight:600; }
.badge-success { background:rgba(52,211,153,.18); color:#34d399; }
.badge-danger { background:rgba(248,113,113,.18); color:#f87171; }
.badge-warning { background:rgba(251,191,36,.18); color:#fbbf24; }
.badge-secondary { background:rgba(148,163,184,.18); color:#cbd5e1; }
.badge-info { background:var(--accent-dim); color:var(--accent); }
/* ── 状态点 ───────────────────────────────────────────────────── */
.status-dot { width:9px; height:9px; border-radius:50%; display:inline-block; margin-right:5px;
vertical-align:middle; }
.dot-online { background:var(--green); box-shadow:0 0 6px rgba(16,185,129,.6); }
.dot-online { background:var(--green); box-shadow:0 0 6px rgba(52,211,153,.6); }
.dot-offline { background:var(--red); }
.dot-warning { background:var(--yellow); }
.dot-gray { background:var(--gray); }
@@ -107,7 +125,7 @@ a:hover { color:var(--accent-hover); }
/* ── 统计卡片 ─────────────────────────────────────────────────── */
.stat-card { background:var(--card-bg); border:1px solid var(--border); border-radius:.6rem; padding:1rem; }
.stat-card .stat-label { color:var(--text-muted); font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
.stat-card .stat-value { font-size:1.8rem; font-weight:700; margin:.2rem 0; }
.stat-card .stat-value { font-size:1.8rem; font-weight:700; margin:.2rem 0; color:var(--text); }
.stat-card .stat-sub { color:var(--text-muted); font-size:12px; }
/* ── 表格代码 ─────────────────────────────────────────────────── */
@@ -117,35 +135,49 @@ code { background:var(--bg-tertiary); color:var(--accent); padding:.15rem .4rem;
/* ── 分页 ─────────────────────────────────────────────────────── */
.pagination { margin-bottom:0; }
.page-link { color:var(--accent); background:var(--card-bg); border-color:var(--border); }
.page-link:hover { background:var(--bg-tertiary); color:var(--accent); }
.page-item.active .page-link { background:var(--accent); border-color:var(--accent); color:#fff; }
.page-link:hover { background:var(--bg-tertiary); color:var(--accent-hover); }
.page-item.active .page-link { background:var(--accent); border-color:var(--accent); color:#0b0e14; }
.page-item.disabled .page-link { color:var(--gray); }
/* ── 进度条 ──────────────────────────────────────────────────── */
.progress { background:var(--bg-tertiary); }
.progress-bar { font-weight:600; }
/* ── 模态框 ───────────────────────────────────────────────────── */
.modal-content { background:var(--card-bg); border:1px solid var(--border); color:var(--text); }
.modal-content { background:var(--card-bg); border:1px solid var(--border-strong); color:var(--text); }
.modal-header { border-bottom:1px solid var(--border); }
.modal-header .modal-title { color:var(--text); font-weight:600; }
.modal-footer { border-top:1px solid var(--border); }
.btn-close { filter:invert(1); }
.btn-close { filter:invert(1) grayscale(100%) brightness(200%); }
/* ── Alert ─────────────────────────────────────────────────────── */
.alert { border:1px solid var(--border); color:var(--text); background:var(--card-bg); }
.alert-success { background:rgba(52,211,153,.12); border-color:rgba(52,211,153,.4); color:#34d399; }
.alert-danger { background:rgba(248,113,113,.12); border-color:rgba(248,113,113,.4); color:#f87171; }
.alert-warning { background:rgba(251,191,36,.12); border-color:rgba(251,191,36,.4); color:#fbbf24; }
.alert-info { background:var(--accent-dim); border-color:rgba(165,180,252,.4); color:var(--accent); }
/* ── 消息 ─────────────────────────────────────────────────────── */
.toast { background:var(--card-bg); border:1px solid var(--border); color:var(--text); }
.toast-container { position:fixed; top:1rem; right:1rem; z-index:1060; }
/* ── Chart.js canvas 容器 ─────────────────────────────────────── */
canvas { max-width:100%; }
/* ── 响应式 ───────────────────────────────────────────────────── */
@media(max-width:768px){
.sidebar { display:none; }
.main-content { margin-left:0; padding:1rem; }
.main-content { margin-left:0; padding:1rem; padding-top:calc(56px + 1rem); }
}
/* ── 滚动条 ───────────────────────────────────────────────────── */
::-webkit-scrollbar { width:6px; height:6px; }
::-webkit-scrollbar { width:8px; height:8px; }
::-webkit-scrollbar-track { background:var(--bg-primary); }
::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
::-webkit-scrollbar-thumb { background:var(--border-strong); border-radius:4px; }
::-webkit-scrollbar-thumb:hover { background:var(--gray); }
/* ── 覆盖 Bootstrap text-muted 提高深色背景对比度 ─────────────── */
.text-muted { color:var(--text-muted) !important; }
.form-label { color:#cbd5e0 !important; font-size:13px; margin-bottom:.3rem; font-weight:500; }
</style>
{% block extra_style %}{% endblock %}
</head>
@@ -155,7 +187,7 @@ code { background:var(--bg-tertiary); color:var(--accent); padding:.15rem .4rem;
<div class="container-fluid">
<a class="navbar-brand" href="/"><i class="bi bi-hdd-rack"></i> SOCKS Manager</a>
<div class="d-flex align-items-center gap-2">
{% if nav.instances %}<span class="text-muted small">仪表盘</span>{% endif %}
{% if page_name %}<span class="text-muted small">{{ page_name }}</span>{% endif %}
<a href="/logout" class="btn btn-outline-secondary btn-sm"><i class="bi bi-box-arrow-right"></i> 退出</a>
</div>
</div>
@@ -165,7 +197,7 @@ code { background:var(--bg-tertiary); color:var(--accent); padding:.15rem .4rem;
<div class="sidebar">
<div class="nav flex-column">
<div class="sidebar-section">概览</div>
<a class="nav-link {% if nav.instances %}active{% endif %}" href="/dashboard">
<a class="nav-link {% if nav.dashboard %}active{% endif %}" href="/dashboard">
<i class="bi bi-speedometer2"></i> 仪表盘</a>
<a class="nav-link {% if nav.instances %}active{% endif %}" href="/instances">
<i class="bi bi-server"></i> 代理实例</a>
+18 -2
View File
@@ -1,8 +1,24 @@
{% extends "base.html" %}
{% block title %}活跃连接 · SOCKS Manager{% endblock %}
{% macro fmtBytes(b) -%}
{%- if not b %}0 B
{%- else -%}
{%- set u = ['B','KB','MB','GB','TB'] -%}
{%- set ns = namespace(v=b, i=0) -%}
{%- for _ in range(4) -%}
{%- if ns.v >= 1024 and ns.i < 4 -%}
{%- set ns.v = ns.v / 1024 -%}
{%- set ns.i = ns.i + 1 -%}
{%- endif -%}
{%- endfor -%}
{{ '%.1f'|format(ns.v) }} {{ u[ns.i] }}
{%- endif -%}
{%- endmacro %}
{% block content %}
<h5 class="mb-3"><i class="bi bi-activity"></i> 活跃连接 <span class="text-muted small">({{ conns|length }} 条)</span></h5>
<div class="page-header">
<h5><i class="bi bi-activity"></i> 活跃连接 <span class="text-muted small">({{ conns|length }} 条)</span></h5>
</div>
<div class="card">
<div class="card-body p-0">
@@ -20,7 +36,7 @@
<td><code>{{ c.src }}</code></td>
<td><code>{{ c.dst }}</code></td>
<td><span class="badge badge-info">{{ c.protocol }}</span></td>
<td><small class="text-muted">{{ c.started }}</small></td>
<td><small class="text-muted">{{ c.started[:19].replace('T',' ') if c.started else '-' }}</small></td>
<td>{{ fmtBytes(c.bytes_in) }}</td>
<td>{{ fmtBytes(c.bytes_out) }}</td>
<td>
+14 -6
View File
@@ -2,7 +2,9 @@
{% block title %}仪表盘 · SOCKS Manager{% endblock %}
{% block content %}
<h5 class="mb-3"><i class="bi bi-speedometer2"></i> 仪表盘</h5>
<div class="page-header">
<h5><i class="bi bi-speedometer2"></i> 仪表盘</h5>
</div>
<!-- 系统状态卡片 -->
<div class="row g-3 mb-3">
@@ -75,7 +77,7 @@
<span class="text-muted small">每 10 秒刷新</span>
</div>
<div class="card-body">
<canvas id="metricsChart" height="80"></canvas>
<canvas id="metricsChart" style="height:240px"></canvas>
</div>
</div>
@@ -118,14 +120,20 @@ const chart = new Chart(ctx, {
maintainAspectRatio: false,
animation: false,
plugins: {
legend: { labels: { color: '#94a3b8' } },
legend: { labels: { color: '#cbd5e1' } },
},
scales: {
x: { grid: { color: '#1f2335' }, ticks: { color: '#6b7280' } },
y: { grid: { color: '#1f2335' }, ticks: { color: '#6b7280' }, min: 0, max: 100 },
x: { grid: { color: '#262b3d' }, ticks: { color: '#a3acbf', maxTicksLimit: 10 } },
y: { grid: { color: '#262b3d' }, ticks: { color: '#a3acbf' }, min: 0, max: 100 },
},
}
});
// 工具函数
function fmtTs(s) {
if (!s) return '-';
// 把 ISO 格式 "2026-07-16T14:33:38.297700" 转成 "2026-07-16 14:33:38"
return s.replace('T', ' ').slice(0, 19);
}
// 加载最近日志
async function loadRecentLogs() {
@@ -144,7 +152,7 @@ async function loadRecentLogs() {
else if (l.event.includes('fail') || l.event.includes('reject')) badgeClass = 'danger';
else if (l.event.includes('start') || l.event.includes('stop')) badgeClass = 'warning';
return `<tr>
<td><small>${l.timestamp.slice(0,19).replace('T',' ')}</small></td>
<td><small>${fmtTs(l.timestamp)}</small></td>
<td><span class="badge badge-${badgeClass}">${l.event}</span></td>
<td>${l.user || '-'}</td>
<td><code>${l.src_ip || '-'}</code></td>
+7 -7
View File
@@ -2,8 +2,8 @@
{% block title %}代理实例 · SOCKS Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-server"></i> 代理实例</h5>
<div class="page-header">
<h5><i class="bi bi-server"></i> 代理实例</h5>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addInstanceModal">
<i class="bi bi-plus-lg"></i> 新建实例</button>
</div>
@@ -44,17 +44,17 @@
<div class="btn-group btn-group-sm">
{% if not inst.running %}
<form method="post" action="/instances/{{ inst.id }}/start" style="display:inline">
<button class="btn btn-outline-success"><i class="bi bi-play-fill"></i></button>
<button class="btn btn-outline-success" title="启动"><i class="bi bi-play-fill"></i></button>
</form>
{% else %}
<form method="post" action="/instances/{{ inst.id }}/stop" style="display:inline">
<button class="btn btn-outline-warning"><i class="bi bi-pause-fill"></i></button>
<button class="btn btn-outline-warning" title="停止"><i class="bi bi-pause-fill"></i></button>
</form>
<form method="post" action="/instances/{{ inst.id }}/restart" style="display:inline">
<button class="btn btn-outline-primary"><i class="bi bi-arrow-clockwise"></i></button>
<button class="btn btn-outline-primary" title="重启"><i class="bi bi-arrow-clockwise"></i></button>
</form>
{% endif %}
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
<button class="btn btn-outline-secondary" title="编辑" data-bs-toggle="modal"
data-bs-target="#editInstanceModal"
data-name="{{ inst.name }}"
data-host="{{ inst.host }}"
@@ -70,7 +70,7 @@
</button>
<form method="post" action="/instances/{{ inst.id }}/delete" style="display:inline"
onsubmit="return confirmDelete('确认删除实例 {{ inst.name }}')">
<button class="btn btn-outline-danger"><i class="bi bi-trash"></i></button>
<button class="btn btn-outline-danger" title="删除"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
+4 -2
View File
@@ -2,7 +2,9 @@
{% block title %}审计日志 · SOCKS Manager{% endblock %}
{% block content %}
<h5 class="mb-3"><i class="bi bi-journal-text"></i> 审计日志 <span class="text-muted small">({{ total }} 条)</span></h5>
<div class="page-header">
<h5><i class="bi bi-journal-text"></i> 审计日志 <span class="text-muted small">({{ total }} 条)</span></h5>
</div>
<!-- 筛选 -->
<div class="card mb-3">
@@ -43,7 +45,7 @@
<tbody>
{% for l in logs %}
<tr>
<td><small>{{ l.timestamp }}</small></td>
<td><small>{{ l.timestamp[:19].replace('T',' ') if l.timestamp else '-' }}</small></td>
<td>
<span class="badge badge-{{
'success' if 'success' in l.event else
+6 -4
View File
@@ -2,7 +2,9 @@
{% block title %}流量统计 · SOCKS Manager{% endblock %}
{% block content %}
<h5 class="mb-3"><i class="bi bi-graph-up"></i> 流量统计</h5>
<div class="page-header">
<h5><i class="bi bi-graph-up"></i> 流量统计</h5>
</div>
<!-- 流量趋势图表 -->
<div class="card mb-3">
@@ -89,10 +91,10 @@ new Chart(ctx, {
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#94a3b8' } } },
plugins: { legend: { labels: { color: '#cbd5e1' } } },
scales: {
x: { grid: { color: '#1f2335' }, ticks: { color: '#6b7280', maxTicksLimit: 15 } },
y: { grid: { color: '#1f2335' }, ticks: { color: '#6b7280' } },
x: { grid: { color: '#262b3d' }, ticks: { color: '#a3acbf', maxTicksLimit: 10 } },
y: { grid: { color: '#262b3d' }, ticks: { color: '#a3acbf' } },
},
}
});
+3 -1
View File
@@ -2,7 +2,9 @@
{% block title %}系统管理 · SOCKS Manager{% endblock %}
{% block content %}
<h5 class="mb-3"><i class="bi bi-shield-lock"></i> 系统管理</h5>
<div class="page-header">
<h5><i class="bi bi-shield-lock"></i> 系统管理</h5>
</div>
<!-- 备份管理 -->
<div class="card mb-3">
+10 -6
View File
@@ -2,7 +2,7 @@
{% block title %}用户管理 · SOCKS Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="page-header">
<div>
<h5 class="mb-0"><i class="bi bi-people"></i> 用户管理</h5>
<span class="text-muted small">共 {{ total }} 个用户</span>
@@ -11,7 +11,7 @@
<form method="get" class="d-flex">
<input name="search" class="form-control form-control-sm" placeholder="搜索用户名"
value="{{ search }}" style="width:180px">
<button type="submit" class="btn btn-outline-secondary btn-sm"><i class="bi bi-search"></i></button>
<button type="submit" class="btn btn-outline-secondary btn-sm" title="搜索"><i class="bi bi-search"></i></button>
</form>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addUserModal">
<i class="bi bi-plus-lg"></i> 新建用户</button>
@@ -55,7 +55,7 @@
<td><small class="text-muted">{{ u.created_at[:10] }}</small></td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editUserModal"
<button class="btn btn-outline-secondary" title="编辑" data-bs-toggle="modal" data-bs-target="#editUserModal"
data-id="{{ u.id }}"
data-username="{{ u.username }}"
data-bw-down="{{ u.bandwidth_down }}"
@@ -69,18 +69,22 @@
<i class="bi bi-pencil"></i>
</button>
<form method="post" action="/users/{{ u.id }}/toggle" style="display:inline">
<button class="btn btn-outline-{{ 'warning' if u.enabled else 'success' }}">
<input type="hidden" name="enabled" value="{{ 'false' if u.enabled else 'true' }}">
<button class="btn btn-outline-{{ 'warning' if u.enabled else 'success' }}"
title="{{ '禁用' if u.enabled else '启用' }}">
<i class="bi bi-{{ 'eye-slash' if u.enabled else 'eye' }}"></i>
</button>
</form>
<form method="post" action="/users/{{ u.id }}/ban" style="display:inline">
<button class="btn btn-outline-{{ 'warning' if not u.banned else 'secondary' }}">
<input type="hidden" name="ban" value="{{ 'false' if u.banned else 'true' }}">
<button class="btn btn-outline-{{ 'warning' if not u.banned else 'secondary' }}"
title="{{ '封禁' if not u.banned else '解封' }}">
<i class="bi bi-{{ 'ban' if not u.banned else 'check-circle' }}"></i>
</button>
</form>
<form method="post" action="/users/{{ u.id }}/delete" style="display:inline"
onsubmit="return confirmDelete('确认删除用户 {{ u.username }}')">
<button class="btn btn-outline-danger"><i class="bi bi-trash"></i></button>
<button class="btn btn-outline-danger" title="删除"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
+63 -14
View File
@@ -12,15 +12,33 @@ bp = Blueprint("web", __name__)
@bp.app_context_processor
def inject_nav():
from flask import session as flask_session
p = request.path
if p == "/" or p.startswith("/dashboard"):
page_name = "仪表盘"
elif p.startswith("/instances"):
page_name = "代理实例"
elif p.startswith("/users"):
page_name = "用户管理"
elif p == "/stats":
page_name = "流量统计"
elif p.startswith("/stats/connections"):
page_name = "活跃连接"
elif p.startswith("/logs"):
page_name = "审计日志"
elif p.startswith("/system"):
page_name = "系统管理"
else:
page_name = ""
return {
"nav": {
"instances": request.path.startswith("/instances") or
request.path == "/" or request.path == "/dashboard",
"users": request.path.startswith("/users"),
"stats": request.path.startswith("/stats"),
"logs": request.path.startswith("/logs"),
"system": request.path.startswith("/system"),
"dashboard": p == "/" or p.startswith("/dashboard"),
"instances": p.startswith("/instances"),
"users": p.startswith("/users"),
"stats": p.startswith("/stats"),
"logs": p.startswith("/logs"),
"system": p.startswith("/system"),
},
"page_name": page_name,
"logged_in": flask_session.get("logged_in", False),
}
@@ -45,8 +63,11 @@ def instances():
@bp.route("/instances/<int:iid>/start", methods=["POST"])
@login_required
def start_instance(iid):
current_app.instance_manager.start_instance(iid)
flash("实例已启动", "success")
try:
current_app.instance_manager.start_instance(iid)
flash("实例已启动", "success")
except OSError as e:
flash(f"启动失败(端口可能被占用): {e}", "danger")
return redirect(url_for("web.instances"))
@@ -61,8 +82,11 @@ def stop_instance(iid):
@bp.route("/instances/<int:iid>/restart", methods=["POST"])
@login_required
def restart_instance(iid):
current_app.instance_manager.restart_instance(iid)
flash("实例已重启", "info")
try:
current_app.instance_manager.restart_instance(iid)
flash("实例已重启", "info")
except OSError as e:
flash(f"重启失败(端口可能被占用): {e}", "danger")
return redirect(url_for("web.instances"))
@@ -116,7 +140,10 @@ def edit_instance(iid):
db.session.commit()
if inst.running:
current_app.instance_manager.restart_instance(iid)
try:
current_app.instance_manager.restart_instance(iid)
except OSError as e:
flash(f"重启失败(端口可能被占用): {e}", "warning")
flash("配置已更新", "success")
return redirect(url_for("web.instances"))
@@ -205,7 +232,19 @@ def delete_user(uid):
@bp.route("/users/<int:uid>/toggle", methods=["POST"])
@login_required
def toggle_user(uid):
enabled = "enabled" in request.form
# 支持两种调用方式:
# 1) 隐藏字段 enabled=on/off (web form)
# 2) 不带字段时切换当前状态 (传统行为, 防止表单无字段导致总是禁用)
if "enabled" in request.form:
enabled = request.form.get("enabled") in ("on", "true", "1", "yes")
else:
from models import User
from database import db
u = db.session.query(User).get(uid)
if not u:
flash("用户不存在", "danger")
return redirect(url_for("web.users"))
enabled = not u.enabled
result = current_app.user_service.toggle_user(uid, enabled)
flash("" + ("启用" if enabled else "禁用") + "该用户", "success")
return redirect(url_for("web.users"))
@@ -214,7 +253,17 @@ def toggle_user(uid):
@bp.route("/users/<int:uid>/ban", methods=["POST"])
@login_required
def ban_user(uid):
banned = "ban" in request.form
# 同 toggle_user 的两种调用方式
if "ban" in request.form:
banned = request.form.get("ban") in ("on", "true", "1", "yes")
else:
from models import User
from database import db
u = db.session.query(User).get(uid)
if not u:
flash("用户不存在", "danger")
return redirect(url_for("web.users"))
banned = not u.banned
result = current_app.user_service.ban_user(uid, banned)
flash("" + ("封禁" if banned else "解封") + "该用户", "success")
return redirect(url_for("web.users"))
@@ -265,7 +314,7 @@ def system():
sys_version=platform.python_version(),
sys_platform=platform.platform(),
sys_hostname=platform.node(),
uptime=time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()))
uptime=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()))
@bp.route("/system/backup", methods=["POST"])