feat: 拆分html,完善secret处理
This commit is contained in:
parent
a3ddc33d17
commit
2d40357789
|
|
@ -64,7 +64,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("/api/mapping/list", h.authMiddleware(h.handleListMappings))
|
||||
mux.HandleFunc("/api/stats/traffic", h.authMiddleware(h.handleGetTrafficStats))
|
||||
mux.HandleFunc("/api/stats/monitor", h.authMiddleware(h.handleTrafficMonitor))
|
||||
mux.HandleFunc("/admin", h.authMiddleware(h.handleManagement))
|
||||
mux.HandleFunc("/admin", h.handleManagement)
|
||||
mux.HandleFunc("/health", h.handleHealth)
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +73,9 @@ func (h *Handler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// 从请求头中获取 API Key
|
||||
apiKey := r.Header.Get("X-API-Key")
|
||||
if apiKey == "" {
|
||||
apiKey = r.Header.Get("secret")
|
||||
}
|
||||
|
||||
// 如果请求头中没有,尝试从查询参数中获取
|
||||
if apiKey == "" {
|
||||
|
|
@ -363,11 +366,11 @@ func (h *Handler) handleGetTrafficStats(w http.ResponseWriter, r *http.Request)
|
|||
// handleTrafficMonitor 流量监控页面
|
||||
func (h *Handler) handleTrafficMonitor(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, html)
|
||||
fmt.Fprint(w, GetTraffticMonitorHTML())
|
||||
}
|
||||
|
||||
// handleManagement 管理页面
|
||||
func (h *Handler) handleManagement(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, managementHTML)
|
||||
fmt.Fprint(w, GetManagementHTML())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,341 +1,31 @@
|
|||
package api
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>流量监控 - Go Tunnel</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
color: #667eea;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.3em;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.chart-container h2 {
|
||||
color: #667eea;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.mapping-list {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.mapping-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.mapping-item h4 {
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mapping-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #4CAF50;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.update-time {
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin-top: 20px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1><span class="status-indicator"></span> 流量监控面板</h1>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>总发送流量</h3>
|
||||
<div class="stat-value" id="total-sent">0 B</div>
|
||||
<div class="stat-label">Total Sent</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>总接收流量</h3>
|
||||
<div class="stat-value" id="total-received">0 B</div>
|
||||
<div class="stat-label">Total Received</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>隧道发送</h3>
|
||||
<div class="stat-value" id="tunnel-sent">0 B</div>
|
||||
<div class="stat-label">Tunnel Sent</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>隧道接收</h3>
|
||||
<div class="stat-value" id="tunnel-received">0 B</div>
|
||||
<div class="stat-label">Tunnel Received</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h2>实时流量趋势</h2>
|
||||
<canvas id="trafficChart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="mapping-list">
|
||||
<h2 style="color: #667eea; margin-bottom: 20px;">端口映射流量</h2>
|
||||
<div id="mapping-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="update-time">
|
||||
最后更新: <span id="update-time">-</span> | 每 3 秒自动刷新
|
||||
</div>
|
||||
</div>
|
||||
import "port-forward/server/html"
|
||||
|
||||
<script>
|
||||
// 初始化图表
|
||||
const ctx = document.getElementById('trafficChart');
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: '发送 (KB/s)',
|
||||
data: [],
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: '接收 (KB/s)',
|
||||
data: [],
|
||||
borderColor: '#764ba2',
|
||||
backgroundColor: 'rgba(118, 75, 162, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 2.5,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value.toFixed(2) + ' KB/s';
|
||||
}
|
||||
}
|
||||
},
|
||||
x: {
|
||||
display: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
func GetTraffticMonitorHTML() string {
|
||||
file, err := html.AssetsFS.Open("traffic_monitor.html")
|
||||
if err != nil {
|
||||
return "<h1>Failed to load HTML</h1>"
|
||||
}
|
||||
defer file.Close()
|
||||
buf := make([]byte, 40960) // 40KB buffer
|
||||
n, err := file.Read(buf)
|
||||
if err != nil {
|
||||
return "<h1>Failed to read HTML</h1>"
|
||||
}
|
||||
return string(buf[:n])
|
||||
}
|
||||
|
||||
let lastData = null;
|
||||
const maxDataPoints = 20;
|
||||
|
||||
// 格式化字节数
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 更新统计数据
|
||||
function updateStats(data) {
|
||||
document.getElementById('total-sent').textContent = formatBytes(data.total_sent);
|
||||
document.getElementById('total-received').textContent = formatBytes(data.total_received);
|
||||
document.getElementById('tunnel-sent').textContent = formatBytes(data.tunnel.bytes_sent);
|
||||
document.getElementById('tunnel-received').textContent = formatBytes(data.tunnel.bytes_received);
|
||||
|
||||
// 更新时间
|
||||
const now = new Date();
|
||||
document.getElementById('update-time').textContent = now.toLocaleTimeString('zh-CN');
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
function updateChart(data) {
|
||||
const now = new Date().toLocaleTimeString('zh-CN');
|
||||
|
||||
// 计算速率 (如果有上次数据)
|
||||
let sendRate = 0;
|
||||
let recvRate = 0;
|
||||
|
||||
if (lastData) {
|
||||
const timeDiff = 3; // 3秒间隔
|
||||
sendRate = (data.total_sent - lastData.total_sent) / timeDiff / 1024; // KB/s
|
||||
recvRate = (data.total_received - lastData.total_received) / timeDiff / 1024; // KB/s
|
||||
}
|
||||
|
||||
lastData = data;
|
||||
|
||||
// 添加新数据点
|
||||
chart.data.labels.push(now);
|
||||
chart.data.datasets[0].data.push(sendRate);
|
||||
chart.data.datasets[1].data.push(recvRate);
|
||||
|
||||
// 限制数据点数量
|
||||
if (chart.data.labels.length > maxDataPoints) {
|
||||
chart.data.labels.shift();
|
||||
chart.data.datasets[0].data.shift();
|
||||
chart.data.datasets[1].data.shift();
|
||||
}
|
||||
|
||||
chart.update('none'); // 无动画更新,更流畅
|
||||
}
|
||||
|
||||
// 更新端口映射列表
|
||||
function updateMappings(mappings) {
|
||||
const container = document.getElementById('mapping-list');
|
||||
|
||||
if (mappings.length === 0) {
|
||||
container.innerHTML = '<p style="color: #999; text-align: center;">暂无端口映射</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = mappings.map(m =>
|
||||
'<div class="mapping-item">' +
|
||||
'<h4>端口 ' + m.port + '</h4>' +
|
||||
'<div class="mapping-stats">' +
|
||||
'<span>发送: ' + formatBytes(m.bytes_sent) + '</span>' +
|
||||
'<span>接收: ' + formatBytes(m.bytes_received) + '</span>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
// 获取流量数据
|
||||
async function fetchTrafficData() {
|
||||
try {
|
||||
const response = await fetch('/api/stats/traffic');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
updateStats(result.data);
|
||||
updateChart(result.data);
|
||||
updateMappings(result.data.mappings || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取流量数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
fetchTrafficData();
|
||||
|
||||
// 定时刷新 (每3秒)
|
||||
setInterval(fetchTrafficData, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
func GetManagementHTML() string {
|
||||
file, err := html.AssetsFS.Open("management.html")
|
||||
if err != nil {
|
||||
return "<h1>Failed to load HTML</h1>"
|
||||
}
|
||||
defer file.Close()
|
||||
buf := make([]byte, 40960) // 40KB buffer
|
||||
n, err := file.Read(buf)
|
||||
if err != nil {
|
||||
return "<h1>Failed to read HTML</h1>"
|
||||
}
|
||||
return string(buf[:n])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package html
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.html
|
||||
var AssetsFS embed.FS
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
package api
|
||||
|
||||
const managementHTML = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
|
@ -13,27 +11,27 @@ const managementHTML = `
|
|||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
|
@ -41,7 +39,7 @@ const managementHTML = `
|
|||
overflow: hidden;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
.nav-tab {
|
||||
flex: 1;
|
||||
padding: 15px 20px;
|
||||
|
|
@ -52,45 +50,45 @@ const managementHTML = `
|
|||
transition: all 0.3s;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
|
||||
.nav-tab.active {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #667eea;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
.nav-tab:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
.tab-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 0 0 15px 15px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
|
||||
.tab-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.tab-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
|
|
@ -98,8 +96,9 @@ const managementHTML = `
|
|||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select {
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 12px 15px;
|
||||
border: 2px solid #ddd;
|
||||
|
|
@ -107,13 +106,14 @@ const managementHTML = `
|
|||
font-size: 1em;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus, .form-group select:focus {
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 10px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
|
||||
.btn {
|
||||
padding: 12px 25px;
|
||||
border: none;
|
||||
|
|
@ -125,42 +125,42 @@ const managementHTML = `
|
|||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(45deg, #ff6b6b, #ee5a52);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
|
||||
.mapping-list {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
.mapping-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
|
|
@ -172,33 +172,33 @@ const managementHTML = `
|
|||
align-items: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
|
||||
.mapping-item:hover {
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
|
||||
.mapping-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
.mapping-info h4 {
|
||||
color: #667eea;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
.mapping-details {
|
||||
color: #666;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
|
||||
.mapping-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
|
|
@ -207,48 +207,48 @@ const managementHTML = `
|
|||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
||||
.status-tunnel {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
|
||||
.status-direct {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
|
||||
.message {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
.message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
|
||||
.message.info {
|
||||
background: #cce7ff;
|
||||
color: #004085;
|
||||
border: 1px solid #b3d7ff;
|
||||
}
|
||||
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
|
|
@ -258,31 +258,36 @@ const managementHTML = `
|
|||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
|
|
@ -290,67 +295,68 @@ const managementHTML = `
|
|||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
.mapping-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
|
||||
.mapping-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
.quick-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🚀 端口映射管理中心</h1>
|
||||
|
||||
|
||||
<div class="nav-tabs">
|
||||
<button class="nav-tab active" onclick="switchTab('overview')">概览</button>
|
||||
<button class="nav-tab" onclick="switchTab('create')">创建映射</button>
|
||||
<button class="nav-tab" onclick="switchTab('manage')">管理映射</button>
|
||||
<button class="nav-tab" onclick="switchTab('monitor')">流量监控</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- 概览页面 -->
|
||||
<div id="overview" class="tab-pane active">
|
||||
<h2 style="color: #667eea; margin-bottom: 20px;">系统概览</h2>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-mappings">-</div>
|
||||
|
|
@ -369,7 +375,7 @@ const managementHTML = `
|
|||
<div class="stat-label">隧道状态</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="quick-actions">
|
||||
<button class="btn btn-primary" onclick="switchTab('create')">
|
||||
➕ 快速创建映射
|
||||
|
|
@ -381,7 +387,7 @@ const managementHTML = `
|
|||
📊 查看监控
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="recent-mappings">
|
||||
<h3 style="color: #667eea; margin-bottom: 15px;">最近的映射</h3>
|
||||
<div id="recent-mappings-list" class="loading">
|
||||
|
|
@ -390,33 +396,30 @@ const managementHTML = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 创建映射页面 -->
|
||||
<div id="create" class="tab-pane">
|
||||
<h2 style="color: #667eea; margin-bottom: 20px;">创建新的端口映射</h2>
|
||||
|
||||
|
||||
<div id="create-message"></div>
|
||||
|
||||
|
||||
<form id="create-form">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="source-port">源端口 (本地监听端口) *</label>
|
||||
<input type="number" id="source-port" min="1" max="65535" required
|
||||
placeholder="例如: 8080">
|
||||
<input type="number" id="source-port" min="1" max="65535" required placeholder="例如: 8080">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<label for="target-host">目标主机 *</label>
|
||||
<input type="text" id="target-host" required
|
||||
placeholder="例如: localhost 或 192.168.1.100">
|
||||
<input type="text" id="target-host" required placeholder="例如: localhost 或 192.168.1.100">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<label for="target-port">目标端口 *</label>
|
||||
<input type="number" id="target-port" min="1" max="65535" required
|
||||
placeholder="例如: 3000">
|
||||
<input type="number" id="target-port" min="1" max="65535" required placeholder="例如: 3000">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<label>连接模式</label>
|
||||
<div class="checkbox-group">
|
||||
|
|
@ -429,7 +432,7 @@ const managementHTML = `
|
|||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style="text-align: center; margin-top: 30px;">
|
||||
<button type="submit" class="btn btn-primary" style="margin-right: 15px;">
|
||||
🚀 创建映射
|
||||
|
|
@ -440,29 +443,29 @@ const managementHTML = `
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 管理映射页面 -->
|
||||
<div id="manage" class="tab-pane">
|
||||
<h2 style="color: #667eea; margin-bottom: 20px;">管理端口映射</h2>
|
||||
|
||||
|
||||
<div id="manage-message"></div>
|
||||
|
||||
|
||||
<div style="text-align: right; margin-bottom: 20px;">
|
||||
<button class="btn btn-secondary" onclick="loadMappings()">
|
||||
🔄 刷新列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="mappings-list" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>加载映射列表中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 流量监控页面 -->
|
||||
<div id="monitor" class="tab-pane">
|
||||
<h2 style="color: #667eea; margin-bottom: 20px;">流量监控</h2>
|
||||
|
||||
|
||||
<div style="text-align: center; padding: 40px;">
|
||||
<p style="color: #666; margin-bottom: 20px;">
|
||||
点击下方按钮打开详细的流量监控页面
|
||||
|
|
@ -478,25 +481,116 @@ const managementHTML = `
|
|||
<script>
|
||||
// 全局变量
|
||||
let currentMappings = [];
|
||||
|
||||
let apiKey = '';
|
||||
|
||||
// 检查是否已有 API Key
|
||||
function checkAuth() {
|
||||
const savedKey = sessionStorage.getItem('apiKey');
|
||||
if (savedKey) {
|
||||
apiKey = savedKey;
|
||||
showMainContent();
|
||||
} else {
|
||||
showAuthDialog();
|
||||
}
|
||||
}
|
||||
|
||||
// 显示认证对话框
|
||||
function showAuthDialog() {
|
||||
const authHTML = '' +
|
||||
'<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; ' +
|
||||
' background: rgba(0,0,0,0.5); display: flex; align-items: center; ' +
|
||||
' justify-content: center; z-index: 9999;">' +
|
||||
' <div style="background: white; padding: 40px; border-radius: 15px; ' +
|
||||
' box-shadow: 0 10px 40px rgba(0,0,0,0.3); max-width: 400px; width: 90%;">' +
|
||||
' <h2 style="color: #667eea; margin-bottom: 20px; text-align: center;">' +
|
||||
' 🔐 请输入访问密钥' +
|
||||
' </h2>' +
|
||||
' <div id="auth-error" style="display: none;" class="message error"></div>' +
|
||||
' <form id="auth-form">' +
|
||||
' <div class="form-group">' +
|
||||
' <label for="secret-input">API Key / Secret</label>' +
|
||||
' <input type="password" id="secret-input" required ' +
|
||||
' placeholder="请输入您的访问密钥"' +
|
||||
' style="width: 100%; padding: 12px; border: 2px solid #ddd; ' +
|
||||
' border-radius: 8px; font-size: 1em;">' +
|
||||
' </div>' +
|
||||
' <button type="submit" class="btn btn-primary" ' +
|
||||
' style="width: 100%; margin-top: 20px;">' +
|
||||
' 验证并进入' +
|
||||
' </button>' +
|
||||
' </form>' +
|
||||
' </div>' +
|
||||
'</div>';
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', authHTML);
|
||||
|
||||
document.getElementById('auth-form').addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
const secretInput = document.getElementById('secret-input');
|
||||
const secret = secretInput.value.trim();
|
||||
|
||||
if (!secret) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 API Key
|
||||
try {
|
||||
const response = await fetch('/health?api_key=' + encodeURIComponent(secret));
|
||||
|
||||
if (response.ok) {
|
||||
// 验证成功
|
||||
apiKey = secret;
|
||||
sessionStorage.setItem('apiKey', secret);
|
||||
document.querySelector('[style*="position: fixed"]').remove();
|
||||
showMainContent();
|
||||
} else {
|
||||
// 验证失败
|
||||
const errorDiv = document.getElementById('auth-error');
|
||||
errorDiv.textContent = '❌ 访问密钥无效,请重试';
|
||||
errorDiv.style.display = 'block';
|
||||
secretInput.value = '';
|
||||
secretInput.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorDiv = document.getElementById('auth-error');
|
||||
errorDiv.textContent = '❌ 验证失败: ' + error.message;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('secret-input').focus();
|
||||
}
|
||||
|
||||
// 显示主内容
|
||||
function showMainContent() {
|
||||
document.querySelector('.container').style.display = 'block';
|
||||
refreshOverview();
|
||||
}
|
||||
|
||||
// 获取带 API Key 的 URL
|
||||
function getApiUrl(url) {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return url + separator + 'api_key=' + encodeURIComponent(apiKey);
|
||||
}
|
||||
|
||||
// 切换标签页
|
||||
function switchTab(tabName) {
|
||||
// 隐藏所有标签页内容
|
||||
document.querySelectorAll('.tab-pane').forEach(pane => {
|
||||
pane.classList.remove('active');
|
||||
});
|
||||
|
||||
|
||||
// 移除所有标签页按钮的激活状态
|
||||
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
|
||||
|
||||
// 显示选中的标签页
|
||||
document.getElementById(tabName).classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
|
||||
|
||||
// 根据标签页执行相应的初始化
|
||||
switch(tabName) {
|
||||
switch (tabName) {
|
||||
case 'overview':
|
||||
refreshOverview();
|
||||
break;
|
||||
|
|
@ -505,12 +599,12 @@ const managementHTML = `
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 显示消息
|
||||
function showMessage(containerId, type, message) {
|
||||
const container = document.getElementById(containerId);
|
||||
container.innerHTML = '<div class="message ' + type + '">' + message + '</div>';
|
||||
|
||||
|
||||
// 3秒后自动隐藏成功消息
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
|
|
@ -518,29 +612,29 @@ const managementHTML = `
|
|||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 创建映射表单提交
|
||||
document.getElementById('create-form').addEventListener('submit', async function(e) {
|
||||
document.getElementById('create-form').addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
const formData = {
|
||||
source_port: parseInt(document.getElementById('source-port').value),
|
||||
target_host: document.getElementById('target-host').value,
|
||||
target_port: parseInt(document.getElementById('target-port').value),
|
||||
use_tunnel: document.getElementById('use-tunnel').checked
|
||||
};
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/mapping/create', {
|
||||
const response = await fetch(getApiUrl('/api/mapping/create'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
if (result.success) {
|
||||
showMessage('create-message', 'success', '✅ ' + result.message);
|
||||
resetCreateForm();
|
||||
|
|
@ -551,29 +645,29 @@ const managementHTML = `
|
|||
showMessage('create-message', 'error', '❌ 网络错误: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 重置创建表单
|
||||
function resetCreateForm() {
|
||||
document.getElementById('create-form').reset();
|
||||
}
|
||||
|
||||
|
||||
// 删除映射
|
||||
async function deleteMapping(port) {
|
||||
if (!confirm('确定要删除端口 ' + port + ' 的映射吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/mapping/remove', {
|
||||
const response = await fetch(getApiUrl('/api/mapping/remove'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ port: port })
|
||||
});
|
||||
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
if (result.success) {
|
||||
showMessage('manage-message', 'success', '✅ ' + result.message);
|
||||
loadMappings(); // 重新加载列表
|
||||
|
|
@ -584,16 +678,16 @@ const managementHTML = `
|
|||
showMessage('manage-message', 'error', '❌ 网络错误: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 加载映射列表
|
||||
async function loadMappings() {
|
||||
const container = document.getElementById('mappings-list');
|
||||
container.innerHTML = '<div class="loading"><div class="spinner"></div><p>加载中...</p></div>';
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/mapping/list');
|
||||
const response = await fetch(getApiUrl('/api/mapping/list'));
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
if (result.success) {
|
||||
currentMappings = result.data.mappings || [];
|
||||
renderMappings(currentMappings);
|
||||
|
|
@ -604,68 +698,68 @@ const managementHTML = `
|
|||
container.innerHTML = '<div class="message error">❌ 网络错误: ' + error.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 渲染映射列表
|
||||
function renderMappings(mappings) {
|
||||
const container = document.getElementById('mappings-list');
|
||||
|
||||
|
||||
if (mappings.length === 0) {
|
||||
container.innerHTML = '<div class="message info">📝 暂无端口映射,点击"创建映射"标签页开始创建。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = mappings.map(mapping =>
|
||||
|
||||
const html = mappings.map(mapping =>
|
||||
'<div class="mapping-item">' +
|
||||
'<div class="mapping-info">' +
|
||||
'<h4>端口 ' + mapping.source_port + '</h4>' +
|
||||
'<div class="mapping-details">' +
|
||||
'<strong>目标:</strong> ' + mapping.target_host + ':' + mapping.target_port + '<br>' +
|
||||
'<strong>模式:</strong> ' +
|
||||
'<span class="status-badge ' + (mapping.use_tunnel ? 'status-tunnel' : 'status-direct') + '">' +
|
||||
(mapping.use_tunnel ? '隧道模式' : '直连模式') +
|
||||
'</span><br>' +
|
||||
'<strong>创建时间:</strong> ' + new Date(mapping.created_at).toLocaleString('zh-CN') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="mapping-actions">' +
|
||||
'<button class="btn btn-danger" onclick="deleteMapping(' + mapping.source_port + ')">' +
|
||||
'🗑️ 删除' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="mapping-info">' +
|
||||
'<h4>端口 ' + mapping.source_port + '</h4>' +
|
||||
'<div class="mapping-details">' +
|
||||
'<strong>目标:</strong> ' + mapping.target_host + ':' + mapping.target_port + '<br>' +
|
||||
'<strong>模式:</strong> ' +
|
||||
'<span class="status-badge ' + (mapping.use_tunnel ? 'status-tunnel' : 'status-direct') + '">' +
|
||||
(mapping.use_tunnel ? '隧道模式' : '直连模式') +
|
||||
'</span><br>' +
|
||||
'<strong>创建时间:</strong> ' + new Date(mapping.created_at).toLocaleString('zh-CN') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="mapping-actions">' +
|
||||
'<button class="btn btn-danger" onclick="deleteMapping(' + mapping.source_port + ')">' +
|
||||
'🗑️ 删除' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
// 刷新概览页面
|
||||
async function refreshOverview() {
|
||||
try {
|
||||
// 加载映射列表
|
||||
const mappingsResponse = await fetch('/api/mapping/list');
|
||||
const mappingsResponse = await fetch(getApiUrl('/api/mapping/list'));
|
||||
const mappingsResult = await mappingsResponse.json();
|
||||
|
||||
|
||||
// 加载健康状态
|
||||
const healthResponse = await fetch('/health');
|
||||
const healthResponse = await fetch(getApiUrl('/health'));
|
||||
const healthResult = await healthResponse.json();
|
||||
|
||||
|
||||
if (mappingsResult.success) {
|
||||
const mappings = mappingsResult.data.mappings || [];
|
||||
const tunnelMappings = mappings.filter(m => m.use_tunnel).length;
|
||||
const directMappings = mappings.filter(m => !m.use_tunnel).length;
|
||||
|
||||
|
||||
document.getElementById('total-mappings').textContent = mappings.length;
|
||||
document.getElementById('tunnel-mappings').textContent = tunnelMappings;
|
||||
document.getElementById('direct-mappings').textContent = directMappings;
|
||||
|
||||
|
||||
// 渲染最近的映射(最多5个)
|
||||
const recentMappings = mappings.slice(-5).reverse();
|
||||
renderRecentMappings(recentMappings);
|
||||
}
|
||||
|
||||
|
||||
if (healthResult) {
|
||||
const tunnelStatus = healthResult.tunnel_enabled ?
|
||||
(healthResult.tunnel_connected ? '🟢 已连接' : '🟡 未连接') :
|
||||
const tunnelStatus = healthResult.tunnel_enabled ?
|
||||
(healthResult.tunnel_connected ? '🟢 已连接' : '🟡 未连接') :
|
||||
'🔴 未启用';
|
||||
document.getElementById('tunnel-status').textContent = tunnelStatus;
|
||||
}
|
||||
|
|
@ -673,43 +767,45 @@ const managementHTML = `
|
|||
console.error('刷新概览失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 渲染最近的映射
|
||||
function renderRecentMappings(mappings) {
|
||||
const container = document.getElementById('recent-mappings-list');
|
||||
|
||||
|
||||
if (mappings.length === 0) {
|
||||
container.innerHTML = '<div class="message info">暂无映射记录</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = mappings.map(mapping =>
|
||||
|
||||
const html = mappings.map(mapping =>
|
||||
'<div class="mapping-item">' +
|
||||
'<div class="mapping-info">' +
|
||||
'<h4>端口 ' + mapping.source_port + '</h4>' +
|
||||
'<div class="mapping-details">' +
|
||||
'<strong>目标:</strong> ' + mapping.target_host + ':' + mapping.target_port + ' | ' +
|
||||
'<span class="status-badge ' + (mapping.use_tunnel ? 'status-tunnel' : 'status-direct') + '">' +
|
||||
(mapping.use_tunnel ? '隧道' : '直连') +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="mapping-info">' +
|
||||
'<h4>端口 ' + mapping.source_port + '</h4>' +
|
||||
'<div class="mapping-details">' +
|
||||
'<strong>目标:</strong> ' + mapping.target_host + ':' + mapping.target_port + ' | ' +
|
||||
'<span class="status-badge ' + (mapping.use_tunnel ? 'status-tunnel' : 'status-direct') + '">' +
|
||||
(mapping.use_tunnel ? '隧道' : '直连') +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
// 打开流量监控页面
|
||||
function openTrafficMonitor() {
|
||||
window.open('/api/stats/monitor', '_blank');
|
||||
window.open(getApiUrl('/api/stats/monitor'), '_blank');
|
||||
}
|
||||
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
refreshOverview();
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// 隐藏主内容,等待认证
|
||||
document.querySelector('.container').style.display = 'none';
|
||||
checkAuth();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
</html>
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>流量监控 - Go Tunnel</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
color: #667eea;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.3em;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.chart-container h2 {
|
||||
color: #667eea;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.mapping-list {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.mapping-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.mapping-item h4 {
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mapping-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #4CAF50;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.update-time {
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin-top: 20px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1><span class="status-indicator"></span> 流量监控面板</h1>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>总发送流量</h3>
|
||||
<div class="stat-value" id="total-sent">0 B</div>
|
||||
<div class="stat-label">Total Sent</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>总接收流量</h3>
|
||||
<div class="stat-value" id="total-received">0 B</div>
|
||||
<div class="stat-label">Total Received</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>隧道发送</h3>
|
||||
<div class="stat-value" id="tunnel-sent">0 B</div>
|
||||
<div class="stat-label">Tunnel Sent</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>隧道接收</h3>
|
||||
<div class="stat-value" id="tunnel-received">0 B</div>
|
||||
<div class="stat-label">Tunnel Received</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h2>实时流量趋势</h2>
|
||||
<canvas id="trafficChart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="mapping-list">
|
||||
<h2 style="color: #667eea; margin-bottom: 20px;">端口映射流量</h2>
|
||||
<div id="mapping-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="update-time">
|
||||
最后更新: <span id="update-time">-</span> | 每 3 秒自动刷新
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 全局变量
|
||||
let apiKey = '';
|
||||
|
||||
// 检查是否已有 API Key
|
||||
function checkAuth() {
|
||||
const savedKey = sessionStorage.getItem('apiKey');
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlApiKey = urlParams.get('api_key');
|
||||
|
||||
if (urlApiKey) {
|
||||
// 从 URL 参数中获取
|
||||
apiKey = urlApiKey;
|
||||
sessionStorage.setItem('apiKey', urlApiKey);
|
||||
showMainContent();
|
||||
} else if (savedKey) {
|
||||
// 从 sessionStorage 中获取
|
||||
apiKey = savedKey;
|
||||
showMainContent();
|
||||
} else {
|
||||
// 需要认证
|
||||
showAuthDialog();
|
||||
}
|
||||
}
|
||||
|
||||
// 显示认证对话框
|
||||
function showAuthDialog() {
|
||||
const authHTML = '' +
|
||||
'<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; ' +
|
||||
' background: rgba(0,0,0,0.5); display: flex; align-items: center; ' +
|
||||
' justify-content: center; z-index: 9999;">' +
|
||||
' <div style="background: white; padding: 40px; border-radius: 15px; ' +
|
||||
' box-shadow: 0 10px 40px rgba(0,0,0,0.3); max-width: 400px; width: 90%;">' +
|
||||
' <h2 style="color: #667eea; margin-bottom: 20px; text-align: center;">' +
|
||||
' 🔐 请输入访问密钥' +
|
||||
' </h2>' +
|
||||
' <div id="auth-error" style="display: none; padding: 10px; background: #f8d7da; ' +
|
||||
' color: #721c24; border-radius: 5px; margin-bottom: 15px;"></div>' +
|
||||
' <form id="auth-form">' +
|
||||
' <div style="margin-bottom: 20px;">' +
|
||||
' <label for="secret-input" style="display: block; margin-bottom: 8px; ' +
|
||||
' color: #333; font-weight: bold;">API Key / Secret</label>' +
|
||||
' <input type="password" id="secret-input" required ' +
|
||||
' placeholder="请输入您的访问密钥"' +
|
||||
' style="width: 100%; padding: 12px; border: 2px solid #ddd; ' +
|
||||
' border-radius: 8px; font-size: 1em;">' +
|
||||
' </div>' +
|
||||
' <button type="submit" ' +
|
||||
' style="width: 100%; padding: 12px; background: linear-gradient(45deg, #667eea, #764ba2); ' +
|
||||
' color: white; border: none; border-radius: 8px; font-size: 1em; ' +
|
||||
' cursor: pointer; font-weight: bold;">' +
|
||||
' 验证并进入' +
|
||||
' </button>' +
|
||||
' </form>' +
|
||||
' </div>' +
|
||||
'</div>';
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', authHTML);
|
||||
|
||||
document.getElementById('auth-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const secretInput = document.getElementById('secret-input');
|
||||
const secret = secretInput.value.trim();
|
||||
|
||||
if (!secret) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 API Key
|
||||
try {
|
||||
const response = await fetch('/health?api_key=' + encodeURIComponent(secret));
|
||||
|
||||
if (response.ok) {
|
||||
// 验证成功
|
||||
apiKey = secret;
|
||||
sessionStorage.setItem('apiKey', secret);
|
||||
document.querySelector('[style*="position: fixed"]').remove();
|
||||
showMainContent();
|
||||
} else {
|
||||
// 验证失败
|
||||
const errorDiv = document.getElementById('auth-error');
|
||||
errorDiv.textContent = '❌ 访问密钥无效,请重试';
|
||||
errorDiv.style.display = 'block';
|
||||
secretInput.value = '';
|
||||
secretInput.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorDiv = document.getElementById('auth-error');
|
||||
errorDiv.textContent = '❌ 验证失败: ' + error.message;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('secret-input').focus();
|
||||
}
|
||||
|
||||
// 显示主内容
|
||||
function showMainContent() {
|
||||
document.querySelector('.container').style.display = 'block';
|
||||
initChart();
|
||||
fetchTrafficData();
|
||||
setInterval(fetchTrafficData, 3000);
|
||||
}
|
||||
|
||||
// 获取带 API Key 的 URL
|
||||
function getApiUrl(url) {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return url + separator + 'api_key=' + encodeURIComponent(apiKey);
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
let chart = null;
|
||||
function initChart() {
|
||||
const ctx = document.getElementById('trafficChart');
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: '发送 (KB/s)',
|
||||
data: [],
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: '接收 (KB/s)',
|
||||
data: [],
|
||||
borderColor: '#764ba2',
|
||||
backgroundColor: 'rgba(118, 75, 162, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 2.5,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value.toFixed(2) + ' KB/s';
|
||||
}
|
||||
}
|
||||
},
|
||||
x: {
|
||||
display: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let lastData = null;
|
||||
const maxDataPoints = 20;
|
||||
|
||||
// 格式化字节数
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 更新统计数据
|
||||
function updateStats(data) {
|
||||
document.getElementById('total-sent').textContent = formatBytes(data.total_sent);
|
||||
document.getElementById('total-received').textContent = formatBytes(data.total_received);
|
||||
document.getElementById('tunnel-sent').textContent = formatBytes(data.tunnel.bytes_sent);
|
||||
document.getElementById('tunnel-received').textContent = formatBytes(data.tunnel.bytes_received);
|
||||
|
||||
// 更新时间
|
||||
const now = new Date();
|
||||
document.getElementById('update-time').textContent = now.toLocaleTimeString('zh-CN');
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
function updateChart(data) {
|
||||
const now = new Date().toLocaleTimeString('zh-CN');
|
||||
|
||||
// 计算速率 (如果有上次数据)
|
||||
let sendRate = 0;
|
||||
let recvRate = 0;
|
||||
|
||||
if (lastData) {
|
||||
const timeDiff = 3; // 3秒间隔
|
||||
sendRate = (data.total_sent - lastData.total_sent) / timeDiff / 1024; // KB/s
|
||||
recvRate = (data.total_received - lastData.total_received) / timeDiff / 1024; // KB/s
|
||||
}
|
||||
|
||||
lastData = data;
|
||||
|
||||
// 添加新数据点
|
||||
chart.data.labels.push(now);
|
||||
chart.data.datasets[0].data.push(sendRate);
|
||||
chart.data.datasets[1].data.push(recvRate);
|
||||
|
||||
// 限制数据点数量
|
||||
if (chart.data.labels.length > maxDataPoints) {
|
||||
chart.data.labels.shift();
|
||||
chart.data.datasets[0].data.shift();
|
||||
chart.data.datasets[1].data.shift();
|
||||
}
|
||||
|
||||
chart.update('none'); // 无动画更新,更流畅
|
||||
}
|
||||
|
||||
// 更新端口映射列表
|
||||
function updateMappings(mappings) {
|
||||
const container = document.getElementById('mapping-list');
|
||||
|
||||
if (mappings.length === 0) {
|
||||
container.innerHTML = '<p style="color: #999; text-align: center;">暂无端口映射</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = mappings.map(m =>
|
||||
'<div class="mapping-item">' +
|
||||
'<h4>端口 ' + m.port + '</h4>' +
|
||||
'<div class="mapping-stats">' +
|
||||
'<span>发送: ' + formatBytes(m.bytes_sent) + '</span>' +
|
||||
'<span>接收: ' + formatBytes(m.bytes_received) + '</span>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
// 获取流量数据
|
||||
async function fetchTrafficData() {
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/api/stats/traffic'));
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
updateStats(result.data);
|
||||
updateChart(result.data);
|
||||
updateMappings(result.data.mappings || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取流量数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 隐藏主内容,等待认证
|
||||
document.querySelector('.container').style.display = 'none';
|
||||
checkAuth();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue