feat:自动更换ip+流量监控
This commit is contained in:
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(mkdir:*)",
|
||||||
|
"Bash(dir:*)",
|
||||||
|
"Bash(python -m py_compile:*)",
|
||||||
|
"Bash(python:*)",
|
||||||
|
"Bash(pip install:*)",
|
||||||
|
"Bash(mysql:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Flask / runtime
|
||||||
|
backend/data/
|
||||||
|
backend/logs/
|
||||||
|
|
||||||
|
# Node / Vite
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
|
||||||
741
app.py
Normal file
741
app.py
Normal file
@@ -0,0 +1,741 @@
|
|||||||
|
"""
|
||||||
|
ProxyAuto - 自动 IP 更换与 Cloudflare DNS 同步工具
|
||||||
|
|
||||||
|
Flask 版本
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from functools import wraps
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
# 确保当前目录在路径中
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from flask import Flask, render_template, request, jsonify, redirect, url_for, session
|
||||||
|
|
||||||
|
from database import (
|
||||||
|
Config,
|
||||||
|
ProxyMachine,
|
||||||
|
User,
|
||||||
|
ensure_singleton_config,
|
||||||
|
ensure_admin_user,
|
||||||
|
get_session,
|
||||||
|
mask_secret,
|
||||||
|
LOG_DIR,
|
||||||
|
)
|
||||||
|
from services.scheduler import (
|
||||||
|
get_scheduler_status,
|
||||||
|
get_scheduler,
|
||||||
|
start_machine_auto,
|
||||||
|
stop_machine_auto,
|
||||||
|
update_all_schedulers,
|
||||||
|
get_machine_scheduler_status,
|
||||||
|
)
|
||||||
|
from services.ip_change import run_ip_change_for_machine
|
||||||
|
from services.aws_region import is_valid_aws_region, normalize_aws_region
|
||||||
|
from services.traffic_monitor import (
|
||||||
|
get_machine_traffic,
|
||||||
|
get_current_month_traffic,
|
||||||
|
get_current_day_traffic,
|
||||||
|
get_all_time_traffic,
|
||||||
|
format_bytes,
|
||||||
|
)
|
||||||
|
from services.traffic_alert import check_all_traffic_alerts, reset_machine_alert
|
||||||
|
from services.email_service import send_email
|
||||||
|
|
||||||
|
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = 'proxyauto-secret-key-change-in-production'
|
||||||
|
|
||||||
|
# 正则验证
|
||||||
|
_INSTANCE_ID_RE = re.compile(r"^i-[0-9a-f]{8,17}$", re.IGNORECASE)
|
||||||
|
_LIGHTSAIL_INSTANCE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
||||||
|
|
||||||
|
# 初始化
|
||||||
|
db_session = get_session()
|
||||||
|
ensure_admin_user(db_session)
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
# 初始化调度器
|
||||||
|
update_all_schedulers()
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
"""登录验证装饰器"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if 'user_id' not in session:
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def generate_captcha():
|
||||||
|
"""生成简单的数学验证码"""
|
||||||
|
a = random.randint(1, 20)
|
||||||
|
b = random.randint(1, 20)
|
||||||
|
op = random.choice(['+', '-', 'x'])
|
||||||
|
if op == '+':
|
||||||
|
answer = a + b
|
||||||
|
question = f"{a} + {b} = ?"
|
||||||
|
elif op == '-':
|
||||||
|
if a < b:
|
||||||
|
a, b = b, a
|
||||||
|
answer = a - b
|
||||||
|
question = f"{a} - {b} = ?"
|
||||||
|
else:
|
||||||
|
answer = a * b
|
||||||
|
question = f"{a} x {b} = ?"
|
||||||
|
return question, answer
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
"""登录页面"""
|
||||||
|
if request.method == 'GET':
|
||||||
|
question, answer = generate_captcha()
|
||||||
|
session['captcha_answer'] = answer
|
||||||
|
return render_template('login.html', captcha_question=question)
|
||||||
|
|
||||||
|
# POST 处理登录
|
||||||
|
data = request.json or request.form
|
||||||
|
username = data.get('username', '').strip()
|
||||||
|
password = data.get('password', '')
|
||||||
|
captcha = data.get('captcha', '').strip()
|
||||||
|
|
||||||
|
# 验证码检查
|
||||||
|
correct_answer = session.get('captcha_answer')
|
||||||
|
if correct_answer is None or str(captcha) != str(correct_answer):
|
||||||
|
return jsonify({"ok": False, "message": "验证码错误"}), 400
|
||||||
|
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter_by(username=username).first()
|
||||||
|
if not user or not user.check_password(password):
|
||||||
|
# 重新生成验证码
|
||||||
|
question, answer = generate_captcha()
|
||||||
|
session['captcha_answer'] = answer
|
||||||
|
return jsonify({"ok": False, "message": "用户名或密码错误", "new_captcha": question}), 401
|
||||||
|
|
||||||
|
session['user_id'] = user.id
|
||||||
|
session['username'] = user.username
|
||||||
|
session.pop('captcha_answer', None)
|
||||||
|
return jsonify({"ok": True, "message": "登录成功"})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/logout')
|
||||||
|
def logout():
|
||||||
|
"""注销"""
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/refresh-captcha', methods=['POST'])
|
||||||
|
def refresh_captcha():
|
||||||
|
"""刷新验证码"""
|
||||||
|
question, answer = generate_captcha()
|
||||||
|
session['captcha_answer'] = answer
|
||||||
|
return jsonify({"ok": True, "question": question})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
"""控制台页面"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
machines = db.query(ProxyMachine).order_by(ProxyMachine.name.asc()).all()
|
||||||
|
# 获取每台机器的调度状态
|
||||||
|
machines_status = []
|
||||||
|
for m in machines:
|
||||||
|
status = get_machine_scheduler_status(m.id)
|
||||||
|
machines_status.append({
|
||||||
|
"machine": m,
|
||||||
|
"scheduler": status
|
||||||
|
})
|
||||||
|
return render_template('dashboard.html', machines_status=machines_status)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/run-ip-change/<int:machine_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_run_ip_change(machine_id):
|
||||||
|
"""执行指定机器的 IP 更换"""
|
||||||
|
result = run_ip_change_for_machine(machine_id)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/toggle-auto/<int:machine_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_toggle_auto(machine_id):
|
||||||
|
"""切换指定机器的自动任务状态"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
machine = db.query(ProxyMachine).filter_by(id=machine_id).first()
|
||||||
|
if not machine:
|
||||||
|
return jsonify({"ok": False, "message": "机器不存在"}), 404
|
||||||
|
|
||||||
|
if machine.auto_enabled:
|
||||||
|
machine.auto_enabled = False
|
||||||
|
db.commit()
|
||||||
|
stop_machine_auto(machine_id)
|
||||||
|
return jsonify({"ok": True, "running": False, "message": f"已停止 {machine.name} 的自动任务"})
|
||||||
|
else:
|
||||||
|
machine.auto_enabled = True
|
||||||
|
db.commit()
|
||||||
|
start_machine_auto(machine_id, machine.change_interval_seconds)
|
||||||
|
return jsonify({"ok": True, "running": True, "message": f"已启动 {machine.name} 的自动任务"})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/settings')
|
||||||
|
@login_required
|
||||||
|
def settings():
|
||||||
|
"""配置页面"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
config = ensure_singleton_config(db)
|
||||||
|
return render_template('settings.html', config=config, mask_secret=mask_secret)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/settings', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_save_settings():
|
||||||
|
"""保存全局配置"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
config = ensure_singleton_config(db)
|
||||||
|
data = request.json
|
||||||
|
|
||||||
|
# AWS 凭证
|
||||||
|
if data.get('aws_access_key', '').strip():
|
||||||
|
config.aws_access_key = data['aws_access_key'].strip()
|
||||||
|
if data.get('aws_secret_key', '').strip():
|
||||||
|
config.aws_secret_key = data['aws_secret_key'].strip()
|
||||||
|
|
||||||
|
# Cloudflare 设置
|
||||||
|
config.cloudflare_auth_type = data.get('cloudflare_auth_type', 'api_token')
|
||||||
|
|
||||||
|
if config.cloudflare_auth_type == 'api_token':
|
||||||
|
if data.get('cf_api_token', '').strip():
|
||||||
|
config.cf_api_token = data['cf_api_token'].strip()
|
||||||
|
else:
|
||||||
|
config.cf_email = data.get('cf_email', '').strip() or None
|
||||||
|
if data.get('cf_api_key', '').strip():
|
||||||
|
config.cf_api_key = data['cf_api_key'].strip()
|
||||||
|
|
||||||
|
config.release_old_eip = data.get('release_old_eip', True)
|
||||||
|
|
||||||
|
# SMTP 配置
|
||||||
|
if data.get('smtp_host', '').strip():
|
||||||
|
config.smtp_host = data['smtp_host'].strip()
|
||||||
|
config.smtp_port = data.get('smtp_port', 587)
|
||||||
|
if data.get('smtp_user', '').strip():
|
||||||
|
config.smtp_user = data['smtp_user'].strip()
|
||||||
|
if data.get('smtp_password', '').strip():
|
||||||
|
config.smtp_password = data['smtp_password'].strip()
|
||||||
|
config.smtp_use_tls = data.get('smtp_use_tls', True)
|
||||||
|
if data.get('alert_email', '').strip():
|
||||||
|
config.alert_email = data['alert_email'].strip()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return jsonify({"ok": True, "message": "配置已更新"})
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
return jsonify({"ok": False, "message": str(e)}), 400
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/machines')
|
||||||
|
@login_required
|
||||||
|
def machines():
|
||||||
|
"""节点管理页面"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
machines_list = db.query(ProxyMachine).order_by(ProxyMachine.name.asc()).all()
|
||||||
|
return render_template('machines.html', machines=machines_list)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/machines', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_add_machine():
|
||||||
|
"""添加节点"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
aws_service = data.get('aws_service', 'ec2')
|
||||||
|
aws_region = data.get('aws_region', '').strip()
|
||||||
|
instance_id = data.get('instance_id', '').strip()
|
||||||
|
note = data.get('note', '').strip()
|
||||||
|
enabled = data.get('enabled', True)
|
||||||
|
|
||||||
|
# 域名配置
|
||||||
|
cf_zone_id = data.get('cf_zone_id', '').strip()
|
||||||
|
cf_record_name = data.get('cf_record_name', '').strip()
|
||||||
|
cf_proxied = data.get('cf_proxied', False)
|
||||||
|
|
||||||
|
# 时间配置
|
||||||
|
change_interval_minutes = int(data.get('change_interval_minutes', 60))
|
||||||
|
auto_enabled = data.get('auto_enabled', False)
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
errors.append("节点名称不能为空")
|
||||||
|
if not is_valid_aws_region(normalize_aws_region(aws_region)):
|
||||||
|
errors.append("AWS 区域格式不正确")
|
||||||
|
if not instance_id:
|
||||||
|
errors.append("实例 ID 不能为空")
|
||||||
|
elif aws_service == "ec2" and not _INSTANCE_ID_RE.match(instance_id):
|
||||||
|
errors.append("EC2 Instance ID 格式不正确")
|
||||||
|
elif aws_service == "lightsail" and not _LIGHTSAIL_INSTANCE_NAME_RE.match(instance_id):
|
||||||
|
errors.append("Lightsail 实例名格式不正确")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return jsonify({"ok": False, "errors": errors}), 400
|
||||||
|
|
||||||
|
machine = ProxyMachine(
|
||||||
|
name=name,
|
||||||
|
aws_service=aws_service,
|
||||||
|
aws_region=normalize_aws_region(aws_region),
|
||||||
|
aws_instance_id=instance_id,
|
||||||
|
note=note or None,
|
||||||
|
enabled=enabled,
|
||||||
|
cf_zone_id=cf_zone_id or None,
|
||||||
|
cf_record_name=cf_record_name or None,
|
||||||
|
cf_proxied=cf_proxied,
|
||||||
|
change_interval_seconds=max(60, change_interval_minutes * 60),
|
||||||
|
auto_enabled=auto_enabled,
|
||||||
|
traffic_alert_enabled=data.get('traffic_alert_enabled', False),
|
||||||
|
traffic_alert_limit_gb=data.get('traffic_alert_limit_gb'),
|
||||||
|
)
|
||||||
|
db.add(machine)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 如果启用了自动任务,启动调度
|
||||||
|
if auto_enabled:
|
||||||
|
start_machine_auto(machine.id, machine.change_interval_seconds)
|
||||||
|
|
||||||
|
return jsonify({"ok": True, "message": "节点添加成功", "id": machine.id})
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
return jsonify({"ok": False, "message": str(e)}), 400
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/machines/<int:machine_id>', methods=['PUT'])
|
||||||
|
@login_required
|
||||||
|
def api_update_machine(machine_id):
|
||||||
|
"""更新节点"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
machine = db.query(ProxyMachine).filter_by(id=machine_id).first()
|
||||||
|
if not machine:
|
||||||
|
return jsonify({"ok": False, "message": "节点不存在"}), 404
|
||||||
|
|
||||||
|
data = request.json
|
||||||
|
|
||||||
|
machine.name = data.get('name', machine.name).strip()
|
||||||
|
machine.aws_service = data.get('aws_service', machine.aws_service)
|
||||||
|
machine.aws_region = normalize_aws_region(data.get('aws_region', machine.aws_region))
|
||||||
|
machine.aws_instance_id = data.get('instance_id', machine.aws_instance_id).strip()
|
||||||
|
machine.note = data.get('note', '').strip() or None
|
||||||
|
machine.enabled = data.get('enabled', machine.enabled)
|
||||||
|
|
||||||
|
# 域名配置
|
||||||
|
machine.cf_zone_id = data.get('cf_zone_id', '').strip() or None
|
||||||
|
machine.cf_record_name = data.get('cf_record_name', '').strip() or None
|
||||||
|
machine.cf_proxied = data.get('cf_proxied', machine.cf_proxied)
|
||||||
|
|
||||||
|
# 时间配置
|
||||||
|
change_interval_minutes = int(data.get('change_interval_minutes', machine.change_interval_seconds // 60))
|
||||||
|
machine.change_interval_seconds = max(60, change_interval_minutes * 60)
|
||||||
|
|
||||||
|
old_auto_enabled = machine.auto_enabled
|
||||||
|
machine.auto_enabled = data.get('auto_enabled', machine.auto_enabled)
|
||||||
|
|
||||||
|
# 流量预警配置
|
||||||
|
machine.traffic_alert_enabled = data.get('traffic_alert_enabled', machine.traffic_alert_enabled)
|
||||||
|
machine.traffic_alert_limit_gb = data.get('traffic_alert_limit_gb')
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 更新调度器
|
||||||
|
if machine.auto_enabled and not old_auto_enabled:
|
||||||
|
start_machine_auto(machine.id, machine.change_interval_seconds)
|
||||||
|
elif not machine.auto_enabled and old_auto_enabled:
|
||||||
|
stop_machine_auto(machine.id)
|
||||||
|
elif machine.auto_enabled:
|
||||||
|
# 更新间隔
|
||||||
|
stop_machine_auto(machine.id)
|
||||||
|
start_machine_auto(machine.id, machine.change_interval_seconds)
|
||||||
|
|
||||||
|
return jsonify({"ok": True, "message": "节点已更新"})
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
return jsonify({"ok": False, "message": str(e)}), 400
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/machines/<int:machine_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
def api_delete_machine(machine_id):
|
||||||
|
"""删除节点"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
machine = db.query(ProxyMachine).filter_by(id=machine_id).first()
|
||||||
|
if not machine:
|
||||||
|
return jsonify({"ok": False, "message": "节点不存在"}), 404
|
||||||
|
|
||||||
|
# 停止调度
|
||||||
|
stop_machine_auto(machine_id)
|
||||||
|
|
||||||
|
db.delete(machine)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return jsonify({"ok": True, "message": "节点已删除"})
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
return jsonify({"ok": False, "message": str(e)}), 400
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/logs')
|
||||||
|
@login_required
|
||||||
|
def logs():
|
||||||
|
"""日志查看页面"""
|
||||||
|
return render_template('logs.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/logs')
|
||||||
|
@login_required
|
||||||
|
def api_get_logs():
|
||||||
|
"""获取日志内容"""
|
||||||
|
lines_count = request.args.get('lines', 100, type=int)
|
||||||
|
lines_count = max(50, min(500, lines_count))
|
||||||
|
|
||||||
|
log_file = LOG_DIR / "proxyauto.log"
|
||||||
|
|
||||||
|
if not log_file.exists():
|
||||||
|
return jsonify({"ok": True, "content": "", "empty": True})
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
lines = f.read().splitlines()
|
||||||
|
|
||||||
|
display_lines = lines[-lines_count:]
|
||||||
|
return jsonify({"ok": True, "content": "\n".join(display_lines), "empty": len(display_lines) == 0})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/status')
|
||||||
|
@login_required
|
||||||
|
def api_status():
|
||||||
|
"""获取系统状态"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
machines = db.query(ProxyMachine).all()
|
||||||
|
machines_data = []
|
||||||
|
for m in machines:
|
||||||
|
status = get_machine_scheduler_status(m.id)
|
||||||
|
machines_data.append({
|
||||||
|
"id": m.id,
|
||||||
|
"name": m.name,
|
||||||
|
"enabled": m.enabled,
|
||||||
|
"auto_enabled": m.auto_enabled,
|
||||||
|
"current_ip": m.current_ip,
|
||||||
|
"last_run_at": m.last_run_at.isoformat() if m.last_run_at else None,
|
||||||
|
"last_success": m.last_success,
|
||||||
|
"scheduler_running": status["running"],
|
||||||
|
"next_run_time": status["next_run_time"],
|
||||||
|
})
|
||||||
|
return jsonify({"ok": True, "machines": machines_data})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 流量监控相关路由 ============
|
||||||
|
|
||||||
|
@app.route('/traffic')
|
||||||
|
@login_required
|
||||||
|
def traffic():
|
||||||
|
"""流量监控页面"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
machines_list = db.query(ProxyMachine).filter_by(enabled=True).order_by(ProxyMachine.name.asc()).all()
|
||||||
|
return render_template('traffic.html', machines=machines_list)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/traffic/<int:machine_id>')
|
||||||
|
@login_required
|
||||||
|
def api_get_traffic(machine_id):
|
||||||
|
"""获取指定机器的流量数据"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
machine = db.query(ProxyMachine).filter_by(id=machine_id).first()
|
||||||
|
if not machine:
|
||||||
|
return jsonify({"ok": False, "message": "机器不存在"}), 404
|
||||||
|
|
||||||
|
config = ensure_singleton_config(db)
|
||||||
|
if not config.aws_access_key or not config.aws_secret_key:
|
||||||
|
return jsonify({"ok": False, "message": "AWS 凭证未配置"}), 400
|
||||||
|
|
||||||
|
# 解析日期参数
|
||||||
|
start_str = request.args.get('start')
|
||||||
|
end_str = request.args.get('end')
|
||||||
|
|
||||||
|
if start_str and end_str:
|
||||||
|
start_time = datetime.strptime(start_str, '%Y-%m-%d').replace(tzinfo=SHANGHAI_TZ)
|
||||||
|
end_time = datetime.strptime(end_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=SHANGHAI_TZ)
|
||||||
|
else:
|
||||||
|
# 默认查询当月
|
||||||
|
now = datetime.now(SHANGHAI_TZ)
|
||||||
|
start_time = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_time = now
|
||||||
|
|
||||||
|
# 根据时间跨度决定数据点间隔
|
||||||
|
days_diff = (end_time - start_time).days
|
||||||
|
if days_diff <= 1:
|
||||||
|
period = 300 # 5分钟
|
||||||
|
elif days_diff <= 7:
|
||||||
|
period = 3600 # 1小时
|
||||||
|
else:
|
||||||
|
period = 86400 # 1天
|
||||||
|
|
||||||
|
traffic_data = get_machine_traffic(
|
||||||
|
aws_service=machine.aws_service,
|
||||||
|
region=machine.aws_region,
|
||||||
|
instance_id=machine.aws_instance_id,
|
||||||
|
aws_access_key=config.aws_access_key,
|
||||||
|
aws_secret_key=config.aws_secret_key,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(traffic_data)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"ok": False, "message": f"日期格式错误: {str(e)}"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "message": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/traffic/<int:machine_id>/current')
|
||||||
|
@login_required
|
||||||
|
def api_get_current_traffic(machine_id):
|
||||||
|
"""获取当月流量摘要"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
machine = db.query(ProxyMachine).filter_by(id=machine_id).first()
|
||||||
|
if not machine:
|
||||||
|
return jsonify({"ok": False, "message": "机器不存在"}), 404
|
||||||
|
|
||||||
|
config = ensure_singleton_config(db)
|
||||||
|
if not config.aws_access_key or not config.aws_secret_key:
|
||||||
|
return jsonify({"ok": False, "message": "AWS 凭证未配置"}), 400
|
||||||
|
|
||||||
|
traffic_data = get_current_month_traffic(
|
||||||
|
aws_service=machine.aws_service,
|
||||||
|
region=machine.aws_region,
|
||||||
|
instance_id=machine.aws_instance_id,
|
||||||
|
aws_access_key=config.aws_access_key,
|
||||||
|
aws_secret_key=config.aws_secret_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
if traffic_data.get("ok"):
|
||||||
|
traffic_data["network_in_formatted"] = format_bytes(traffic_data["network_in"])
|
||||||
|
traffic_data["network_out_formatted"] = format_bytes(traffic_data["network_out"])
|
||||||
|
traffic_data["total_formatted"] = format_bytes(traffic_data["total"])
|
||||||
|
|
||||||
|
return jsonify(traffic_data)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/traffic/summary')
|
||||||
|
@login_required
|
||||||
|
def api_get_traffic_summary():
|
||||||
|
"""获取所有机器的流量汇总数据"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
machines = db.query(ProxyMachine).filter_by(enabled=True).all()
|
||||||
|
if not machines:
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"month": {"network_in": 0, "network_out": 0, "total": 0},
|
||||||
|
"day": {"network_in": 0, "network_out": 0, "total": 0},
|
||||||
|
"all_time": {"network_in": 0, "network_out": 0, "total": 0},
|
||||||
|
})
|
||||||
|
|
||||||
|
config = ensure_singleton_config(db)
|
||||||
|
if not config.aws_access_key or not config.aws_secret_key:
|
||||||
|
return jsonify({"ok": False, "message": "AWS 凭证未配置"}), 400
|
||||||
|
|
||||||
|
# 初始化汇总数据
|
||||||
|
month_total = {"network_in": 0, "network_out": 0, "total": 0}
|
||||||
|
day_total = {"network_in": 0, "network_out": 0, "total": 0}
|
||||||
|
all_time_total = {"network_in": 0, "network_out": 0, "total": 0}
|
||||||
|
|
||||||
|
for machine in machines:
|
||||||
|
# 获取当月流量
|
||||||
|
month_data = get_current_month_traffic(
|
||||||
|
aws_service=machine.aws_service,
|
||||||
|
region=machine.aws_region,
|
||||||
|
instance_id=machine.aws_instance_id,
|
||||||
|
aws_access_key=config.aws_access_key,
|
||||||
|
aws_secret_key=config.aws_secret_key,
|
||||||
|
)
|
||||||
|
if month_data.get("ok"):
|
||||||
|
month_total["network_in"] += month_data.get("network_in", 0)
|
||||||
|
month_total["network_out"] += month_data.get("network_out", 0)
|
||||||
|
month_total["total"] += month_data.get("total", 0)
|
||||||
|
|
||||||
|
# 获取当日流量
|
||||||
|
day_data = get_current_day_traffic(
|
||||||
|
aws_service=machine.aws_service,
|
||||||
|
region=machine.aws_region,
|
||||||
|
instance_id=machine.aws_instance_id,
|
||||||
|
aws_access_key=config.aws_access_key,
|
||||||
|
aws_secret_key=config.aws_secret_key,
|
||||||
|
)
|
||||||
|
if day_data.get("ok"):
|
||||||
|
day_total["network_in"] += day_data.get("network_in", 0)
|
||||||
|
day_total["network_out"] += day_data.get("network_out", 0)
|
||||||
|
day_total["total"] += day_data.get("total", 0)
|
||||||
|
|
||||||
|
# 获取历史总流量
|
||||||
|
all_time_data = get_all_time_traffic(
|
||||||
|
aws_service=machine.aws_service,
|
||||||
|
region=machine.aws_region,
|
||||||
|
instance_id=machine.aws_instance_id,
|
||||||
|
aws_access_key=config.aws_access_key,
|
||||||
|
aws_secret_key=config.aws_secret_key,
|
||||||
|
created_at=machine.created_at,
|
||||||
|
)
|
||||||
|
if all_time_data.get("ok"):
|
||||||
|
all_time_total["network_in"] += all_time_data.get("network_in", 0)
|
||||||
|
all_time_total["network_out"] += all_time_data.get("network_out", 0)
|
||||||
|
all_time_total["total"] += all_time_data.get("total", 0)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"month": {
|
||||||
|
"network_in": month_total["network_in"],
|
||||||
|
"network_out": month_total["network_out"],
|
||||||
|
"total": month_total["total"],
|
||||||
|
"network_in_formatted": format_bytes(month_total["network_in"]),
|
||||||
|
"network_out_formatted": format_bytes(month_total["network_out"]),
|
||||||
|
"total_formatted": format_bytes(month_total["total"]),
|
||||||
|
},
|
||||||
|
"day": {
|
||||||
|
"network_in": day_total["network_in"],
|
||||||
|
"network_out": day_total["network_out"],
|
||||||
|
"total": day_total["total"],
|
||||||
|
"network_in_formatted": format_bytes(day_total["network_in"]),
|
||||||
|
"network_out_formatted": format_bytes(day_total["network_out"]),
|
||||||
|
"total_formatted": format_bytes(day_total["total"]),
|
||||||
|
},
|
||||||
|
"all_time": {
|
||||||
|
"network_in": all_time_total["network_in"],
|
||||||
|
"network_out": all_time_total["network_out"],
|
||||||
|
"total": all_time_total["total"],
|
||||||
|
"network_in_formatted": format_bytes(all_time_total["network_in"]),
|
||||||
|
"network_out_formatted": format_bytes(all_time_total["network_out"]),
|
||||||
|
"total_formatted": format_bytes(all_time_total["total"]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "message": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/traffic/check-alerts', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_check_traffic_alerts():
|
||||||
|
"""手动触发流量预警检查"""
|
||||||
|
result = check_all_traffic_alerts()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/machines/<int:machine_id>/reset-alert', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_reset_machine_alert(machine_id):
|
||||||
|
"""重置机器的流量预警状态"""
|
||||||
|
result = reset_machine_alert(machine_id)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/test-email', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_test_email():
|
||||||
|
"""发送测试邮件"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
config = ensure_singleton_config(db)
|
||||||
|
if not config.smtp_host or not config.alert_email:
|
||||||
|
return jsonify({"ok": False, "message": "请先配置 SMTP 服务器和接收邮箱"}), 400
|
||||||
|
|
||||||
|
result = send_email(
|
||||||
|
smtp_host=config.smtp_host,
|
||||||
|
smtp_port=config.smtp_port or 587,
|
||||||
|
smtp_user=config.smtp_user,
|
||||||
|
smtp_password=config.smtp_password,
|
||||||
|
use_tls=config.smtp_use_tls,
|
||||||
|
to_email=config.alert_email,
|
||||||
|
subject="[ProxyAuto] 测试邮件",
|
||||||
|
body="这是一封来自 ProxyAuto Pro 的测试邮件。\n\n如果您收到此邮件,说明邮件配置正确。",
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# 启动流量预警定时检查任务
|
||||||
|
def _traffic_alert_job():
|
||||||
|
"""流量预警检查任务"""
|
||||||
|
check_all_traffic_alerts()
|
||||||
|
|
||||||
|
|
||||||
|
def init_traffic_alert_scheduler():
|
||||||
|
"""初始化流量预警定时任务"""
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
scheduler.add_job(
|
||||||
|
_traffic_alert_job,
|
||||||
|
"interval",
|
||||||
|
minutes=10, # 每10分钟检查一次
|
||||||
|
id="traffic_alert_check",
|
||||||
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 初始化流量预警调度
|
||||||
|
init_traffic_alert_scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=8501, debug=True)
|
||||||
BIN
data/proxyauto.sqlite3
Normal file
BIN
data/proxyauto.sqlite3
Normal file
Binary file not shown.
180
database.py
Normal file
180
database.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""数据库模型和配置管理"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, Float, create_engine
|
||||||
|
from sqlalchemy.orm import Session, declarative_base, relationship, sessionmaker
|
||||||
|
|
||||||
|
# 加载 .env 配置文件
|
||||||
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
|
load_dotenv(APP_DIR / ".env")
|
||||||
|
|
||||||
|
# 路径配置
|
||||||
|
DATA_DIR = APP_DIR / "data"
|
||||||
|
LOG_DIR = APP_DIR / "logs"
|
||||||
|
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
# MySQL 连接格式: mysql+pymysql://用户名:密码@主机:端口/数据库名?charset=utf8mb4
|
||||||
|
# 在 .env 文件中配置 DATABASE_URL
|
||||||
|
DEFAULT_MYSQL_URL = "mysql+pymysql://proxyauto:proxyauto@localhost:3306/proxyauto?charset=utf8mb4"
|
||||||
|
DATABASE_URL = os.environ.get("DATABASE_URL", DEFAULT_MYSQL_URL)
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
echo=False,
|
||||||
|
pool_pre_ping=True, # 自动重连
|
||||||
|
pool_recycle=3600, # 连接池回收时间
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""简单的密码哈希"""
|
||||||
|
return hashlib.sha256(password.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""用户表"""
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
username = Column(String(64), nullable=False, unique=True)
|
||||||
|
password_hash = Column(String(128), nullable=False)
|
||||||
|
is_admin = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
def check_password(self, password: str) -> bool:
|
||||||
|
return self.password_hash == hash_password(password)
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyMachine(Base):
|
||||||
|
"""代理机器表 - 每台机器可独立配置域名和更换时间"""
|
||||||
|
__tablename__ = "proxy_machines"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
name = Column(String(128), nullable=False, unique=True)
|
||||||
|
aws_service = Column(String(32), nullable=False, default="ec2") # ec2 | lightsail
|
||||||
|
aws_region = Column(String(64), nullable=False, default="us-east-1")
|
||||||
|
aws_instance_id = Column(String(64), nullable=False)
|
||||||
|
note = Column(String(255), nullable=True)
|
||||||
|
enabled = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
# 域名配置 - 每台机器独立绑定
|
||||||
|
cf_zone_id = Column(String(64), nullable=True)
|
||||||
|
cf_record_name = Column(String(255), nullable=True)
|
||||||
|
cf_record_id = Column(String(64), nullable=True)
|
||||||
|
cf_proxied = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# 独立的更换时间配置
|
||||||
|
change_interval_seconds = Column(Integer, nullable=False, default=3600)
|
||||||
|
auto_enabled = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# 当前状态
|
||||||
|
current_ip = Column(String(64), nullable=True)
|
||||||
|
last_run_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_success = Column(Boolean, nullable=True)
|
||||||
|
last_message = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# 流量预警配置
|
||||||
|
# Lightsail: 总流量预警(上传+下载),单位 GB
|
||||||
|
# EC2: 上传流量预警,单位 GB
|
||||||
|
traffic_alert_enabled = Column(Boolean, nullable=False, default=False)
|
||||||
|
traffic_alert_limit_gb = Column(Float, nullable=True) # 流量限制(GB)
|
||||||
|
traffic_alert_triggered = Column(Boolean, nullable=False, default=False) # 是否已触发预警
|
||||||
|
traffic_last_check_at = Column(DateTime(timezone=True), nullable=True) # 上次检查时间
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Config(Base):
|
||||||
|
"""全局配置表 - AWS/Cloudflare 凭证"""
|
||||||
|
__tablename__ = "configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
||||||
|
# AWS 凭证(全局共用)
|
||||||
|
aws_access_key = Column(String(255), nullable=True)
|
||||||
|
aws_secret_key = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# Cloudflare 凭证(全局共用)
|
||||||
|
cloudflare_auth_type = Column(String(32), nullable=False, default="api_token") # api_token | global_key
|
||||||
|
cf_api_token = Column(String(255), nullable=True)
|
||||||
|
cf_email = Column(String(255), nullable=True)
|
||||||
|
cf_api_key = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# 全局设置
|
||||||
|
release_old_eip = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
# 邮件通知配置
|
||||||
|
smtp_host = Column(String(255), nullable=True)
|
||||||
|
smtp_port = Column(Integer, nullable=True, default=587)
|
||||||
|
smtp_user = Column(String(255), nullable=True)
|
||||||
|
smtp_password = Column(String(255), nullable=True)
|
||||||
|
smtp_use_tls = Column(Boolean, nullable=False, default=True)
|
||||||
|
alert_email = Column(String(255), nullable=True) # 接收预警的邮箱
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
# 创建表
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session() -> Session:
|
||||||
|
return SessionLocal()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_singleton_config(session: Session) -> Config:
|
||||||
|
config = session.query(Config).order_by(Config.id.asc()).first()
|
||||||
|
if config:
|
||||||
|
return config
|
||||||
|
|
||||||
|
config = Config(
|
||||||
|
cloudflare_auth_type="api_token",
|
||||||
|
release_old_eip=True,
|
||||||
|
)
|
||||||
|
session.add(config)
|
||||||
|
session.commit()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_admin_user(session: Session) -> User:
|
||||||
|
"""确保管理员用户存在"""
|
||||||
|
admin = session.query(User).filter_by(username="admin").first()
|
||||||
|
if admin:
|
||||||
|
return admin
|
||||||
|
|
||||||
|
admin = User(
|
||||||
|
username="admin",
|
||||||
|
password_hash=hash_password("80012029Lz@"),
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
session.add(admin)
|
||||||
|
session.commit()
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
def mask_secret(value: Optional[str], keep_start: int = 3, keep_end: int = 3) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
text = value.strip()
|
||||||
|
if len(text) <= keep_start + keep_end + 2:
|
||||||
|
return "••••••"
|
||||||
|
return f"{text[:keep_start]}••••••{text[-keep_end:]}"
|
||||||
99
init_database.sql
Normal file
99
init_database.sql
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
-- ProxyAuto Pro MySQL 初始化脚本
|
||||||
|
-- 执行方式: mysql -u root -p < init_database.sql
|
||||||
|
|
||||||
|
-- 创建数据库
|
||||||
|
CREATE DATABASE IF NOT EXISTS proxyauto DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 创建用户并授权
|
||||||
|
CREATE USER IF NOT EXISTS 'proxyauto'@'localhost' IDENTIFIED BY 'proxyauto';
|
||||||
|
GRANT ALL PRIVILEGES ON proxyauto.* TO 'proxyauto'@'localhost';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
|
-- 使用数据库
|
||||||
|
USE proxyauto;
|
||||||
|
|
||||||
|
-- 用户表
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(128) NOT NULL,
|
||||||
|
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 代理机器表
|
||||||
|
CREATE TABLE IF NOT EXISTS proxy_machines (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL UNIQUE,
|
||||||
|
aws_service VARCHAR(32) NOT NULL DEFAULT 'ec2',
|
||||||
|
aws_region VARCHAR(64) NOT NULL DEFAULT 'us-east-1',
|
||||||
|
aws_instance_id VARCHAR(64) NOT NULL,
|
||||||
|
note VARCHAR(255) NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- 域名配置
|
||||||
|
cf_zone_id VARCHAR(64) NULL,
|
||||||
|
cf_record_name VARCHAR(255) NULL,
|
||||||
|
cf_record_id VARCHAR(64) NULL,
|
||||||
|
cf_proxied BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- 更换时间配置
|
||||||
|
change_interval_seconds INT NOT NULL DEFAULT 3600,
|
||||||
|
auto_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- 当前状态
|
||||||
|
current_ip VARCHAR(64) NULL,
|
||||||
|
last_run_at DATETIME(6) NULL,
|
||||||
|
last_success BOOLEAN NULL,
|
||||||
|
last_message TEXT NULL,
|
||||||
|
|
||||||
|
-- 流量预警配置
|
||||||
|
traffic_alert_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
traffic_alert_limit_gb FLOAT NULL,
|
||||||
|
traffic_alert_triggered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
traffic_last_check_at DATETIME(6) NULL,
|
||||||
|
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 全局配置表
|
||||||
|
CREATE TABLE IF NOT EXISTS configs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|
||||||
|
-- AWS 凭证
|
||||||
|
aws_access_key VARCHAR(255) NULL,
|
||||||
|
aws_secret_key VARCHAR(255) NULL,
|
||||||
|
|
||||||
|
-- Cloudflare 凭证
|
||||||
|
cloudflare_auth_type VARCHAR(32) NOT NULL DEFAULT 'api_token',
|
||||||
|
cf_api_token VARCHAR(255) NULL,
|
||||||
|
cf_email VARCHAR(255) NULL,
|
||||||
|
cf_api_key VARCHAR(255) NULL,
|
||||||
|
|
||||||
|
-- 全局设置
|
||||||
|
release_old_eip BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- 邮件通知配置
|
||||||
|
smtp_host VARCHAR(255) NULL,
|
||||||
|
smtp_port INT NULL DEFAULT 587,
|
||||||
|
smtp_user VARCHAR(255) NULL,
|
||||||
|
smtp_password VARCHAR(255) NULL,
|
||||||
|
smtp_use_tls BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
alert_email VARCHAR(255) NULL,
|
||||||
|
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 插入默认管理员用户 (密码: 80012029Lz@)
|
||||||
|
INSERT INTO users (username, password_hash, is_admin) VALUES
|
||||||
|
('admin', 'cd9989255736652cd0161653ab79fed5f7cc3dc9308b61045106e78e073202ae', TRUE)
|
||||||
|
ON DUPLICATE KEY UPDATE username=username;
|
||||||
|
|
||||||
|
-- 插入默认配置
|
||||||
|
INSERT INTO configs (cloudflare_auth_type, release_old_eip) VALUES ('api_token', TRUE)
|
||||||
|
ON DUPLICATE KEY UPDATE id=id;
|
||||||
|
|
||||||
|
SELECT 'ProxyAuto 数据库初始化完成!' AS message;
|
||||||
91
init_tables.sql
Normal file
91
init_tables.sql
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
-- ProxyAuto Pro MySQL 表结构初始化
|
||||||
|
-- 注意: 需要先确保数据库 proxyauto 已存在,并且有权限操作
|
||||||
|
|
||||||
|
-- 使用数据库
|
||||||
|
USE proxyauto;
|
||||||
|
|
||||||
|
-- 用户表
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(128) NOT NULL,
|
||||||
|
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 代理机器表
|
||||||
|
CREATE TABLE IF NOT EXISTS proxy_machines (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL UNIQUE,
|
||||||
|
aws_service VARCHAR(32) NOT NULL DEFAULT 'ec2',
|
||||||
|
aws_region VARCHAR(64) NOT NULL DEFAULT 'us-east-1',
|
||||||
|
aws_instance_id VARCHAR(64) NOT NULL,
|
||||||
|
note VARCHAR(255) NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- 域名配置
|
||||||
|
cf_zone_id VARCHAR(64) NULL,
|
||||||
|
cf_record_name VARCHAR(255) NULL,
|
||||||
|
cf_record_id VARCHAR(64) NULL,
|
||||||
|
cf_proxied BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- 更换时间配置
|
||||||
|
change_interval_seconds INT NOT NULL DEFAULT 3600,
|
||||||
|
auto_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- 当前状态
|
||||||
|
current_ip VARCHAR(64) NULL,
|
||||||
|
last_run_at DATETIME(6) NULL,
|
||||||
|
last_success BOOLEAN NULL,
|
||||||
|
last_message TEXT NULL,
|
||||||
|
|
||||||
|
-- 流量预警配置
|
||||||
|
traffic_alert_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
traffic_alert_limit_gb FLOAT NULL,
|
||||||
|
traffic_alert_triggered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
traffic_last_check_at DATETIME(6) NULL,
|
||||||
|
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 全局配置表
|
||||||
|
CREATE TABLE IF NOT EXISTS configs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|
||||||
|
-- AWS 凭证
|
||||||
|
aws_access_key VARCHAR(255) NULL,
|
||||||
|
aws_secret_key VARCHAR(255) NULL,
|
||||||
|
|
||||||
|
-- Cloudflare 凭证
|
||||||
|
cloudflare_auth_type VARCHAR(32) NOT NULL DEFAULT 'api_token',
|
||||||
|
cf_api_token VARCHAR(255) NULL,
|
||||||
|
cf_email VARCHAR(255) NULL,
|
||||||
|
cf_api_key VARCHAR(255) NULL,
|
||||||
|
|
||||||
|
-- 全局设置
|
||||||
|
release_old_eip BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- 邮件通知配置
|
||||||
|
smtp_host VARCHAR(255) NULL,
|
||||||
|
smtp_port INT NULL DEFAULT 587,
|
||||||
|
smtp_user VARCHAR(255) NULL,
|
||||||
|
smtp_password VARCHAR(255) NULL,
|
||||||
|
smtp_use_tls BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
alert_email VARCHAR(255) NULL,
|
||||||
|
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 插入默认管理员用户 (密码: 80012029Lz@)
|
||||||
|
INSERT INTO users (username, password_hash, is_admin) VALUES
|
||||||
|
('admin', 'cd9989255736652cd0161653ab79fed5f7cc3dc9308b61045106e78e073202ae', TRUE)
|
||||||
|
ON DUPLICATE KEY UPDATE username=username;
|
||||||
|
|
||||||
|
-- 插入默认配置
|
||||||
|
INSERT INTO configs (cloudflare_auth_type, release_old_eip) VALUES ('api_token', TRUE)
|
||||||
|
ON DUPLICATE KEY UPDATE id=id;
|
||||||
|
|
||||||
|
SELECT 'ProxyAuto 表结构初始化完成!' AS message;
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
SQLAlchemy>=2.0.0
|
||||||
|
APScheduler>=3.10.4
|
||||||
|
boto3>=1.35.0
|
||||||
|
requests>=2.32.0
|
||||||
1
services/__init__.py
Normal file
1
services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Services 包"""
|
||||||
104
services/aws_eip.py
Normal file
104
services/aws_eip.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""AWS EC2 Elastic IP 操作"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from .aws_region import normalize_aws_region
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ElasticIpInfo:
|
||||||
|
allocation_id: str
|
||||||
|
association_id: str | None
|
||||||
|
public_ip: str | None
|
||||||
|
|
||||||
|
|
||||||
|
_INSTANCE_ID_RE = re.compile(r"^i-[0-9a-f]{8,17}$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_instance_id(value: str | None) -> bool:
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
return bool(_INSTANCE_ID_RE.match(value.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def create_ec2_client(
|
||||||
|
*,
|
||||||
|
region: str,
|
||||||
|
aws_access_key: str | None,
|
||||||
|
aws_secret_key: str | None,
|
||||||
|
):
|
||||||
|
kwargs: dict[str, Any] = {"region_name": normalize_aws_region(region)}
|
||||||
|
if aws_access_key and aws_secret_key:
|
||||||
|
kwargs["aws_access_key_id"] = aws_access_key
|
||||||
|
kwargs["aws_secret_access_key"] = aws_secret_key
|
||||||
|
return boto3.client("ec2", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def get_instance_elastic_ip(ec2_client, *, instance_id: str) -> ElasticIpInfo | None:
|
||||||
|
resp = ec2_client.describe_addresses(
|
||||||
|
Filters=[{"Name": "instance-id", "Values": [instance_id]}]
|
||||||
|
)
|
||||||
|
addresses = resp.get("Addresses") or []
|
||||||
|
if not addresses:
|
||||||
|
return None
|
||||||
|
|
||||||
|
addr = addresses[0]
|
||||||
|
allocation_id = addr.get("AllocationId")
|
||||||
|
if not allocation_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return ElasticIpInfo(
|
||||||
|
allocation_id=allocation_id,
|
||||||
|
association_id=addr.get("AssociationId"),
|
||||||
|
public_ip=addr.get("PublicIp"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def disassociate_elastic_ip(ec2_client, *, association_id: str) -> None:
|
||||||
|
ec2_client.disassociate_address(AssociationId=association_id)
|
||||||
|
|
||||||
|
|
||||||
|
def release_elastic_ip(ec2_client, *, allocation_id: str) -> None:
|
||||||
|
ec2_client.release_address(AllocationId=allocation_id)
|
||||||
|
|
||||||
|
|
||||||
|
def allocate_elastic_ip(ec2_client) -> ElasticIpInfo:
|
||||||
|
resp = ec2_client.allocate_address(Domain="vpc")
|
||||||
|
return ElasticIpInfo(
|
||||||
|
allocation_id=resp["AllocationId"],
|
||||||
|
association_id=None,
|
||||||
|
public_ip=resp.get("PublicIp"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def associate_elastic_ip(ec2_client, *, instance_id: str, allocation_id: str) -> str:
|
||||||
|
resp = ec2_client.associate_address(InstanceId=instance_id, AllocationId=allocation_id)
|
||||||
|
return resp.get("AssociationId") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_elastic_ip(
|
||||||
|
ec2_client,
|
||||||
|
*,
|
||||||
|
instance_id: str,
|
||||||
|
release_old: bool,
|
||||||
|
) -> dict[str, str | None]:
|
||||||
|
current = get_instance_elastic_ip(ec2_client, instance_id=instance_id)
|
||||||
|
|
||||||
|
if current and current.association_id:
|
||||||
|
disassociate_elastic_ip(ec2_client, association_id=current.association_id)
|
||||||
|
|
||||||
|
if current and release_old:
|
||||||
|
release_elastic_ip(ec2_client, allocation_id=current.allocation_id)
|
||||||
|
|
||||||
|
new_eip = allocate_elastic_ip(ec2_client)
|
||||||
|
associate_elastic_ip(ec2_client, instance_id=instance_id, allocation_id=new_eip.allocation_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"public_ip": new_eip.public_ip,
|
||||||
|
"allocation_id": new_eip.allocation_id,
|
||||||
|
}
|
||||||
24
services/aws_region.py
Normal file
24
services/aws_region.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""AWS 区域验证"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
_AWS_REGION_RE = re.compile(r"^[a-z]{2}(-gov)?-[a-z]+-\d+$")
|
||||||
|
_AWS_AZ_RE = re.compile(r"^([a-z]{2}(-gov)?-[a-z]+-\d+)[a-z]$")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_aws_region(value: str | None) -> str:
|
||||||
|
raw = (value or "").strip().lower()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
m = _AWS_AZ_RE.match(raw)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_aws_region(value: str | None) -> bool:
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
return bool(_AWS_REGION_RE.match(value.strip().lower()))
|
||||||
161
services/cloudflare_dns.py
Normal file
161
services/cloudflare_dns.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Cloudflare DNS API 封装"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
API_BASE = "https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CloudflareAuth:
|
||||||
|
auth_type: str # api_token | global_key
|
||||||
|
api_token: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
api_key: str | None = None
|
||||||
|
|
||||||
|
def headers(self) -> dict[str, str]:
|
||||||
|
if self.auth_type == "api_token":
|
||||||
|
if not self.api_token:
|
||||||
|
raise ValueError("Cloudflare API Token 不能为空")
|
||||||
|
return {"Authorization": f"Bearer {self.api_token}"}
|
||||||
|
|
||||||
|
if self.auth_type == "global_key":
|
||||||
|
if not self.email or not self.api_key:
|
||||||
|
raise ValueError("Cloudflare Email / Global API Key 不能为空")
|
||||||
|
return {"X-Auth-Email": self.email, "X-Auth-Key": self.api_key}
|
||||||
|
|
||||||
|
raise ValueError("cloudflare_auth_type 只能是 api_token 或 global_key")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_response(resp: requests.Response) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
resp.raise_for_status()
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not resp.ok or not data.get("success"):
|
||||||
|
errors = data.get("errors") or []
|
||||||
|
message = errors[0].get("message") if errors else f"Cloudflare API 请求失败: {resp.status_code}"
|
||||||
|
raise RuntimeError(message)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
auth: CloudflareAuth,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
timeout_seconds: int = 15,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
url = f"{API_BASE}{path}"
|
||||||
|
headers = {"Content-Type": "application/json", **auth.headers()}
|
||||||
|
resp = requests.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
json=json,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
)
|
||||||
|
return _check_response(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def find_a_record(
|
||||||
|
*,
|
||||||
|
zone_id: str,
|
||||||
|
record_name: str,
|
||||||
|
auth: CloudflareAuth,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
data = _request(
|
||||||
|
"GET",
|
||||||
|
f"/zones/{zone_id}/dns_records",
|
||||||
|
auth=auth,
|
||||||
|
params={"type": "A", "name": record_name},
|
||||||
|
)
|
||||||
|
result = data.get("result") or []
|
||||||
|
return result[0] if result else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_a_record(
|
||||||
|
*,
|
||||||
|
zone_id: str,
|
||||||
|
record_id: str,
|
||||||
|
record_name: str,
|
||||||
|
ip: str,
|
||||||
|
proxied: bool,
|
||||||
|
auth: CloudflareAuth,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
data = _request(
|
||||||
|
"PUT",
|
||||||
|
f"/zones/{zone_id}/dns_records/{record_id}",
|
||||||
|
auth=auth,
|
||||||
|
json={"type": "A", "name": record_name, "content": ip, "proxied": proxied},
|
||||||
|
)
|
||||||
|
return data["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def create_a_record(
|
||||||
|
*,
|
||||||
|
zone_id: str,
|
||||||
|
record_name: str,
|
||||||
|
ip: str,
|
||||||
|
proxied: bool,
|
||||||
|
auth: CloudflareAuth,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
data = _request(
|
||||||
|
"POST",
|
||||||
|
f"/zones/{zone_id}/dns_records",
|
||||||
|
auth=auth,
|
||||||
|
json={"type": "A", "name": record_name, "content": ip, "proxied": proxied},
|
||||||
|
)
|
||||||
|
return data["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_a_record(
|
||||||
|
*,
|
||||||
|
zone_id: str,
|
||||||
|
record_name: str,
|
||||||
|
ip: str,
|
||||||
|
proxied: bool,
|
||||||
|
record_id: str | None,
|
||||||
|
auth: CloudflareAuth,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if record_id:
|
||||||
|
try:
|
||||||
|
return update_a_record(
|
||||||
|
zone_id=zone_id,
|
||||||
|
record_id=record_id,
|
||||||
|
record_name=record_name,
|
||||||
|
ip=ip,
|
||||||
|
proxied=proxied,
|
||||||
|
auth=auth,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
record = find_a_record(zone_id=zone_id, record_name=record_name, auth=auth)
|
||||||
|
if record:
|
||||||
|
return update_a_record(
|
||||||
|
zone_id=zone_id,
|
||||||
|
record_id=record["id"],
|
||||||
|
record_name=record_name,
|
||||||
|
ip=ip,
|
||||||
|
proxied=proxied,
|
||||||
|
auth=auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
return create_a_record(
|
||||||
|
zone_id=zone_id,
|
||||||
|
record_name=record_name,
|
||||||
|
ip=ip,
|
||||||
|
proxied=proxied,
|
||||||
|
auth=auth,
|
||||||
|
)
|
||||||
197
services/email_service.py
Normal file
197
services/email_service.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""邮件发送服务"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(
|
||||||
|
smtp_host: str,
|
||||||
|
smtp_port: int,
|
||||||
|
smtp_user: str,
|
||||||
|
smtp_password: str,
|
||||||
|
use_tls: bool,
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
html_body: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
发送邮件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
smtp_host: SMTP 服务器地址
|
||||||
|
smtp_port: SMTP 端口
|
||||||
|
smtp_user: SMTP 用户名
|
||||||
|
smtp_password: SMTP 密码
|
||||||
|
use_tls: 是否使用 TLS
|
||||||
|
to_email: 收件人邮箱
|
||||||
|
subject: 邮件主题
|
||||||
|
body: 纯文本内容
|
||||||
|
html_body: HTML 内容(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"ok": True/False, "message": "..."}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 创建邮件
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = smtp_user
|
||||||
|
msg["To"] = to_email
|
||||||
|
|
||||||
|
# 添加纯文本内容
|
||||||
|
part1 = MIMEText(body, "plain", "utf-8")
|
||||||
|
msg.attach(part1)
|
||||||
|
|
||||||
|
# 添加 HTML 内容
|
||||||
|
if html_body:
|
||||||
|
part2 = MIMEText(html_body, "html", "utf-8")
|
||||||
|
msg.attach(part2)
|
||||||
|
|
||||||
|
# 连接 SMTP 服务器
|
||||||
|
if use_tls:
|
||||||
|
server = smtplib.SMTP(smtp_host, smtp_port, timeout=30)
|
||||||
|
server.starttls()
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=30)
|
||||||
|
|
||||||
|
server.login(smtp_user, smtp_password)
|
||||||
|
server.sendmail(smtp_user, [to_email], msg.as_string())
|
||||||
|
server.quit()
|
||||||
|
|
||||||
|
logger.info("Email sent successfully to %s: %s", to_email, subject)
|
||||||
|
return {"ok": True, "message": "邮件发送成功"}
|
||||||
|
|
||||||
|
except smtplib.SMTPAuthenticationError as e:
|
||||||
|
logger.error("SMTP authentication failed: %s", e)
|
||||||
|
return {"ok": False, "message": "SMTP 认证失败,请检查用户名和密码"}
|
||||||
|
except smtplib.SMTPConnectError as e:
|
||||||
|
logger.error("SMTP connection failed: %s", e)
|
||||||
|
return {"ok": False, "message": "无法连接到 SMTP 服务器"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to send email")
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def send_traffic_alert_email(
|
||||||
|
smtp_host: str,
|
||||||
|
smtp_port: int,
|
||||||
|
smtp_user: str,
|
||||||
|
smtp_password: str,
|
||||||
|
use_tls: bool,
|
||||||
|
to_email: str,
|
||||||
|
machine_name: str,
|
||||||
|
aws_service: str,
|
||||||
|
current_traffic_gb: float,
|
||||||
|
limit_gb: float,
|
||||||
|
traffic_type: str, # "total" or "upload"
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
发送流量预警邮件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
machine_name: 机器名称
|
||||||
|
aws_service: 服务类型(ec2/lightsail)
|
||||||
|
current_traffic_gb: 当前流量(GB)
|
||||||
|
limit_gb: 限制流量(GB)
|
||||||
|
traffic_type: 流量类型(total=总流量, upload=上传流量)
|
||||||
|
"""
|
||||||
|
if traffic_type == "total":
|
||||||
|
traffic_desc = "总流量(上传+下载)"
|
||||||
|
else:
|
||||||
|
traffic_desc = "上传流量"
|
||||||
|
|
||||||
|
subject = f"[ProxyAuto] 流量预警 - {machine_name}"
|
||||||
|
|
||||||
|
body = f"""
|
||||||
|
流量预警通知
|
||||||
|
|
||||||
|
机器名称: {machine_name}
|
||||||
|
服务类型: {aws_service.upper()}
|
||||||
|
预警类型: {traffic_desc}超限
|
||||||
|
|
||||||
|
当前{traffic_desc}: {current_traffic_gb:.2f} GB
|
||||||
|
设定限制: {limit_gb:.2f} GB
|
||||||
|
超出: {current_traffic_gb - limit_gb:.2f} GB
|
||||||
|
|
||||||
|
系统已自动暂停该机器的 IP 自动更换任务。
|
||||||
|
|
||||||
|
---
|
||||||
|
ProxyAuto Pro 自动通知
|
||||||
|
"""
|
||||||
|
|
||||||
|
html_body = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.6; color: #333; }}
|
||||||
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||||
|
.header {{ background: linear-gradient(135deg, #1F6BFF, #3D8BFF); color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
|
||||||
|
.content {{ background: #f9f9f9; padding: 20px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
|
||||||
|
.alert {{ background: #FEE2E2; border-left: 4px solid #EF4444; padding: 15px; margin: 15px 0; border-radius: 4px; }}
|
||||||
|
.info-row {{ display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e0e0e0; }}
|
||||||
|
.info-label {{ color: #666; }}
|
||||||
|
.info-value {{ font-weight: 600; }}
|
||||||
|
.footer {{ text-align: center; padding: 15px; color: #999; font-size: 12px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h2 style="margin: 0;">流量预警通知</h2>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="alert">
|
||||||
|
<strong>警告:</strong>{traffic_desc}已超出设定限制!
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">机器名称</span>
|
||||||
|
<span class="info-value">{machine_name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">服务类型</span>
|
||||||
|
<span class="info-value">{aws_service.upper()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">当前{traffic_desc}</span>
|
||||||
|
<span class="info-value" style="color: #EF4444;">{current_traffic_gb:.2f} GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">设定限制</span>
|
||||||
|
<span class="info-value">{limit_gb:.2f} GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">超出流量</span>
|
||||||
|
<span class="info-value" style="color: #EF4444;">{current_traffic_gb - limit_gb:.2f} GB</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 20px; color: #666;">
|
||||||
|
系统已自动暂停该机器的 IP 自动更换任务。请登录控制台查看详情。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
ProxyAuto Pro 自动通知
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return send_email(
|
||||||
|
smtp_host=smtp_host,
|
||||||
|
smtp_port=smtp_port,
|
||||||
|
smtp_user=smtp_user,
|
||||||
|
smtp_password=smtp_password,
|
||||||
|
use_tls=use_tls,
|
||||||
|
to_email=to_email,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
html_body=html_body,
|
||||||
|
)
|
||||||
152
services/ip_change.py
Normal file
152
services/ip_change.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""IP 更换核心逻辑"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from database import Config, ProxyMachine, get_session, ensure_singleton_config
|
||||||
|
from services.aws_eip import create_ec2_client, is_valid_instance_id, rotate_elastic_ip
|
||||||
|
from services.lightsail_static_ip import (
|
||||||
|
create_lightsail_client,
|
||||||
|
is_valid_lightsail_instance_name,
|
||||||
|
rotate_lightsail_static_ip,
|
||||||
|
)
|
||||||
|
from services.cloudflare_dns import CloudflareAuth, upsert_a_record
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cf_auth(config: Config) -> CloudflareAuth:
|
||||||
|
auth_type = (config.cloudflare_auth_type or "").strip() or "api_token"
|
||||||
|
if auth_type == "api_token":
|
||||||
|
return CloudflareAuth(auth_type="api_token", api_token=config.cf_api_token)
|
||||||
|
return CloudflareAuth(
|
||||||
|
auth_type="global_key",
|
||||||
|
email=config.cf_email,
|
||||||
|
api_key=config.cf_api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def change_ip_for_machine(machine: ProxyMachine, config: Config) -> dict[str, str]:
|
||||||
|
"""为指定机器更换 IP 并更新 DNS"""
|
||||||
|
|
||||||
|
if not machine.enabled:
|
||||||
|
raise ValueError(f"机器 {machine.name} 已被禁用")
|
||||||
|
|
||||||
|
aws_service = (machine.aws_service or "ec2").strip().lower()
|
||||||
|
|
||||||
|
# 执行 IP 轮换
|
||||||
|
if aws_service == "lightsail":
|
||||||
|
if not is_valid_lightsail_instance_name(machine.aws_instance_id):
|
||||||
|
raise ValueError(
|
||||||
|
f"Lightsail 实例名格式不正确:{machine.aws_instance_id}"
|
||||||
|
)
|
||||||
|
lightsail = create_lightsail_client(
|
||||||
|
region=machine.aws_region,
|
||||||
|
aws_access_key=config.aws_access_key,
|
||||||
|
aws_secret_key=config.aws_secret_key,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Rotating Lightsail Static IP for instance %s (%s)",
|
||||||
|
machine.aws_instance_id,
|
||||||
|
machine.name,
|
||||||
|
)
|
||||||
|
aws_result = rotate_lightsail_static_ip(
|
||||||
|
lightsail,
|
||||||
|
instance_name=machine.aws_instance_id,
|
||||||
|
release_old=bool(config.release_old_eip),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if not is_valid_instance_id(machine.aws_instance_id):
|
||||||
|
raise ValueError(
|
||||||
|
f"EC2 Instance ID 格式不正确:{machine.aws_instance_id}(应类似 i-xxxxxxxxxxxxxxxxx)"
|
||||||
|
)
|
||||||
|
ec2 = create_ec2_client(
|
||||||
|
region=machine.aws_region,
|
||||||
|
aws_access_key=config.aws_access_key,
|
||||||
|
aws_secret_key=config.aws_secret_key,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Rotating Elastic IP for instance %s (%s)",
|
||||||
|
machine.aws_instance_id,
|
||||||
|
machine.name,
|
||||||
|
)
|
||||||
|
aws_result = rotate_elastic_ip(
|
||||||
|
ec2,
|
||||||
|
instance_id=machine.aws_instance_id,
|
||||||
|
release_old=bool(config.release_old_eip),
|
||||||
|
)
|
||||||
|
|
||||||
|
public_ip = aws_result.get("public_ip")
|
||||||
|
if not public_ip:
|
||||||
|
raise RuntimeError("AWS 未返回新的 Public IP")
|
||||||
|
|
||||||
|
message = f"IP 已更换为 {public_ip}"
|
||||||
|
|
||||||
|
# 如果机器配置了域名,更新 DNS
|
||||||
|
if machine.cf_zone_id and machine.cf_record_name:
|
||||||
|
logger.info("Updating Cloudflare A record %s -> %s", machine.cf_record_name, public_ip)
|
||||||
|
record = upsert_a_record(
|
||||||
|
zone_id=machine.cf_zone_id,
|
||||||
|
record_name=machine.cf_record_name,
|
||||||
|
ip=public_ip,
|
||||||
|
proxied=bool(machine.cf_proxied),
|
||||||
|
record_id=machine.cf_record_id,
|
||||||
|
auth=_get_cf_auth(config),
|
||||||
|
)
|
||||||
|
record_id = record.get("id")
|
||||||
|
if record_id:
|
||||||
|
machine.cf_record_id = record_id
|
||||||
|
message = f"已更新 {machine.cf_record_name} -> {public_ip}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"public_ip": public_ip,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_ip_change_for_machine(machine_id: int) -> dict:
|
||||||
|
"""为指定机器执行一次 IP 更换"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
machine = session.query(ProxyMachine).filter_by(id=machine_id).first()
|
||||||
|
if not machine:
|
||||||
|
return {"ok": False, "message": "机器不存在"}
|
||||||
|
|
||||||
|
config = ensure_singleton_config(session)
|
||||||
|
started_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = change_ip_for_machine(machine, config)
|
||||||
|
machine.last_run_at = started_at
|
||||||
|
machine.last_success = True
|
||||||
|
machine.current_ip = result.get("public_ip")
|
||||||
|
machine.last_message = result.get("message") or "OK"
|
||||||
|
session.add(machine)
|
||||||
|
session.commit()
|
||||||
|
logger.info("IP change success for %s: %s", machine.name, machine.current_ip)
|
||||||
|
return {"ok": True, "machine_name": machine.name, **result}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("IP change failed for %s", machine.name)
|
||||||
|
machine.last_run_at = started_at
|
||||||
|
machine.last_success = False
|
||||||
|
machine.last_message = str(exc)
|
||||||
|
session.add(machine)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": False, "machine_name": machine.name, "message": str(exc)}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# 兼容旧接口
|
||||||
|
def run_ip_change() -> dict:
|
||||||
|
"""执行一次 IP 更换(兼容旧接口)"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
# 获取第一台启用的机器
|
||||||
|
machine = session.query(ProxyMachine).filter_by(enabled=True).first()
|
||||||
|
if not machine:
|
||||||
|
return {"ok": False, "message": "没有可用的机器"}
|
||||||
|
return run_ip_change_for_machine(machine.id)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
132
services/lightsail_static_ip.py
Normal file
132
services/lightsail_static_ip.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""AWS Lightsail Static IP 操作"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from .aws_region import normalize_aws_region
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LightsailStaticIp:
|
||||||
|
name: str
|
||||||
|
ip_address: str | None
|
||||||
|
attached_to: str | None
|
||||||
|
is_attached: bool
|
||||||
|
|
||||||
|
|
||||||
|
_INSTANCE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_lightsail_instance_name(value: str | None) -> bool:
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
return bool(_INSTANCE_NAME_RE.match(value.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def create_lightsail_client(
|
||||||
|
*,
|
||||||
|
region: str,
|
||||||
|
aws_access_key: str | None,
|
||||||
|
aws_secret_key: str | None,
|
||||||
|
):
|
||||||
|
kwargs: dict[str, Any] = {"region_name": normalize_aws_region(region)}
|
||||||
|
if aws_access_key and aws_secret_key:
|
||||||
|
kwargs["aws_access_key_id"] = aws_access_key
|
||||||
|
kwargs["aws_secret_access_key"] = aws_secret_key
|
||||||
|
return boto3.client("lightsail", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_static_ips(client) -> list[LightsailStaticIp]:
|
||||||
|
resp = client.get_static_ips()
|
||||||
|
items = resp.get("staticIps") or []
|
||||||
|
result: list[LightsailStaticIp] = []
|
||||||
|
for item in items:
|
||||||
|
result.append(
|
||||||
|
LightsailStaticIp(
|
||||||
|
name=item.get("name") or "",
|
||||||
|
ip_address=item.get("ipAddress"),
|
||||||
|
attached_to=item.get("attachedTo"),
|
||||||
|
is_attached=bool(item.get("isAttached")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [ip for ip in result if ip.name]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_attached_static_ip(client, *, instance_name: str) -> LightsailStaticIp | None:
|
||||||
|
for ip in _list_static_ips(client):
|
||||||
|
if ip.is_attached and ip.attached_to == instance_name:
|
||||||
|
return ip
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_static_ip_name(instance_name: str) -> str:
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9-]+", "-", instance_name).strip("-").lower() or "instance"
|
||||||
|
safe = safe[:24]
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||||
|
return f"proxyauto-{safe}-{ts}"
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_lightsail_static_ip(
|
||||||
|
client,
|
||||||
|
*,
|
||||||
|
instance_name: str,
|
||||||
|
release_old: bool,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
current = _get_attached_static_ip(client, instance_name=instance_name)
|
||||||
|
|
||||||
|
if current:
|
||||||
|
client.detach_static_ip(staticIpName=current.name)
|
||||||
|
|
||||||
|
if release_old:
|
||||||
|
for attempt in range(8):
|
||||||
|
try:
|
||||||
|
client.release_static_ip(staticIpName=current.name)
|
||||||
|
break
|
||||||
|
except ClientError as exc:
|
||||||
|
if attempt == 7:
|
||||||
|
raise
|
||||||
|
code = exc.response.get("Error", {}).get("Code")
|
||||||
|
if code in {"OperationFailureException", "InvalidInputException"}:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
new_name = _generate_static_ip_name(instance_name)
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
client.allocate_static_ip(staticIpName=new_name)
|
||||||
|
break
|
||||||
|
except ClientError as exc:
|
||||||
|
if attempt == 4:
|
||||||
|
raise
|
||||||
|
message = (exc.response.get("Error", {}).get("Message") or "").lower()
|
||||||
|
if "already exists" in message or "alreadyexist" in message:
|
||||||
|
new_name = f"{new_name}-{attempt + 1}"
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
client.attach_static_ip(staticIpName=new_name, instanceName=instance_name)
|
||||||
|
|
||||||
|
public_ip: str | None = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
resp = client.get_static_ip(staticIpName=new_name)
|
||||||
|
static_ip = resp.get("staticIp") or {}
|
||||||
|
public_ip = static_ip.get("ipAddress")
|
||||||
|
if public_ip:
|
||||||
|
break
|
||||||
|
except ClientError:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
if not public_ip:
|
||||||
|
raise RuntimeError("Lightsail 未返回新的 Static IP 地址,请稍后重试")
|
||||||
|
|
||||||
|
return {"public_ip": public_ip, "static_ip_name": new_name}
|
||||||
124
services/scheduler.py
Normal file
124
services/scheduler.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""后台定时任务调度器 - 支持每台机器独立调度"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_scheduler: BackgroundScheduler | None = None
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
# 使用上海时区
|
||||||
|
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_job_id(machine_id: int) -> str:
|
||||||
|
return f"ip_change_machine_{machine_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler() -> BackgroundScheduler:
|
||||||
|
global _scheduler
|
||||||
|
with _lock:
|
||||||
|
if _scheduler is None:
|
||||||
|
_scheduler = BackgroundScheduler(timezone=SHANGHAI_TZ)
|
||||||
|
_scheduler.start()
|
||||||
|
return _scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler_status() -> dict[str, Any]:
|
||||||
|
"""获取整体调度器状态"""
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
jobs = scheduler.get_jobs()
|
||||||
|
return {
|
||||||
|
"running": len(jobs) > 0,
|
||||||
|
"job_count": len(jobs),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_machine_scheduler_status(machine_id: int) -> dict[str, Any]:
|
||||||
|
"""获取指定机器的调度状态"""
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
job_id = _get_job_id(machine_id)
|
||||||
|
job = scheduler.get_job(job_id)
|
||||||
|
|
||||||
|
next_run_time = None
|
||||||
|
if job and job.next_run_time:
|
||||||
|
# 确保转换为上海时区
|
||||||
|
next_run_time = job.next_run_time.astimezone(SHANGHAI_TZ).isoformat()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"running": bool(job),
|
||||||
|
"next_run_time": next_run_time,
|
||||||
|
"job_id": job_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _job_func(machine_id: int) -> None:
|
||||||
|
"""定时任务执行函数"""
|
||||||
|
from services.ip_change import run_ip_change_for_machine
|
||||||
|
run_ip_change_for_machine(machine_id)
|
||||||
|
|
||||||
|
|
||||||
|
def start_machine_auto(machine_id: int, interval_seconds: int) -> None:
|
||||||
|
"""启动指定机器的自动任务"""
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
job_id = _get_job_id(machine_id)
|
||||||
|
interval = max(10, interval_seconds)
|
||||||
|
|
||||||
|
scheduler.add_job(
|
||||||
|
_job_func,
|
||||||
|
"interval",
|
||||||
|
args=[machine_id],
|
||||||
|
seconds=interval,
|
||||||
|
id=job_id,
|
||||||
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
misfire_grace_time=30,
|
||||||
|
)
|
||||||
|
logger.info("Machine %d auto job scheduled: every %ss", machine_id, interval)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_machine_auto(machine_id: int) -> None:
|
||||||
|
"""停止指定机器的自动任务"""
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
job_id = _get_job_id(machine_id)
|
||||||
|
job = scheduler.get_job(job_id)
|
||||||
|
if job:
|
||||||
|
scheduler.remove_job(job_id)
|
||||||
|
logger.info("Machine %d auto job stopped", machine_id)
|
||||||
|
|
||||||
|
|
||||||
|
def update_all_schedulers() -> None:
|
||||||
|
"""根据数据库配置更新所有机器的调度器状态"""
|
||||||
|
from database import get_session, ProxyMachine
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
machines = session.query(ProxyMachine).filter_by(auto_enabled=True).all()
|
||||||
|
for machine in machines:
|
||||||
|
start_machine_auto(machine.id, machine.change_interval_seconds)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# 兼容旧接口
|
||||||
|
def start_auto(interval_seconds: int) -> None:
|
||||||
|
"""兼容旧接口"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def stop_auto() -> None:
|
||||||
|
"""兼容旧接口"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def update_scheduler_from_config() -> None:
|
||||||
|
"""兼容旧接口"""
|
||||||
|
update_all_schedulers()
|
||||||
193
services/traffic_alert.py
Normal file
193
services/traffic_alert.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""流量预警检查服务"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from database import Config, ProxyMachine, get_session, ensure_singleton_config
|
||||||
|
from services.traffic_monitor import get_current_month_traffic
|
||||||
|
from services.email_service import send_traffic_alert_email
|
||||||
|
from services.scheduler import stop_machine_auto
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
# 字节转GB
|
||||||
|
BYTES_PER_GB = 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def check_machine_traffic_alert(machine: ProxyMachine, config: Config) -> dict:
|
||||||
|
"""
|
||||||
|
检查单台机器的流量预警
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"triggered": bool, "message": str}
|
||||||
|
"""
|
||||||
|
if not machine.traffic_alert_enabled or not machine.traffic_alert_limit_gb:
|
||||||
|
return {"triggered": False, "message": "未启用流量预警"}
|
||||||
|
|
||||||
|
if not config.aws_access_key or not config.aws_secret_key:
|
||||||
|
return {"triggered": False, "message": "AWS 凭证未配置"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取当月流量
|
||||||
|
traffic_data = get_current_month_traffic(
|
||||||
|
aws_service=machine.aws_service,
|
||||||
|
region=machine.aws_region,
|
||||||
|
instance_id=machine.aws_instance_id,
|
||||||
|
aws_access_key=config.aws_access_key,
|
||||||
|
aws_secret_key=config.aws_secret_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not traffic_data.get("ok"):
|
||||||
|
return {"triggered": False, "message": f"获取流量失败: {traffic_data.get('message')}"}
|
||||||
|
|
||||||
|
# 根据服务类型判断预警条件
|
||||||
|
if machine.aws_service == "lightsail":
|
||||||
|
# Lightsail: 总流量预警
|
||||||
|
current_bytes = traffic_data["total"]
|
||||||
|
traffic_type = "total"
|
||||||
|
else:
|
||||||
|
# EC2: 上传流量预警
|
||||||
|
current_bytes = traffic_data["network_out"]
|
||||||
|
traffic_type = "upload"
|
||||||
|
|
||||||
|
current_gb = current_bytes / BYTES_PER_GB
|
||||||
|
limit_gb = machine.traffic_alert_limit_gb
|
||||||
|
|
||||||
|
if current_gb >= limit_gb:
|
||||||
|
return {
|
||||||
|
"triggered": True,
|
||||||
|
"message": f"流量超限: {current_gb:.2f} GB / {limit_gb:.2f} GB",
|
||||||
|
"current_gb": current_gb,
|
||||||
|
"limit_gb": limit_gb,
|
||||||
|
"traffic_type": traffic_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"triggered": False,
|
||||||
|
"message": f"流量正常: {current_gb:.2f} GB / {limit_gb:.2f} GB",
|
||||||
|
"current_gb": current_gb,
|
||||||
|
"limit_gb": limit_gb,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to check traffic for %s", machine.name)
|
||||||
|
return {"triggered": False, "message": f"检查失败: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_traffic_alert(machine: ProxyMachine, config: Config, alert_result: dict) -> None:
|
||||||
|
"""
|
||||||
|
处理流量预警:暂停机器 + 发送邮件
|
||||||
|
"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
# 重新获取机器对象(确保在当前 session 中)
|
||||||
|
db_machine = session.query(ProxyMachine).filter_by(id=machine.id).first()
|
||||||
|
if not db_machine:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 标记已触发预警
|
||||||
|
db_machine.traffic_alert_triggered = True
|
||||||
|
db_machine.traffic_last_check_at = datetime.now(SHANGHAI_TZ)
|
||||||
|
|
||||||
|
# 暂停自动任务
|
||||||
|
if db_machine.auto_enabled:
|
||||||
|
db_machine.auto_enabled = False
|
||||||
|
stop_machine_auto(db_machine.id)
|
||||||
|
logger.warning("Machine %s auto job stopped due to traffic alert", db_machine.name)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# 发送邮件通知
|
||||||
|
if config.smtp_host and config.alert_email:
|
||||||
|
send_traffic_alert_email(
|
||||||
|
smtp_host=config.smtp_host,
|
||||||
|
smtp_port=config.smtp_port or 587,
|
||||||
|
smtp_user=config.smtp_user,
|
||||||
|
smtp_password=config.smtp_password,
|
||||||
|
use_tls=config.smtp_use_tls,
|
||||||
|
to_email=config.alert_email,
|
||||||
|
machine_name=db_machine.name,
|
||||||
|
aws_service=db_machine.aws_service,
|
||||||
|
current_traffic_gb=alert_result["current_gb"],
|
||||||
|
limit_gb=alert_result["limit_gb"],
|
||||||
|
traffic_type=alert_result["traffic_type"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Email not sent: SMTP or alert email not configured")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def check_all_traffic_alerts() -> dict:
|
||||||
|
"""
|
||||||
|
检查所有机器的流量预警
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"checked": int, "triggered": int, "results": [...]}
|
||||||
|
"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
config = ensure_singleton_config(session)
|
||||||
|
|
||||||
|
# 获取所有启用了流量预警且未触发的机器
|
||||||
|
machines = session.query(ProxyMachine).filter(
|
||||||
|
ProxyMachine.traffic_alert_enabled == True,
|
||||||
|
ProxyMachine.traffic_alert_triggered == False,
|
||||||
|
ProxyMachine.enabled == True,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
triggered_count = 0
|
||||||
|
|
||||||
|
for machine in machines:
|
||||||
|
result = check_machine_traffic_alert(machine, config)
|
||||||
|
results.append({
|
||||||
|
"machine_id": machine.id,
|
||||||
|
"machine_name": machine.name,
|
||||||
|
**result,
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.get("triggered"):
|
||||||
|
triggered_count += 1
|
||||||
|
handle_traffic_alert(machine, config, result)
|
||||||
|
logger.warning("Traffic alert triggered for %s: %s", machine.name, result["message"])
|
||||||
|
|
||||||
|
# 更新检查时间
|
||||||
|
machine.traffic_last_check_at = datetime.now(SHANGHAI_TZ)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"checked": len(machines),
|
||||||
|
"triggered": triggered_count,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to check traffic alerts")
|
||||||
|
return {"ok": False, "message": str(e), "checked": 0, "triggered": 0, "results": []}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_machine_alert(machine_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
重置机器的预警状态(手动解除预警)
|
||||||
|
"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
machine = session.query(ProxyMachine).filter_by(id=machine_id).first()
|
||||||
|
if not machine:
|
||||||
|
return {"ok": False, "message": "机器不存在"}
|
||||||
|
|
||||||
|
machine.traffic_alert_triggered = False
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {"ok": True, "message": f"已重置 {machine.name} 的预警状态"}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
338
services/traffic_monitor.py
Normal file
338
services/traffic_monitor.py
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
"""流量监控服务 - 获取 EC2/Lightsail 流量数据"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def create_cloudwatch_client(region: str, aws_access_key: str, aws_secret_key: str):
|
||||||
|
"""创建 CloudWatch 客户端"""
|
||||||
|
return boto3.client(
|
||||||
|
"cloudwatch",
|
||||||
|
region_name=region,
|
||||||
|
aws_access_key_id=aws_access_key,
|
||||||
|
aws_secret_access_key=aws_secret_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_ec2_traffic(
|
||||||
|
region: str,
|
||||||
|
instance_id: str,
|
||||||
|
aws_access_key: str,
|
||||||
|
aws_secret_key: str,
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
period: int = 3600,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取 EC2 实例的流量数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
region: AWS 区域
|
||||||
|
instance_id: EC2 实例 ID
|
||||||
|
aws_access_key: AWS Access Key
|
||||||
|
aws_secret_key: AWS Secret Key
|
||||||
|
start_time: 开始时间
|
||||||
|
end_time: 结束时间
|
||||||
|
period: 数据点间隔(秒),默认1小时
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"network_in": 下载流量(字节),
|
||||||
|
"network_out": 上传流量(字节),
|
||||||
|
"data_points": 详细数据点列表
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cloudwatch = create_cloudwatch_client(region, aws_access_key, aws_secret_key)
|
||||||
|
|
||||||
|
# 获取 NetworkIn(下载)
|
||||||
|
network_in_response = cloudwatch.get_metric_statistics(
|
||||||
|
Namespace="AWS/EC2",
|
||||||
|
MetricName="NetworkIn",
|
||||||
|
Dimensions=[{"Name": "InstanceId", "Value": instance_id}],
|
||||||
|
StartTime=start_time,
|
||||||
|
EndTime=end_time,
|
||||||
|
Period=period,
|
||||||
|
Statistics=["Sum"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取 NetworkOut(上传)
|
||||||
|
network_out_response = cloudwatch.get_metric_statistics(
|
||||||
|
Namespace="AWS/EC2",
|
||||||
|
MetricName="NetworkOut",
|
||||||
|
Dimensions=[{"Name": "InstanceId", "Value": instance_id}],
|
||||||
|
StartTime=start_time,
|
||||||
|
EndTime=end_time,
|
||||||
|
Period=period,
|
||||||
|
Statistics=["Sum"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 计算总流量
|
||||||
|
network_in_total = sum(dp["Sum"] for dp in network_in_response.get("Datapoints", []))
|
||||||
|
network_out_total = sum(dp["Sum"] for dp in network_out_response.get("Datapoints", []))
|
||||||
|
|
||||||
|
# 合并数据点用于图表
|
||||||
|
data_points = []
|
||||||
|
in_points = {dp["Timestamp"]: dp["Sum"] for dp in network_in_response.get("Datapoints", [])}
|
||||||
|
out_points = {dp["Timestamp"]: dp["Sum"] for dp in network_out_response.get("Datapoints", [])}
|
||||||
|
|
||||||
|
all_timestamps = sorted(set(in_points.keys()) | set(out_points.keys()))
|
||||||
|
for ts in all_timestamps:
|
||||||
|
data_points.append({
|
||||||
|
"timestamp": ts.astimezone(SHANGHAI_TZ).isoformat(),
|
||||||
|
"network_in": in_points.get(ts, 0),
|
||||||
|
"network_out": out_points.get(ts, 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"network_in": network_in_total,
|
||||||
|
"network_out": network_out_total,
|
||||||
|
"total": network_in_total + network_out_total,
|
||||||
|
"data_points": data_points,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to get EC2 traffic for %s", instance_id)
|
||||||
|
return {"ok": False, "message": str(e), "network_in": 0, "network_out": 0, "total": 0, "data_points": []}
|
||||||
|
|
||||||
|
|
||||||
|
def get_lightsail_traffic(
|
||||||
|
region: str,
|
||||||
|
instance_name: str,
|
||||||
|
aws_access_key: str,
|
||||||
|
aws_secret_key: str,
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
period: int = 3600,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取 Lightsail 实例的流量数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
region: AWS 区域
|
||||||
|
instance_name: Lightsail 实例名称
|
||||||
|
aws_access_key: AWS Access Key
|
||||||
|
aws_secret_key: AWS Secret Key
|
||||||
|
start_time: 开始时间
|
||||||
|
end_time: 结束时间
|
||||||
|
period: 数据点间隔(秒),默认1小时
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"network_in": 下载流量(字节),
|
||||||
|
"network_out": 上传流量(字节),
|
||||||
|
"data_points": 详细数据点列表
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
lightsail = boto3.client(
|
||||||
|
"lightsail",
|
||||||
|
region_name=region,
|
||||||
|
aws_access_key_id=aws_access_key,
|
||||||
|
aws_secret_access_key=aws_secret_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取 NetworkIn(下载)
|
||||||
|
network_in_response = lightsail.get_instance_metric_data(
|
||||||
|
instanceName=instance_name,
|
||||||
|
metricName="NetworkIn",
|
||||||
|
period=period,
|
||||||
|
startTime=start_time,
|
||||||
|
endTime=end_time,
|
||||||
|
unit="Bytes",
|
||||||
|
statistics=["Sum"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取 NetworkOut(上传)
|
||||||
|
network_out_response = lightsail.get_instance_metric_data(
|
||||||
|
instanceName=instance_name,
|
||||||
|
metricName="NetworkOut",
|
||||||
|
period=period,
|
||||||
|
startTime=start_time,
|
||||||
|
endTime=end_time,
|
||||||
|
unit="Bytes",
|
||||||
|
statistics=["Sum"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 计算总流量
|
||||||
|
in_data = network_in_response.get("metricData", [])
|
||||||
|
out_data = network_out_response.get("metricData", [])
|
||||||
|
|
||||||
|
network_in_total = sum(dp.get("sum", 0) for dp in in_data)
|
||||||
|
network_out_total = sum(dp.get("sum", 0) for dp in out_data)
|
||||||
|
|
||||||
|
# 合并数据点
|
||||||
|
data_points = []
|
||||||
|
in_points = {dp["timestamp"]: dp.get("sum", 0) for dp in in_data}
|
||||||
|
out_points = {dp["timestamp"]: dp.get("sum", 0) for dp in out_data}
|
||||||
|
|
||||||
|
all_timestamps = sorted(set(in_points.keys()) | set(out_points.keys()))
|
||||||
|
for ts in all_timestamps:
|
||||||
|
data_points.append({
|
||||||
|
"timestamp": ts.astimezone(SHANGHAI_TZ).isoformat(),
|
||||||
|
"network_in": in_points.get(ts, 0),
|
||||||
|
"network_out": out_points.get(ts, 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"network_in": network_in_total,
|
||||||
|
"network_out": network_out_total,
|
||||||
|
"total": network_in_total + network_out_total,
|
||||||
|
"data_points": data_points,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to get Lightsail traffic for %s", instance_name)
|
||||||
|
return {"ok": False, "message": str(e), "network_in": 0, "network_out": 0, "total": 0, "data_points": []}
|
||||||
|
|
||||||
|
|
||||||
|
def get_machine_traffic(
|
||||||
|
aws_service: str,
|
||||||
|
region: str,
|
||||||
|
instance_id: str,
|
||||||
|
aws_access_key: str,
|
||||||
|
aws_secret_key: str,
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
period: int = 3600,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
统一接口:根据服务类型获取流量数据
|
||||||
|
"""
|
||||||
|
if aws_service == "lightsail":
|
||||||
|
return get_lightsail_traffic(
|
||||||
|
region=region,
|
||||||
|
instance_name=instance_id,
|
||||||
|
aws_access_key=aws_access_key,
|
||||||
|
aws_secret_key=aws_secret_key,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return get_ec2_traffic(
|
||||||
|
region=region,
|
||||||
|
instance_id=instance_id,
|
||||||
|
aws_access_key=aws_access_key,
|
||||||
|
aws_secret_key=aws_secret_key,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_month_traffic(
|
||||||
|
aws_service: str,
|
||||||
|
region: str,
|
||||||
|
instance_id: str,
|
||||||
|
aws_access_key: str,
|
||||||
|
aws_secret_key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取当月流量数据"""
|
||||||
|
now = datetime.now(SHANGHAI_TZ)
|
||||||
|
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
return get_machine_traffic(
|
||||||
|
aws_service=aws_service,
|
||||||
|
region=region,
|
||||||
|
instance_id=instance_id,
|
||||||
|
aws_access_key=aws_access_key,
|
||||||
|
aws_secret_key=aws_secret_key,
|
||||||
|
start_time=start_of_month,
|
||||||
|
end_time=now,
|
||||||
|
period=3600, # 每小时一个数据点
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_day_traffic(
|
||||||
|
aws_service: str,
|
||||||
|
region: str,
|
||||||
|
instance_id: str,
|
||||||
|
aws_access_key: str,
|
||||||
|
aws_secret_key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取当日流量数据"""
|
||||||
|
now = datetime.now(SHANGHAI_TZ)
|
||||||
|
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
return get_machine_traffic(
|
||||||
|
aws_service=aws_service,
|
||||||
|
region=region,
|
||||||
|
instance_id=instance_id,
|
||||||
|
aws_access_key=aws_access_key,
|
||||||
|
aws_secret_key=aws_secret_key,
|
||||||
|
start_time=start_of_day,
|
||||||
|
end_time=now,
|
||||||
|
period=300, # 每5分钟一个数据点
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_time_traffic(
|
||||||
|
aws_service: str,
|
||||||
|
region: str,
|
||||||
|
instance_id: str,
|
||||||
|
aws_access_key: str,
|
||||||
|
aws_secret_key: str,
|
||||||
|
created_at: datetime = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取建站至今的总流量数据
|
||||||
|
|
||||||
|
注意: CloudWatch 数据保留期限有限:
|
||||||
|
- 小于60秒的数据点保留3小时
|
||||||
|
- 60秒(1分钟)的数据点保留15天
|
||||||
|
- 300秒(5分钟)的数据点保留63天
|
||||||
|
- 3600秒(1小时)的数据点保留455天(约15个月)
|
||||||
|
|
||||||
|
因此这里只能获取最近约15个月的数据
|
||||||
|
"""
|
||||||
|
now = datetime.now(SHANGHAI_TZ)
|
||||||
|
|
||||||
|
# 如果提供了创建时间,使用它;否则使用15个月前
|
||||||
|
if created_at:
|
||||||
|
# 确保时区正确
|
||||||
|
if created_at.tzinfo is None:
|
||||||
|
start_time = created_at.replace(tzinfo=SHANGHAI_TZ)
|
||||||
|
else:
|
||||||
|
start_time = created_at.astimezone(SHANGHAI_TZ)
|
||||||
|
else:
|
||||||
|
# CloudWatch 最多保留约15个月的小时级数据
|
||||||
|
start_time = now - timedelta(days=455)
|
||||||
|
|
||||||
|
return get_machine_traffic(
|
||||||
|
aws_service=aws_service,
|
||||||
|
region=region,
|
||||||
|
instance_id=instance_id,
|
||||||
|
aws_access_key=aws_access_key,
|
||||||
|
aws_secret_key=aws_secret_key,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=now,
|
||||||
|
period=86400, # 每天一个数据点
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_bytes(bytes_value: float) -> str:
|
||||||
|
"""格式化字节数为可读格式"""
|
||||||
|
if bytes_value < 0:
|
||||||
|
return "0 B"
|
||||||
|
|
||||||
|
units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
unit_index = 0
|
||||||
|
value = float(bytes_value)
|
||||||
|
|
||||||
|
while value >= 1024 and unit_index < len(units) - 1:
|
||||||
|
value /= 1024
|
||||||
|
unit_index += 1
|
||||||
|
|
||||||
|
if unit_index == 0:
|
||||||
|
return f"{int(value)} {units[unit_index]}"
|
||||||
|
return f"{value:.2f} {units[unit_index]}"
|
||||||
1605
static/css/style.css
Normal file
1605
static/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
177
static/js/main.js
Normal file
177
static/js/main.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/* ProxyAuto Pro - 主 JavaScript 文件 */
|
||||||
|
|
||||||
|
// Toast 通知
|
||||||
|
function showToast(type, message, duration = 4000) {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
toast.innerHTML = `
|
||||||
|
<span class="toast-message">${escapeHtml(message)}</span>
|
||||||
|
<button class="toast-close" onclick="this.parentElement.remove()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
// 自动移除
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
toast.style.transform = 'translateX(20px)';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML 转义
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用 API 请求函数
|
||||||
|
async function apiRequest(url, options = {}) {
|
||||||
|
const defaultOptions = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergedOptions = {
|
||||||
|
...defaultOptions,
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...defaultOptions.headers,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, mergedOptions);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期时间
|
||||||
|
function formatDateTime(isoString) {
|
||||||
|
if (!isoString) return '--';
|
||||||
|
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
function formatTime(isoString) {
|
||||||
|
if (!isoString) return '--';
|
||||||
|
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认对话框
|
||||||
|
function confirmAction(message) {
|
||||||
|
return confirm(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载完成后的初始化
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// 可以在这里添加全局初始化逻辑
|
||||||
|
console.log('ProxyAuto Pro initialized');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 移动端侧边栏切换
|
||||||
|
function toggleSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const overlay = document.querySelector('.sidebar-overlay');
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.classList.toggle('open');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.toggle('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化倒计时
|
||||||
|
function formatCountdown(seconds) {
|
||||||
|
if (seconds <= 0) return '即将更换';
|
||||||
|
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}时${minutes}分${secs}秒`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}分${secs}秒`;
|
||||||
|
} else {
|
||||||
|
return `${secs}秒`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化24小时制时间
|
||||||
|
function formatTime24(isoString) {
|
||||||
|
if (!isoString) return '--';
|
||||||
|
|
||||||
|
const date = new Date(isoString);
|
||||||
|
const pad = n => n.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新所有倒计时
|
||||||
|
function updateCountdowns() {
|
||||||
|
const countdownElements = document.querySelectorAll('[data-next-run]');
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
countdownElements.forEach(el => {
|
||||||
|
const nextRunTime = new Date(el.dataset.nextRun).getTime();
|
||||||
|
const remaining = Math.max(0, Math.floor((nextRunTime - now) / 1000));
|
||||||
|
|
||||||
|
const countdownSpan = el.querySelector('.countdown-value');
|
||||||
|
if (countdownSpan) {
|
||||||
|
countdownSpan.textContent = formatCountdown(remaining);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动倒计时更新器
|
||||||
|
let countdownInterval = null;
|
||||||
|
function startCountdownUpdater() {
|
||||||
|
if (countdownInterval) clearInterval(countdownInterval);
|
||||||
|
updateCountdowns();
|
||||||
|
countdownInterval = setInterval(updateCountdowns, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最小加载时间包装器
|
||||||
|
async function withMinLoadTime(promise, minMs = 500) {
|
||||||
|
const start = Date.now();
|
||||||
|
const result = await promise;
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
if (elapsed < minMs) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, minMs - elapsed));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
91
templates/base.html
Normal file
91
templates/base.html
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}ProxyAuto Pro{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Mobile Menu Toggle -->
|
||||||
|
<button class="mobile-menu-btn" onclick="toggleSidebar()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="sidebar-overlay" onclick="toggleSidebar()"></div>
|
||||||
|
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h1 class="logo">ProxyAuto Pro</h1>
|
||||||
|
<button class="sidebar-close" onclick="toggleSidebar()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav class="nav-menu">
|
||||||
|
<a href="{{ url_for('dashboard') }}" class="nav-item {% if request.endpoint == 'dashboard' %}active{% endif %}">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
|
</svg>
|
||||||
|
<span>运行概览</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('settings') }}" class="nav-item {% if request.endpoint == 'settings' %}active{% endif %}">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
</svg>
|
||||||
|
<span>系统设置</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('machines') }}" class="nav-item {% if request.endpoint == 'machines' %}active{% endif %}">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
|
||||||
|
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
||||||
|
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
<span>节点管理</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('traffic') }}" class="nav-item {% if request.endpoint == 'traffic' %}active{% endif %}">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||||
|
</svg>
|
||||||
|
<span>流量监控</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('logs') }}" class="nav-item {% if request.endpoint == 'logs' %}active{% endif %}">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
<span>运行日志</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="version-info">
|
||||||
|
ProxyAuto Pro<br>
|
||||||
|
<span class="version">v2.2.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
427
templates/dashboard.html
Normal file
427
templates/dashboard.html
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}运行概览 - ProxyAuto Pro{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>运行概览</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not machines_status %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
|
||||||
|
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
||||||
|
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
<h3>暂无节点</h3>
|
||||||
|
<p>请先前往<a href="{{ url_for('machines') }}">节点管理</a>添加机器</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<!-- 总流量统计 -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">总流量统计</h2>
|
||||||
|
<div class="traffic-summary-grid" id="traffic-summary">
|
||||||
|
<div class="traffic-summary-card">
|
||||||
|
<div class="summary-header">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||||
|
</svg>
|
||||||
|
<span class="summary-title">当月流量</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-metrics">
|
||||||
|
<div class="summary-metric">
|
||||||
|
<span class="summary-label">下载</span>
|
||||||
|
<span class="summary-value" id="month-in">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-metric">
|
||||||
|
<span class="summary-label">上传</span>
|
||||||
|
<span class="summary-value" id="month-out">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-metric total">
|
||||||
|
<span class="summary-label">总计</span>
|
||||||
|
<span class="summary-value" id="month-total">加载中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="traffic-summary-card">
|
||||||
|
<div class="summary-header">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
|
</svg>
|
||||||
|
<span class="summary-title">当日流量</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-metrics">
|
||||||
|
<div class="summary-metric">
|
||||||
|
<span class="summary-label">下载</span>
|
||||||
|
<span class="summary-value" id="day-in">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-metric">
|
||||||
|
<span class="summary-label">上传</span>
|
||||||
|
<span class="summary-value" id="day-out">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-metric total">
|
||||||
|
<span class="summary-label">总计</span>
|
||||||
|
<span class="summary-value" id="day-total">加载中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="traffic-summary-card">
|
||||||
|
<div class="summary-header">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||||
|
</svg>
|
||||||
|
<span class="summary-title">历史总流量</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-metrics">
|
||||||
|
<div class="summary-metric">
|
||||||
|
<span class="summary-label">下载</span>
|
||||||
|
<span class="summary-value" id="all-in">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-metric">
|
||||||
|
<span class="summary-label">上传</span>
|
||||||
|
<span class="summary-value" id="all-out">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-metric total">
|
||||||
|
<span class="summary-label">总计</span>
|
||||||
|
<span class="summary-value" id="all-total">加载中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 节点列表 -->
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
{% for item in machines_status %}
|
||||||
|
{% set machine = item.machine %}
|
||||||
|
{% set scheduler = item.scheduler %}
|
||||||
|
<div class="machine-dashboard-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title-row">
|
||||||
|
<span class="status-dot {% if machine.enabled %}active{% else %}inactive{% endif %}"></span>
|
||||||
|
<h3 class="card-title">{{ machine.name }}</h3>
|
||||||
|
{% if machine.auto_enabled %}
|
||||||
|
<span class="badge badge-auto">自动</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-subtitle">
|
||||||
|
{{ machine.aws_service|upper }} | {{ machine.aws_region }}
|
||||||
|
{% if machine.cf_record_name %}
|
||||||
|
| {{ machine.cf_record_name }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-metrics">
|
||||||
|
<div class="card-metric">
|
||||||
|
<div class="metric-label">当前 IP</div>
|
||||||
|
<div class="metric-value ip-value">{{ machine.current_ip or '未获取' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-metric">
|
||||||
|
<div class="metric-label">最近更换</div>
|
||||||
|
<div class="metric-value time-value">
|
||||||
|
{% if machine.last_run_at %}
|
||||||
|
{{ machine.last_run_at.strftime('%m-%d %H:%M:%S') }}
|
||||||
|
{% else %}
|
||||||
|
--
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-metric">
|
||||||
|
<div class="metric-label">更换间隔</div>
|
||||||
|
<div class="metric-value">{{ machine.change_interval_seconds // 60 }} 分钟</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-traffic" data-machine-id="{{ machine.id }}" data-limit-gb="{{ machine.traffic_alert_limit_gb or 0 }}" data-service="{{ machine.aws_service }}">
|
||||||
|
<div class="traffic-row">
|
||||||
|
<div class="traffic-item">
|
||||||
|
<span class="traffic-label">当月下载</span>
|
||||||
|
<span class="traffic-value" data-type="in">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div class="traffic-item">
|
||||||
|
<span class="traffic-label">当月上传</span>
|
||||||
|
<span class="traffic-value" data-type="out">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div class="traffic-item">
|
||||||
|
<span class="traffic-label">总流量</span>
|
||||||
|
<span class="traffic-value" data-type="total">加载中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if machine.traffic_alert_enabled %}
|
||||||
|
<div class="traffic-progress-section">
|
||||||
|
<div class="traffic-progress-header">
|
||||||
|
<span class="progress-label">已使用</span>
|
||||||
|
<span class="progress-percent" data-type="percent">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="traffic-progress-bar">
|
||||||
|
<div class="traffic-progress-fill" data-type="progress" style="width: 0%;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="traffic-progress-footer">
|
||||||
|
<span class="progress-used" data-type="used">--</span>
|
||||||
|
<span class="progress-limit">/ {{ machine.traffic_alert_limit_gb }} GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if machine.last_run_at %}
|
||||||
|
<div class="card-status {% if machine.last_success %}success{% else %}error{% endif %}">
|
||||||
|
{% if machine.last_success %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
|
</svg>
|
||||||
|
<span>上次成功</span>
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
<span>上次失败</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if scheduler.next_run_time and scheduler.running %}
|
||||||
|
<div class="card-countdown" data-next-run="{{ scheduler.next_run_time }}">
|
||||||
|
<div class="countdown-row">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
|
</svg>
|
||||||
|
<div class="countdown-info">
|
||||||
|
<div class="countdown-label">距离下次更换</div>
|
||||||
|
<div class="countdown-value">计算中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="next-time-row">
|
||||||
|
下次更换: {{ scheduler.next_run_time[:19].replace('T', ' ') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif not scheduler.running %}
|
||||||
|
<div class="card-countdown paused">
|
||||||
|
<div class="countdown-row">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="6" y="4" width="4" height="16"/>
|
||||||
|
<rect x="14" y="4" width="4" height="16"/>
|
||||||
|
</svg>
|
||||||
|
<div class="countdown-info">
|
||||||
|
<div class="countdown-label">自动更换已暂停</div>
|
||||||
|
<div class="countdown-value">点击"启动"开始</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-primary btn-action" onclick="runIpChange({{ machine.id }}, '{{ machine.name }}', this)">
|
||||||
|
<svg class="btn-icon-normal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="23 4 23 10 17 10"/>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="btn-icon-loading spinner" viewBox="0 0 24 24" style="display: none;">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="32" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span class="btn-text">更换 IP</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-action" onclick="toggleAuto({{ machine.id }}, this)">
|
||||||
|
{% if scheduler.running %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="6" y="4" width="4" height="16"/>
|
||||||
|
<rect x="14" y="4" width="4" height="16"/>
|
||||||
|
</svg>
|
||||||
|
<span>暂停</span>
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||||
|
</svg>
|
||||||
|
<span>启动</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// 页面加载后启动倒计时和流量加载
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
startCountdownUpdater();
|
||||||
|
loadAllTrafficData();
|
||||||
|
loadTrafficSummary();
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const k = 1024;
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTrafficSummary() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/traffic/summary');
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
// 当月流量
|
||||||
|
document.getElementById('month-in').textContent = result.month.network_in_formatted || formatBytes(result.month.network_in);
|
||||||
|
document.getElementById('month-out').textContent = result.month.network_out_formatted || formatBytes(result.month.network_out);
|
||||||
|
document.getElementById('month-total').textContent = result.month.total_formatted || formatBytes(result.month.total);
|
||||||
|
|
||||||
|
// 当日流量
|
||||||
|
document.getElementById('day-in').textContent = result.day.network_in_formatted || formatBytes(result.day.network_in);
|
||||||
|
document.getElementById('day-out').textContent = result.day.network_out_formatted || formatBytes(result.day.network_out);
|
||||||
|
document.getElementById('day-total').textContent = result.day.total_formatted || formatBytes(result.day.total);
|
||||||
|
|
||||||
|
// 历史总流量
|
||||||
|
document.getElementById('all-in').textContent = result.all_time.network_in_formatted || formatBytes(result.all_time.network_in);
|
||||||
|
document.getElementById('all-out').textContent = result.all_time.network_out_formatted || formatBytes(result.all_time.network_out);
|
||||||
|
document.getElementById('all-total').textContent = result.all_time.total_formatted || formatBytes(result.all_time.total);
|
||||||
|
} else {
|
||||||
|
setTrafficSummaryError('加载失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setTrafficSummaryError('--');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTrafficSummaryError(text) {
|
||||||
|
['month-in', 'month-out', 'month-total', 'day-in', 'day-out', 'day-total', 'all-in', 'all-out', 'all-total'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllTrafficData() {
|
||||||
|
const trafficCards = document.querySelectorAll('.card-traffic');
|
||||||
|
trafficCards.forEach(card => {
|
||||||
|
const machineId = card.dataset.machineId;
|
||||||
|
loadTrafficData(machineId, card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTrafficData(machineId, card) {
|
||||||
|
const limitGb = parseFloat(card.dataset.limitGb) || 0;
|
||||||
|
const service = card.dataset.service;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/traffic/${machineId}/current`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
card.querySelector('[data-type="in"]').textContent = formatBytes(result.network_in);
|
||||||
|
card.querySelector('[data-type="out"]').textContent = formatBytes(result.network_out);
|
||||||
|
card.querySelector('[data-type="total"]').textContent = formatBytes(result.total);
|
||||||
|
|
||||||
|
// 计算百分比
|
||||||
|
if (limitGb > 0) {
|
||||||
|
// Lightsail 用总流量,EC2 用上传流量
|
||||||
|
const usedBytes = service === 'lightsail' ? result.total : result.network_out;
|
||||||
|
const usedGb = usedBytes / (1024 * 1024 * 1024);
|
||||||
|
const percent = Math.min((usedGb / limitGb) * 100, 100);
|
||||||
|
|
||||||
|
const percentEl = card.querySelector('[data-type="percent"]');
|
||||||
|
const progressEl = card.querySelector('[data-type="progress"]');
|
||||||
|
const usedEl = card.querySelector('[data-type="used"]');
|
||||||
|
|
||||||
|
if (percentEl) percentEl.textContent = percent.toFixed(2) + '%';
|
||||||
|
if (progressEl) {
|
||||||
|
progressEl.style.width = percent + '%';
|
||||||
|
// 根据百分比设置颜色
|
||||||
|
if (percent >= 90) {
|
||||||
|
progressEl.classList.add('danger');
|
||||||
|
progressEl.classList.remove('warning');
|
||||||
|
} else if (percent >= 70) {
|
||||||
|
progressEl.classList.add('warning');
|
||||||
|
progressEl.classList.remove('danger');
|
||||||
|
} else {
|
||||||
|
progressEl.classList.remove('warning', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (usedEl) usedEl.textContent = usedGb.toFixed(2) + ' GB';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
card.querySelector('[data-type="in"]').textContent = '--';
|
||||||
|
card.querySelector('[data-type="out"]').textContent = '--';
|
||||||
|
card.querySelector('[data-type="total"]').textContent = '--';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
card.querySelector('[data-type="in"]').textContent = '--';
|
||||||
|
card.querySelector('[data-type="out"]').textContent = '--';
|
||||||
|
card.querySelector('[data-type="total"]').textContent = '--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runIpChange(machineId, machineName, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
const normalIcon = btn.querySelector('.btn-icon-normal');
|
||||||
|
const loadingIcon = btn.querySelector('.btn-icon-loading');
|
||||||
|
const btnText = btn.querySelector('.btn-text');
|
||||||
|
|
||||||
|
normalIcon.style.display = 'none';
|
||||||
|
loadingIcon.style.display = 'block';
|
||||||
|
btnText.textContent = '执行中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await withMinLoadTime(
|
||||||
|
fetch(`/api/run-ip-change/${machineId}`, { method: 'POST' }).then(r => r.json()),
|
||||||
|
800
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
showToast('success', `${machineName}: 新 IP ${result.public_ip}`);
|
||||||
|
setTimeout(() => location.reload(), 1500);
|
||||||
|
} else {
|
||||||
|
showToast('error', `${machineName}: ${result.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', `请求失败: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
normalIcon.style.display = 'block';
|
||||||
|
loadingIcon.style.display = 'none';
|
||||||
|
btnText.textContent = '更换 IP';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAuto(machineId, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await withMinLoadTime(
|
||||||
|
fetch(`/api/toggle-auto/${machineId}`, { method: 'POST' }).then(r => r.json()),
|
||||||
|
500
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
showToast('success', result.message);
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
} else {
|
||||||
|
showToast('error', result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', `请求失败: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
211
templates/login.html
Normal file
211
templates/login.html
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>登录 - ProxyAuto Pro</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.login-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
animation: riseIn 480ms var(--ease-smooth);
|
||||||
|
}
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.login-logo {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.login-subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.captcha-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.captcha-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.captcha-display {
|
||||||
|
background: var(--surface-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.captcha-refresh {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 200ms var(--ease-smooth);
|
||||||
|
}
|
||||||
|
.captcha-refresh:hover {
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.captcha-refresh svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.login-error {
|
||||||
|
background: var(--error-soft);
|
||||||
|
color: var(--error);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.login-error.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="login-logo">ProxyAuto Pro</div>
|
||||||
|
<div class="login-subtitle">请登录以继续</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="login-error" class="login-error"></div>
|
||||||
|
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">用户名</label>
|
||||||
|
<input type="text" id="username" name="username" required autofocus placeholder="请输入用户名">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">密码</label>
|
||||||
|
<input type="password" id="password" name="password" required placeholder="请输入密码">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>验证码</label>
|
||||||
|
<div class="captcha-row">
|
||||||
|
<div class="captcha-display" id="captcha-question">{{ captcha_question }}</div>
|
||||||
|
<button type="button" class="captcha-refresh" onclick="refreshCaptcha()" title="刷新验证码">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="23 4 23 10 17 10"/>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="captcha" name="captcha" required placeholder="答案" style="width: 80px; text-align: center;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions" style="margin-top: 1.5rem;">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg" style="width: 100%;">
|
||||||
|
<span class="btn-text">登 录</span>
|
||||||
|
<span class="btn-loading" style="display: none;">
|
||||||
|
<svg class="spinner" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="32" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
登录中...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function refreshCaptcha() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/refresh-captcha', { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.ok) {
|
||||||
|
document.getElementById('captcha-question').textContent = result.question;
|
||||||
|
document.getElementById('captcha').value = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新验证码失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const btn = this.querySelector('button[type="submit"]');
|
||||||
|
const btnText = btn.querySelector('.btn-text');
|
||||||
|
const btnLoading = btn.querySelector('.btn-loading');
|
||||||
|
const errorDiv = document.getElementById('login-error');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btnText.style.display = 'none';
|
||||||
|
btnLoading.style.display = 'flex';
|
||||||
|
errorDiv.classList.remove('show');
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
username: document.getElementById('username').value,
|
||||||
|
password: document.getElementById('password').value,
|
||||||
|
captcha: document.getElementById('captcha').value,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
errorDiv.textContent = result.message;
|
||||||
|
errorDiv.classList.add('show');
|
||||||
|
document.getElementById('captcha').value = '';
|
||||||
|
if (result.new_captcha) {
|
||||||
|
document.getElementById('captcha-question').textContent = result.new_captcha;
|
||||||
|
} else {
|
||||||
|
refreshCaptcha();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorDiv.textContent = '登录失败,请稍后重试';
|
||||||
|
errorDiv.classList.add('show');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btnText.style.display = 'inline';
|
||||||
|
btnLoading.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
92
templates/logs.html
Normal file
92
templates/logs.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}运行日志 - ProxyAuto Pro{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>运行日志</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="logs-toolbar">
|
||||||
|
<div class="form-group inline">
|
||||||
|
<label for="lines-count">显示最近</label>
|
||||||
|
<select id="lines-count" onchange="loadLogs()">
|
||||||
|
<option value="50">50 行</option>
|
||||||
|
<option value="100" selected>100 行</option>
|
||||||
|
<option value="200">200 行</option>
|
||||||
|
<option value="300">300 行</option>
|
||||||
|
<option value="500">500 行</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" onclick="loadLogs()">
|
||||||
|
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="23 4 23 10 17 10"/>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
|
</svg>
|
||||||
|
刷新日志
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div id="logs-container">
|
||||||
|
<div class="loading-state" id="logs-loading">
|
||||||
|
<svg class="spinner large" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="32" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<p>加载日志中...</p>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state" id="logs-empty" style="display: none;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
</svg>
|
||||||
|
<h3>暂无日志</h3>
|
||||||
|
<p>系统首次运行后将生成日志</p>
|
||||||
|
</div>
|
||||||
|
<pre id="logs-content" class="logs-content" style="display: none;"></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function loadLogs() {
|
||||||
|
const linesCount = document.getElementById('lines-count').value;
|
||||||
|
const loading = document.getElementById('logs-loading');
|
||||||
|
const empty = document.getElementById('logs-empty');
|
||||||
|
const content = document.getElementById('logs-content');
|
||||||
|
|
||||||
|
loading.style.display = 'flex';
|
||||||
|
empty.style.display = 'none';
|
||||||
|
content.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/logs?lines=${linesCount}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
loading.style.display = 'none';
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
if (result.empty || !result.content) {
|
||||||
|
empty.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
content.textContent = result.content;
|
||||||
|
content.style.display = 'block';
|
||||||
|
// 滚动到底部
|
||||||
|
content.scrollTop = content.scrollHeight;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('error', result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
showToast('error', `加载失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载时自动加载日志
|
||||||
|
document.addEventListener('DOMContentLoaded', loadLogs);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
430
templates/machines.html
Normal file
430
templates/machines.html
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}节点管理 - ProxyAuto Pro{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>节点管理</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="collapsible" id="add-machine-section">
|
||||||
|
<button class="collapsible-header" onclick="toggleCollapsible('add-machine-section')">
|
||||||
|
<svg class="collapsible-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
<span>添加新节点</span>
|
||||||
|
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="collapsible-content" {% if machines|length > 0 %}style="display: none;"{% endif %}>
|
||||||
|
<form id="add-machine-form" class="form-card inline-form">
|
||||||
|
<h3 class="form-section-title">基本信息</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_name">节点名称</label>
|
||||||
|
<input type="text" id="new_name" name="name" placeholder="例如: US-East-Proxy" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_aws_service">AWS 服务</label>
|
||||||
|
<select id="new_aws_service" name="aws_service">
|
||||||
|
<option value="ec2">EC2</option>
|
||||||
|
<option value="lightsail">Lightsail</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_aws_region">AWS 区域 (Region)</label>
|
||||||
|
<input type="text" id="new_aws_region" name="aws_region" value="us-east-1" placeholder="例如: us-west-2" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_instance_id" id="new_instance_id_label">EC2 Instance ID</label>
|
||||||
|
<input type="text" id="new_instance_id" name="instance_id" placeholder="例如: i-0123456789abcdef0" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-divider"></div>
|
||||||
|
<h3 class="form-section-title">域名绑定 (可选)</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_cf_zone_id">Cloudflare Zone ID</label>
|
||||||
|
<input type="text" id="new_cf_zone_id" name="cf_zone_id" placeholder="留空则不绑定域名">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_cf_record_name">DNS 记录名称</label>
|
||||||
|
<input type="text" id="new_cf_record_name" name="cf_record_name" placeholder="例如: api.example.com">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="new_cf_proxied" name="cf_proxied">
|
||||||
|
<span class="checkbox-text">启用 Cloudflare 代理 (橙色云朵)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-divider"></div>
|
||||||
|
<h3 class="form-section-title">自动更换设置</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_change_interval">更换间隔 (分钟)</label>
|
||||||
|
<input type="number" id="new_change_interval" name="change_interval_minutes" value="60" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display: flex; align-items: center; padding-top: 1.5rem;">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="new_auto_enabled" name="auto_enabled">
|
||||||
|
<span class="checkbox-text">启用自动更换</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-divider"></div>
|
||||||
|
<h3 class="form-section-title">流量预警 (可选)</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group" style="display: flex; align-items: center; padding-top: 1.5rem;">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="new_traffic_alert_enabled" name="traffic_alert_enabled" onchange="toggleTrafficAlertFields('new')">
|
||||||
|
<span class="checkbox-text">启用流量预警</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="new_traffic_limit_group" style="display: none;">
|
||||||
|
<label for="new_traffic_alert_limit_gb" id="new_traffic_limit_label">流量限制 (GB)</label>
|
||||||
|
<input type="number" id="new_traffic_alert_limit_gb" name="traffic_alert_limit_gb" step="0.1" min="0" placeholder="例如: 100">
|
||||||
|
<small class="form-hint" id="new_traffic_limit_hint">EC2: 上传流量预警 / Lightsail: 总流量预警</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-divider"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_note">备注 (可选)</label>
|
||||||
|
<input type="text" id="new_note" name="note" placeholder="例如: 生产环境主节点">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="new_enabled" name="enabled" checked>
|
||||||
|
<span class="checkbox-text">立即启用</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">添加节点</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
{% if not machines %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
|
||||||
|
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
||||||
|
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
<h3>暂无节点</h3>
|
||||||
|
<p>请点击上方"添加新节点"开始配置</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="machine-list">
|
||||||
|
{% for machine in machines %}
|
||||||
|
<div class="machine-card" data-id="{{ machine.id }}">
|
||||||
|
<div class="machine-header">
|
||||||
|
<div class="machine-info">
|
||||||
|
<span class="status-dot {% if machine.enabled %}active{% else %}inactive{% endif %}"></span>
|
||||||
|
<span class="machine-name">{{ machine.name }}</span>
|
||||||
|
{% if machine.auto_enabled %}
|
||||||
|
<span class="badge badge-auto">自动</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="machine-meta">
|
||||||
|
<span class="meta-item">{{ machine.aws_service|upper }}</span>
|
||||||
|
<span class="meta-item">{{ machine.aws_region }}</span>
|
||||||
|
{% if machine.current_ip %}
|
||||||
|
<span class="meta-item ip-badge">{{ machine.current_ip }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if machine.cf_record_name %}
|
||||||
|
<span class="meta-item domain-badge">{{ machine.cf_record_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="machine-actions">
|
||||||
|
<button class="btn btn-sm btn-ghost" onclick="toggleEdit({{ machine.id }})">编辑</button>
|
||||||
|
<button class="btn btn-sm btn-danger-ghost" onclick="deleteMachine({{ machine.id }}, '{{ machine.name }}')">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if machine.note %}
|
||||||
|
<div class="machine-note-row">{{ machine.note }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="machine-edit" id="edit-{{ machine.id }}" style="display: none;">
|
||||||
|
<form class="edit-form" onsubmit="updateMachine(event, {{ machine.id }})">
|
||||||
|
<h3 class="form-section-title">基本信息</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>名称</label>
|
||||||
|
<input type="text" name="name" value="{{ machine.name }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>服务类型</label>
|
||||||
|
<select name="aws_service">
|
||||||
|
<option value="ec2" {% if machine.aws_service == 'ec2' %}selected{% endif %}>EC2</option>
|
||||||
|
<option value="lightsail" {% if machine.aws_service == 'lightsail' %}selected{% endif %}>Lightsail</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>区域</label>
|
||||||
|
<input type="text" name="aws_region" value="{{ machine.aws_region }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>实例 ID</label>
|
||||||
|
<input type="text" name="instance_id" value="{{ machine.aws_instance_id }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-divider"></div>
|
||||||
|
<h3 class="form-section-title">域名绑定</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Cloudflare Zone ID</label>
|
||||||
|
<input type="text" name="cf_zone_id" value="{{ machine.cf_zone_id or '' }}" placeholder="留空则不绑定域名">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>DNS 记录名称</label>
|
||||||
|
<input type="text" name="cf_record_name" value="{{ machine.cf_record_name or '' }}" placeholder="例如: api.example.com">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="cf_proxied" {% if machine.cf_proxied %}checked{% endif %}>
|
||||||
|
<span class="checkbox-text">启用 Cloudflare 代理</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-divider"></div>
|
||||||
|
<h3 class="form-section-title">自动更换设置</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>更换间隔 (分钟)</label>
|
||||||
|
<input type="number" name="change_interval_minutes" value="{{ machine.change_interval_seconds // 60 }}" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display: flex; align-items: center; padding-top: 1.5rem;">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="auto_enabled" {% if machine.auto_enabled %}checked{% endif %}>
|
||||||
|
<span class="checkbox-text">启用自动更换</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-divider"></div>
|
||||||
|
<h3 class="form-section-title">流量预警</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group" style="display: flex; align-items: center; padding-top: 1.5rem;">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="traffic_alert_enabled" {% if machine.traffic_alert_enabled %}checked{% endif %} onchange="toggleTrafficAlertFields('edit-{{ machine.id }}')">
|
||||||
|
<span class="checkbox-text">启用流量预警</span>
|
||||||
|
</label>
|
||||||
|
{% if machine.traffic_alert_triggered %}
|
||||||
|
<span class="badge" style="background: var(--error-soft); color: var(--error); margin-left: 0.5rem;">已触发预警</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="form-group traffic-limit-group" {% if not machine.traffic_alert_enabled %}style="display: none;"{% endif %}>
|
||||||
|
<label>流量限制 (GB)</label>
|
||||||
|
<input type="number" name="traffic_alert_limit_gb" value="{{ machine.traffic_alert_limit_gb or '' }}" step="0.1" min="0" placeholder="例如: 100">
|
||||||
|
<small class="form-hint">{{ 'Lightsail: 总流量预警' if machine.aws_service == 'lightsail' else 'EC2: 上传流量预警' }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if machine.traffic_alert_triggered %}
|
||||||
|
<div class="form-group" style="margin-top: 0.5rem;">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" onclick="resetTrafficAlert({{ machine.id }})">重置预警状态</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="form-divider"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>备注</label>
|
||||||
|
<input type="text" name="note" value="{{ machine.note or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="enabled" {% if machine.enabled %}checked{% endif %}>
|
||||||
|
<span class="checkbox-text">启用</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions inline">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">保存修改</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" onclick="toggleEdit({{ machine.id }})">取消</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// 切换服务类型标签
|
||||||
|
document.getElementById('new_aws_service').addEventListener('change', function() {
|
||||||
|
const label = document.getElementById('new_instance_id_label');
|
||||||
|
const input = document.getElementById('new_instance_id');
|
||||||
|
const trafficHint = document.getElementById('new_traffic_limit_hint');
|
||||||
|
if (this.value === 'lightsail') {
|
||||||
|
label.textContent = 'Lightsail 实例名';
|
||||||
|
input.placeholder = '例如: my-lightsail-instance';
|
||||||
|
if (trafficHint) trafficHint.textContent = 'Lightsail: 总流量 (上传+下载) 预警';
|
||||||
|
} else {
|
||||||
|
label.textContent = 'EC2 Instance ID';
|
||||||
|
input.placeholder = '例如: i-0123456789abcdef0';
|
||||||
|
if (trafficHint) trafficHint.textContent = 'EC2: 上传流量预警';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换流量预警字段显示
|
||||||
|
function toggleTrafficAlertFields(prefix) {
|
||||||
|
if (prefix === 'new') {
|
||||||
|
const checkbox = document.getElementById('new_traffic_alert_enabled');
|
||||||
|
const group = document.getElementById('new_traffic_limit_group');
|
||||||
|
group.style.display = checkbox.checked ? 'block' : 'none';
|
||||||
|
} else {
|
||||||
|
const editPanel = document.getElementById(prefix);
|
||||||
|
const checkbox = editPanel.querySelector('[name="traffic_alert_enabled"]');
|
||||||
|
const group = editPanel.querySelector('.traffic-limit-group');
|
||||||
|
if (group) group.style.display = checkbox.checked ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置流量预警
|
||||||
|
async function resetTrafficAlert(machineId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/machines/${machineId}/reset-alert`, { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.ok) {
|
||||||
|
showToast('success', result.message);
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
} else {
|
||||||
|
showToast('error', result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', `重置失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加节点
|
||||||
|
document.getElementById('add-machine-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
name: document.getElementById('new_name').value,
|
||||||
|
aws_service: document.getElementById('new_aws_service').value,
|
||||||
|
aws_region: document.getElementById('new_aws_region').value,
|
||||||
|
instance_id: document.getElementById('new_instance_id').value,
|
||||||
|
note: document.getElementById('new_note').value,
|
||||||
|
enabled: document.getElementById('new_enabled').checked,
|
||||||
|
cf_zone_id: document.getElementById('new_cf_zone_id').value,
|
||||||
|
cf_record_name: document.getElementById('new_cf_record_name').value,
|
||||||
|
cf_proxied: document.getElementById('new_cf_proxied').checked,
|
||||||
|
change_interval_minutes: parseInt(document.getElementById('new_change_interval').value) || 60,
|
||||||
|
auto_enabled: document.getElementById('new_auto_enabled').checked,
|
||||||
|
traffic_alert_enabled: document.getElementById('new_traffic_alert_enabled').checked,
|
||||||
|
traffic_alert_limit_gb: parseFloat(document.getElementById('new_traffic_alert_limit_gb').value) || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/machines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
showToast('success', '节点添加成功!');
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
} else {
|
||||||
|
const msg = result.errors ? result.errors.join(', ') : result.message;
|
||||||
|
showToast('error', msg);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', `添加失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleCollapsible(id) {
|
||||||
|
const section = document.getElementById(id);
|
||||||
|
const content = section.querySelector('.collapsible-content');
|
||||||
|
const isOpen = content.style.display !== 'none';
|
||||||
|
content.style.display = isOpen ? 'none' : 'block';
|
||||||
|
section.classList.toggle('open', !isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEdit(id) {
|
||||||
|
const editPanel = document.getElementById(`edit-${id}`);
|
||||||
|
editPanel.style.display = editPanel.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMachine(e, id) {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const formData = {
|
||||||
|
name: form.querySelector('[name="name"]').value,
|
||||||
|
aws_service: form.querySelector('[name="aws_service"]').value,
|
||||||
|
aws_region: form.querySelector('[name="aws_region"]').value,
|
||||||
|
instance_id: form.querySelector('[name="instance_id"]').value,
|
||||||
|
note: form.querySelector('[name="note"]').value,
|
||||||
|
enabled: form.querySelector('[name="enabled"]').checked,
|
||||||
|
cf_zone_id: form.querySelector('[name="cf_zone_id"]').value,
|
||||||
|
cf_record_name: form.querySelector('[name="cf_record_name"]').value,
|
||||||
|
cf_proxied: form.querySelector('[name="cf_proxied"]').checked,
|
||||||
|
change_interval_minutes: parseInt(form.querySelector('[name="change_interval_minutes"]').value) || 60,
|
||||||
|
auto_enabled: form.querySelector('[name="auto_enabled"]').checked,
|
||||||
|
traffic_alert_enabled: form.querySelector('[name="traffic_alert_enabled"]').checked,
|
||||||
|
traffic_alert_limit_gb: parseFloat(form.querySelector('[name="traffic_alert_limit_gb"]').value) || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/machines/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
showToast('success', '节点已更新');
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
} else {
|
||||||
|
showToast('error', result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', `更新失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMachine(id, name) {
|
||||||
|
if (!confirm(`确定要删除节点 "${name}" 吗?此操作不可恢复。`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/machines/${id}`, { method: 'DELETE' });
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
showToast('success', '节点已删除');
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
} else {
|
||||||
|
showToast('error', result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', `删除失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
259
templates/settings.html
Normal file
259
templates/settings.html
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}系统设置 - ProxyAuto Pro{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>系统设置</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="settings-form" class="form-card">
|
||||||
|
<section class="form-section">
|
||||||
|
<h2 class="section-title">AWS 凭证</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="aws_access_key">AWS Access Key</label>
|
||||||
|
{% if config.aws_access_key %}
|
||||||
|
<div class="current-value">当前: {{ mask_secret(config.aws_access_key) }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<input type="text" id="aws_access_key" name="aws_access_key"
|
||||||
|
placeholder="输入新的 Access Key">
|
||||||
|
<small class="form-hint">留空则保持原值不变</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="aws_secret_key">AWS Secret Key</label>
|
||||||
|
{% if config.aws_secret_key %}
|
||||||
|
<div class="current-value">当前: {{ mask_secret(config.aws_secret_key) }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<input type="password" id="aws_secret_key" name="aws_secret_key"
|
||||||
|
placeholder="输入新的 Secret Key">
|
||||||
|
<small class="form-hint">留空则保持原值不变</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: 1rem;">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="release_old_eip" name="release_old_eip"
|
||||||
|
{% if config.release_old_eip %}checked{% endif %}>
|
||||||
|
<span class="checkbox-text">自动释放旧 Elastic IP</span>
|
||||||
|
<small class="form-hint" style="display: block; margin-left: 1.5rem;">建议开启,避免产生闲置 IP 费用</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="form-divider"></div>
|
||||||
|
|
||||||
|
<section class="form-section">
|
||||||
|
<h2 class="section-title">Cloudflare 认证</h2>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.85rem; margin-bottom: 1rem;">
|
||||||
|
Cloudflare 凭证用于更新 DNS 记录。每台机器的域名绑定请在<a href="{{ url_for('machines') }}">节点管理</a>中配置。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>认证方式</label>
|
||||||
|
<div class="segmented-control">
|
||||||
|
<input type="radio" id="auth_api_token" name="cloudflare_auth_type" value="api_token"
|
||||||
|
{% if config.cloudflare_auth_type != 'global_key' %}checked{% endif %}>
|
||||||
|
<label for="auth_api_token">API Token</label>
|
||||||
|
<input type="radio" id="auth_global_key" name="cloudflare_auth_type" value="global_key"
|
||||||
|
{% if config.cloudflare_auth_type == 'global_key' %}checked{% endif %}>
|
||||||
|
<label for="auth_global_key">Global API Key</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="api-token-fields" class="auth-fields" style="{% if config.cloudflare_auth_type == 'global_key' %}display: none;{% endif %}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cf_api_token">API Token</label>
|
||||||
|
{% if config.cf_api_token %}
|
||||||
|
<div class="current-value">当前: {{ mask_secret(config.cf_api_token) }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<input type="password" id="cf_api_token" name="cf_api_token"
|
||||||
|
placeholder="输入新的 API Token">
|
||||||
|
<small class="form-hint">留空则保持原值不变</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="global-key-fields" class="auth-fields" style="{% if config.cloudflare_auth_type != 'global_key' %}display: none;{% endif %}">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cf_email">Cloudflare Email</label>
|
||||||
|
{% if config.cf_email %}
|
||||||
|
<div class="current-value">当前: {{ config.cf_email }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<input type="email" id="cf_email" name="cf_email" value="{{ config.cf_email or '' }}"
|
||||||
|
placeholder="输入 Cloudflare 账户邮箱">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cf_api_key">Global API Key</label>
|
||||||
|
{% if config.cf_api_key %}
|
||||||
|
<div class="current-value">当前: {{ mask_secret(config.cf_api_key) }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<input type="password" id="cf_api_key" name="cf_api_key"
|
||||||
|
placeholder="输入新的 Global API Key">
|
||||||
|
<small class="form-hint">留空则保持原值不变</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="form-divider"></div>
|
||||||
|
|
||||||
|
<section class="form-section">
|
||||||
|
<h2 class="section-title">邮件通知设置</h2>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.85rem; margin-bottom: 1rem;">
|
||||||
|
配置 SMTP 服务器用于发送流量预警邮件通知。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp_host">SMTP 服务器</label>
|
||||||
|
<input type="text" id="smtp_host" name="smtp_host" value="{{ config.smtp_host or '' }}"
|
||||||
|
placeholder="例如: smtp.gmail.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp_port">端口</label>
|
||||||
|
<input type="number" id="smtp_port" name="smtp_port" value="{{ config.smtp_port or 587 }}"
|
||||||
|
placeholder="587">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp_user">SMTP 用户名</label>
|
||||||
|
<input type="text" id="smtp_user" name="smtp_user" value="{{ config.smtp_user or '' }}"
|
||||||
|
placeholder="发件邮箱地址">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp_password">SMTP 密码</label>
|
||||||
|
{% if config.smtp_password %}
|
||||||
|
<div class="current-value">当前: {{ mask_secret(config.smtp_password) }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<input type="password" id="smtp_password" name="smtp_password"
|
||||||
|
placeholder="输入新密码 (留空保持原值)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="alert_email">预警接收邮箱</label>
|
||||||
|
<input type="email" id="alert_email" name="alert_email" value="{{ config.alert_email or '' }}"
|
||||||
|
placeholder="接收预警通知的邮箱">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display: flex; align-items: center; padding-top: 1.5rem;">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="smtp_use_tls" name="smtp_use_tls"
|
||||||
|
{% if config.smtp_use_tls %}checked{% endif %}>
|
||||||
|
<span class="checkbox-text">使用 STARTTLS</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top: 0.5rem;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="testEmail()">
|
||||||
|
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||||
|
<polyline points="22,6 12,13 2,6"/>
|
||||||
|
</svg>
|
||||||
|
发送测试邮件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<span class="btn-text">保存系统配置</span>
|
||||||
|
<span class="btn-loading" style="display: none;">
|
||||||
|
<svg class="spinner" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="32" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
保存中...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// 切换认证方式
|
||||||
|
document.querySelectorAll('input[name="cloudflare_auth_type"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', function() {
|
||||||
|
document.getElementById('api-token-fields').style.display =
|
||||||
|
this.value === 'api_token' ? 'block' : 'none';
|
||||||
|
document.getElementById('global-key-fields').style.display =
|
||||||
|
this.value === 'global_key' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
document.getElementById('settings-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const btn = this.querySelector('button[type="submit"]');
|
||||||
|
const btnText = btn.querySelector('.btn-text');
|
||||||
|
const btnLoading = btn.querySelector('.btn-loading');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btnText.style.display = 'none';
|
||||||
|
btnLoading.style.display = 'flex';
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
aws_access_key: document.getElementById('aws_access_key').value,
|
||||||
|
aws_secret_key: document.getElementById('aws_secret_key').value,
|
||||||
|
cloudflare_auth_type: document.querySelector('input[name="cloudflare_auth_type"]:checked').value,
|
||||||
|
cf_api_token: document.getElementById('cf_api_token').value,
|
||||||
|
cf_email: document.getElementById('cf_email').value,
|
||||||
|
cf_api_key: document.getElementById('cf_api_key').value,
|
||||||
|
release_old_eip: document.getElementById('release_old_eip').checked,
|
||||||
|
smtp_host: document.getElementById('smtp_host').value,
|
||||||
|
smtp_port: parseInt(document.getElementById('smtp_port').value) || 587,
|
||||||
|
smtp_user: document.getElementById('smtp_user').value,
|
||||||
|
smtp_password: document.getElementById('smtp_password').value,
|
||||||
|
smtp_use_tls: document.getElementById('smtp_use_tls').checked,
|
||||||
|
alert_email: document.getElementById('alert_email').value,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
showToast('success', '配置已更新!');
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
} else {
|
||||||
|
showToast('error', result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', `保存失败: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btnText.style.display = 'inline';
|
||||||
|
btnLoading.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 测试邮件发送
|
||||||
|
async function testEmail() {
|
||||||
|
const smtpHost = document.getElementById('smtp_host').value;
|
||||||
|
const alertEmail = document.getElementById('alert_email').value;
|
||||||
|
|
||||||
|
if (!smtpHost || !alertEmail) {
|
||||||
|
showToast('error', '请先填写 SMTP 服务器和接收邮箱');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/test-email', { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.ok) {
|
||||||
|
showToast('success', '测试邮件发送成功,请检查收件箱');
|
||||||
|
} else {
|
||||||
|
showToast('error', result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', `发送失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
311
templates/traffic.html
Normal file
311
templates/traffic.html
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}流量监控 - ProxyAuto Pro{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>流量监控</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not machines %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
|
||||||
|
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
||||||
|
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
<h3>暂无节点</h3>
|
||||||
|
<p>请先前往<a href="{{ url_for('machines') }}">节点管理</a>添加机器</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="traffic-query-card form-card">
|
||||||
|
<h3 class="form-section-title">查询条件</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="machine_id">选择节点</label>
|
||||||
|
<select id="machine_id" name="machine_id">
|
||||||
|
{% for machine in machines %}
|
||||||
|
<option value="{{ machine.id }}" data-service="{{ machine.aws_service }}">
|
||||||
|
{{ machine.name }} ({{ machine.aws_service|upper }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="query_type">查询类型</label>
|
||||||
|
<select id="query_type" name="query_type" onchange="updateDateInputs()">
|
||||||
|
<option value="month">按月查询</option>
|
||||||
|
<option value="day">按天查询</option>
|
||||||
|
<option value="range">自定义时间段</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="month-inputs" class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="query_year">年份</label>
|
||||||
|
<select id="query_year">
|
||||||
|
<option value="2026">2026</option>
|
||||||
|
<option value="2025">2025</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="query_month">月份</label>
|
||||||
|
<select id="query_month">
|
||||||
|
<option value="1">1月</option>
|
||||||
|
<option value="2">2月</option>
|
||||||
|
<option value="3">3月</option>
|
||||||
|
<option value="4">4月</option>
|
||||||
|
<option value="5">5月</option>
|
||||||
|
<option value="6">6月</option>
|
||||||
|
<option value="7">7月</option>
|
||||||
|
<option value="8">8月</option>
|
||||||
|
<option value="9">9月</option>
|
||||||
|
<option value="10">10月</option>
|
||||||
|
<option value="11">11月</option>
|
||||||
|
<option value="12">12月</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="day-inputs" class="form-grid" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="query_date">日期</label>
|
||||||
|
<input type="text" id="query_date" class="datepicker" placeholder="点击选择日期" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="range-inputs" class="form-grid" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="start_date">开始日期</label>
|
||||||
|
<input type="text" id="start_date" class="datepicker" placeholder="点击选择日期" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="end_date">结束日期</label>
|
||||||
|
<input type="text" id="end_date" class="datepicker" placeholder="点击选择日期" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="queryTraffic()">
|
||||||
|
<svg class="btn-icon-normal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="btn-icon-loading spinner" viewBox="0 0 24 24" style="display: none;">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="32" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span class="btn-text">查询流量</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="traffic-result" style="display: none;">
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">流量统计</h2>
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">下载流量</div>
|
||||||
|
<div class="metric-value" id="network-in">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">上传流量</div>
|
||||||
|
<div class="metric-value" id="network-out">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">总流量</div>
|
||||||
|
<div class="metric-value" id="network-total">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">流量趋势</h2>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="traffic-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/material_blue.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/zh.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let trafficChart = null;
|
||||||
|
let queryDatePicker, startDatePicker, endDatePicker;
|
||||||
|
|
||||||
|
// 初始化日期选择器
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('query_year').value = now.getFullYear();
|
||||||
|
document.getElementById('query_month').value = now.getMonth() + 1;
|
||||||
|
|
||||||
|
// Flatpickr 通用配置
|
||||||
|
const fpConfig = {
|
||||||
|
locale: 'zh',
|
||||||
|
dateFormat: 'Y-m-d',
|
||||||
|
disableMobile: true,
|
||||||
|
allowInput: false,
|
||||||
|
defaultDate: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化日期选择器
|
||||||
|
queryDatePicker = flatpickr('#query_date', fpConfig);
|
||||||
|
startDatePicker = flatpickr('#start_date', fpConfig);
|
||||||
|
endDatePicker = flatpickr('#end_date', fpConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateDateInputs() {
|
||||||
|
const type = document.getElementById('query_type').value;
|
||||||
|
document.getElementById('month-inputs').style.display = type === 'month' ? 'grid' : 'none';
|
||||||
|
document.getElementById('day-inputs').style.display = type === 'day' ? 'grid' : 'none';
|
||||||
|
document.getElementById('range-inputs').style.display = type === 'range' ? 'grid' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const k = 1024;
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryTraffic() {
|
||||||
|
const btn = document.querySelector('.form-actions button');
|
||||||
|
const normalIcon = btn.querySelector('.btn-icon-normal');
|
||||||
|
const loadingIcon = btn.querySelector('.btn-icon-loading');
|
||||||
|
const btnText = btn.querySelector('.btn-text');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
normalIcon.style.display = 'none';
|
||||||
|
loadingIcon.style.display = 'block';
|
||||||
|
btnText.textContent = '查询中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const machineId = document.getElementById('machine_id').value;
|
||||||
|
const queryType = document.getElementById('query_type').value;
|
||||||
|
|
||||||
|
let startDate, endDate;
|
||||||
|
|
||||||
|
if (queryType === 'month') {
|
||||||
|
const year = document.getElementById('query_year').value;
|
||||||
|
const month = document.getElementById('query_month').value;
|
||||||
|
startDate = `${year}-${month.padStart(2, '0')}-01`;
|
||||||
|
// 计算月末
|
||||||
|
const lastDay = new Date(year, month, 0).getDate();
|
||||||
|
endDate = `${year}-${month.padStart(2, '0')}-${lastDay}`;
|
||||||
|
} else if (queryType === 'day') {
|
||||||
|
startDate = document.getElementById('query_date').value;
|
||||||
|
endDate = startDate;
|
||||||
|
} else {
|
||||||
|
startDate = document.getElementById('start_date').value;
|
||||||
|
endDate = document.getElementById('end_date').value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/traffic/${machineId}?start=${startDate}&end=${endDate}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
displayTrafficResult(result);
|
||||||
|
} else {
|
||||||
|
showToast('error', result.message || '查询失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', `查询失败: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
normalIcon.style.display = 'block';
|
||||||
|
loadingIcon.style.display = 'none';
|
||||||
|
btnText.textContent = '查询流量';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayTrafficResult(data) {
|
||||||
|
document.getElementById('traffic-result').style.display = 'block';
|
||||||
|
|
||||||
|
document.getElementById('network-in').textContent = formatBytes(data.network_in);
|
||||||
|
document.getElementById('network-out').textContent = formatBytes(data.network_out);
|
||||||
|
document.getElementById('network-total').textContent = formatBytes(data.total);
|
||||||
|
|
||||||
|
// 绘制图表
|
||||||
|
renderChart(data.data_points);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart(dataPoints) {
|
||||||
|
const ctx = document.getElementById('traffic-chart').getContext('2d');
|
||||||
|
|
||||||
|
if (trafficChart) {
|
||||||
|
trafficChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = dataPoints.map(dp => {
|
||||||
|
const date = new Date(dp.timestamp);
|
||||||
|
return `${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:00`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const inData = dataPoints.map(dp => dp.network_in / (1024 * 1024)); // MB
|
||||||
|
const outData = dataPoints.map(dp => dp.network_out / (1024 * 1024)); // MB
|
||||||
|
|
||||||
|
trafficChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '下载 (MB)',
|
||||||
|
data: inData,
|
||||||
|
borderColor: '#10B981',
|
||||||
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '上传 (MB)',
|
||||||
|
data: outData,
|
||||||
|
borderColor: '#1F6BFF',
|
||||||
|
backgroundColor: 'rgba(31, 107, 255, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '时间'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '流量 (MB)'
|
||||||
|
},
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user