feat:自动更换ip+流量监控
This commit is contained in:
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