feat:自动更换ip+流量监控

This commit is contained in:
2026-01-07 17:19:53 +08:00
commit 035da64084
27 changed files with 6182 additions and 0 deletions

91
templates/base.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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 %}