Files
PortRelay/cmd/portrelay-agent/web/index.html
T
DeepFal 5b355fb759 feat: 通道组并入「添加通道」表单,不再单独新建
- 去掉单独的「新建通道组」入口:添加通道表单的目标可以一次填多个
  (10.0.1.11-13:22、10.0.1.11:8080-8082、逗号分隔),预览每个地址分到的连续端口
- 表单新增「通道组」选项:不分组 / 新建通道组 / 已有组;多个目标自动放进新建的组,
  组名默认与通道名称相同;加入组时名称可不填,按「组名 (主机)」命名
- 闲置关闭、最长时长、限速、访问控制、责任人等设置对每个地址都生效,与单个通道一致
- 编辑通道时可以新建通道组并移入;组卡片上「添加地址」打开同一个表单并选好该组
- 编辑通道组只负责改名、责任人和加入已有通道
- 多个目标的连通性测试并发进行并汇总结果;网页表单补上责任人与限速
- 接口:POST /api/tunnels/batch(dry_run 预览、group_id / new_group),
  PUT /api/tunnels/{id} 支持 new_group;移除 /api/groups/preview 及组接口里的批量建通道
2026-10-13 20:55:57 +08:00

1915 lines
96 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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);
max-height: 90vh; overflow-y: auto;
}
.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; }
/* 通道组 */
.group-icon { color: #38bdf8; font-size: 16px; }
.group-summary { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font-size: 13px; color: var(--text-muted); }
.group-summary .route-endpoint { font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; }
.member-list { border-top: 1px solid rgba(148, 163, 184, 0.14); margin-bottom: 14px; }
.member-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid rgba(148, 163, 184, 0.1); font-size: 13px; }
.member-row.off { opacity: 0.6; }
.member-route { display: flex; align-items: center; gap: 8px; font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; white-space: nowrap; }
.member-route .route-arrow { font-size: 13px; }
.member-name { color: var(--text-muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; }
.member-meta { margin-left: auto; color: var(--text-muted); font-size: 12px; white-space: nowrap; font-variant-numeric: tabular-nums; }
.member-hint { color: var(--text-muted); font-size: 12px; padding: 8px 0; }
.member-row .chip { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 5px; cursor: pointer; background: rgba(52, 211, 153, 0.12); color: #34d399; }
.mdot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: #64748b; }
.mdot.on { background: #34d399; } .mdot.err { background: #f87171; } .mdot.wait { background: #fbbf24; }
.member-actions { display: flex; gap: 2px; }
.icon-btn { background: none; border: 0; cursor: pointer; font-size: 13px; padding: 2px 4px; border-radius: 4px; opacity: 0.75; }
.icon-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.08); opacity: 1; }
.icon-btn:disabled { opacity: 0.25; cursor: default; }
.check-list { max-height: 140px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
.form-group label.check-item { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 13px; font-weight: normal; color: var(--text); cursor: pointer; }
.check-item input { width: auto !important; }
.check-item code { color: var(--text-muted); font-size: 12px; }
.preview-row { font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
.plan-box { margin-top: 8px; padding: 8px 10px; border-radius: 6px; background: rgba(148, 163, 184, 0.08); color: var(--text-muted); font-size: 12px; line-height: 1.7; }
.plan-box .route-endpoint { color: var(--accent, #38bdf8); }
.add-member { background: none; border: 0; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 8px 0; }
.add-member:hover { color: var(--text); }
@media (max-width: 860px) { .member-name, .member-meta { display: none; } }
.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="input-row">
<div class="form-group" style="flex: 1;">
<label>通道名称 / 备注</label>
<input type="text" id="tunnelName" placeholder="例如:MySQL 实习库 或 财务系统 SSH" oninput="schedulePlan()">
</div>
<div class="form-group" style="flex: 1;">
<label>责任人(可选)</label>
<input type="text" id="tunnelOwner" placeholder="例如:张三 / 数据组">
</div>
</div>
<div class="form-group">
<label id="tunnelTargetLabel">目标地址(中转机 Mac 可直连的内网目标,可填多个)</label>
<div class="input-row">
<input type="text" id="tunnelTarget" required placeholder="例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22" oninput="schedulePlan()">
<button type="button" class="btn-secondary" onclick="probeTarget()">探测测试</button>
</div>
<div class="input-hint" id="probeResult"></div>
<div class="plan-box" id="tunnelPlan" style="display:none;"></div>
</div>
<div class="form-group" id="tunnelPortWrap">
<label>对外监听端口 (范围: 33001 - 34000)</label>
<div class="input-row">
<input type="number" id="tunnelPort" min="33001" max="34000" placeholder="33660">
<button type="button" class="btn-secondary" onclick="autoRecommendPort()">推荐空闲端口</button>
</div>
</div>
<div class="form-group">
<label>通道组</label>
<div class="input-row">
<select id="tunnelGroup" onchange="onTunnelGroupChange()"></select>
<input type="text" id="tunnelNewGroup" style="display:none;" placeholder="通道组名称" oninput="onTunnelGroupChange()">
</div>
<div class="input-hint" id="tunnelGroupHint"></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>单通道带宽限速(QoS 防挤占)</label>
<select id="tunnelBandwidth">
<option value="0" selected>不限速</option>
<option value="2">2 MB/s</option>
<option value="5">5 MB/s</option>
<option value="10">10 MB/s</option>
<option value="20">20 MB/s</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" id="tunnelSaveBtn">保存通道</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: Edit Tunnel Group(新地址在新建代理通道表单里创建) -->
<div class="modal-overlay" id="groupModal">
<div class="modal-card">
<div class="modal-header">
<h3>编辑通道组</h3>
<button class="btn-secondary btn-sm" onclick="closeGroupModal()">✕</button>
</div>
<form onsubmit="saveGroup(event)">
<input type="hidden" id="groupId">
<div class="input-hint" style="margin: -8px 0 14px;">组内地址共用邀请与授权:同事授权一次即可访问整组;组内任一地址在用时,整组都不会闲置关闭。</div>
<div class="form-group">
<label>通道组名称</label>
<input type="text" id="groupName" required placeholder="例如:订单服务集群">
</div>
<div class="form-group">
<label>责任人(可选)</label>
<input type="text" id="groupOwner" placeholder="例如:张三 / 运维组">
</div>
<div class="form-group" id="groupExistingWrap">
<label>把已有通道加入该组(可选)</label>
<div class="check-list" id="groupExisting"></div>
</div>
<div class="input-hint">要开放新的地址,请在通道组卡片上点「+ 添加地址」</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeGroupModal()">取消</button>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
</div>
</div>
<!-- Modal: Delete Tunnel Group -->
<div class="modal-overlay" id="groupDeleteModal">
<div class="modal-card" style="width: 460px;">
<div class="modal-header">
<h3 id="groupDeleteTitle">删除通道组</h3>
<button class="btn-secondary btn-sm" onclick="closeGroupDelete()">✕</button>
</div>
<div class="input-hint" style="font-size: 13px; line-height: 1.7;">组的邀请链接与授权会立即失效。解散后组内通道变回独立通道;删除则一并注销所有端口并断开连接。</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeGroupDelete()">取消</button>
<button type="button" class="btn-secondary" id="groupDissolveBtn" onclick="confirmGroupDelete(false)">解散,保留通道</button>
<button type="button" class="btn-danger" onclick="confirmGroupDelete(true)">删除组和全部通道</button>
</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 currentGroups = [];
let publicHost = '';
let latestMetrics = null;
async function init() {
await fetchStatus();
await fetchTunnels();
setupSSE();
}
function escapeHtml(v) {
return String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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('t:${id}')">🔔 待审批 ${m.pending.length}</span>`;
if ((m.dynamic_leases || []).length) html += `<span class="chip green" onclick="openDrawer('t:${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 [tr, gr] = await Promise.all([fetch('/api/tunnels'), fetch('/api/groups')]);
currentTunnels = await tr.json();
if (gr.ok) currentGroups = await gr.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 statusOK = t => statusFilter === 'all' || (statusFilter === 'running') === !!t.enabled;
// 独立通道与通道组按配置顺序混排:组出现在它第一个成员的位置;组名命中时显示整组
const entries = [], seen = new Set();
let matched = 0;
currentTunnels.forEach(t => {
const g = t.group_id && currentGroups.find(x => x.id === t.group_id);
if (g) {
if (seen.has(g.id)) return;
seen.add(g.id);
const groupHit = query.trim() !== '' && tunnelMatches({ name: g.name, owner: g.owner, target: '', listen_port: '' }, query);
const shown = groupMembers(g).filter(m => statusOK(m) && (groupHit || tunnelMatches(m, query)));
if (shown.length) { entries.push(groupCardHtml(g, groupMembers(g), shown)); matched += shown.length; }
} else if (statusOK(t) && tunnelMatches(t, query)) {
entries.push(tunnelCardHtml(t)); matched++;
}
});
currentGroups.filter(g => !seen.has(g.id) && !(g.tunnel_ids || []).length).forEach(g => {
if (statusFilter === 'all' && tunnelMatches({ name: g.name, owner: g.owner, target: '', listen_port: '' }, query)) entries.push(groupCardHtml(g, [], []));
});
const filtering = query.trim() !== '' || statusFilter !== 'all';
document.getElementById('tunnelCount').innerText = filtering
? `匹配 ${matched} / 共 ${currentTunnels.length} 个`
: `${currentTunnels.length} 个通道` + (currentGroups.length ? ` · ${currentGroups.length} 个组` : '');
grid.innerHTML = entries.join('');
if (currentTunnels.length === 0 && currentGroups.length === 0) {
grid.innerHTML = '<div style="text-align:center; padding: 40px; color: var(--text-muted);">暂无中继通道,点击上方按钮添加第一个通道;目标可以一次填多台主机,会放进同一个通道组</div>';
} else if (entries.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 tunnelCardHtml(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('t:${escapeHtml(t.id)}')">📨 邀请</button>
<button class="btn-secondary btn-sm" onclick="openDrawer('t:${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>
`;
}
function setupSSE() {
const evtSource = new EventSource('/api/stream');
evtSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
renderStatus(data);
// 服务端推送完整通道列表(含开关状态),到期自动关闭等变化也能实时反映
if (Array.isArray(data.groups)) currentGroups = data.groups;
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();
}
// 新建代理通道;groupId 非空时默认加入该通道组(通道组卡片上的「添加地址」)
function openCreateModal(groupId) {
document.getElementById('modalTitle').innerText = '新建代理通道';
document.getElementById('tunnelId').value = '';
document.getElementById('tunnelName').value = '';
document.getElementById('tunnelOwner').value = '';
document.getElementById('tunnelTarget').value = '';
document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标,可填多个)';
document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22';
setAccessForm([]);
setLifecycleForm(30, 0, 0);
fillTunnelGroupSelect(groupId || '');
document.getElementById('probeResult').innerText = '';
resetPlan();
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('tunnelOwner').value = t.owner || '';
document.getElementById('tunnelTarget').value = t.target;
document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标)';
document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306';
document.getElementById('tunnelPort').value = t.listen_port;
setAccessForm(t.whitelist || []);
setLifecycleForm(t.effective_idle_close_minutes ?? 30, t.duration_minutes || 0, t.max_bandwidth_mb || 0);
fillTunnelGroupSelect(t.group_id || '');
document.getElementById('probeResult').innerText = '';
resetPlan();
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 probeOne(target) {
const res = await fetch('/api/probe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target })
});
return res.json();
}
async function probeTarget() {
const target = document.getElementById('tunnelTarget').value.trim();
if (!target) {
alert('请先输入目标地址');
return;
}
const targets = isBatch() ? tunnelPlan.map(t => t.target) : [target];
const hint = document.getElementById('probeResult');
hint.innerText = '探测中...';
hint.style.color = 'var(--text-muted)';
try {
const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' }))));
const failed = results.map((r, i) => [targets[i], r]).filter(([, r]) => !r.reachable);
if (targets.length === 1) {
const data = results[0];
hint.innerText = data.reachable ? `✓ 连通性测试通过!延迟: ${data.rtt}` : `✗ 连通失败: ${data.error || '连接超时'}`;
} else if (!failed.length) {
hint.innerText = `✓ 全部 ${targets.length} 个目标连通`;
} else {
const [t, r] = failed[0];
hint.innerText = `✗ ${targets.length - failed.length}/${targets.length} 连通;${t} 连通失败: ${r.error || '连接超时'}` + (failed.length > 1 ? `(共 ${failed.length} 个失败)` : '');
}
hint.style.color = failed.length ? 'var(--danger)' : 'var(--success)';
} catch (e) {
hint.innerText = '探测请求失败';
hint.style.color = 'var(--danger)';
}
}
// ---- 多个目标:预览将创建的通道,并放进通道组 ----
let tunnelPlan = [], tunnelPlanError = null, planTimer = null;
function resetPlan() {
tunnelPlan = [];
tunnelPlanError = null;
clearTimeout(planTimer);
renderPlan();
}
// 可能填了多个目标(逗号 / 空格分隔,或含范围 "10.0.1.11-13:22"),需要服务端展开
function mayBeMulti() {
if (document.getElementById('tunnelId').value) return false;
const target = document.getElementById('tunnelTarget').value.trim();
return target.split(/[\s,,、;;]+/).filter(Boolean).length > 1 || target.includes('-');
}
function isBatch() {
return mayBeMulti() && tunnelPlan.length > 1;
}
// 通道组选项:'' 不分组,'__new__' 新建,其余为已有组 ID
function groupFields() {
const sel = document.getElementById('tunnelGroup').value;
if (sel === '__new__') {
const name = document.getElementById('tunnelNewGroup').value.trim() || document.getElementById('tunnelName').value.trim();
return { group_id: '', new_group: name };
}
return { group_id: sel, new_group: '' };
}
function tunnelPayload(port, whitelist) {
return {
name: document.getElementById('tunnelName').value.trim(),
owner: document.getElementById('tunnelOwner').value.trim(),
target: document.getElementById('tunnelTarget').value.trim(),
listen_port: port,
whitelist,
idle_close_minutes: parseInt(document.getElementById('tunnelIdleClose').value, 10),
duration_minutes: parseInt(document.getElementById('tunnelMaxDuration').value, 10),
max_bandwidth_mb: parseFloat(document.getElementById('tunnelBandwidth').value),
...groupFields()
};
}
function schedulePlan() {
clearTimeout(planTimer);
if (!mayBeMulti()) { tunnelPlan = []; tunnelPlanError = null; renderPlan(); return; }
planTimer = setTimeout(async () => {
try {
const res = await fetch('/api/tunnels/batch', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...tunnelPayload(0, []), dry_run: true })
});
if (res.ok) {
tunnelPlan = (await res.json()).tunnels || [];
tunnelPlanError = null;
} else {
tunnelPlan = [];
tunnelPlanError = await apiError(res);
}
} catch (e) {
tunnelPlan = [];
tunnelPlanError = '预览失败:' + e.message;
}
renderPlan();
}, 300);
}
function renderPlan() {
const batch = isBatch();
const box = document.getElementById('tunnelPlan');
document.getElementById('tunnelPortWrap').style.display = batch ? 'none' : 'block';
// 多个目标必须放进同一个通道组
const sel = document.getElementById('tunnelGroup');
const none = sel.querySelector('option[value=""]');
if (none) none.disabled = batch;
if (batch && sel.value === '') { sel.value = '__new__'; onTunnelGroupChange(true); }
updateGroupHint();
document.getElementById('tunnelSaveBtn').innerText = batch ? `创建 ${tunnelPlan.length} 个通道` : '保存通道';
if (mayBeMulti() && tunnelPlanError) {
box.style.display = 'block';
box.style.color = 'var(--danger)';
box.innerText = tunnelPlanError;
return;
}
if (!batch) { box.style.display = 'none'; box.innerHTML = ''; return; }
const shown = tunnelPlan.slice(0, 6);
box.style.display = 'block';
box.style.color = '';
box.innerHTML = shown.map(m => `<div class="preview-row"><span class="route-endpoint">${escapeHtml(publicHost)}:${m.listen_port}</span> <span class="route-arrow">➔</span> <span class="route-target">${escapeHtml(m.target)}</span></div>`).join('')
+ `<div>${tunnelPlan.length > shown.length ? '… ' : ''}共 ${tunnelPlan.length} 个地址,每个地址一个通道,访问端口自动连续分配</div>`;
}
function fillTunnelGroupSelect(selected) {
document.getElementById('tunnelGroup').innerHTML =
'<option value="">不分组</option><option value="__new__">新建通道组</option>' +
(currentGroups.length ? '<option disabled>──────────</option>' : '') +
currentGroups.map(g => `<option value="${escapeHtml(g.id)}" ${g.id === selected ? 'selected' : ''}>${escapeHtml(g.name)}</option>`).join('');
document.getElementById('tunnelNewGroup').value = '';
onTunnelGroupChange(true);
}
function updateGroupHint() {
const sel = document.getElementById('tunnelGroup').value;
const isNew = sel === '__new__';
const name = document.getElementById('tunnelName').value.trim();
const newInput = document.getElementById('tunnelNewGroup');
newInput.style.display = isNew ? 'block' : 'none';
newInput.placeholder = name ? `组名,默认「${name}」` : '通道组名称';
document.getElementById('tunnelGroupHint').innerText = isBatch()
? '多个地址会放进同一个通道组:共用邀请和授权,同事授权一次即可访问全部地址'
: (sel ? '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址' : '');
// 放进通道组时名称可以不填,按组名命名
const g = isNew ? newInput.value.trim() : (currentGroups.find(x => x.id === sel) || {}).name;
document.getElementById('tunnelName').placeholder = (!document.getElementById('tunnelId').value && g)
? `可不填,默认「${g} (主机)」` : '例如:MySQL 实习库 或 财务系统 SSH';
}
function onTunnelGroupChange(skipPlan) {
updateGroupHint();
if (!skipPlan) schedulePlan();
}
// 闲置自动关闭 / 最长开启时长;旧配置中不在选项里的值临时加入下拉框
function setLifecycleForm(idle, maxMinutes, bandwidth) {
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));
ensure(document.getElementById('tunnelBandwidth'), bandwidth, `${bandwidth} MB/s`);
}
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 batch = isBatch();
const sel = document.getElementById('tunnelGroup').value;
const port = parseInt(document.getElementById('tunnelPort').value, 10);
if (!batch && !(port > 33000 && port <= 34000)) { alert('端口须在 33001-34000 范围内'); return; }
if (mayBeMulti() && tunnelPlanError) { alert(tunnelPlanError); return; }
if (!document.getElementById('tunnelName').value.trim() && (id || !sel)) { alert('请填写通道名称'); return; }
if (sel === '__new__' && !groupFields().new_group) { alert('请填写通道组名称'); return; }
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 = tunnelPayload(batch ? 0 : port, whitelist);
try {
// 新建统一走 /api/tunnels/batch:单个目标、多个目标、新建或加入通道组都在这一个表单里
const res = await fetch(id ? `/api/tunnels/${encodeURIComponent(id)}` : '/api/tunnels/batch', {
method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
alert((id ? '保存失败: ' : '创建失败: ') + await apiError(res));
return;
}
const data = await res.json();
closeTunnelModal();
await fetchTunnels();
// 新建后直接生成邀请:新建的通道组邀请整组;加入已有组时组的授权自动覆盖新地址,不用再邀请
const joinsExisting = sel !== '' && sel !== '__new__';
if (!id && mode !== 'all' && !joinsExisting) {
const key = data.group ? 'g:' + data.group.id : 't:' + data.tunnels[0].id;
setTimeout(() => openInviteModal(key, false), 800);
}
} 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();
}
// ---- 授权作用范围:'t:<通道ID>' 或 'g:<通道组ID>'(组内通道共用邀请与授权)----
function parseScope(key) {
return key.startsWith('g:') ? { group: true, id: key.slice(2) } : { group: false, id: key.startsWith('t:') ? key.slice(2) : key };
}
function scopeBase(key) {
const s = parseScope(key);
return (s.group ? '/api/groups/' : '/api/tunnels/') + encodeURIComponent(s.id);
}
function groupMembers(g) {
return (g.tunnel_ids || []).map(id => currentTunnels.find(t => t.id === id)).filter(Boolean);
}
function scopeInfo(key) {
const s = parseScope(key);
let name, members, m;
if (s.group) {
const g = currentGroups.find(x => x.id === s.id);
if (!g) return null;
name = g.name; members = groupMembers(g); m = g.metrics || {};
} else {
const t = currentTunnels.find(x => x.id === s.id);
if (!t) return null;
name = t.name; members = [t]; m = t.metrics || {};
}
return {
group: s.group, name, members, enabled: members.some(t => t.enabled),
leases: m.dynamic_leases || [], invites: m.invites || [], pending: m.pending || [],
};
}
// Drawer
let drawerScope = null;
function openDrawer(key) {
if (!key.includes(':')) key = 't:' + key;
const info = scopeInfo(key);
drawerScope = key;
document.getElementById('drawerTitle').innerText = info
? `${info.name}${info.group ? `(${info.members.length} 个地址)` : ''} - 连接与授权` : '连接与授权';
renderDrawerClients();
document.getElementById('clientDrawer').classList.add('open');
}
function closeDrawer() {
document.getElementById('clientDrawer').classList.remove('open');
drawerScope = 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(key) {
const info = scopeInfo(key);
if (!info) return { pending: '', auth: '' };
const { leases, pending, invites } = info;
const k = escapeHtml(key);
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('${k}', '${escapeHtml(r.id)}', false)">拒绝</button>
<button class="btn-primary btn-sm" onclick="decideRequest('${k}', '${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;">
${info.enabled ? `<button class="btn-primary btn-sm" onclick="openInviteModal('${k}')">新建邀请</button>` : ''}
${leases.length ? `<button class="btn-danger btn-sm" onclick="revokeLease('${k}', '')">全部撤销</button>` : ''}
</div>
</div>`;
if (info.group) {
html += '<div style="color: var(--text-muted); font-size: 12px; margin: -4px 0 10px;">组内所有地址共用邀请与授权:同事授权一次即可访问整组,计时从首次连上任一地址开始。</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('${k}', '${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('${k}', '${escapeHtml(inv.code)}')">复制文案</button>
<button class="btn-danger btn-sm" onclick="revokeInvite('${k}', '${escapeHtml(inv.code)}')">作废</button>
</div>
</div>`;
}).join('');
return { pending: pendingHtml, auth: html + '<div style="height: 10px;"></div>' };
}
async function decideRequest(key, requestId, approve) {
const res = await fetch(`${scopeBase(key)}/requests/${encodeURIComponent(requestId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approve })
});
if (!res.ok) alert('操作失败: ' + await apiError(res));
}
async function revokeInvite(key, code) {
if (!confirm('作废该邀请链接?通过它获得的访问权限也会被收回。')) return;
const res = await fetch(`${scopeBase(key)}/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(key, code) {
const res = await fetch(`${scopeBase(key)}/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 inviteScope = null;
function openInviteModal(key, autoGenerate) {
if (!key.includes(':')) key = 't:' + key;
const info = scopeInfo(key);
inviteScope = key;
document.getElementById('inviteTitle').innerText = info
? (info.group ? `邀请访问「${info.name}」(${info.members.filter(t => t.enabled).length} 个地址)` : `邀请访问「${info.name}」`)
: '邀请访问';
resetInviteModal();
document.getElementById('inviteModal').classList.add('open');
if (autoGenerate) generateInvite();
}
function closeInviteModal() {
document.getElementById('inviteModal').classList.remove('open');
inviteScope = 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 (!inviteScope) return;
const res = await fetch(`${scopeBase(inviteScope)}/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();
const box = document.getElementById('inviteShareText');
box.value = data.share_text;
box.rows = Math.min(16, Math.max(8, data.share_text.split('\n').length + 2));
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(key, ip) {
if (!confirm(ip ? `撤销 ${ip} 的访问权限?该设备的连接会被立即断开。` : '撤销全部已授权设备?')) return;
const res = await fetch(`${scopeBase(key)}/leases/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip })
});
if (!res.ok) alert('撤销失败: ' + await apiError(res));
}
function renderDrawerClients() {
if (!drawerScope) return;
const info = scopeInfo(drawerScope);
if (!info) return;
const memberIds = new Set(info.members.map(t => t.id));
const targetOf = new Map(info.members.map(t => [t.id, t.target]));
const list = ((latestMetrics && latestMetrics.active_conns) || []).filter(c => memberIds.has(c.tunnel_id));
const container = document.getElementById('drawerClientsList');
const parts = renderLeases(drawerScope);
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 leaseNames = new Map(info.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);
const targets = [...new Set(conns.map(c => targetOf.get(c.tunnel_id)))].filter(Boolean);
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>
${info.group ? `<div>访问目标: <code>${targets.map(escapeHtml).join('、')}</code></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;
}
// ---- 通道组卡片 ----
const sumOf = (arr, f) => arr.reduce((a, x) => a + (f(x) || 0), 0);
function groupStatus(members) {
const on = members.filter(t => t.enabled);
if (!members.length) return ['badge-disabled', '暂无通道'];
if (!on.length) return ['badge-disabled', '已暂停'];
if (on.some(t => (t.metrics || {}).status === 'error')) return ['badge-error', '部分异常'];
if (on.some(t => (t.metrics || {}).status === 'offline')) return ['badge-syncing', '等待连接服务器'];
if (on.length < members.length) return ['badge-syncing', `${on.length}/${members.length} 运行中`];
return ['badge-online', '运行中'];
}
function groupAccessPill(members) {
if (!members.length) return '';
if (members.some(t => (t.whitelist || []).includes('*'))) return '<span class="access-pill all">⚠️ 含全部放通 *</span>';
const kinds = new Set(members.map(t => [...(t.whitelist || [])].sort().join(',')));
if (kinds.size > 1) return '<span class="access-pill fixed">成员访问方式不同</span>';
return (members[0].whitelist || []).length === 0 ? '<span class="access-pill">🔒 仅授权链接</span>' : '<span class="access-pill fixed">白名单 + 授权链接</span>';
}
function portRangeText(members) {
const ports = members.map(t => t.listen_port).sort((a, b) => a - b);
if (!ports.length) return '';
if (ports.length === 1) return String(ports[0]);
return ports[ports.length - 1] - ports[0] + 1 === ports.length ? `${ports[0]}–${ports[ports.length - 1]}` : ports.join(', ');
}
function groupLifecycleHtml(g, members) {
const on = members.filter(t => t.enabled);
const parts = [];
if (on.length) {
const min = arr => arr.filter(Boolean).sort()[0];
parts.push(`<span>开启 ${fmtDT(min(on.map(t => t.started_at)))}</span>`);
const first = min(on.map(t => t.first_connected_at));
parts.push(`<span>${first ? '首次连接 ' + fmtDT(first) : '尚无连接'}</span>`);
parts.push(`<span class="strong" title="组内连接在线时间最长的一个地址的累计时长">实际连接 ${fmtSecs(Math.max(...on.map(t => t.activity ? t.activity.connected_sec : (t.connected_sec || 0))))}</span>`);
const acts = on.map(t => t.activity).filter(Boolean);
if (acts.some(a => a.in_use)) {
parts.push('<span class="ok" title="组内有在线连接、有效授权、待审批申请或可用的邀请链接">● 使用中</span>');
} else if (acts.length) {
const act = acts.filter(a => a.auto_close_at).sort((a, b) => a.auto_close_at.localeCompare(b.auto_close_at))[0];
if (act) {
const left = -ago(act.auto_close_at);
parts.push(`<span class="${left < 300 ? 'warn' : ''}">已闲置 ${fmtSecs(ago(act.idle_since))},${fmtSecs(left)} 后整组自动关闭</span>`);
} else {
parts.push(`<span>已闲置 ${fmtSecs(ago(acts[0].idle_since))}(不自动关闭)</span>`);
}
}
const exp = min(on.map(t => t.expires_at));
if (exp) parts.push(`<span>最长开启至 ${fmtDT(exp)}</span>`);
} else if (members.length) {
if (members[0].close_reason) parts.push(`<span>关闭原因:${escapeHtml(members[0].close_reason)}</span>`);
const idle = members[0].effective_idle_close_minutes ?? 30;
parts.push(`<span>闲置自动关闭:${idle > 0 ? fmtMinutes(idle) : '关'}</span>`);
}
const m = g.metrics || {}, key = escapeHtml('g:' + g.id);
if ((m.pending || []).length) parts.push(`<span class="chip orange" onclick="openDrawer('${key}')">🔔 待审批 ${m.pending.length}</span>`);
if ((m.dynamic_leases || []).length) parts.push(`<span class="chip green" onclick="openDrawer('${key}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`);
return parts.join('');
}
function memberRowHtml(g, t) {
const m = t.metrics || {}, id = escapeHtml(t.id);
const dot = !t.enabled ? 'off' : (m.status === 'online' ? 'on' : (m.status === 'error' ? 'err' : 'wait'));
const own = (m.dynamic_leases || []).length + (m.pending || []).length;
// 批量创建的成员按“组名 (主机)”自动命名,不重复显示;加入组的已有通道保留原名
const custom = !t.name.startsWith(g.name + ' (');
return `
<div class="member-row ${t.enabled ? '' : 'off'}">
<span class="mdot ${dot}" title="${t.enabled ? escapeHtml(m.error || '运行中') : '已关闭'}"></span>
<span class="member-route"><span class="route-endpoint">${escapeHtml(publicHost)}:${t.listen_port}</span><span class="route-arrow">➔</span><span class="route-target">${escapeHtml(t.target)}</span></span>
${custom ? `<span class="member-name">${escapeHtml(t.name)}</span>` : ''}
${own ? `<span class="chip green" onclick="openDrawer('t:${id}')">单独授权 ${own}</span>` : ''}
<span class="member-meta">${m.active_conns ? m.active_conns + ' 连接 · ' : ''}${t.enabled ? `↑ ${formatRate(m.rate_in || 0)} ↓ ${formatRate(m.rate_out || 0)}` : '已关闭'}</span>
<label class="switch mini" title="${t.enabled ? '单独关闭这个地址' : '单独开启这个地址'}">
<input type="checkbox" ${t.enabled ? 'checked' : ''} onchange="toggleTunnel('${id}', this.checked)">
<span class="slider"></span>
</label>
<span class="member-actions">
<button class="icon-btn" title="单独邀请这个地址" ${t.enabled ? '' : 'disabled'} onclick="openInviteModal('t:${id}')">📨</button>
<button class="icon-btn" title="连接与授权" onclick="openDrawer('t:${id}')">👥</button>
<button class="icon-btn" title="编辑" onclick="editTunnel('${id}')">✏️</button>
<button class="icon-btn" title="移出通道组" onclick="moveOutOfGroup('${id}')">⤴️</button>
<button class="icon-btn" title="删除" onclick="deleteTunnel('${id}')">🗑️</button>
</span>
</div>`;
}
function groupCardHtml(g, members, shown) {
const on = members.filter(t => t.enabled), gid = escapeHtml(g.id), key = escapeHtml('g:' + g.id);
const [badgeClass, badgeText] = groupStatus(members);
const rateIn = sumOf(on, t => (t.metrics || {}).rate_in), rateOut = sumOf(on, t => (t.metrics || {}).rate_out);
const session = sumOf(on, t => ((t.metrics || {}).bytes_in || 0) + ((t.metrics || {}).bytes_out || 0));
const history = sumOf(members, t => (t.historical_bytes_in || 0) + (t.historical_bytes_out || 0)) + session;
const s = g.rate_history, peak = s ? Math.max(0, ...s.down, ...s.up) : 0;
const toggle = `<label class="switch mini" title="${on.length ? '关闭整组' : '开启整组'}">
<input type="checkbox" ${on.length ? 'checked' : ''} ${members.length ? '' : 'disabled'} onchange="toggleGroup('${gid}', this.checked)">
<span class="slider"></span>
</label>`;
const side = on.length ? `<div class="tunnel-side with-chart">
<div class="side-top">
<div class="traffic-legend">
<span><i style="background:${DOWN_COLOR}"></i>↓ ${formatRate(rateOut)}</span>
<span><i style="background:${UP_COLOR}"></i>↑ ${formatRate(rateIn)}</span>
</div>
${toggle}
</div>
<div class="traffic-panel">
<div class="traffic-chart">${peak ? sparkSVG(s, 260, 46, 'g' + gid) : '<div class="empty">近 10 分钟无流量</div>'}</div>
<div class="traffic-foot"><span>近 10 分钟</span><span>${peak ? '峰值 ' + formatRate(peak) : ''}</span></div>
</div>
</div>` : `<div class="tunnel-side"><div class="side-top">${toggle}</div></div>`;
const hidden = members.length - shown.length;
return `
<div class="tunnel-card group-card ${on.length || !members.length ? '' : 'disabled'}">
<div class="tunnel-body">
<div class="tunnel-info">
<div class="tunnel-title-row">
<span class="group-icon" title="通道组">▤</span>
<span class="tunnel-name">${escapeHtml(g.name)}</span>
<span class="badge ${badgeClass}">${badgeText}</span>
${groupAccessPill(members)}
</div>
<div class="group-summary">
<span>${members.length} 个地址</span>
${members.length ? `<span class="route-endpoint">${escapeHtml(publicHost)}:${portRangeText(members)}</span>
<button class="btn-secondary btn-sm" onclick="copyGroupAddresses('${gid}')">复制全部地址</button>` : ''}
</div>
<div class="lifecycle-row">${groupLifecycleHtml(g, members)}</div>
</div>
${side}
</div>
<div class="member-list">
${shown.map(t => memberRowHtml(g, t)).join('')}
${hidden > 0 ? `<div class="member-hint">已按搜索 / 筛选隐藏 ${hidden} 个地址</div>` : ''}
${!members.length ? '<div class="member-hint">组内暂无通道</div>' : ''}
<button class="add-member" onclick="openCreateModal('${gid}')" title="在该组里开放新的主机或端口,组的邀请和授权自动覆盖新地址">+ 添加地址</button>
</div>
<div class="tunnel-bottom">
<div class="metrics-summary">
<span>连接 <b>${sumOf(on, t => (t.metrics || {}).active_conns)}</b></span>
<span>本次 <b>${formatBytes(session)}</b></span>
<span>历史 <b>${formatBytes(history)}</b></span>
${on.length ? `<span class="rate-fallback">速率 <b>↑ ${formatRate(rateIn)} ↓ ${formatRate(rateOut)}</b></span>` : ''}
</div>
<div class="tunnel-actions">
<button class="btn-primary btn-sm" ${on.length ? '' : 'disabled title="请先开启通道组"'} onclick="openInviteModal('${key}')">📨 邀请整组</button>
<button class="btn-secondary btn-sm" onclick="openDrawer('${key}')">👥 连接与授权</button>
<button class="btn-secondary btn-sm" onclick="openGroupModal('${gid}')">✏️ 编辑</button>
<button class="btn-danger btn-sm" onclick="openGroupDelete('${gid}')">🗑️ 删除</button>
</div>
</div>
</div>`;
}
async function copyGroupAddresses(groupId) {
const g = currentGroups.find(x => x.id === groupId);
if (!g) return;
const text = groupMembers(g).map(t => `${publicHost}:${t.listen_port} → ${t.target}`).join('\n');
if (await copyText(text)) alert('已复制全部访问地址');
}
async function toggleGroup(id, enabled) {
const res = await fetch(`/api/groups/${encodeURIComponent(id)}/toggle`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled })
});
if (!res.ok) alert('操作失败: ' + await apiError(res));
await fetchTunnels();
}
async function moveOutOfGroup(id) {
const res = await fetch(`/api/tunnels/${encodeURIComponent(id)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_id: '' })
});
if (!res.ok) alert('操作失败: ' + await apiError(res));
await fetchTunnels();
}
// ---- 编辑通道组:名称、责任人、把已有通道加入该组 ----
function openGroupModal(id) {
const g = currentGroups.find(x => x.id === id);
if (!g) return;
document.getElementById('groupId').value = g.id;
document.getElementById('groupName').value = g.name;
document.getElementById('groupOwner').value = g.owner || '';
const free = currentTunnels.filter(t => !t.group_id);
document.getElementById('groupExistingWrap').style.display = free.length ? 'block' : 'none';
document.getElementById('groupExisting').innerHTML = free.map(t => `
<label class="check-item"><input type="checkbox" value="${escapeHtml(t.id)}">
<span>${escapeHtml(t.name)}</span><code>:${t.listen_port} → ${escapeHtml(t.target)}</code></label>`).join('');
document.getElementById('groupModal').classList.add('open');
}
function closeGroupModal() {
document.getElementById('groupModal').classList.remove('open');
}
async function saveGroup(e) {
e.preventDefault();
const id = document.getElementById('groupId').value;
const payload = {
name: document.getElementById('groupName').value.trim(),
owner: document.getElementById('groupOwner').value.trim(),
tunnel_ids: [...document.querySelectorAll('#groupExisting input:checked')].map(i => i.value)
};
const res = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
});
if (!res.ok) { alert('保存失败: ' + await apiError(res)); return; }
closeGroupModal();
await fetchTunnels();
}
// ---- 删除 / 解散通道组 ----
let groupToDelete = null;
function openGroupDelete(id) {
const g = currentGroups.find(x => x.id === id);
if (!g) return;
groupToDelete = id;
document.getElementById('groupDeleteTitle').innerText = `删除通道组「${g.name}」?`;
document.getElementById('groupDissolveBtn').innerText = `解散,保留 ${groupMembers(g).length} 个通道`;
document.getElementById('groupDeleteModal').classList.add('open');
}
function closeGroupDelete() {
document.getElementById('groupDeleteModal').classList.remove('open');
groupToDelete = null;
}
async function confirmGroupDelete(withMembers) {
if (!groupToDelete) return;
const res = await fetch(`/api/groups/${encodeURIComponent(groupToDelete)}${withMembers ? '?delete_members=1' : ''}`, { method: 'DELETE' });
if (!res.ok) alert('删除失败: ' + await apiError(res));
closeGroupDelete();
await fetchTunnels();
}
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>