Files
PortRelay/cmd/portrelay-agent/web/index.html
T
DeepFal 9a675ba96e feat: 新建时不再选择通道组,「添加地址」固定加入所在组;占位提示统一措辞
- 「添加通道」去掉通道组下拉:一个地址为独立通道,多个地址自动放进以名称命名的新组,
  只在目标列表下方说明结果
- 组卡片「添加地址」标题为「添加地址到「组名」」,固定加入该组,名称可选(留空按「组名 (主机)」命名),
  按钮为「添加 / 添加 N 个地址」,不再自动弹出邀请
- 通道组选择只保留在编辑已有通道时(不分组 / 新建通道组 / 已有组)
- 占位提示统一为「如:…」并说明格式:IP 或域名、IP 或 CIDR 网段、负责人或团队等
- App 与网页面板一致
2026-10-13 21:31:22 +08:00

2081 lines
106 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); }
.target-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.target-row input { min-width: 0; }
.target-row .t-ports { flex: 0 0 150px; }
.target-head { display: flex; gap: 8px; font-size: 11px; color: var(--text-muted); margin-bottom: 4px; }
.target-head span:first-child { flex: 1; }
.target-head span:last-child { flex: 0 0 182px; }
.plan-box .ok { color: var(--success); } .plan-box .bad { color: var(--danger); }
.dim-toggle { display: inline-flex; border: 1px solid var(--card-border); border-radius: 6px; overflow: hidden; float: right; }
.dim-toggle button { background: none; border: 0; color: var(--text-muted); font-size: 11px; padding: 2px 10px; cursor: pointer; }
.dim-toggle button.on { background: rgba(56, 189, 248, 0.18); color: var(--text); }
.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 id="tunnelNameLabel">通道名称</label>
<input type="text" id="tunnelName" placeholder="如:订单数据库" 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">目标服务(每行一台主机,多个端口用逗号隔开)</label>
<div class="target-head" id="targetHead"><span>主机</span><span>端口</span></div>
<div id="targetRows"></div>
<div class="input-row" style="justify-content: space-between; align-items: center;">
<button type="button" class="add-member" id="addRowBtn" onclick="addTargetRow()" title="也可以直接粘贴多个地址(每行一个或逗号分隔,可带 :端口),会自动拆成多行">+ 添加主机</button>
<button type="button" class="btn-secondary btn-sm" onclick="probeTarget()">探测测试</button>
</div>
<div class="input-hint" id="probeResult"></div>
<div class="plan-box" id="tunnelPlan" style="display:none;"></div>
<div class="input-hint" id="tunnelAddHint"></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" id="tunnelGroupWrap">
<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="IP 或 CIDR 网段,逗号分隔,如 192.168.1.100, 10.0.0.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 非空时默认加入该通道组(通道组卡片上的「添加地址」)
// 组卡片「添加地址」打开时固定加入的通道组;新建时不需要选择通道组
let fixedGroupId = null;
function openCreateModal(groupId) {
fixedGroupId = groupId || null;
const g = currentGroups.find(x => x.id === fixedGroupId);
document.getElementById('modalTitle').innerText = fixedGroupId ? `添加地址到「${g ? g.name : '通道组'}」` : '新建代理通道';
document.getElementById('tunnelId').value = '';
document.getElementById('tunnelName').value = '';
document.getElementById('tunnelOwner').value = '';
document.getElementById('tunnelTargetLabel').innerText = '目标服务(每行一台主机,多个端口用逗号隔开)';
fillTunnelGroupSelect('');
setAccessForm([]);
setLifecycleForm(30, 0, 0);
resetPlan();
setTargetRows([{ host: '', ports: '' }]);
document.getElementById('tunnelModal').classList.add('open');
document.querySelector('#targetRows .t-host').focus();
}
function editTunnel(id) {
const t = currentTunnels.find(item => item.id === id);
if (!t) return;
fixedGroupId = null;
document.getElementById('modalTitle').innerText = '编辑代理通道';
document.getElementById('tunnelId').value = t.id;
document.getElementById('tunnelName').value = t.name;
document.getElementById('tunnelOwner').value = t.owner || '';
document.getElementById('tunnelTargetLabel').innerText = '目标服务(内网主机与端口)';
const hp = splitHostPort(t.target) || [t.target, ''];
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 || '');
resetPlan();
setTargetRows([{ host: hp[0], ports: hp[1] }]);
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;
}
}
// ---- 目标列表:每行一台主机 + 一个或多个端口 ----
const isEditing = () => !!document.getElementById('tunnelId').value;
const listItems = s => s.split(/[\s,,、;;]+/).filter(Boolean);
// "10.0.1.11:22" / "[fd00::1]:22" → [主机, 端口];不带端口(含裸 IPv6)返回 null
function splitHostPort(item) {
const m = /^\[(.+)\]:(.+)$/.exec(item);
if (m) return [m[1], m[2]];
const parts = item.split(':');
return parts.length === 2 && parts[0] && parts[1] ? parts : null;
}
// 粘贴进来的一串目标拆成多行:每项是 "主机" 或 "主机:端口",同一主机的端口并到一行
function parseTargets(text, defaultPorts) {
const out = [];
for (const item of listItems(text)) {
const [host, port] = splitHostPort(item) || [item, ''];
const row = out.find(r => r.host === host);
if (!row) out.push({ host, ports: port || defaultPorts });
else if (port && !listItems(row.ports).includes(port)) row.ports = row.ports ? `${row.ports}, ${port}` : port;
}
return out;
}
// 拼成 "主机:端口, …" 交给服务端展开;还没填完整的行先跳过
function composeTarget(rows) {
const out = [];
for (const r of rows) {
const ports = listItems(r.ports);
for (const h of listItems(r.host)) {
if (splitHostPort(h)) { out.push(h); continue; }
if (!ports.length) continue;
const host = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
ports.forEach(p => out.push(`${host}:${p}`));
}
}
return out.join(', ');
}
function readTargetRows() {
return [...document.querySelectorAll('#targetRows .target-row')].map(r => ({
host: r.querySelector('.t-host').value.trim(), ports: r.querySelector('.t-ports').value.trim()
}));
}
function setTargetRows(rows) {
const edit = isEditing();
document.getElementById('targetRows').innerHTML = rows.map(r => `
<div class="target-row">
<input type="text" class="t-host" value="${escapeHtml(r.host)}" placeholder="${edit ? 'IP 或域名' : 'IP 或域名,支持粘贴多行'}" oninput="schedulePlan()" onpaste="onHostPaste(event)">
<input type="text" class="t-ports" value="${escapeHtml(r.ports)}" placeholder="${edit ? '如 3306' : '如 22, 3306'}" oninput="schedulePlan()">
${edit ? '' : `<button type="button" class="icon-btn" onclick="removeTargetRow(this)" title="删除这一行" ${rows.length === 1 ? 'disabled' : ''}>⊖</button>`}
</div>`).join('');
document.getElementById('addRowBtn').style.display = edit ? 'none' : '';
document.getElementById('targetHead').style.display = edit ? 'none' : 'flex';
schedulePlan();
}
// 新的一行沿用上一行的端口(同一组机器通常开同样的端口)
function addTargetRow() {
const rows = readTargetRows();
rows.push({ host: '', ports: rows.length ? rows[rows.length - 1].ports : '' });
setTargetRows(rows);
const hosts = document.querySelectorAll('#targetRows .t-host');
hosts[hosts.length - 1].focus();
}
function removeTargetRow(btn) {
const all = [...document.querySelectorAll('#targetRows .target-row')];
const rows = readTargetRows();
rows.splice(all.indexOf(btn.closest('.target-row')), 1);
setTargetRows(rows.length ? rows : [{ host: '', ports: '' }]);
}
// 一次粘贴多个主机(或 主机:端口)时拆成多行
function onHostPaste(e) {
if (isEditing()) return;
const text = (e.clipboardData || window.clipboardData).getData('text');
if (listItems(text).length < 2 && !splitHostPort(text.trim())) return;
e.preventDefault();
const all = [...document.querySelectorAll('#targetRows .target-row')];
const idx = all.indexOf(e.target.closest('.target-row'));
const rows = readTargetRows();
const parsed = parseTargets(text, rows[idx].ports);
// 粘贴到空行时替换这一行,否则插在它后面
if (rows[idx].host) rows.splice(idx + 1, 0, ...parsed);
else rows.splice(idx, 1, ...parsed);
setTargetRows(rows);
}
async function probeOne(target) {
const res = await fetch('/api/probe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target })
});
return res.json();
}
let probeResults = {};
async function probeTarget() {
const target = composeTarget(readTargetRows());
if (!target) { alert('请先填写目标主机和端口'); return; }
const targets = isEditing() ? [target] : tunnelPlan.map(t => t.target);
if (!targets.length) return;
const hint = document.getElementById('probeResult');
hint.innerText = '探测中...';
hint.style.color = 'var(--text-muted)';
const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' }))));
probeResults = {};
targets.forEach((t, i) => { probeResults[t] = results[i]; });
if (isEditing()) {
const r = results[0];
hint.innerText = r.reachable ? `✓ 连通性测试通过!延迟: ${r.rtt}` : `✗ 连通失败: ${r.error || '连接超时'}`;
hint.style.color = r.reachable ? 'var(--success)' : 'var(--danger)';
return;
}
hint.innerText = '';
renderPlan();
}
// ---- 预览将创建的通道(含自动分配的访问端口);多个地址自动放进通道组 ----
let tunnelPlan = [], tunnelPlanError = null, planTimer = null, plannedTarget = '';
function resetPlan() {
tunnelPlan = [];
tunnelPlanError = null;
probeResults = {};
document.getElementById('probeResult').innerText = '';
renderPlan();
}
function isBatch() {
return !isEditing() && tunnelPlan.length > 1;
}
// 通道组选项:'' 不分组,'__new__' 新建,其余为已有组 ID
function groupFields() {
const name = document.getElementById('tunnelName').value.trim();
if (!isEditing()) {
// 新建:「添加地址」固定加入该组;多个地址自动新建以名称命名的组;一个地址不分组
if (fixedGroupId) return { group_id: fixedGroupId, new_group: '' };
return { group_id: '', new_group: isBatch() ? name : '' };
}
const sel = document.getElementById('tunnelGroup').value;
if (sel !== '__new__') return { group_id: sel, new_group: '' };
return { group_id: '', new_group: document.getElementById('tunnelNewGroup').value.trim() || name };
}
function tunnelPayload(port, whitelist) {
return {
name: document.getElementById('tunnelName').value.trim(),
owner: document.getElementById('tunnelOwner').value.trim(),
target: composeTarget(readTargetRows()),
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);
const target = composeTarget(readTargetRows());
if (target !== plannedTarget) { probeResults = {}; plannedTarget = target; }
if (isEditing() || !target) { 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(), edit = isEditing();
document.getElementById('tunnelPortWrap').style.display = edit ? 'block' : 'none';
document.getElementById('tunnelGroupWrap').style.display = edit ? 'block' : 'none';
updateGroupHint();
const n = tunnelPlan.length;
document.getElementById('tunnelSaveBtn').innerText = fixedGroupId ? (batch ? `添加 ${n} 个地址` : '添加')
: (batch ? `创建 ${n} 个通道` : '保存通道');
const box = document.getElementById('tunnelPlan');
if (tunnelPlanError) {
box.style.display = 'block';
box.style.color = 'var(--danger)';
box.innerText = tunnelPlanError;
return;
}
if (!tunnelPlan.length) { box.style.display = 'none'; box.innerHTML = ''; return; }
const shown = tunnelPlan.slice(0, 8);
const tested = tunnelPlan.filter(m => probeResults[m.target]);
const failed = tested.filter(m => !probeResults[m.target].reachable);
const status = m => {
const r = probeResults[m.target];
if (!r) return '';
return r.reachable ? ` <span class="ok">✓ ${escapeHtml(r.rtt)}</span>` : ` <span class="bad" title="${escapeHtml(r.error || '连接超时')}">✗ 不通</span>`;
};
let caption = tunnelPlan.length === 1 ? '访问端口自动分配' : '每个地址一个通道,访问端口自动连续分配';
if (tested.length === tunnelPlan.length && !failed.length) caption = tunnelPlan.length === 1 ? '目标连接成功' : `全部 ${tunnelPlan.length} 个目标连接成功`;
if (failed.length) caption = `<span class="bad">${failed.length} 个目标连不上,鼠标停在 ✗ 上可查看原因</span>`;
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>${status(m)}</div>`).join('')
+ `<div>${tunnelPlan.length > shown.length ? `… 共 ${tunnelPlan.length} 个地址,` : ''}${caption}</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 = '';
updateGroupHint();
}
function updateGroupHint() {
const name = document.getElementById('tunnelName').value.trim();
const nameInput = document.getElementById('tunnelName');
const label = document.getElementById('tunnelNameLabel');
if (!isEditing()) {
// 新建:不需要选择通道组,只说明结果
const g = fixedGroupId ? ((currentGroups.find(x => x.id === fixedGroupId) || {}).name || '通道组') : null;
let hint = '';
if (g) hint = `新地址加入通道组「${g}」,自动沿用组的邀请和授权,已授权的同事无需重新授权`;
else if (isBatch()) hint = `${tunnelPlan.length} 个地址将放进通道组${name ? `「${name}」` : '(以上面填写的名称命名)'}:共用邀请和授权,同事授权一次即可访问全部地址`;
document.getElementById('tunnelAddHint').innerText = hint;
label.innerText = g ? '通道名称(可选)' : (isBatch() ? '通道组名称' : '通道名称');
nameInput.placeholder = g ? `留空则命名为「${g} (主机)」` : (isBatch() ? '如:订单服务集群' : '如:订单数据库');
return;
}
document.getElementById('tunnelAddHint').innerText = '';
label.innerText = '通道名称';
nameInput.placeholder = '如:订单数据库';
const sel = document.getElementById('tunnelGroup').value;
const newInput = document.getElementById('tunnelNewGroup');
newInput.style.display = sel === '__new__' ? 'block' : 'none';
newInput.placeholder = name ? `通道组名称,留空则用「${name}」` : '通道组名称';
document.getElementById('tunnelGroupHint').innerText = sel === '__new__' ? '新建通道组并移入;之后可在组卡片的「编辑」里把其它通道加入'
: (sel ? '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址' : '');
}
function onTunnelGroupChange() {
updateGroupHint();
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 joinsExisting = !id && !!fixedGroupId;
const name = document.getElementById('tunnelName').value.trim();
const target = composeTarget(readTargetRows());
let port = 0;
if (id) {
port = parseInt(document.getElementById('tunnelPort').value, 10);
if (!(port > 33000 && port <= 34000)) { alert('端口须在 33001-34000 范围内'); return; }
if (!target) { alert('请填写目标主机和端口'); return; }
if (target.includes(',')) { alert('一个通道只对应一个目标端口;要开放更多端口,请用「新建代理通道」'); return; }
} else {
if (tunnelPlanError) { alert(tunnelPlanError); return; }
if (!tunnelPlan.length) { alert('请填写目标主机和端口'); return; }
}
if (!name && (id || !joinsExisting)) { alert(batch ? '请填写通道组名称' : '请填写通道名称'); return; }
if (id && 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(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();
// 新建后直接生成邀请:新建的通道组邀请整组;加入已有组时组的授权自动覆盖新地址,不用再邀请
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));
}
// 通道组的在线连接可以按设备(来源 IP)或按组内地址查看
let drawerByAddress = false;
function setDrawerDimension(v) {
drawerByAddress = v;
renderDrawerClients();
}
const hostOfAddr = a => { const m = /^\[(.+)\]:\d+$/.exec(a) || /^([^:]+):\d+$/.exec(a); return m ? m[1] : a; };
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 ip = hostOfAddr(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));
const byAddress = info.group && drawerByAddress;
const inUse = new Set(list.map(c => c.tunnel_id)).size;
const toggle = info.group ? `<span class="dim-toggle"><button type="button" class="${byAddress ? '' : 'on'}" onclick="setDrawerDimension(false)">按设备</button><button type="button" class="${byAddress ? 'on' : ''}" onclick="setDrawerDimension(true)">按地址</button></span>` : '';
const header = `<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (${byAddress ? `${inUse}/${info.members.length} 个地址在用` : `${ips.length} 台 · ${list.length} 连接`})${toggle}</b>`;
if (byAddress) {
container.innerHTML = parts.pending + header + info.members.map(t => {
const conns = list.filter(c => c.tunnel_id === t.id);
const byIp = {};
conns.forEach(c => { const ip = hostOfAddr(c.client_ip); (byIp[ip] = byIp[ip] || []).push(c); });
const devs = Object.keys(byIp).sort();
const kill = cs => escapeHtml(JSON.stringify(cs.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: 12px 14px; margin-bottom: 10px; ${conns.length ? '' : 'opacity: 0.55;'}">
<div style="display: flex; justify-content: space-between; align-items: center; gap: 8px;">
<div style="font-size: 13px;">
<code style="color: #38bdf8; font-weight: 600;">${escapeHtml(publicHost)}:${t.listen_port}</code> <span class="route-arrow">➔</span> <code>${escapeHtml(t.target)}</code>
<span style="font-size: 11px; color: var(--text-muted); margin-left: 6px;">${conns.length ? `${devs.length} 台设备 · ${conns.length} 个连接` : (t.enabled ? '无连接' : '已关闭')}</span>
</div>
${conns.length ? `<button class="btn-danger btn-sm" onclick='killConns(${kill(conns)})'>全部断开</button>` : ''}
</div>
${devs.map(ip => `
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--text-muted); padding: 6px 0 0;">
<span><code style="color: var(--text);">${escapeHtml(ip)}</code>${leaseNames.get(ip) ? ` <span style="color: #34d399;">${escapeHtml(leaseNames.get(ip))}</span>` : ''}
· ${byIp[ip].length} 个连接 · ↑ ${formatRate(sum(byIp[ip], 'rate_in'))} / ↓ ${formatRate(sum(byIp[ip], 'rate_out'))}</span>
<button class="btn-secondary btn-sm" onclick='killConns(${kill(byIp[ip])})'>断开</button>
</div>`).join('')}
</div>`;
}).join('') + parts.auth;
return;
}
container.innerHTML = parts.pending + header + 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>