边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上; 通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。 - portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页) - portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计) - mac-native:SwiftUI 菜单栏 App
1375 lines
65 KiB
HTML
1375 lines
65 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>PortRelay - 端口中继与安全管控</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0f172a;
|
||
--card-bg: rgba(30, 41, 59, 0.7);
|
||
--card-border: rgba(255, 255, 255, 0.08);
|
||
--accent: #38bdf8;
|
||
--accent-hover: #0ea5e9;
|
||
--accent-glow: rgba(56, 189, 248, 0.25);
|
||
--success: #10b981;
|
||
--warning: #f59e0b;
|
||
--danger: #ef4444;
|
||
--text: #f8fafc;
|
||
--text-muted: #94a3b8;
|
||
--radius: 12px;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
|
||
background: radial-gradient(circle at top left, #1e1b4b, #0f172a 40%, #020617);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
padding: 24px;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
.container { max-width: 1200px; margin: 0 auto; }
|
||
|
||
/* Header */
|
||
header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding-bottom: 24px;
|
||
border-bottom: 1px solid var(--card-border);
|
||
margin-bottom: 24px;
|
||
}
|
||
.logo-area { display: flex; align-items: center; gap: 14px; }
|
||
.logo-badge {
|
||
width: 42px; height: 42px; border-radius: 10px;
|
||
background: linear-gradient(135deg, #0284c7, #6366f1);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 22px; box-shadow: 0 4px 15px var(--accent-glow);
|
||
}
|
||
.logo-text h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
||
.logo-text p { font-size: 13px; color: var(--text-muted); }
|
||
|
||
.header-actions { display: flex; align-items: center; gap: 12px; }
|
||
.status-pill {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 6px 14px; border-radius: 20px;
|
||
background: rgba(15, 23, 42, 0.8); border: 1px solid var(--card-border);
|
||
font-size: 13px; font-weight: 500;
|
||
}
|
||
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--danger); }
|
||
.status-dot.online { background: var(--success); box-shadow: 0 0 10px var(--success); }
|
||
|
||
button {
|
||
white-space: nowrap; flex-shrink: 0;
|
||
cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 500;
|
||
border: none; border-radius: 8px; padding: 8px 16px;
|
||
transition: all 0.15s ease; display: inline-flex; align-items: center; gap: 6px;
|
||
}
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, var(--accent), #0284c7);
|
||
color: #fff; box-shadow: 0 2px 10px var(--accent-glow);
|
||
}
|
||
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); }
|
||
.btn-secondary {
|
||
background: rgba(255, 255, 255, 0.08); color: var(--text);
|
||
border: 1px solid var(--card-border);
|
||
}
|
||
.btn-secondary:hover { background: rgba(255, 255, 255, 0.14); }
|
||
.btn-danger { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
|
||
.btn-danger:hover { background: var(--danger); color: #fff; }
|
||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||
button:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; transform: none; filter: grayscale(0.6); }
|
||
|
||
/* Top Metrics Overview */
|
||
.overview-grid {
|
||
display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||
gap: 16px; margin-bottom: 28px;
|
||
}
|
||
.metric-card {
|
||
background: var(--card-bg); border: 1px solid var(--card-border);
|
||
border-radius: var(--radius); padding: 18px; backdrop-filter: blur(12px);
|
||
}
|
||
.metric-card .title { font-size: 13px; color: var(--text-muted); margin-bottom: 8px; }
|
||
.metric-card .value {
|
||
font-size: 24px; font-weight: 700; color: #fff; font-variant-numeric: tabular-nums;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.metric-card .value.compact { font-size: 18px; line-height: 29px; white-space: normal; }
|
||
.metric-card .value.compact span { white-space: nowrap; margin-right: 10px; }
|
||
.metric-card .sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||
|
||
/* Tunnels Section */
|
||
.section-header {
|
||
display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;
|
||
}
|
||
.section-title { font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||
|
||
.tunnel-grid { display: flex; flex-direction: column; gap: 14px; margin-bottom: 32px; }
|
||
.section-header { flex-wrap: wrap; gap: 10px; }
|
||
.tunnel-filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||
.seg { display: inline-flex; background: rgba(15, 23, 42, 0.6); border: 1px solid var(--card-border); border-radius: 8px; padding: 2px; }
|
||
.seg button { background: transparent; color: var(--text-muted); padding: 4px 12px; font-size: 12px; border-radius: 6px; }
|
||
.seg button.active { background: rgba(56, 189, 248, 0.18); color: var(--text); }
|
||
.search-box {
|
||
display: flex; align-items: center; gap: 6px; width: 280px; padding: 6px 10px; border-radius: 8px;
|
||
background: rgba(15, 23, 42, 0.6); border: 1px solid var(--card-border); font-size: 12px;
|
||
}
|
||
.search-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); }
|
||
.search-box input { flex: 1; min-width: 0; background: transparent; border: 0; outline: none; color: var(--text); font: inherit; font-size: 13px; }
|
||
.tunnel-card {
|
||
background: var(--card-bg); border: 1px solid var(--card-border);
|
||
border-radius: var(--radius); padding: 20px; backdrop-filter: blur(16px);
|
||
transition: border-color 0.2s ease, transform 0.2s ease;
|
||
}
|
||
.tunnel-card:hover { border-color: rgba(56, 189, 248, 0.35); }
|
||
.tunnel-card.disabled { opacity: 0.6; }
|
||
|
||
.tunnel-top {
|
||
display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px;
|
||
}
|
||
.tunnel-title-row { display: flex; align-items: center; gap: 12px; }
|
||
.tunnel-name { font-size: 17px; font-weight: 600; }
|
||
.badge {
|
||
font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 6px;
|
||
text-transform: uppercase;
|
||
}
|
||
.badge-online { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
|
||
.badge-disabled { background: rgba(148, 163, 184, 0.15); color: #94a3b8; }
|
||
.badge-error { background: rgba(239, 68, 68, 0.15); color: #f87171; }
|
||
.badge-syncing { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
|
||
|
||
/* Switch */
|
||
.switch {
|
||
position: relative; display: inline-block; width: 44px; height: 24px;
|
||
}
|
||
.switch input { opacity: 0; width: 0; height: 0; }
|
||
.slider {
|
||
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
|
||
background-color: #334155; transition: .25s; border-radius: 24px;
|
||
}
|
||
.slider:before {
|
||
position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px;
|
||
background-color: white; transition: .25s; border-radius: 50%;
|
||
}
|
||
input:checked + .slider { background-color: var(--success); }
|
||
input:checked + .slider:before { transform: translateX(20px); }
|
||
|
||
/* Route Box */
|
||
.route-box {
|
||
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
|
||
background: rgba(15, 23, 42, 0.6); border-radius: 8px;
|
||
border: 1px solid rgba(255, 255, 255, 0.04); margin-bottom: 14px;
|
||
font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; font-size: 14px;
|
||
}
|
||
.route-endpoint { color: #38bdf8; font-weight: 600; }
|
||
.route-arrow { color: #64748b; font-size: 16px; }
|
||
.route-target { color: #a5b4fc; font-weight: 500; }
|
||
|
||
/* Whitelist pills */
|
||
.whitelist-row {
|
||
display: flex; align-items: center; gap: 8px; margin-bottom: 14px; flex-wrap: wrap;
|
||
}
|
||
.whitelist-label { font-size: 12px; color: var(--text-muted); }
|
||
.whitelist-pill {
|
||
font-size: 12px; padding: 2px 8px; border-radius: 4px;
|
||
background: rgba(56, 189, 248, 0.12); color: #7dd3fc;
|
||
border: 1px solid rgba(56, 189, 248, 0.25); font-family: monospace;
|
||
}
|
||
.whitelist-pill.all {
|
||
background: rgba(245, 158, 11, 0.12); color: #fbbf24; border-color: rgba(245, 158, 11, 0.3);
|
||
}
|
||
|
||
/* Tunnel Bottom */
|
||
.tunnel-bottom {
|
||
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px 16px;
|
||
padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||
font-size: 13px; color: var(--text-muted);
|
||
}
|
||
.metrics-summary { display: flex; flex-wrap: wrap; gap: 6px 20px; font-variant-numeric: tabular-nums; }
|
||
.metrics-summary span { white-space: nowrap; }
|
||
.metrics-summary span b { color: var(--text); font-weight: 600; }
|
||
.tunnel-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
|
||
.tunnel-body { display: flex; gap: 20px; align-items: flex-start; margin-bottom: 14px; }
|
||
.tunnel-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
|
||
.tunnel-info .route-box { margin: 0; }
|
||
.tunnel-info .tunnel-title-row { flex-wrap: wrap; row-gap: 6px; }
|
||
.tunnel-side { flex-shrink: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||
.tunnel-side.with-chart { width: 260px; }
|
||
.side-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; }
|
||
.tunnel-side:not(.with-chart) .side-top { justify-content: flex-end; }
|
||
.switch.mini { width: 36px; height: 20px; }
|
||
.switch.mini .slider:before { width: 14px; height: 14px; left: 3px; bottom: 3px; }
|
||
.switch.mini input:checked + .slider:before { transform: translateX(16px); }
|
||
.traffic-panel { width: 100%; font-variant-numeric: tabular-nums; }
|
||
.traffic-legend { display: flex; gap: 12px; font-size: 12px; font-weight: 600; color: var(--text); }
|
||
.traffic-legend i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; vertical-align: 1px; }
|
||
.traffic-foot { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||
.traffic-chart { position: relative; height: 46px; border-bottom: 1px solid rgba(148, 163, 184, 0.18); }
|
||
.traffic-chart .empty {
|
||
position: absolute; inset: 0 0 10px; display: flex; align-items: center; justify-content: center;
|
||
font-size: 11px; color: var(--text-muted);
|
||
}
|
||
.rate-fallback { display: none !important; }
|
||
@media (max-width: 860px) {
|
||
.traffic-panel, .traffic-legend { display: none; }
|
||
.tunnel-side.with-chart { width: auto; }
|
||
.tunnel-side .side-top { justify-content: flex-end; }
|
||
.rate-fallback { display: inline !important; }
|
||
}
|
||
.stat-spark { margin-top: 6px; height: 26px; }
|
||
.access-pill {
|
||
font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 6px; white-space: nowrap;
|
||
background: rgba(52, 211, 153, 0.12); color: #34d399; border: 1px solid rgba(52, 211, 153, 0.25);
|
||
}
|
||
.access-pill.all { background: rgba(248, 113, 113, 0.12); color: #f87171; border-color: rgba(248, 113, 113, 0.3); }
|
||
.access-pill.fixed { background: rgba(148, 163, 184, 0.12); color: #cbd5e1; border-color: rgba(148, 163, 184, 0.25); }
|
||
.lifecycle-row .chip { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 5px; cursor: pointer; }
|
||
.lifecycle-row .chip.green { background: rgba(52, 211, 153, 0.12); color: #34d399; }
|
||
.lifecycle-row .chip.orange { background: rgba(251, 191, 36, 0.14); color: #fbbf24; }
|
||
.lifecycle-row .chip.purple { background: rgba(192, 132, 252, 0.14); color: #c084fc; cursor: default; }
|
||
.lifecycle-row {
|
||
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; margin: 0;
|
||
font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums;
|
||
}
|
||
.lifecycle-row span { white-space: nowrap; }
|
||
.lifecycle-row .strong { color: var(--text); font-weight: 600; }
|
||
.lifecycle-row .ok { color: #34d399; font-weight: 600; }
|
||
.lifecycle-row .warn { color: #fbbf24; }
|
||
|
||
/* Modal / Drawer */
|
||
.modal-overlay {
|
||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(8px);
|
||
display: none; align-items: center; justify-content: center; z-index: 1000;
|
||
}
|
||
.modal-overlay.open { display: flex; }
|
||
.modal-card {
|
||
background: #1e293b; border: 1px solid var(--card-border);
|
||
border-radius: 16px; width: 520px; max-width: 90vw; padding: 24px;
|
||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
||
}
|
||
.modal-header {
|
||
display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;
|
||
}
|
||
.modal-header h3 { font-size: 18px; font-weight: 600; }
|
||
.form-group { margin-bottom: 16px; }
|
||
.form-group label {
|
||
display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: var(--text);
|
||
}
|
||
.form-group input, .form-group textarea, .form-group select {
|
||
width: 100%; padding: 10px 12px; border-radius: 8px;
|
||
background: rgba(15, 23, 42, 0.8); border: 1px solid var(--card-border);
|
||
color: #fff; font-size: 14px; outline: none; font-family: inherit;
|
||
}
|
||
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
|
||
border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow);
|
||
}
|
||
.input-hint { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||
.input-row { display: flex; gap: 8px; }
|
||
.input-row input { min-width: 0; }
|
||
.drawer-row-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||
.drawer code { font-variant-numeric: tabular-nums; }
|
||
.modal-footer {
|
||
display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px;
|
||
}
|
||
|
||
/* Logs Table */
|
||
.table-card {
|
||
background: var(--card-bg); border: 1px solid var(--card-border);
|
||
border-radius: var(--radius); overflow: hidden; margin-bottom: 32px;
|
||
}
|
||
table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; }
|
||
th {
|
||
background: rgba(15, 23, 42, 0.7); padding: 12px 16px; font-weight: 600;
|
||
color: var(--text-muted); border-bottom: 1px solid var(--card-border);
|
||
}
|
||
td { padding: 12px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.04); }
|
||
tr:last-child td { border-bottom: none; }
|
||
tr:hover td { background: rgba(255, 255, 255, 0.02); }
|
||
|
||
.tag-danger {
|
||
background: rgba(239, 68, 68, 0.15); color: #f87171;
|
||
padding: 2px 6px; border-radius: 4px; font-size: 11px;
|
||
}
|
||
|
||
/* Drawer */
|
||
.drawer {
|
||
position: fixed; top: 0; right: -480px; width: 480px; height: 100%;
|
||
background: #1e293b; border-left: 1px solid var(--card-border);
|
||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5); z-index: 1001;
|
||
transition: right 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||
padding: 24px; display: flex; flex-direction: column;
|
||
}
|
||
.drawer.open { right: 0; }
|
||
.drawer-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||
.drawer-content { flex: 1; overflow-y: auto; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<!-- Header -->
|
||
<header>
|
||
<div class="logo-area">
|
||
<div class="logo-badge">⚡</div>
|
||
<div class="logo-text">
|
||
<h1>PortRelay 控制台</h1>
|
||
<p>高性能定向反向中继与安全审计</p>
|
||
</div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<div class="status-pill">
|
||
<div id="statusDot" class="status-dot"></div>
|
||
<span id="statusText">正在连接服务器...</span>
|
||
</div>
|
||
<button class="btn-primary" onclick="openCreateModal()">+ 新建代理通道</button>
|
||
<button class="btn-secondary" onclick="openSettingsModal()">⚙️ 设置</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Overview Metrics -->
|
||
<div class="overview-grid">
|
||
<div class="metric-card">
|
||
<div class="title">活跃通道 / 总数</div>
|
||
<div class="value" id="statTunnels">0 / 0</div>
|
||
<div class="sub">放通端口池: 33001-34000</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="title">活跃连接</div>
|
||
<div class="value" id="statActiveConns">0</div>
|
||
<div class="sub" id="statTotalConns">历史累计连接: 0</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="title">实时速率 (上行 / 下行)</div>
|
||
<div class="value compact" id="statRate">0 KB/s</div>
|
||
<div class="stat-spark" id="statRateSpark" title="近 10 分钟(所有通道合计)"></div>
|
||
<div class="sub" id="statTotalBytes">累计传输: 0 MB</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="title">安全拦截未授权尝试</div>
|
||
<div class="value" style="color: #f87171;" id="statBlocked">0</div>
|
||
<div class="sub">边缘白名单实时防护</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tunnels List -->
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
<span>已配置通道列表</span>
|
||
<span id="tunnelCount" style="font-size: 12px; font-weight: 400; color: var(--text-muted);"></span>
|
||
<button class="btn-secondary btn-sm" onclick="fetchTunnels()">↻ 刷新</button>
|
||
</div>
|
||
<div class="tunnel-filters">
|
||
<div class="seg" id="statusSeg">
|
||
<button class="active" data-v="all" onclick="setStatusFilter('all')">全部</button>
|
||
<button data-v="running" onclick="setStatusFilter('running')">运行中</button>
|
||
<button data-v="paused" onclick="setStatusFilter('paused')">已暂停</button>
|
||
</div>
|
||
<div class="search-box">
|
||
<span>🔍</span>
|
||
<input id="tunnelSearch" type="text" placeholder="搜索名称、地址、端口、责任人…" title="多个关键字用空格分隔;按 / 快速聚焦,Esc 清空"
|
||
oninput="renderTunnels()" onkeydown="if (event.key === 'Escape') { this.value = ''; renderTunnels(); }">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tunnel-grid" id="tunnelGrid">
|
||
<!-- Cards rendered dynamically -->
|
||
</div>
|
||
|
||
<!-- Security Events Table -->
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
<span>🛡️ 边缘安全拦截与审计日志 (实时)</span>
|
||
</div>
|
||
</div>
|
||
<div class="table-card">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>时间</th>
|
||
<th>目标端口</th>
|
||
<th>非法客户端 IP</th>
|
||
<th>拦截原因</th>
|
||
<th>防护策略</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="secEventsBody">
|
||
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">暂无拦截事件,系统运行安全</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Create / Edit Tunnel -->
|
||
<div class="modal-overlay" id="tunnelModal">
|
||
<div class="modal-card">
|
||
<div class="modal-header">
|
||
<h3 id="modalTitle">新建代理通道</h3>
|
||
<button class="btn-secondary btn-sm" onclick="closeTunnelModal()">✕</button>
|
||
</div>
|
||
<form id="tunnelForm" onsubmit="saveTunnel(event)">
|
||
<input type="hidden" id="tunnelId">
|
||
<div class="form-group">
|
||
<label>通道名称 / 备注</label>
|
||
<input type="text" id="tunnelName" required placeholder="例如:MySQL 实习库 或 财务系统 SSH">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>目标地址 (中转机 Mac 可直连的内网目标)</label>
|
||
<div class="input-row">
|
||
<input type="text" id="tunnelTarget" required placeholder="例如:10.0.0.10:3306">
|
||
<button type="button" class="btn-secondary" onclick="probeTarget()">探测测试</button>
|
||
</div>
|
||
<div class="input-hint" id="probeResult"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>对外监听端口 (范围: 33001 - 34000)</label>
|
||
<div class="input-row">
|
||
<input type="number" id="tunnelPort" required min="33001" max="34000" placeholder="33660">
|
||
<button type="button" class="btn-secondary" onclick="autoRecommendPort()">推荐空闲端口</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>闲置自动关闭(没有连接、有效授权、待审批和可用邀请时开始计时)</label>
|
||
<select id="tunnelIdleClose">
|
||
<option value="15">15 分钟</option>
|
||
<option value="30" selected>30 分钟</option>
|
||
<option value="60">1 小时</option>
|
||
<option value="120">2 小时</option>
|
||
<option value="0">不自动关闭</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>最长开启时长(可选,到时无论是否在用都会关闭)</label>
|
||
<select id="tunnelMaxDuration">
|
||
<option value="0" selected>不限制</option>
|
||
<option value="120">2 小时</option>
|
||
<option value="480">8 小时</option>
|
||
<option value="1440">1 天</option>
|
||
<option value="10080">7 天</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>访问控制</label>
|
||
<select id="tunnelAccessMode" onchange="onAccessModeChange()">
|
||
<option value="link">仅授权链接(推荐)</option>
|
||
<option value="specific">固定 IP / 网段 + 授权链接</option>
|
||
<option value="all">全部放通 *(不安全)</option>
|
||
</select>
|
||
<input type="text" id="tunnelWhitelist" style="margin-top: 8px; display: none;" placeholder="例如 192.168.1.100, 192.168.1.0/24(逗号分隔)">
|
||
<div class="input-hint" id="accessModeHint"></div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-secondary" onclick="closeTunnelModal()">取消</button>
|
||
<button type="submit" class="btn-primary">保存通道</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Invite -->
|
||
<div class="modal-overlay" id="inviteModal">
|
||
<div class="modal-card">
|
||
<div class="modal-header">
|
||
<h3 id="inviteTitle">邀请访问</h3>
|
||
<button class="btn-secondary btn-sm" onclick="closeInviteModal()">✕</button>
|
||
</div>
|
||
<div id="inviteForm">
|
||
<div class="form-group">
|
||
<label>备注(发给谁,可选)</label>
|
||
<input type="text" id="inviteNote" placeholder="例如:张三">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>链接有效期</label>
|
||
<select id="inviteValid">
|
||
<option value="60">1 小时</option>
|
||
<option value="1440" selected>24 小时</option>
|
||
<option value="10080">7 天</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>可用设备数</label>
|
||
<select id="inviteMaxUses">
|
||
<option value="1" selected>1 台</option>
|
||
<option value="3">3 台</option>
|
||
<option value="0">不限</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>授权后可访问</label>
|
||
<select id="inviteLease">
|
||
<option value="1h">1 小时</option>
|
||
<option value="2h" selected>2 小时</option>
|
||
<option value="8h">8 小时</option>
|
||
<option value="today">当天有效</option>
|
||
<option value="tunnel">通道开启期间</option>
|
||
</select>
|
||
<label style="display:flex; align-items:center; gap:8px; margin-top:10px; font-weight: normal;">
|
||
<input type="checkbox" id="inviteApproval" style="width:auto;"> 同事打开链接后需要我批准才放通
|
||
</label>
|
||
<div class="input-hint">“链接有效期”是多久内要打开链接;“授权后可访问”是授权后能用多久。</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-primary" onclick="generateInvite()">生成邀请链接</button>
|
||
</div>
|
||
</div>
|
||
<div id="inviteResult" style="display:none;">
|
||
<div class="form-group">
|
||
<label>分享文案(可直接发给同事)</label>
|
||
<textarea id="inviteShareText" readonly rows="8" style="width:100%; box-sizing:border-box; font-size:13px;"></textarea>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-secondary" onclick="resetInviteModal()">再生成一个</button>
|
||
<button type="button" class="btn-secondary" id="inviteCopyLink">仅复制链接</button>
|
||
<button type="button" class="btn-primary" id="inviteCopyText">复制分享文案</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Settings -->
|
||
<div class="modal-overlay" id="settingsModal">
|
||
<div class="modal-card">
|
||
<div class="modal-header">
|
||
<h3>服务器通信设置</h3>
|
||
<button class="btn-secondary btn-sm" onclick="closeSettingsModal()">✕</button>
|
||
</div>
|
||
<form onsubmit="saveSettings(event)">
|
||
<div class="form-group">
|
||
<label>服务器控制地址 (地址:端口)</label>
|
||
<input type="text" id="settingServerAddr" required placeholder="例如 gw.example.com:33000">
|
||
<div class="input-hint">边缘网关的地址与控制端口。局域网、VPN、虚拟组网或公网地址均可,只要本机能连通</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>认证令牌 (Token)</label>
|
||
<input type="password" id="settingToken" required placeholder="与服务器一致的令牌">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>对外访问地址 (访问者连接通道时使用)</label>
|
||
<input type="text" id="settingPublicHost" placeholder="留空则使用服务器地址">
|
||
<div class="input-hint">只填主机名或 IP,用于展示访问地址和复制连接命令</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-secondary" onclick="closeSettingsModal()">取消</button>
|
||
<button type="submit" class="btn-primary">保存并重连</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Drawer: Active Connections Details -->
|
||
<div class="drawer" id="clientDrawer">
|
||
<div class="drawer-header">
|
||
<h3 id="drawerTitle">当前活跃连接详情</h3>
|
||
<button class="btn-secondary btn-sm" onclick="closeDrawer()">✕</button>
|
||
</div>
|
||
<div class="drawer-content">
|
||
<div id="drawerClientsList">
|
||
<!-- Rendered clients -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentTunnels = [];
|
||
let publicHost = '';
|
||
let latestMetrics = null;
|
||
|
||
async function init() {
|
||
await fetchStatus();
|
||
await fetchTunnels();
|
||
setupSSE();
|
||
}
|
||
|
||
function escapeHtml(v) {
|
||
return String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|
||
|
||
async function apiError(res) {
|
||
try {
|
||
const data = await res.json();
|
||
return data.error || `HTTP ${res.status}`;
|
||
} catch (e) {
|
||
return `HTTP ${res.status}`;
|
||
}
|
||
}
|
||
|
||
const fmtSecs = secs => {
|
||
secs = Math.max(0, Math.floor(secs || 0));
|
||
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), sec = secs % 60;
|
||
return h > 0 ? `${h}小时${m}分` : (m > 0 ? `${m}分${sec}秒` : `${sec}秒`);
|
||
};
|
||
const fmtMinutes = m => m >= 1440 && m % 1440 === 0 ? `${m / 1440} 天` : (m >= 60 && m % 60 === 0 ? `${m / 60} 小时` : `${m} 分钟`);
|
||
const ago = v => (Date.now() - new Date(v)) / 1000;
|
||
|
||
let totalRateHistory = null;
|
||
const DOWN_COLOR = '#38bdf8', UP_COLOR = '#2dd4bf';
|
||
|
||
// 近 10 分钟流量曲线(SVG,面积 + 折线,纵轴随峰值缩放)
|
||
function sparkSVG(series, w, h, gid, withArea = true) {
|
||
if (!series || !series.down) return '';
|
||
const n = series.down.length;
|
||
const peak = Math.max(1024, ...series.down, ...series.up) * 1.18;
|
||
const pts = arr => arr.map((v, i) => [(i / (n - 1)) * w, h - (v / peak) * (h - 2) - 1]);
|
||
const line = p => p.map((q, i) => (i ? 'L' : 'M') + q[0].toFixed(1) + ',' + q[1].toFixed(1)).join('');
|
||
const area = p => line(p) + `L${w},${h}L0,${h}Z`;
|
||
const d = pts(series.down), u = pts(series.up);
|
||
return `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" preserveAspectRatio="none" style="display:block">
|
||
<defs>
|
||
<linearGradient id="gd-${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${DOWN_COLOR}" stop-opacity=".35"/><stop offset="1" stop-color="${DOWN_COLOR}" stop-opacity="0"/></linearGradient>
|
||
<linearGradient id="gu-${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${UP_COLOR}" stop-opacity=".30"/><stop offset="1" stop-color="${UP_COLOR}" stop-opacity="0"/></linearGradient>
|
||
</defs>
|
||
${withArea ? `<path d="${area(d)}" fill="url(#gd-${gid})"/><path d="${area(u)}" fill="url(#gu-${gid})"/>` : ''}
|
||
<path d="${line(d)}" fill="none" stroke="${DOWN_COLOR}" stroke-width="1.5" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>
|
||
<path d="${line(u)}" fill="none" stroke="${UP_COLOR}" stroke-width="1.5" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>
|
||
</svg>`;
|
||
}
|
||
|
||
// 卡片右栏:当前速率 + 开关,下方为不带背景的流量曲线;已暂停时只有开关
|
||
function sideHtml(t) {
|
||
const m = t.metrics || {}, s = t.rate_history, id = escapeHtml(t.id);
|
||
const toggle = `<label class="switch mini" title="${t.enabled ? '关闭通道' : '开启通道'}">
|
||
<input type="checkbox" ${t.enabled ? 'checked' : ''} onchange="toggleTunnel('${id}', this.checked)">
|
||
<span class="slider"></span>
|
||
</label>`;
|
||
if (!t.enabled) return `<div class="tunnel-side"><div class="side-top">${toggle}</div></div>`;
|
||
const peak = s ? Math.max(0, ...s.down, ...s.up) : 0;
|
||
return `<div class="tunnel-side with-chart">
|
||
<div class="side-top">
|
||
<div class="traffic-legend">
|
||
<span><i style="background:${DOWN_COLOR}"></i>↓ ${formatRate(m.rate_out || 0)}</span>
|
||
<span><i style="background:${UP_COLOR}"></i>↑ ${formatRate(m.rate_in || 0)}</span>
|
||
</div>
|
||
${toggle}
|
||
</div>
|
||
<div class="traffic-panel">
|
||
<div class="traffic-chart">${peak ? sparkSVG(s, 260, 46, id) : '<div class="empty">近 10 分钟无流量</div>'}</div>
|
||
<div class="traffic-foot"><span>近 10 分钟</span><span>${peak ? '峰值 ' + formatRate(peak) : ''}</span></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function accessPillHtml(t) {
|
||
const wl = t.whitelist || [];
|
||
if (wl.length === 0) return '<span class="access-pill">🔒 仅授权链接</span>';
|
||
if (wl.includes('*')) return '<span class="access-pill all" title="任何能访问服务器的设备都可以连接">⚠️ 全部放通 *</span>';
|
||
const label = wl.length === 1 ? escapeHtml(wl[0]) : `${wl.length} 个 IP/网段`;
|
||
return `<span class="access-pill fixed" title="固定白名单:${escapeHtml(wl.join(', '))}">${label} + 授权链接</span>`;
|
||
}
|
||
|
||
function badgeChipsHtml(t) {
|
||
const m = t.metrics || {}, id = escapeHtml(t.id);
|
||
let html = '';
|
||
if (t.max_bandwidth_mb) html += `<span class="chip purple">限速 ${t.max_bandwidth_mb} MB/s</span>`;
|
||
if ((m.pending || []).length) html += `<span class="chip orange" onclick="openDrawer('${id}')">🔔 待审批 ${m.pending.length}</span>`;
|
||
if ((m.dynamic_leases || []).length) html += `<span class="chip green" onclick="openDrawer('${id}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`;
|
||
return html;
|
||
}
|
||
|
||
// 通道时间口径:开启 / 首次连接 / 实际连接 / 使用状态 / 最长开启
|
||
function lifecycleHtml(t) {
|
||
const parts = [];
|
||
if (t.enabled) {
|
||
const act = t.activity;
|
||
parts.push(`<span>开启 ${fmtDT(t.started_at)}</span>`);
|
||
parts.push(`<span>${t.first_connected_at ? '首次连接 ' + fmtDT(t.first_connected_at) : '尚无连接'}</span>`);
|
||
parts.push(`<span class="strong" title="本次开启后,至少有一个连接在线的累计时长">实际连接 ${fmtSecs(act ? act.connected_sec : t.connected_sec)}</span>`);
|
||
if (act && act.in_use) {
|
||
parts.push('<span class="ok" title="有在线连接、有效授权、待审批申请或可用的邀请链接">● 使用中</span>');
|
||
} else if (act && act.auto_close_at) {
|
||
const left = -ago(act.auto_close_at);
|
||
parts.push(`<span class="${left < 300 ? 'warn' : ''}">已闲置 ${fmtSecs(ago(act.idle_since))},${fmtSecs(left)} 后自动关闭</span>`);
|
||
} else if (act) {
|
||
parts.push(`<span>已闲置 ${fmtSecs(ago(act.idle_since))}(不自动关闭)</span>`);
|
||
}
|
||
if (t.expires_at) parts.push(`<span>最长开启至 ${fmtDT(t.expires_at)}</span>`);
|
||
} else {
|
||
if (t.last_duration_sec) parts.push(`<span>上次运行 ${fmtSecs(t.last_duration_sec)} · 实际连接 ${fmtSecs(t.last_connected_sec)}</span>`);
|
||
if (t.close_reason) parts.push(`<span>关闭原因:${escapeHtml(t.close_reason)}</span>`);
|
||
const idle = t.effective_idle_close_minutes ?? 30;
|
||
parts.push(`<span>闲置自动关闭:${idle > 0 ? fmtMinutes(idle) : '关'}</span>`);
|
||
if (t.duration_minutes) parts.push(`<span>最长开启 ${fmtMinutes(t.duration_minutes)}</span>`);
|
||
}
|
||
return parts.join('');
|
||
}
|
||
|
||
function renderStatus(data) {
|
||
if (data.public_host) publicHost = data.public_host;
|
||
if (data.total_rate_history) totalRateHistory = data.total_rate_history;
|
||
const dot = document.getElementById('statusDot');
|
||
const text = document.getElementById('statusText');
|
||
if (data.connected) {
|
||
dot.className = 'status-dot online';
|
||
text.innerText = `已连接服务器 (${data.server_addr})`;
|
||
} else {
|
||
dot.className = 'status-dot';
|
||
text.innerText = `未连接服务器 (${data.server_addr})` + (data.last_error ? ` · ${data.last_error}` : '');
|
||
}
|
||
}
|
||
|
||
async function fetchStatus() {
|
||
try {
|
||
const res = await fetch('/api/status');
|
||
renderStatus(await res.json());
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
async function fetchTunnels() {
|
||
try {
|
||
const res = await fetch('/api/tunnels');
|
||
currentTunnels = await res.json();
|
||
renderTunnels();
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (!bytes || 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(1)) + ' ' + sizes[i];
|
||
}
|
||
|
||
function formatRate(bytesPerSec) {
|
||
return formatBytes(bytesPerSec) + '/s';
|
||
}
|
||
|
||
let statusFilter = 'all';
|
||
|
||
function setStatusFilter(v) {
|
||
statusFilter = v;
|
||
document.querySelectorAll('#statusSeg button').forEach(b => b.classList.toggle('active', b.dataset.v === v));
|
||
renderTunnels();
|
||
}
|
||
|
||
// 通道搜索:多个关键字(空格分隔)需全部命中,不区分大小写
|
||
function tunnelMatches(t, query) {
|
||
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||
if (!tokens.length) return true;
|
||
const m = t.metrics || {}, wl = t.whitelist || [];
|
||
const hay = [
|
||
t.name, t.target, String(t.listen_port), `${publicHost}:${t.listen_port}`, t.owner, t.notes, wl.join(' '),
|
||
t.enabled ? '运行中 开启' : '已暂停 关闭',
|
||
wl.length === 0 ? '仅授权链接' : (wl.includes('*') ? '全部放通' : '固定ip 白名单'),
|
||
...(m.dynamic_leases || []).flatMap(l => [l.name, l.ip]),
|
||
...(m.invites || []).map(i => i.note),
|
||
].filter(Boolean).join(' ').toLowerCase();
|
||
return tokens.every(tok => hay.includes(tok));
|
||
}
|
||
|
||
document.addEventListener('keydown', e => {
|
||
const tag = (document.activeElement && document.activeElement.tagName) || '';
|
||
if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) {
|
||
e.preventDefault();
|
||
document.getElementById('tunnelSearch').focus();
|
||
}
|
||
});
|
||
|
||
function renderTunnels() {
|
||
const grid = document.getElementById('tunnelGrid');
|
||
|
||
let totalConns = 0;
|
||
let activeConns = 0;
|
||
let totalRateIn = 0;
|
||
let totalRateOut = 0;
|
||
let totalBytes = 0;
|
||
let totalBlocked = 0;
|
||
|
||
// 概览统计始终基于全部通道;列表只渲染匹配搜索与筛选的通道
|
||
currentTunnels.forEach(t => {
|
||
const m = t.metrics || {};
|
||
activeConns += (m.active_conns || 0);
|
||
totalConns += (m.total_conns || 0);
|
||
totalRateIn += (m.rate_in || 0);
|
||
totalRateOut += (m.rate_out || 0);
|
||
totalBytes += ((m.bytes_in || 0) + (m.bytes_out || 0));
|
||
totalBlocked += (m.blocked_attempts || 0);
|
||
});
|
||
const query = (document.getElementById('tunnelSearch') || {}).value || '';
|
||
const visible = currentTunnels.filter(t =>
|
||
(statusFilter === 'all' || (statusFilter === 'running') === !!t.enabled) && tunnelMatches(t, query));
|
||
const filtering = query.trim() !== '' || statusFilter !== 'all';
|
||
document.getElementById('tunnelCount').innerText = filtering
|
||
? `匹配 ${visible.length} / 共 ${currentTunnels.length} 个` : `${currentTunnels.length} 个通道`;
|
||
|
||
grid.innerHTML = visible.map(t => {
|
||
const m = t.metrics || {};
|
||
|
||
const badge = {
|
||
online: ['badge-online', '运行中'],
|
||
syncing: ['badge-syncing', '同步中'],
|
||
offline: ['badge-syncing', '等待连接服务器'],
|
||
error: ['badge-error', '异常'],
|
||
disabled: ['badge-disabled', '已暂停'],
|
||
}[t.enabled ? (m.status || 'syncing') : 'disabled'] || ['badge-syncing', '同步中'];
|
||
const [badgeClass, badgeText] = badge;
|
||
const errorTitle = m.status === 'error' && m.error ? ` title="${escapeHtml(m.error)}"` : '';
|
||
|
||
const sessionBytes = (m.bytes_in || 0) + (m.bytes_out || 0);
|
||
const histBytes = (t.historical_bytes_in || 0) + (t.historical_bytes_out || 0) + (t.enabled ? sessionBytes : 0);
|
||
|
||
|
||
return `
|
||
<div class="tunnel-card ${t.enabled ? '' : 'disabled'}">
|
||
<div class="tunnel-body">
|
||
<div class="tunnel-info">
|
||
<div class="tunnel-title-row">
|
||
<span class="tunnel-name">${escapeHtml(t.name)}</span>
|
||
<span class="badge ${badgeClass}"${errorTitle}>${badgeText}</span>
|
||
${accessPillHtml(t)}
|
||
</div>
|
||
${m.status === 'error' && m.error ? `<div style="color:#f87171; font-size:12px;">⚠️ 服务器注册失败:${escapeHtml(m.error)}</div>` : ''}
|
||
<div class="route-box">
|
||
<span class="route-endpoint">${escapeHtml(publicHost)}:${t.listen_port}</span>
|
||
<span class="route-arrow">➔</span>
|
||
<span class="route-target">${escapeHtml(t.target)}</span>
|
||
</div>
|
||
<div class="lifecycle-row">${lifecycleHtml(t)}${badgeChipsHtml(t)}</div>
|
||
</div>
|
||
${sideHtml(t)}
|
||
</div>
|
||
|
||
<div class="tunnel-bottom">
|
||
<div class="metrics-summary">
|
||
<span>连接 <b>${m.active_conns || 0}</b></span>
|
||
<span>本次 <b>${formatBytes(sessionBytes)}</b></span>
|
||
<span>历史 <b>${formatBytes(histBytes)}</b></span>
|
||
${t.enabled ? `<span class="rate-fallback">速率 <b>↑ ${formatRate(m.rate_in || 0)} ↓ ${formatRate(m.rate_out || 0)}</b></span>` : ''}
|
||
${m.blocked_attempts ? `<span style="color: #f87171;">拦截 <b>${m.blocked_attempts}</b></span>` : ''}
|
||
</div>
|
||
<div class="tunnel-actions">
|
||
<button class="btn-primary btn-sm" ${t.enabled ? '' : 'disabled title="请先开启通道"'} onclick="openInviteModal('${escapeHtml(t.id)}')">📨 邀请</button>
|
||
<button class="btn-secondary btn-sm" onclick="openDrawer('${escapeHtml(t.id)}')">👥 连接与授权</button>
|
||
<button class="btn-secondary btn-sm" onclick="editTunnel('${escapeHtml(t.id)}')">✏️ 编辑</button>
|
||
<button class="btn-danger btn-sm" onclick="deleteTunnel('${escapeHtml(t.id)}')">🗑️ 删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
if (currentTunnels.length === 0) {
|
||
grid.innerHTML = '<div style="text-align:center; padding: 40px; color: var(--text-muted);">暂无中继通道,点击上方按钮添加第一个通道</div>';
|
||
} else if (visible.length === 0) {
|
||
grid.innerHTML = `<div class="tunnel-card" style="text-align:center; padding: 40px; color: var(--text-muted);">
|
||
<div style="font-size: 15px; color: var(--text); margin-bottom: 6px;">没有匹配的通道</div>
|
||
<div style="font-size: 12px; margin-bottom: 12px;">${query.trim() ? '关键字:' + escapeHtml(query.trim()) : ''}${statusFilter !== 'all' ? ' 筛选:' + (statusFilter === 'running' ? '运行中' : '已暂停') : ''}</div>
|
||
<button class="btn-secondary btn-sm" onclick="document.getElementById('tunnelSearch').value=''; setStatusFilter('all');">清除搜索与筛选</button>
|
||
</div>`;
|
||
}
|
||
|
||
document.getElementById('statTunnels').innerText = `${currentTunnels.filter(t => t.enabled).length} / ${currentTunnels.length}`;
|
||
document.getElementById('statActiveConns').innerText = activeConns;
|
||
document.getElementById('statTotalConns').innerText = `历史累计连接: ${totalConns}`;
|
||
// 上行/下行各自不换行,放不下时整体换到下一行(不截断)
|
||
document.getElementById('statRate').innerHTML = `<span>↑ ${formatRate(totalRateIn)}</span> <span>↓ ${formatRate(totalRateOut)}</span>`;
|
||
document.getElementById('statRateSpark').innerHTML = sparkSVG(totalRateHistory, 200, 26, 'total', false);
|
||
document.getElementById('statTotalBytes').innerText = `累计传输: ${formatBytes(totalBytes)}`;
|
||
document.getElementById('statBlocked').innerText = totalBlocked;
|
||
}
|
||
|
||
function setupSSE() {
|
||
const evtSource = new EventSource('/api/stream');
|
||
evtSource.onmessage = (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
renderStatus(data);
|
||
|
||
// 服务端推送完整通道列表(含开关状态),到期自动关闭等变化也能实时反映
|
||
if (Array.isArray(data.tunnels)) {
|
||
currentTunnels = data.tunnels;
|
||
renderTunnels();
|
||
}
|
||
latestMetrics = data.metrics || null;
|
||
renderSecEvents((data.metrics && data.metrics.security_events) || []);
|
||
renderDrawerClients();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
};
|
||
evtSource.onerror = () => {
|
||
document.getElementById('statusDot').className = 'status-dot';
|
||
document.getElementById('statusText').innerText = '连接中断,重连中...';
|
||
};
|
||
}
|
||
|
||
function renderSecEvents(events) {
|
||
const tbody = document.getElementById('secEventsBody');
|
||
if (!events || events.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">暂无拦截事件,系统运行安全</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = events.slice(-10).reverse().map(ev => {
|
||
const timeStr = new Date(ev.timestamp).toLocaleTimeString();
|
||
return `
|
||
<tr>
|
||
<td>${timeStr}</td>
|
||
<td><b style="color:#38bdf8;">${escapeHtml(ev.port)}</b></td>
|
||
<td><code>${escapeHtml(ev.client_ip)}</code></td>
|
||
<td><span class="tag-danger">${escapeHtml(ev.reason)}</span></td>
|
||
<td>已立即断开 TCP RST</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
async function toggleTunnel(id, enabled) {
|
||
try {
|
||
const res = await fetch(`/api/tunnels/${encodeURIComponent(id)}/toggle`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled })
|
||
});
|
||
if (!res.ok) alert('操作失败: ' + await apiError(res));
|
||
} catch (e) {
|
||
alert('操作失败: ' + e.message);
|
||
}
|
||
await fetchTunnels();
|
||
}
|
||
|
||
async function deleteTunnel(id) {
|
||
if (!confirm('确认删除该代理通道吗?释放后端口将退回端口池。')) return;
|
||
const res = await fetch(`/api/tunnels/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||
if (!res.ok) alert('删除失败: ' + await apiError(res));
|
||
await fetchTunnels();
|
||
}
|
||
|
||
function openCreateModal() {
|
||
document.getElementById('modalTitle').innerText = '新建代理通道';
|
||
document.getElementById('tunnelId').value = '';
|
||
document.getElementById('tunnelName').value = '';
|
||
document.getElementById('tunnelTarget').value = '';
|
||
setAccessForm([]);
|
||
setLifecycleForm(30, 0);
|
||
document.getElementById('probeResult').innerText = '';
|
||
autoRecommendPort();
|
||
document.getElementById('tunnelModal').classList.add('open');
|
||
}
|
||
|
||
function editTunnel(id) {
|
||
const t = currentTunnels.find(item => item.id === id);
|
||
if (!t) return;
|
||
document.getElementById('modalTitle').innerText = '编辑代理通道';
|
||
document.getElementById('tunnelId').value = t.id;
|
||
document.getElementById('tunnelName').value = t.name;
|
||
document.getElementById('tunnelTarget').value = t.target;
|
||
document.getElementById('tunnelPort').value = t.listen_port;
|
||
setAccessForm(t.whitelist || []);
|
||
setLifecycleForm(t.effective_idle_close_minutes ?? 30, t.duration_minutes || 0);
|
||
document.getElementById('probeResult').innerText = '';
|
||
document.getElementById('tunnelModal').classList.add('open');
|
||
}
|
||
|
||
function closeTunnelModal() {
|
||
document.getElementById('tunnelModal').classList.remove('open');
|
||
}
|
||
|
||
async function autoRecommendPort() {
|
||
try {
|
||
const res = await fetch('/api/recommend-port');
|
||
const data = await res.json();
|
||
document.getElementById('tunnelPort').value = data.recommended_port;
|
||
} catch (e) {
|
||
document.getElementById('tunnelPort').value = 33660;
|
||
}
|
||
}
|
||
|
||
async function probeTarget() {
|
||
const target = document.getElementById('tunnelTarget').value.trim();
|
||
if (!target) {
|
||
alert('请先输入目标地址');
|
||
return;
|
||
}
|
||
const hint = document.getElementById('probeResult');
|
||
hint.innerText = '探测中...';
|
||
hint.style.color = 'var(--text-muted)';
|
||
try {
|
||
const res = await fetch('/api/probe', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ target })
|
||
});
|
||
const data = await res.json();
|
||
if (data.reachable) {
|
||
hint.innerText = `✓ 连通性测试通过!延迟: ${data.rtt}`;
|
||
hint.style.color = 'var(--success)';
|
||
} else {
|
||
hint.innerText = `✗ 连通失败: ${data.error || '连接超时'}`;
|
||
hint.style.color = 'var(--danger)';
|
||
}
|
||
} catch (e) {
|
||
hint.innerText = '探测请求失败';
|
||
hint.style.color = 'var(--danger)';
|
||
}
|
||
}
|
||
|
||
// 闲置自动关闭 / 最长开启时长;旧配置中不在选项里的值临时加入下拉框
|
||
function setLifecycleForm(idle, maxMinutes) {
|
||
const ensure = (sel, v, label) => {
|
||
if (![...sel.options].some(o => o.value === String(v))) sel.add(new Option(label, String(v)));
|
||
sel.value = String(v);
|
||
};
|
||
ensure(document.getElementById('tunnelIdleClose'), idle, `${idle} 分钟`);
|
||
ensure(document.getElementById('tunnelMaxDuration'), maxMinutes, fmtMinutes(maxMinutes));
|
||
}
|
||
|
||
function setAccessForm(wl) {
|
||
const mode = wl.length === 0 ? 'link' : (wl.includes('*') ? 'all' : 'specific');
|
||
document.getElementById('tunnelAccessMode').value = mode;
|
||
document.getElementById('tunnelWhitelist').value = mode === 'specific' ? wl.join(', ') : '';
|
||
onAccessModeChange();
|
||
}
|
||
|
||
function onAccessModeChange() {
|
||
const mode = document.getElementById('tunnelAccessMode').value;
|
||
document.getElementById('tunnelWhitelist').style.display = mode === 'specific' ? 'block' : 'none';
|
||
document.getElementById('accessModeHint').innerText = {
|
||
link: '只有通过邀请链接授权的设备可以访问,保存后可以直接设置并生成邀请链接。',
|
||
specific: '名单内的设备始终可以访问;其他人仍可通过邀请链接临时授权。',
|
||
all: '⚠️ 任何能访问服务器的设备都可以连接,不建议用于数据库等敏感服务。'
|
||
}[mode];
|
||
}
|
||
|
||
async function saveTunnel(e) {
|
||
e.preventDefault();
|
||
const id = document.getElementById('tunnelId').value;
|
||
const name = document.getElementById('tunnelName').value.trim();
|
||
const target = document.getElementById('tunnelTarget').value.trim();
|
||
const port = parseInt(document.getElementById('tunnelPort').value, 10);
|
||
const mode = document.getElementById('tunnelAccessMode').value;
|
||
let whitelist = [];
|
||
if (mode === 'all') whitelist = ['*'];
|
||
if (mode === 'specific') {
|
||
whitelist = document.getElementById('tunnelWhitelist').value.split(',').map(s => s.trim()).filter(Boolean);
|
||
if (whitelist.length === 0) { alert('请填写允许访问的 IP 或网段,或选择“仅授权链接”'); return; }
|
||
if (whitelist.includes('*')) { alert('如需放通全部,请选择“全部放通 *”'); return; }
|
||
}
|
||
|
||
// 编辑时只提交表单中的字段,其余(责任人、时长、限速等)由后端保留
|
||
const payload = {
|
||
name,
|
||
target,
|
||
listen_port: port,
|
||
whitelist,
|
||
idle_close_minutes: parseInt(document.getElementById('tunnelIdleClose').value, 10),
|
||
duration_minutes: parseInt(document.getElementById('tunnelMaxDuration').value, 10)
|
||
};
|
||
|
||
try {
|
||
const res = id
|
||
? await fetch(`/api/tunnels/${encodeURIComponent(id)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
})
|
||
: await fetch('/api/tunnels', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (!res.ok) {
|
||
alert((id ? '保存失败: ' : '创建失败: ') + await apiError(res));
|
||
return;
|
||
}
|
||
// 新建“仅授权链接”通道后直接生成邀请
|
||
if (!id && mode !== 'all') {
|
||
const created = await res.json();
|
||
closeTunnelModal();
|
||
await fetchTunnels();
|
||
setTimeout(() => openInviteModal(created.id, false), 800);
|
||
return;
|
||
}
|
||
closeTunnelModal();
|
||
await fetchTunnels();
|
||
} catch (err) {
|
||
alert('保存错误: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// Settings
|
||
async function openSettingsModal() {
|
||
const res = await fetch('/api/settings');
|
||
const data = await res.json();
|
||
document.getElementById('settingServerAddr').value = data.server_addr;
|
||
document.getElementById('settingToken').value = data.token;
|
||
document.getElementById('settingPublicHost').value = data.public_host || '';
|
||
document.getElementById('settingsModal').classList.add('open');
|
||
}
|
||
|
||
function closeSettingsModal() {
|
||
document.getElementById('settingsModal').classList.remove('open');
|
||
}
|
||
|
||
async function saveSettings(e) {
|
||
e.preventDefault();
|
||
const serverAddr = document.getElementById('settingServerAddr').value.trim();
|
||
const token = document.getElementById('settingToken').value.trim();
|
||
const public_host = document.getElementById('settingPublicHost').value.trim();
|
||
const res = await fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ server_addr: serverAddr, token, public_host })
|
||
});
|
||
if (!res.ok) {
|
||
alert('保存失败: ' + await apiError(res));
|
||
return;
|
||
}
|
||
closeSettingsModal();
|
||
await fetchStatus();
|
||
}
|
||
|
||
// Drawer
|
||
let drawerTunnelId = null;
|
||
function openDrawer(tunnelId) {
|
||
const t = currentTunnels.find(item => item.id === tunnelId);
|
||
drawerTunnelId = tunnelId;
|
||
document.getElementById('drawerTitle').innerText = `${t ? t.name : ''} - 连接与授权`;
|
||
renderDrawerClients();
|
||
document.getElementById('clientDrawer').classList.add('open');
|
||
}
|
||
|
||
function closeDrawer() {
|
||
document.getElementById('clientDrawer').classList.remove('open');
|
||
drawerTunnelId = null;
|
||
}
|
||
|
||
const cardStyle = 'background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; display:flex; justify-content:space-between; align-items:center; gap: 10px;';
|
||
const pad2 = n => String(n).padStart(2, '0');
|
||
const leaseTitle = d => ({ '1h': '1 小时', '8h': '8 小时', today: '当天有效', tunnel: '通道开启期间' }[d] || '2 小时');
|
||
const fmtDT = v => {
|
||
const d = new Date(v);
|
||
const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||
return d.toDateString() === new Date().toDateString() ? `${hm}:${pad2(d.getSeconds())}` : `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${hm}`;
|
||
};
|
||
|
||
function renderLeases(t) {
|
||
if (!t) return { pending: '', auth: '' };
|
||
const m = t.metrics || {};
|
||
const leases = m.dynamic_leases || [];
|
||
const pending = m.pending || [];
|
||
const invites = m.invites || [];
|
||
const id = escapeHtml(t.id);
|
||
let html = '';
|
||
|
||
if (pending.length) {
|
||
html += `<b style="font-size:13px; display:block; margin: 4px 0 10px; color:#fbbf24;">待审批 (${pending.length})</b>`;
|
||
html += pending.map(r => `
|
||
<div style="${cardStyle}">
|
||
<div><b>${escapeHtml(r.name)}</b> <code style="color:#38bdf8;">${escapeHtml(r.ip)}</code>
|
||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">申请于 ${fmtDT(r.at)} · 批准后可访问 ${leaseTitle((invites.find(i => i.code === r.invite) || {}).lease_duration)}</div></div>
|
||
<div class="drawer-row-actions">
|
||
<button class="btn-secondary btn-sm" onclick="decideRequest('${id}', '${escapeHtml(r.id)}', false)">拒绝</button>
|
||
<button class="btn-primary btn-sm" onclick="decideRequest('${id}', '${escapeHtml(r.id)}', true)">批准</button>
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
const pendingHtml = html;
|
||
html = '';
|
||
|
||
html += `
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin: 14px 0 10px;">
|
||
<b style="font-size:13px;">已授权设备 (${leases.length})</b>
|
||
<div style="display:flex; gap:6px;">
|
||
${t.enabled ? `<button class="btn-primary btn-sm" onclick="openInviteModal('${id}')">新建邀请</button>` : ''}
|
||
${leases.length ? `<button class="btn-danger btn-sm" onclick="revokeLease('${id}', '')">全部撤销</button>` : ''}
|
||
</div>
|
||
</div>`;
|
||
html += leases.length === 0
|
||
? '<div style="color: var(--text-muted); font-size: 12px; margin-bottom: 14px;">暂无。点击“新建邀请”把链接发给同事,对方打开并填写姓名后即可访问。</div>'
|
||
: leases.map(l => {
|
||
const mins = Math.max(0, Math.round((new Date(l.expires_at) - Date.now()) / 60000));
|
||
return `
|
||
<div style="${cardStyle}">
|
||
<div><b>${escapeHtml(l.name || '未署名')}</b> <code style="color: #34d399;">${escapeHtml(l.ip)}</code>
|
||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${l.awaiting_connect
|
||
? `已授权,尚未连接 · 首次连接后可访问 ${leaseTitle(l.duration)} · 请在 ${fmtDT(l.expires_at)} 前连接`
|
||
: `${l.started_at ? '首次连接 ' + fmtDT(l.started_at) + ' · ' : ''}有效期至 ${fmtDT(l.expires_at)} · 剩余约 ${mins} 分钟`}</div></div>
|
||
<button class="btn-danger btn-sm" onclick="revokeLease('${id}', '${escapeHtml(l.ip)}')">撤销</button>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
html += `<b style="font-size:13px; display:block; margin: 8px 0 10px;">有效的邀请链接 (${invites.length})</b>`;
|
||
html += invites.length === 0
|
||
? '<div style="color: var(--text-muted); font-size: 12px; margin-bottom: 14px;">暂无有效的邀请链接</div>'
|
||
: invites.map(inv => {
|
||
const used = inv.uses || [];
|
||
const usage = inv.max_uses > 0 ? `${used.length}/${inv.max_uses} 台` : `${used.length} 台(不限)`;
|
||
const opts = `授权后可访问 ${leaseTitle(inv.lease_duration)}${inv.require_approval ? ' · <span style="color:#fbbf24;">需审批</span>' : ''}`;
|
||
return `
|
||
<div style="${cardStyle}">
|
||
<div><b>${inv.note ? '给 ' + escapeHtml(inv.note) : '未备注的邀请'}</b>
|
||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${opts}<br>链接有效至 ${fmtDT(inv.expires_at)} · 已使用 ${usage}${used.length ? '(' + used.map(u => escapeHtml(u.name)).join('、') + ')' : ''}</div></div>
|
||
<div class="drawer-row-actions">
|
||
<button class="btn-secondary btn-sm" onclick="copyInviteShare('${id}', '${escapeHtml(inv.code)}')">复制文案</button>
|
||
<button class="btn-danger btn-sm" onclick="revokeInvite('${id}', '${escapeHtml(inv.code)}')">作废</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
return { pending: pendingHtml, auth: html + '<div style="height: 10px;"></div>' };
|
||
}
|
||
|
||
async function decideRequest(tunnelId, requestId, approve) {
|
||
const res = await fetch(`/api/tunnels/${encodeURIComponent(tunnelId)}/requests/${encodeURIComponent(requestId)}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ approve })
|
||
});
|
||
if (!res.ok) alert('操作失败: ' + await apiError(res));
|
||
}
|
||
|
||
async function revokeInvite(tunnelId, code) {
|
||
if (!confirm('作废该邀请链接?通过它获得的访问权限也会被收回。')) return;
|
||
const res = await fetch(`/api/tunnels/${encodeURIComponent(tunnelId)}/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
|
||
if (!res.ok) alert('作废失败: ' + await apiError(res));
|
||
}
|
||
|
||
async function copyText(text) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
return true;
|
||
} catch (e) {
|
||
prompt('复制以下内容:', text);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function copyInviteShare(tunnelId, code) {
|
||
const res = await fetch(`/api/tunnels/${encodeURIComponent(tunnelId)}/invites/${encodeURIComponent(code)}/share`);
|
||
if (!res.ok) { alert('获取失败: ' + await apiError(res)); return; }
|
||
const data = await res.json();
|
||
if (await copyText(data.share_text)) alert('分享文案已复制');
|
||
}
|
||
|
||
// Invite modal
|
||
let inviteTunnelId = null;
|
||
function openInviteModal(tunnelId, autoGenerate) {
|
||
const t = currentTunnels.find(item => item.id === tunnelId);
|
||
inviteTunnelId = tunnelId;
|
||
document.getElementById('inviteTitle').innerText = `邀请访问「${t ? t.name : ''}」`;
|
||
resetInviteModal();
|
||
document.getElementById('inviteModal').classList.add('open');
|
||
if (autoGenerate) generateInvite();
|
||
}
|
||
|
||
function closeInviteModal() {
|
||
document.getElementById('inviteModal').classList.remove('open');
|
||
inviteTunnelId = null;
|
||
}
|
||
|
||
function resetInviteModal() {
|
||
document.getElementById('inviteNote').value = '';
|
||
document.getElementById('inviteLease').value = '2h';
|
||
document.getElementById('inviteApproval').checked = false;
|
||
document.getElementById('inviteForm').style.display = 'block';
|
||
document.getElementById('inviteResult').style.display = 'none';
|
||
}
|
||
|
||
async function generateInvite() {
|
||
if (!inviteTunnelId) return;
|
||
const res = await fetch(`/api/tunnels/${encodeURIComponent(inviteTunnelId)}/invites`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
note: document.getElementById('inviteNote').value.trim(),
|
||
valid_minutes: parseInt(document.getElementById('inviteValid').value, 10),
|
||
max_uses: parseInt(document.getElementById('inviteMaxUses').value, 10),
|
||
lease_duration: document.getElementById('inviteLease').value,
|
||
require_approval: document.getElementById('inviteApproval').checked
|
||
})
|
||
});
|
||
if (!res.ok) { alert('生成失败: ' + await apiError(res)); return; }
|
||
const data = await res.json();
|
||
document.getElementById('inviteShareText').value = data.share_text;
|
||
document.getElementById('inviteCopyText').onclick = async () => { if (await copyText(data.share_text)) document.getElementById('inviteCopyText').innerText = '已复制'; };
|
||
document.getElementById('inviteCopyLink').onclick = async () => { if (await copyText(data.link)) document.getElementById('inviteCopyLink').innerText = '已复制'; };
|
||
document.getElementById('inviteCopyText').innerText = '复制分享文案';
|
||
document.getElementById('inviteCopyLink').innerText = '仅复制链接';
|
||
document.getElementById('inviteForm').style.display = 'none';
|
||
document.getElementById('inviteResult').style.display = 'block';
|
||
}
|
||
|
||
async function revokeLease(tunnelId, ip) {
|
||
if (!confirm(ip ? `撤销 ${ip} 的访问权限?该设备的连接会被立即断开。` : '撤销该通道全部已授权设备?')) return;
|
||
const res = await fetch(`/api/tunnels/${encodeURIComponent(tunnelId)}/leases/revoke`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ip })
|
||
});
|
||
if (!res.ok) alert('撤销失败: ' + await apiError(res));
|
||
}
|
||
|
||
function renderDrawerClients() {
|
||
if (!drawerTunnelId) return;
|
||
const list = ((latestMetrics && latestMetrics.active_conns) || []).filter(c => c.tunnel_id === drawerTunnelId);
|
||
const container = document.getElementById('drawerClientsList');
|
||
const parts = renderLeases(currentTunnels.find(item => item.id === drawerTunnelId));
|
||
|
||
if (list.length === 0) {
|
||
container.innerHTML = parts.pending + '<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (0)</b><div style="text-align: center; color: var(--text-muted); padding: 16px 0 6px; font-size: 13px;">当前暂无客户端连接此端口</div>' + parts.auth;
|
||
return;
|
||
}
|
||
|
||
// 同一 IP 的连接合并展示
|
||
const groups = {};
|
||
list.forEach(c => {
|
||
const m = /^\[(.+)\]:\d+$/.exec(c.client_ip) || /^([^:]+):\d+$/.exec(c.client_ip);
|
||
const ip = m ? m[1] : c.client_ip;
|
||
(groups[ip] = groups[ip] || []).push(c);
|
||
});
|
||
const tunnel = currentTunnels.find(item => item.id === drawerTunnelId);
|
||
const leaseNames = new Map(((tunnel && tunnel.metrics && tunnel.metrics.dynamic_leases) || []).map(l => [l.ip, l.name || '']));
|
||
const sum = (arr, k) => arr.reduce((a, c) => a + (c[k] || 0), 0);
|
||
const ips = Object.keys(groups).sort((x, y) => groups[y].length - groups[x].length || x.localeCompare(y));
|
||
|
||
container.innerHTML = parts.pending + `<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (${ips.length} 台 · ${list.length} 连接)</b>` + ips.map(ip => {
|
||
const conns = groups[ip];
|
||
const first = conns.map(c => new Date(c.connected_at)).sort((x, y) => x - y)[0];
|
||
const ids = conns.map(c => c.conn_id);
|
||
return `
|
||
<div style="background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 8px; padding: 14px; margin-bottom: 12px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<div>
|
||
<code style="font-size: 14px; color: #38bdf8; font-weight: 600;">${escapeHtml(ip)}</code>
|
||
<span style="font-size: 11px; color: var(--text-muted); margin-left: 6px;">${conns.length} 个连接</span>
|
||
${leaseNames.has(ip) ? `<span style="font-size: 11px; color: #34d399; margin-left: 6px;">已授权${leaseNames.get(ip) ? ' · ' + escapeHtml(leaseNames.get(ip)) : ''}</span>` : ''}
|
||
</div>
|
||
<button class="btn-danger btn-sm" onclick='killConns(${escapeHtml(JSON.stringify(ids))})'>${conns.length > 1 ? '全部断开' : '断开踢出'}</button>
|
||
</div>
|
||
<div style="font-size: 12px; color: var(--text-muted); line-height: 1.6;">
|
||
<div>最早接入: ${first ? fmtDT(first) : '-'}</div>
|
||
<div>传输流量: 上行 ${formatBytes(sum(conns, 'bytes_in'))} / 下行 ${formatBytes(sum(conns, 'bytes_out'))}</div>
|
||
<div>当前速率: ↑ ${formatRate(sum(conns, 'rate_in'))} / ↓ ${formatRate(sum(conns, 'rate_out'))}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('') + parts.auth;
|
||
}
|
||
|
||
async function killConns(ids) {
|
||
for (const id of ids) {
|
||
await fetch('/api/connections/kill', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ conn_id: id })
|
||
});
|
||
}
|
||
renderDrawerClients();
|
||
}
|
||
|
||
async function killConn(connId) {
|
||
await fetch('/api/connections/kill', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ conn_id: connId })
|
||
});
|
||
renderDrawerClients();
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|