Files
PortRelay/cmd/portrelay-agent/web/index.html
T
DeepFal 86e159b3f0 PortRelay:把内网服务临时、可控地开放给同事
边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上;
通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。

- portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页)
- portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计)
- mac-native:SwiftUI 菜单栏 App
2026-10-13 16:33:17 +08:00

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