feat: SOCKS5 代理管理系统 v1.0
Flask + SQLite + Bootstrap5 暗色主题 - 仪表盘: 在线/离线统计 + 最近代理 + 操作日志 - 代理 CRUD: 增删改 + 启用/禁用 + 单/批量健康检测(SOCKS5) - 分组管理: 创建/删除/筛选 - 操作日志: 分页查询 - RESTful API + Web 界面 - 管理员登录认证 (SM_ADMIN_PASSWORD 环境变量)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
socks_manager.db
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from flask import Blueprint, request, session, redirect, url_for, abort, current_app
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not session.get("logged_in"):
|
||||||
|
if request.path.startswith("/api/"):
|
||||||
|
return {"error": "unauthorized"}, 401
|
||||||
|
return redirect(url_for("auth.login", next=request.url))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if session.get("logged_in"):
|
||||||
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
import os
|
||||||
|
user = request.form.get("username", "")
|
||||||
|
pwd = request.form.get("password", "")
|
||||||
|
valid_user = os.environ.get("SM_ADMIN_USER", "admin")
|
||||||
|
valid_pwd = os.environ.get("SM_ADMIN_PASSWORD", "")
|
||||||
|
if user == valid_user and pwd == valid_pwd:
|
||||||
|
session["logged_in"] = True
|
||||||
|
session.permanent = True
|
||||||
|
return redirect(request.args.get("next") or url_for("main.dashboard"))
|
||||||
|
return f"<script>alert('用户名或密码错误');history.back();</script>", 401
|
||||||
|
|
||||||
|
return """
|
||||||
|
<!doctype html>
|
||||||
|
<html><head><meta charset="utf-8">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<title>登录 · SOCKS Manager</title></head>
|
||||||
|
<body class="bg-dark d-flex align-items-center justify-content-center" style="min-height:100vh">
|
||||||
|
<div class="card shadow" style="width:360px">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h4 class="text-center mb-4">SOCKS Manager</h4>
|
||||||
|
<p class="text-muted text-center small mb-4">SOCKS5 代理管理系统</p>
|
||||||
|
<form method="post">
|
||||||
|
<div class="mb-3"><label class="form-label">用户名</label>
|
||||||
|
<input name="username" class="form-control" autofocus required></div>
|
||||||
|
<div class="mb-3"><label class="form-label">密码</label>
|
||||||
|
<input name="password" type="password" class="form-control" required></div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get("SM_SECRET_KEY", "change-me-in-production")
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||||
|
"SM_DB_URI", f"sqlite:///{os.path.join(basedir, 'socks_manager.db')}"
|
||||||
|
)
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# 管理后台认证(在 create_app 时读取,确保 env var 生效)
|
||||||
|
@staticmethod
|
||||||
|
def admin_user():
|
||||||
|
return os.environ.get("SM_ADMIN_USER", "admin")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def admin_password():
|
||||||
|
return os.environ.get("SM_ADMIN_PASSWORD", "")
|
||||||
|
|
||||||
|
# 健康检测
|
||||||
|
HEALTH_CHECK_TIMEOUT = int(os.environ.get("SM_HEALTH_TIMEOUT", "10"))
|
||||||
|
HEALTH_CHECK_INTERVAL = int(os.environ.get("SM_HEALTH_INTERVAL", "300")) # 秒
|
||||||
|
|
||||||
|
LOG_LEVEL = os.environ.get("SM_LOG_LEVEL", "INFO")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_class=Config):
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, app.config["LOG_LEVEL"]),
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
from extensions import db
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
from models import db as _db
|
||||||
|
with app.app_context():
|
||||||
|
_db.create_all()
|
||||||
|
|
||||||
|
# Blueprint 注册
|
||||||
|
from routes import main
|
||||||
|
from routes import api
|
||||||
|
from auth import bp as auth_bp
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(main.bp)
|
||||||
|
app.register_blueprint(api.bp, url_prefix="/api")
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from models import Proxy, db
|
||||||
|
from extensions import db as _db_ext
|
||||||
|
|
||||||
|
log = logging.getLogger("socks.health")
|
||||||
|
|
||||||
|
TEST_URL = "http://httpbin.org/get"
|
||||||
|
|
||||||
|
|
||||||
|
def check_proxy(proxy):
|
||||||
|
"""检查单个 SOCKS5 代理是否可用。返回 {status, latency_ms, error}"""
|
||||||
|
import socks
|
||||||
|
import socket
|
||||||
|
|
||||||
|
host, port = proxy.host, proxy.port
|
||||||
|
timeout = 10
|
||||||
|
status = "unknown"
|
||||||
|
latency = None
|
||||||
|
error = None
|
||||||
|
|
||||||
|
orig_getaddrinfo = socket.getaddrinfo
|
||||||
|
orig_socket = socket.socket
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用 PySocks 创建 SOCKS5 隧道
|
||||||
|
s = socks.socksocket()
|
||||||
|
s.settimeout(timeout)
|
||||||
|
s.set_proxy(socks.SOCKS5, host, port,
|
||||||
|
username=proxy.username or None,
|
||||||
|
password=proxy.password or None)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
# 尝试连接到测试 URL(httpbin.org)
|
||||||
|
s.connect(("httpbin.org", 80))
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
latency = round(elapsed * 1000, 1)
|
||||||
|
|
||||||
|
# 发送一个简单 HTTP HEAD 请求验证隧道通畅
|
||||||
|
s.sendall(b"HEAD /get HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n")
|
||||||
|
resp = s.recv(1024)
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
if resp and (b"200" in resp[:4] or b"301" in resp[:4] or b"302" in resp[:4]):
|
||||||
|
status = "online"
|
||||||
|
else:
|
||||||
|
status = "online" # 连接成功即算在线(某些代理只允许特定目标)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
status = "offline"
|
||||||
|
error = str(e)[:200]
|
||||||
|
log.warning("Proxy %s:%d check failed: %s", host, port, e)
|
||||||
|
finally:
|
||||||
|
socket.getaddrinfo = orig_getaddrinfo
|
||||||
|
socket.socket = orig_socket
|
||||||
|
|
||||||
|
return {"status": status, "latency_ms": latency, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
def run_health_checks():
|
||||||
|
"""对所有 enabled 的代理运行健康检测,更新数据库,返回结果摘要。"""
|
||||||
|
proxies = Proxy.query.filter_by(enabled=True).all()
|
||||||
|
results = []
|
||||||
|
online_count = 0
|
||||||
|
|
||||||
|
for p in proxies:
|
||||||
|
r = check_proxy(p)
|
||||||
|
p.status = r["status"]
|
||||||
|
p.latency_ms = r["latency_ms"]
|
||||||
|
p.last_check = datetime.now(timezone.utc)
|
||||||
|
if r["status"] == "online":
|
||||||
|
online_count += 1
|
||||||
|
results.append({
|
||||||
|
"id": p.id, "name": p.name, "host": p.host, "port": p.port,
|
||||||
|
**r,
|
||||||
|
})
|
||||||
|
|
||||||
|
_db_ext.session.commit()
|
||||||
|
return {
|
||||||
|
"checked": len(results),
|
||||||
|
"online": online_count,
|
||||||
|
"offline": len(results) - online_count,
|
||||||
|
"details": results,
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class Proxy(db.Model):
|
||||||
|
__tablename__ = "proxies"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(128), nullable=False, index=True)
|
||||||
|
host = db.Column(db.String(256), nullable=False)
|
||||||
|
port = db.Column(db.Integer, nullable=False)
|
||||||
|
username = db.Column(db.String(128), nullable=True)
|
||||||
|
password = db.Column(db.String(256), nullable=True)
|
||||||
|
group_id = db.Column(db.Integer, db.ForeignKey("groups.id"), nullable=True)
|
||||||
|
enabled = db.Column(db.Boolean, default=True)
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# 健康状态(由检测任务更新)
|
||||||
|
status = db.Column(db.String(16), default="unknown") # online/offline/unknown/error
|
||||||
|
latency_ms = db.Column(db.Float, nullable=True)
|
||||||
|
last_check = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
group = db.relationship("Group", backref=db.backref("proxies", lazy=True))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Proxy {self.name} {self.host}:{self.port}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Group(db.Model):
|
||||||
|
__tablename__ = "groups"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(128), nullable=False, unique=True)
|
||||||
|
color = db.Column(db.String(16), default="#6366f1")
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Group {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Log(db.Model):
|
||||||
|
__tablename__ = "logs"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
timestamp = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
level = db.Column(db.String(16), default="INFO") # INFO/WARN/ERROR
|
||||||
|
action = db.Column(db.String(64), nullable=True) # add/update/delete/check
|
||||||
|
target = db.Column(db.String(128), nullable=True) # proxy/group name
|
||||||
|
detail = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Log [{self.level}] {self.action}>"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Flask>=3.0
|
||||||
|
Flask-SQLAlchemy>=3.1
|
||||||
|
PySocks>=1.7
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from models import Proxy, Group, Log, db
|
||||||
|
from auth import login_required
|
||||||
|
from health import check_proxy
|
||||||
|
|
||||||
|
bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/proxies", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def list_proxies():
|
||||||
|
g = request.args.get("group")
|
||||||
|
query = Proxy.query
|
||||||
|
if g:
|
||||||
|
query = query.filter_by(group_id=g)
|
||||||
|
items = query.order_by(Proxy.updated_at.desc()).all()
|
||||||
|
return jsonify([{
|
||||||
|
"id": p.id, "name": p.name, "host": p.host, "port": p.port,
|
||||||
|
"username": p.username, "group": p.group.name if p.group else None,
|
||||||
|
"enabled": p.enabled, "status": p.status,
|
||||||
|
"latency_ms": round(p.latency_ms, 1) if p.latency_ms else None,
|
||||||
|
"last_check": p.last_check.isoformat() if p.last_check else None,
|
||||||
|
} for p in items])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/proxies", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def create_proxy():
|
||||||
|
data = request.get_json()
|
||||||
|
p = Proxy(
|
||||||
|
name=data["name"], host=data["host"], port=int(data["port"]),
|
||||||
|
username=data.get("username"), password=data.get("password"),
|
||||||
|
group_id=int(data["group_id"]) if data.get("group_id") else None,
|
||||||
|
notes=data.get("notes"),
|
||||||
|
)
|
||||||
|
db.session.add(p)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"id": p.id}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/proxies/<int:pid>", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def update_proxy(pid):
|
||||||
|
p = Proxy.query.get_or_404(pid)
|
||||||
|
data = request.get_json()
|
||||||
|
for k in ("name", "host", "port", "username", "password", "group_id", "notes"):
|
||||||
|
v = data.get(k)
|
||||||
|
if v is not None:
|
||||||
|
setattr(p, k, int(v) if k in ("port", "group_id") and v else v)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/proxies/<int:pid>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_proxy(pid):
|
||||||
|
p = Proxy.query.get_or_404(pid)
|
||||||
|
db.session.delete(p)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/proxies/<int:pid>/check", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def check_single(pid):
|
||||||
|
p = Proxy.query.get_or_404(pid)
|
||||||
|
result = check_proxy(p)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/proxies/<int:pid>/toggle", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def toggle(pid):
|
||||||
|
p = Proxy.query.get_or_404(pid)
|
||||||
|
p.enabled = not p.enabled
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"enabled": p.enabled})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/groups", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def list_groups():
|
||||||
|
groups = Group.query.all()
|
||||||
|
return jsonify([{"id": g.id, "name": g.name, "color": g.color,
|
||||||
|
"description": g.description,
|
||||||
|
"count": len(g.proxies)} for g in groups])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/stats")
|
||||||
|
@login_required
|
||||||
|
def stats():
|
||||||
|
total = Proxy.query.count()
|
||||||
|
online = Proxy.query.filter_by(status="online", enabled=True).count()
|
||||||
|
offline = Proxy.query.filter_by(status="offline").count()
|
||||||
|
return jsonify({"total": total, "online": online, "offline": offline})
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from auth import login_required
|
||||||
|
from models import Proxy, Group, Log, db
|
||||||
|
from health import check_proxy, run_health_checks
|
||||||
|
|
||||||
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _log(level, action, target=None, detail=None):
|
||||||
|
db.session.add(Log(level=level, action=action, target=target, detail=detail))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dashboard ──────────────────────────────────────────────────
|
||||||
|
@bp.route("/")
|
||||||
|
@bp.route("/dashboard")
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
total = Proxy.query.count()
|
||||||
|
online = Proxy.query.filter_by(status="online", enabled=True).count()
|
||||||
|
offline = Proxy.query.filter_by(status="offline").count()
|
||||||
|
unknown = Proxy.query.filter_by(status="unknown").count()
|
||||||
|
groups = Group.query.count()
|
||||||
|
recent = Log.query.order_by(Log.timestamp.desc()).limit(20).all()
|
||||||
|
proxies = Proxy.query.filter_by(enabled=True).order_by(Proxy.updated_at.desc()).limit(10).all()
|
||||||
|
return render_template("dashboard.html", total=total, online=online, offline=offline,
|
||||||
|
unknown=unknown, groups=groups, recent=recent, proxies=proxies)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Proxy list ─────────────────────────────────────────────────
|
||||||
|
@bp.route("/proxies")
|
||||||
|
@login_required
|
||||||
|
def proxy_list():
|
||||||
|
g = request.args.get("group")
|
||||||
|
query = Proxy.query.order_by(Proxy.updated_at.desc())
|
||||||
|
if g:
|
||||||
|
query = query.filter_by(group_id=g)
|
||||||
|
groups = Group.query.all()
|
||||||
|
return render_template("proxies.html", proxies=query.all(), groups=groups, active_group=g)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/proxies/<int:pid>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_proxy(pid):
|
||||||
|
p = Proxy.query.get_or_404(pid)
|
||||||
|
_log("WARN", "delete", p.name, f"删除代理 {p.host}:{p.port}")
|
||||||
|
db.session.delete(p)
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(url_for("main.proxy_list"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/proxies/toggle/<int:pid>")
|
||||||
|
@login_required
|
||||||
|
def toggle_proxy(pid):
|
||||||
|
p = Proxy.query.get_or_404(pid)
|
||||||
|
p.enabled = not p.enabled
|
||||||
|
db.session.commit()
|
||||||
|
_log("INFO", "toggle", p.name, f"{'启用' if p.enabled else '禁用'}")
|
||||||
|
return redirect(url_for("main.proxy_list"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Add / Edit ─────────────────────────────────────────────────
|
||||||
|
@bp.route("/proxies/add", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def add_proxy():
|
||||||
|
if request.method == "POST":
|
||||||
|
p = Proxy(
|
||||||
|
name=request.form["name"],
|
||||||
|
host=request.form["host"],
|
||||||
|
port=int(request.form["port"]),
|
||||||
|
username=request.form.get("username"),
|
||||||
|
password=request.form.get("password"),
|
||||||
|
group_id=int(request.form["group_id"]) if request.form.get("group_id") else None,
|
||||||
|
notes=request.form.get("notes"),
|
||||||
|
)
|
||||||
|
db.session.add(p)
|
||||||
|
db.session.commit()
|
||||||
|
_log("INFO", "add", p.name, f"添加代理 {p.host}:{p.port}")
|
||||||
|
return redirect(url_for("main.proxy_list"))
|
||||||
|
groups = Group.query.all()
|
||||||
|
return render_template("proxy_form.html", p=None, groups=groups, action="add")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/proxies/<int:pid>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def edit_proxy(pid):
|
||||||
|
p = Proxy.query.get_or_404(pid)
|
||||||
|
if request.method == "POST":
|
||||||
|
p.name = request.form["name"]
|
||||||
|
p.host = request.form["host"]
|
||||||
|
p.port = int(request.form["port"])
|
||||||
|
p.username = request.form.get("username")
|
||||||
|
p.password = request.form.get("password")
|
||||||
|
p.group_id = int(request.form["group_id"]) if request.form.get("group_id") else None
|
||||||
|
p.notes = request.form.get("notes")
|
||||||
|
db.session.commit()
|
||||||
|
_log("INFO", "update", p.name, f"更新代理 {p.host}:{p.port}")
|
||||||
|
return redirect(url_for("main.proxy_list"))
|
||||||
|
groups = Group.query.all()
|
||||||
|
return render_template("proxy_form.html", p=p, groups=groups, action="edit")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Group ──────────────────────────────────────────────────────
|
||||||
|
@bp.route("/groups")
|
||||||
|
@login_required
|
||||||
|
def group_list():
|
||||||
|
groups = Group.query.all()
|
||||||
|
return render_template("groups.html", groups=groups)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/groups/add", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def add_group():
|
||||||
|
name = request.form["name"]
|
||||||
|
if Group.query.filter_by(name=name).first():
|
||||||
|
return jsonify({"error": "分组已存在"}), 400
|
||||||
|
g = Group(name=name, color=request.form.get("color", "#6366f1"),
|
||||||
|
description=request.form.get("description"))
|
||||||
|
db.session.add(g)
|
||||||
|
db.session.commit()
|
||||||
|
_log("INFO", "add", name, "添加分组")
|
||||||
|
return jsonify({"id": g.id, "name": g.name})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/groups/<int:gid>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_group(gid):
|
||||||
|
g = Group.query.get_or_404(gid)
|
||||||
|
if g.proxies:
|
||||||
|
return jsonify({"error": "分组下仍有代理,请先移动"}), 400
|
||||||
|
_log("WARN", "delete", g.name, "删除分组")
|
||||||
|
db.session.delete(g)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Logs ───────────────────────────────────────────────────────
|
||||||
|
@bp.route("/logs")
|
||||||
|
@login_required
|
||||||
|
def logs():
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
ps = db.paginate(Log.query.order_by(Log.timestamp.desc()), page=page, per_page=50)
|
||||||
|
return render_template("logs.html", pages=ps,
|
||||||
|
page_start=max(1, ps.page - 2),
|
||||||
|
page_end=min(ps.pages, ps.page + 2))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/health/check-all")
|
||||||
|
@login_required
|
||||||
|
def health_check_all():
|
||||||
|
results = run_health_checks()
|
||||||
|
return jsonify(results)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""SOCKS5 代理管理系统 — 启动入口。"""
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exec flask --app app:app run --host 0.0.0.0 --port 5000
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{% block title %}SOCKS Manager{% endblock %}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background:#0f1117; color:#e2e8f0; }
|
||||||
|
.card { background:#1a1d27; border:1px solid #2a2d3a; }
|
||||||
|
.navbar { background:#151822 !important; border-bottom:1px solid #2a2d3a !important; }
|
||||||
|
.navbar-brand { color:#818cf8 !important; font-weight:700; }
|
||||||
|
.nav-link { color:#a0aec0 !important; }
|
||||||
|
.nav-link:hover, .nav-link.active { color:#818cf8 !important; }
|
||||||
|
.table { color:#e2e8f0; }
|
||||||
|
.table-dark { background:#1a1d27; }
|
||||||
|
.form-control, .form-select { background:#0f1117; border-color:#2a2d3a; color:#e2e8f0; }
|
||||||
|
.form-control:focus, .form-select:focus { background:#0f1117; border-color:#818cf8; color:#e2e8f0; }
|
||||||
|
.badge-online { background:#10b981; }
|
||||||
|
.badge-offline { background:#ef4444; }
|
||||||
|
.badge-unknown { background:#6b7280; }
|
||||||
|
.badge-error { background:#f59e0b; }
|
||||||
|
.status-dot { width:10px; height:10px; border-radius:50%; display:inline-block; margin-right:6px; }
|
||||||
|
.dot-online { background:#10b981; }
|
||||||
|
.dot-offline { background:#ef4444; }
|
||||||
|
.dot-unknown { background:#6b7280; }
|
||||||
|
.dot-error { background:#f59e0b; }
|
||||||
|
.sidebar { width:240px; min-height:100vh; position:fixed; left:0; top:0; bottom:0; background:#151822; border-right:1px solid #2a2d3a; padding-top:60px; }
|
||||||
|
.sidebar .nav-link { padding:.6rem 1.2rem; border-radius:.4rem; margin:.2rem .8rem; }
|
||||||
|
.sidebar .nav-link:hover, .sidebar .nav-link.active { background:#1e2235; }
|
||||||
|
.main-content { margin-left:240px; padding:1.5rem; }
|
||||||
|
@media (max-width:768px) { .sidebar { display:none; } .main-content { margin-left:0; } }
|
||||||
|
.stat-card .stat-value { font-size:2rem; font-weight:700; }
|
||||||
|
.toast-container { position:fixed; top:1rem; right:1rem; z-index:1060; }
|
||||||
|
</style>
|
||||||
|
{% block extra_style %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg fixed-top">
|
||||||
|
<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">
|
||||||
|
<span class="text-muted small me-3">{{ g.user if g and g.user else 'admin' }}</span>
|
||||||
|
<a href="/logout" class="btn btn-outline-secondary btn-sm">退出</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="nav flex-column">
|
||||||
|
<a class="nav-link {% if 'dashboard' in request.endpoint|default('') %}active{% endif %}" href="/dashboard">
|
||||||
|
<i class="bi bi-speedometer2"></i> 仪表盘</a>
|
||||||
|
<a class="nav-link {% if 'proxy' in request.endpoint|default('') %}active{% endif %}" href="/proxies">
|
||||||
|
<i class="bi bi-globe"></i> 代理列表</a>
|
||||||
|
<a class="nav-link {% if 'group' in request.endpoint|default('') %}active{% endif %}" href="/groups">
|
||||||
|
<i class="bi bi-collection"></i> 分组管理</a>
|
||||||
|
<a class="nav-link {% if 'logs' in request.endpoint|default('') %}active{% endif %}" href="/logs">
|
||||||
|
<i class="bi bi-journal-text"></i> 日志</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
function toast(msg, type='info') {
|
||||||
|
const c = document.getElementById('toastContainer');
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = `toast show align-items-center text-bg-${type} border-0`;
|
||||||
|
t.setAttribute('role','alert');
|
||||||
|
t.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||||
|
c.appendChild(t);
|
||||||
|
setTimeout(()=>t.remove(), 4000);
|
||||||
|
}
|
||||||
|
async function apiFetch(url, opts={}) {
|
||||||
|
const r = await fetch(url, {...opts, headers:{'Content-Type':'application/json', ...opts.headers}});
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% block extra_script %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}仪表盘 · SOCKS Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h4 class="mb-4"><i class="bi bi-speedometer2"></i> 仪表盘</h4>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3"><div class="card stat-card p-3">
|
||||||
|
<div class="text-muted small">代理总数</div>
|
||||||
|
<div class="stat-value">{{ total }}</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="col-md-3"><div class="card stat-card p-3">
|
||||||
|
<div class="text-muted small"><span class="status-dot dot-online"></span>在线</div>
|
||||||
|
<div class="stat-value text-success">{{ online }}</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="col-md-3"><div class="card stat-card p-3">
|
||||||
|
<div class="text-muted small"><span class="status-dot dot-offline"></span>离线</div>
|
||||||
|
<div class="stat-value text-danger">{{ offline }}</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="col-md-3"><div class="card stat-card p-3">
|
||||||
|
<div class="text-muted small">分组数</div>
|
||||||
|
<div class="stat-value">{{ groups }}</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-globe"></i> 最近活跃代理</span>
|
||||||
|
<button class="btn btn-outline-primary btn-sm" onclick="checkAll()">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> 全部检测</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if proxies %}
|
||||||
|
<table class="table table-dark table-hover mb-0">
|
||||||
|
<thead><tr><th>名称</th><th>地址</th><th>状态</th><th>延迟</th><th>最后检测</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in proxies %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ p.name }}</td>
|
||||||
|
<td><code>{{ p.host }}:{{ p.port }}</code></td>
|
||||||
|
<td><span class="status-dot dot-{{ p.status }}"></span>{{ p.status }}</td>
|
||||||
|
<td>{{ "%.1f"|format(p.latency_ms) if p.latency_ms else '-' }}ms</td>
|
||||||
|
<td>{{ p.last_check.strftime('%Y-%m-%d %H:%M') if p.last_check else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4 text-muted">暂无代理,<a href="/proxies/add" class="text-info">添加一个</a></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span><i class="bi bi-journal-text"></i> 最近操作日志</span></div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-dark mb-0 table-sm">
|
||||||
|
<thead><tr><th>时间</th><th>级别</th><th>操作</th><th>目标</th><th>详情</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for l in recent %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ l.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
|
<td><span class="badge bg-{{ 'success' if l.level=='INFO' else 'warning' if l.level=='WARN' else 'danger' }}">{{ l.level }}</span></td>
|
||||||
|
<td>{{ l.action }}</td>
|
||||||
|
<td>{{ l.target or '-' }}</td>
|
||||||
|
<td>{{ l.detail or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_script %}
|
||||||
|
<script>
|
||||||
|
async function checkAll() {
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 检测中...';
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/health/check-all');
|
||||||
|
toast(`检测完成: ${r.checked} 个, ${r.online} 在线, ${r.offline} 离线`, r.offline?'warning':'success');
|
||||||
|
} catch(e) { toast('检测失败: '+e.message, 'danger'); }
|
||||||
|
btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> 全部检测';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}分组管理 · SOCK Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-collection"></i> 分组管理</h4>
|
||||||
|
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addGroupModal">
|
||||||
|
<i class="bi bi-plus-lg"></i> 添加分组</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for g in groups %}
|
||||||
|
<div class="col-md-4 col-lg-3">
|
||||||
|
<div class="card" style="border-left:4px solid {{ g.color }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 style="color:{{ g.color }}">{{ g.name }}</h6>
|
||||||
|
<p class="text-muted small mb-1">{{ g.description or '无描述' }}</p>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="badge bg-secondary">{{ g.proxies|length }} 个代理</span>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" onclick="deleteGroup({{ g.id }}, '{{ g.name }}')">
|
||||||
|
<i class="bi bi-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not groups %}
|
||||||
|
<div class="text-center py-5 text-muted">暂无分组</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Add Group Modal -->
|
||||||
|
<div class="modal fade" id="addGroupModal">
|
||||||
|
<div class="modal-dialog modal-sm">
|
||||||
|
<div class="modal-content" style="background:#1a1d27;border-color:#2a2d3a">
|
||||||
|
<div class="modal-header" style="border-color:#2a2d3a">
|
||||||
|
<h6 class="modal-title">添加分组</h6>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3"><label class="form-label">名称</label>
|
||||||
|
<input id="gName" class="form-control" required></div>
|
||||||
|
<div class="mb-3"><label class="form-label">颜色</label>
|
||||||
|
<input id="gColor" type="color" class="form-control form-control-color" value="#6366f1"></div>
|
||||||
|
<div class="mb-3"><label class="form-label">描述</label>
|
||||||
|
<input id="gDesc" class="form-control"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-color:#2a2d3a">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">取消</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" onclick="addGroup()">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_script %}
|
||||||
|
<script>
|
||||||
|
async function addGroup() {
|
||||||
|
const name = document.getElementById('gName').value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
await fetch('/groups/add', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
|
||||||
|
body:`name=${encodeURIComponent(name)}&color=${document.getElementById('gColor').value}&description=${encodeURIComponent(document.getElementById('gDesc').value)}`});
|
||||||
|
toast('分组已添加', 'success'); location.reload();
|
||||||
|
} catch(e) { toast('添加失败', 'danger'); }
|
||||||
|
}
|
||||||
|
async function deleteGroup(id, name) {
|
||||||
|
if (!confirm(`确认删除分组 "${name}"?`)) return;
|
||||||
|
const r = await fetch(`/groups/${id}`, {method:'DELETE'});
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.error) { toast(j.error, 'warning'); } else { toast('分组已删除', 'success'); location.reload(); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}日志 · SOCKS Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h4 class="mb-4"><i class="bi bi-journal-text"></i> 操作日志</h4>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-dark table-hover mb-0 table-sm">
|
||||||
|
<thead><tr><th>时间</th><th>级别</th><th>操作</th><th>目标</th><th>详情</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for l in pages.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ l.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
|
<td><span class="badge bg-{{ 'success' if l.level=='INFO' else 'warning' if l.level=='WARN' else 'danger' }}">{{ l.level }}</span></td>
|
||||||
|
<td>{{ l.action }}</td>
|
||||||
|
<td>{{ l.target or '-' }}</td>
|
||||||
|
<td>{{ l.detail or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between align-items-center" style="border-color:#2a2d3a">
|
||||||
|
<span class="text-muted small">共 {{ pages.total }} 条</span>
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm mb-0">
|
||||||
|
{% if pages.has_prev %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ pages.prev_num }}" style="color:#818cf8">上一页</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% for n in range(page_start, page_end + 1) %}
|
||||||
|
<li class="page-item {% if n==pages.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="?page={{ n }}" style="color:#818cf8;background:{{ 'transparent' if n!=pages.page else '#818cf8' }};border-color:#2a2d3a">{{ n }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if pages.has_next %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ pages.next_num }}" style="color:#818cf8">下一页</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}代理列表 · SOCKS Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-globe"></i> 代理列表</h4>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<select class="form-select form-select-sm" id="groupFilter" style="width:180px"
|
||||||
|
onchange="location.href='/proxies?group='+this.value">
|
||||||
|
<option value="">全部分组</option>
|
||||||
|
{% for g in groups %}
|
||||||
|
<option value="{{ g.id }}" {% if active_group|default('') == g.id|string %}selected{% endif %}>
|
||||||
|
{{ g.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<a href="/proxies/add" class="btn btn-primary btn-sm"><i class="bi bi-plus-lg"></i> 添加代理</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-dark table-hover align-middle mb-0">
|
||||||
|
<thead><tr><th>名称</th><th>主机</th><th>端口</th><th>认证</th><th>分组</th><th>状态</th><th>延迟</th><th>操作</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in proxies %}
|
||||||
|
<tr class="{% if not p.enabled %}table-secondary{% endif %}">
|
||||||
|
<td><a href="/proxies/{{ p.id }}/edit" class="text-decoration-none">{{ p.name }}</a></td>
|
||||||
|
<td><code>{{ p.host }}</code></td>
|
||||||
|
<td>{{ p.port }}</td>
|
||||||
|
<td>{{ '有认证' if p.username else '无' }}</td>
|
||||||
|
<td>{% if p.group %}<span style="color:{{ p.group.color }}">{{ p.group.name }}</span>{% else %}-{% endif %}</td>
|
||||||
|
<td><span class="status-dot dot-{{ p.status }}"></span>{{ p.status }}<br><small class="text-muted">{{ p.last_check.strftime('%Y-%m-%d %H:%M') if p.last_check else '未检测' }}</small></td>
|
||||||
|
<td>{{ "%.1f"|format(p.latency_ms) if p.latency_ms else '-' }}ms</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="/proxies/{{ p.id }}/edit" class="btn btn-outline-secondary"><i class="bi bi-pencil"></i></a>
|
||||||
|
<a href="/proxies/toggle/{{ p.id }}" class="btn btn-outline-{{ 'secondary' if p.enabled else 'success' }}" onclick="return confirm('确认{{ '禁用' if p.enabled else '启用' }}?')">
|
||||||
|
<i class="bi bi-{{ 'eye-slash' if p.enabled else 'eye' }}"></i></a>
|
||||||
|
<a href="/proxies/{{ p.id }}" class="btn btn-outline-danger" onclick="return confirm('确认删除?')"><i class="bi bi-trash"></i></a>
|
||||||
|
<button class="btn btn-outline-info" onclick="checkOne({{ p.id }})"><i class="bi bi-arrow-clockwise"></i></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if not proxies %}
|
||||||
|
<div class="text-center py-5 text-muted">暂无代理</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_script %}
|
||||||
|
<script>
|
||||||
|
async function checkOne(pid) {
|
||||||
|
try {
|
||||||
|
const r = await apiFetch(`/api/proxies/${pid}/check`, {method:'POST'});
|
||||||
|
toast(`${r.name}: ${r.status}${r.latency_ms? ' ('+r.latency_ms+'ms)':''}`, r.status==='online'?'success':'danger');
|
||||||
|
location.reload();
|
||||||
|
} catch(e) { toast('检测失败', 'danger'); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ '编辑' if action=='edit' else '添加' }}代理 · SOCKS Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-{{ 'pencil' if action=='edit' else 'plus-circle' }}"></i>
|
||||||
|
{{ '编辑代理' if action=='edit' else '添加代理' }}</h4>
|
||||||
|
<a href="/proxies" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left"></i> 返回</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:640px">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">名称</label>
|
||||||
|
<input name="name" class="form-control" required value="{{ p.name if p else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">分组</label>
|
||||||
|
<select name="group_id" class="form-select">
|
||||||
|
<option value="">无</option>
|
||||||
|
{% for g in groups %}
|
||||||
|
<option value="{{ g.id }}" {% if p and p.group and p.group.id==g.id %}selected{% endif %}>
|
||||||
|
{{ g.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">主机地址</label>
|
||||||
|
<input name="host" class="form-control" required placeholder="1.2.3.4 或 socks.example.com"
|
||||||
|
value="{{ p.host if p else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">端口</label>
|
||||||
|
<input name="port" class="form-control" type="number" min="1" max="65535" required
|
||||||
|
value="{{ p.port if p else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">用户名(可选)</label>
|
||||||
|
<input name="username" class="form-control" value="{{ p.username if p and p.username else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">密码(可选)</label>
|
||||||
|
<input name="password" class="form-control" value="{{ p.password if p and p.password else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">备注</label>
|
||||||
|
<textarea name="notes" class="form-control" rows="2">{{ p.notes if p else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> 保存</button>
|
||||||
|
<a href="/proxies" class="btn btn-outline-secondary">取消</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user