- 批量创建:主机支持 IP、主机名、末段范围(10.0.1.11-13)和 主机:端口, 与端口列表展开为 主机 × 端口,自动分配连续的访问端口,创建前可预览 - 授权以组为单位:一条邀请链接、一次审批即可访问组内全部地址; 计时从首次连上任一地址开始,撤销一次全组断开;后加入组的通道自动获得组授权 - 网关授权状态按作用范围(通道 / 通道组)存储,通道的动态白名单统一由状态库重新计算, 通道授权与组授权互不干扰;组内最后一个通道关闭或组被解散时,组的邀请与授权失效 - 闲置联动:组内任一地址在用时整组都不算闲置;支持整组开关 - 已有通道可并入组或移出组;组可改名、追加目标、解散(保留或删除成员) - App 与网页面板:组卡片、新建 / 编辑通道组、组邀请与“连接与授权”,授权页列出全部地址 - 测试:网关 9 个、Agent 5 个通道组用例
1877 lines
94 KiB
HTML
1877 lines
94 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>PortRelay - 端口中继与安全管控</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0f172a;
|
||
--card-bg: rgba(30, 41, 59, 0.7);
|
||
--card-border: rgba(255, 255, 255, 0.08);
|
||
--accent: #38bdf8;
|
||
--accent-hover: #0ea5e9;
|
||
--accent-glow: rgba(56, 189, 248, 0.25);
|
||
--success: #10b981;
|
||
--warning: #f59e0b;
|
||
--danger: #ef4444;
|
||
--text: #f8fafc;
|
||
--text-muted: #94a3b8;
|
||
--radius: 12px;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
|
||
background: radial-gradient(circle at top left, #1e1b4b, #0f172a 40%, #020617);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
padding: 24px;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
.container { max-width: 1200px; margin: 0 auto; }
|
||
|
||
/* Header */
|
||
header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding-bottom: 24px;
|
||
border-bottom: 1px solid var(--card-border);
|
||
margin-bottom: 24px;
|
||
}
|
||
.logo-area { display: flex; align-items: center; gap: 14px; }
|
||
.logo-badge {
|
||
width: 42px; height: 42px; border-radius: 10px;
|
||
background: linear-gradient(135deg, #0284c7, #6366f1);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 22px; box-shadow: 0 4px 15px var(--accent-glow);
|
||
}
|
||
.logo-text h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
||
.logo-text p { font-size: 13px; color: var(--text-muted); }
|
||
|
||
.header-actions { display: flex; align-items: center; gap: 12px; }
|
||
.status-pill {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 6px 14px; border-radius: 20px;
|
||
background: rgba(15, 23, 42, 0.8); border: 1px solid var(--card-border);
|
||
font-size: 13px; font-weight: 500;
|
||
}
|
||
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--danger); }
|
||
.status-dot.online { background: var(--success); box-shadow: 0 0 10px var(--success); }
|
||
|
||
button {
|
||
white-space: nowrap; flex-shrink: 0;
|
||
cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 500;
|
||
border: none; border-radius: 8px; padding: 8px 16px;
|
||
transition: all 0.15s ease; display: inline-flex; align-items: center; gap: 6px;
|
||
}
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, var(--accent), #0284c7);
|
||
color: #fff; box-shadow: 0 2px 10px var(--accent-glow);
|
||
}
|
||
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); }
|
||
.btn-secondary {
|
||
background: rgba(255, 255, 255, 0.08); color: var(--text);
|
||
border: 1px solid var(--card-border);
|
||
}
|
||
.btn-secondary:hover { background: rgba(255, 255, 255, 0.14); }
|
||
.btn-danger { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
|
||
.btn-danger:hover { background: var(--danger); color: #fff; }
|
||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||
button:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; transform: none; filter: grayscale(0.6); }
|
||
|
||
/* Top Metrics Overview */
|
||
.overview-grid {
|
||
display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||
gap: 16px; margin-bottom: 28px;
|
||
}
|
||
.metric-card {
|
||
background: var(--card-bg); border: 1px solid var(--card-border);
|
||
border-radius: var(--radius); padding: 18px; backdrop-filter: blur(12px);
|
||
}
|
||
.metric-card .title { font-size: 13px; color: var(--text-muted); margin-bottom: 8px; }
|
||
.metric-card .value {
|
||
font-size: 24px; font-weight: 700; color: #fff; font-variant-numeric: tabular-nums;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.metric-card .value.compact { font-size: 18px; line-height: 29px; white-space: normal; }
|
||
.metric-card .value.compact span { white-space: nowrap; margin-right: 10px; }
|
||
.metric-card .sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||
|
||
/* Tunnels Section */
|
||
.section-header {
|
||
display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;
|
||
}
|
||
.section-title { font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||
|
||
.tunnel-grid { display: flex; flex-direction: column; gap: 14px; margin-bottom: 32px; }
|
||
.section-header { flex-wrap: wrap; gap: 10px; }
|
||
.tunnel-filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||
.seg { display: inline-flex; background: rgba(15, 23, 42, 0.6); border: 1px solid var(--card-border); border-radius: 8px; padding: 2px; }
|
||
.seg button { background: transparent; color: var(--text-muted); padding: 4px 12px; font-size: 12px; border-radius: 6px; }
|
||
.seg button.active { background: rgba(56, 189, 248, 0.18); color: var(--text); }
|
||
.search-box {
|
||
display: flex; align-items: center; gap: 6px; width: 280px; padding: 6px 10px; border-radius: 8px;
|
||
background: rgba(15, 23, 42, 0.6); border: 1px solid var(--card-border); font-size: 12px;
|
||
}
|
||
.search-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); }
|
||
.search-box input { flex: 1; min-width: 0; background: transparent; border: 0; outline: none; color: var(--text); font: inherit; font-size: 13px; }
|
||
.tunnel-card {
|
||
background: var(--card-bg); border: 1px solid var(--card-border);
|
||
border-radius: var(--radius); padding: 20px; backdrop-filter: blur(16px);
|
||
transition: border-color 0.2s ease, transform 0.2s ease;
|
||
}
|
||
.tunnel-card:hover { border-color: rgba(56, 189, 248, 0.35); }
|
||
.tunnel-card.disabled { opacity: 0.6; }
|
||
|
||
.tunnel-top {
|
||
display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px;
|
||
}
|
||
.tunnel-title-row { display: flex; align-items: center; gap: 12px; }
|
||
.tunnel-name { font-size: 17px; font-weight: 600; }
|
||
.badge {
|
||
font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 6px;
|
||
text-transform: uppercase;
|
||
}
|
||
.badge-online { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
|
||
.badge-disabled { background: rgba(148, 163, 184, 0.15); color: #94a3b8; }
|
||
.badge-error { background: rgba(239, 68, 68, 0.15); color: #f87171; }
|
||
.badge-syncing { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
|
||
|
||
/* Switch */
|
||
.switch {
|
||
position: relative; display: inline-block; width: 44px; height: 24px;
|
||
}
|
||
.switch input { opacity: 0; width: 0; height: 0; }
|
||
.slider {
|
||
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
|
||
background-color: #334155; transition: .25s; border-radius: 24px;
|
||
}
|
||
.slider:before {
|
||
position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px;
|
||
background-color: white; transition: .25s; border-radius: 50%;
|
||
}
|
||
input:checked + .slider { background-color: var(--success); }
|
||
input:checked + .slider:before { transform: translateX(20px); }
|
||
|
||
/* Route Box */
|
||
.route-box {
|
||
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
|
||
background: rgba(15, 23, 42, 0.6); border-radius: 8px;
|
||
border: 1px solid rgba(255, 255, 255, 0.04); margin-bottom: 14px;
|
||
font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; font-size: 14px;
|
||
}
|
||
.route-endpoint { color: #38bdf8; font-weight: 600; }
|
||
.route-arrow { color: #64748b; font-size: 16px; }
|
||
.route-target { color: #a5b4fc; font-weight: 500; }
|
||
|
||
/* Whitelist pills */
|
||
.whitelist-row {
|
||
display: flex; align-items: center; gap: 8px; margin-bottom: 14px; flex-wrap: wrap;
|
||
}
|
||
.whitelist-label { font-size: 12px; color: var(--text-muted); }
|
||
.whitelist-pill {
|
||
font-size: 12px; padding: 2px 8px; border-radius: 4px;
|
||
background: rgba(56, 189, 248, 0.12); color: #7dd3fc;
|
||
border: 1px solid rgba(56, 189, 248, 0.25); font-family: monospace;
|
||
}
|
||
.whitelist-pill.all {
|
||
background: rgba(245, 158, 11, 0.12); color: #fbbf24; border-color: rgba(245, 158, 11, 0.3);
|
||
}
|
||
|
||
/* Tunnel Bottom */
|
||
.tunnel-bottom {
|
||
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px 16px;
|
||
padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||
font-size: 13px; color: var(--text-muted);
|
||
}
|
||
.metrics-summary { display: flex; flex-wrap: wrap; gap: 6px 20px; font-variant-numeric: tabular-nums; }
|
||
.metrics-summary span { white-space: nowrap; }
|
||
.metrics-summary span b { color: var(--text); font-weight: 600; }
|
||
.tunnel-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
|
||
.tunnel-body { display: flex; gap: 20px; align-items: flex-start; margin-bottom: 14px; }
|
||
.tunnel-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
|
||
.tunnel-info .route-box { margin: 0; }
|
||
.tunnel-info .tunnel-title-row { flex-wrap: wrap; row-gap: 6px; }
|
||
.tunnel-side { flex-shrink: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||
.tunnel-side.with-chart { width: 260px; }
|
||
.side-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; }
|
||
.tunnel-side:not(.with-chart) .side-top { justify-content: flex-end; }
|
||
.switch.mini { width: 36px; height: 20px; }
|
||
.switch.mini .slider:before { width: 14px; height: 14px; left: 3px; bottom: 3px; }
|
||
.switch.mini input:checked + .slider:before { transform: translateX(16px); }
|
||
.traffic-panel { width: 100%; font-variant-numeric: tabular-nums; }
|
||
.traffic-legend { display: flex; gap: 12px; font-size: 12px; font-weight: 600; color: var(--text); }
|
||
.traffic-legend i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; vertical-align: 1px; }
|
||
.traffic-foot { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||
.traffic-chart { position: relative; height: 46px; border-bottom: 1px solid rgba(148, 163, 184, 0.18); }
|
||
.traffic-chart .empty {
|
||
position: absolute; inset: 0 0 10px; display: flex; align-items: center; justify-content: center;
|
||
font-size: 11px; color: var(--text-muted);
|
||
}
|
||
.rate-fallback { display: none !important; }
|
||
@media (max-width: 860px) {
|
||
.traffic-panel, .traffic-legend { display: none; }
|
||
.tunnel-side.with-chart { width: auto; }
|
||
.tunnel-side .side-top { justify-content: flex-end; }
|
||
.rate-fallback { display: inline !important; }
|
||
}
|
||
.stat-spark { margin-top: 6px; height: 26px; }
|
||
.access-pill {
|
||
font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 6px; white-space: nowrap;
|
||
background: rgba(52, 211, 153, 0.12); color: #34d399; border: 1px solid rgba(52, 211, 153, 0.25);
|
||
}
|
||
.access-pill.all { background: rgba(248, 113, 113, 0.12); color: #f87171; border-color: rgba(248, 113, 113, 0.3); }
|
||
.access-pill.fixed { background: rgba(148, 163, 184, 0.12); color: #cbd5e1; border-color: rgba(148, 163, 184, 0.25); }
|
||
.lifecycle-row .chip { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 5px; cursor: pointer; }
|
||
.lifecycle-row .chip.green { background: rgba(52, 211, 153, 0.12); color: #34d399; }
|
||
.lifecycle-row .chip.orange { background: rgba(251, 191, 36, 0.14); color: #fbbf24; }
|
||
.lifecycle-row .chip.purple { background: rgba(192, 132, 252, 0.14); color: #c084fc; cursor: default; }
|
||
.lifecycle-row {
|
||
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; margin: 0;
|
||
font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums;
|
||
}
|
||
.lifecycle-row span { white-space: nowrap; }
|
||
.lifecycle-row .strong { color: var(--text); font-weight: 600; }
|
||
.lifecycle-row .ok { color: #34d399; font-weight: 600; }
|
||
.lifecycle-row .warn { color: #fbbf24; }
|
||
|
||
/* Modal / Drawer */
|
||
.modal-overlay {
|
||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(8px);
|
||
display: none; align-items: center; justify-content: center; z-index: 1000;
|
||
}
|
||
.modal-overlay.open { display: flex; }
|
||
.modal-card {
|
||
background: #1e293b; border: 1px solid var(--card-border);
|
||
border-radius: 16px; width: 520px; max-width: 90vw; padding: 24px;
|
||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
||
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; }
|
||
@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-secondary" onclick="openGroupModal()" title="一次添加多个主机 / 端口,共用一条邀请链接">▤ 新建通道组</button>
|
||
<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" id="tunnelGroupWrap" style="display:none;">
|
||
<label>所属通道组</label>
|
||
<select id="tunnelGroup"></select>
|
||
</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: Tunnel Group -->
|
||
<div class="modal-overlay" id="groupModal">
|
||
<div class="modal-card">
|
||
<div class="modal-header">
|
||
<h3 id="groupModalTitle">新建通道组</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">
|
||
<label id="groupTargetsLabel">目标主机与端口</label>
|
||
<div class="input-row">
|
||
<input type="text" id="groupHosts" placeholder="主机:10.0.1.11-13 或 10.0.1.11, 10.0.1.12(也可写 主机:端口)" oninput="scheduleGroupPreview()">
|
||
<input type="text" id="groupPorts" style="max-width: 140px;" placeholder="端口:22, 8080" oninput="scheduleGroupPreview()">
|
||
</div>
|
||
<div class="input-hint" id="groupPreview"></div>
|
||
</div>
|
||
<div class="form-group" id="groupExistingWrap">
|
||
<label id="groupExistingLabel">或加入已有通道(可选)</label>
|
||
<div class="check-list" id="groupExisting"></div>
|
||
</div>
|
||
<div id="groupNewSettings" style="display:none;">
|
||
<div class="form-group">
|
||
<label>新建通道:闲置自动关闭</label>
|
||
<select id="groupIdleClose">
|
||
<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="groupMaxDuration">
|
||
<option value="0" selected>不限制</option>
|
||
<option value="120">2 小时</option>
|
||
<option value="480">8 小时</option>
|
||
<option value="1440">1 天</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>新建通道:访问控制</label>
|
||
<select id="groupAccessMode" onchange="onGroupAccessModeChange()">
|
||
<option value="link">仅授权链接(推荐)</option>
|
||
<option value="specific">固定 IP / 网段 + 授权链接</option>
|
||
<option value="all">全部放通 *(不安全)</option>
|
||
</select>
|
||
<input type="text" id="groupWhitelist" style="margin-top: 8px; display: none;" placeholder="例如 192.168.1.100, 192.168.1.0/24(逗号分隔)">
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-secondary" onclick="closeGroupModal()">取消</button>
|
||
<button type="submit" class="btn-primary" id="groupSaveBtn">创建通道组</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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||
}
|
||
|
||
function openCreateModal() {
|
||
document.getElementById('modalTitle').innerText = '新建代理通道';
|
||
document.getElementById('tunnelId').value = '';
|
||
document.getElementById('tunnelName').value = '';
|
||
document.getElementById('tunnelTarget').value = '';
|
||
setAccessForm([]);
|
||
setLifecycleForm(30, 0);
|
||
fillTunnelGroupSelect('');
|
||
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);
|
||
fillTunnelGroupSelect(t.group_id || '');
|
||
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),
|
||
group_id: document.getElementById('tunnelGroup').value
|
||
};
|
||
|
||
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('t:' + 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();
|
||
}
|
||
|
||
// ---- 授权作用范围:'t:<通道ID>' 或 'g:<通道组ID>'(组内通道共用邀请与授权)----
|
||
function parseScope(key) {
|
||
return key.startsWith('g:') ? { group: true, id: key.slice(2) } : { group: false, id: key.startsWith('t:') ? key.slice(2) : key };
|
||
}
|
||
function scopeBase(key) {
|
||
const s = parseScope(key);
|
||
return (s.group ? '/api/groups/' : '/api/tunnels/') + encodeURIComponent(s.id);
|
||
}
|
||
function groupMembers(g) {
|
||
return (g.tunnel_ids || []).map(id => currentTunnels.find(t => t.id === id)).filter(Boolean);
|
||
}
|
||
function scopeInfo(key) {
|
||
const s = parseScope(key);
|
||
let name, members, m;
|
||
if (s.group) {
|
||
const g = currentGroups.find(x => x.id === s.id);
|
||
if (!g) return null;
|
||
name = g.name; members = groupMembers(g); m = g.metrics || {};
|
||
} else {
|
||
const t = currentTunnels.find(x => x.id === s.id);
|
||
if (!t) return null;
|
||
name = t.name; members = [t]; m = t.metrics || {};
|
||
}
|
||
return {
|
||
group: s.group, name, members, enabled: members.some(t => t.enabled),
|
||
leases: m.dynamic_leases || [], invites: m.invites || [], pending: m.pending || [],
|
||
};
|
||
}
|
||
|
||
// Drawer
|
||
let drawerScope = null;
|
||
function openDrawer(key) {
|
||
if (!key.includes(':')) key = 't:' + key;
|
||
const info = scopeInfo(key);
|
||
drawerScope = key;
|
||
document.getElementById('drawerTitle').innerText = info
|
||
? `${info.name}${info.group ? `(${info.members.length} 个地址)` : ''} - 连接与授权` : '连接与授权';
|
||
renderDrawerClients();
|
||
document.getElementById('clientDrawer').classList.add('open');
|
||
}
|
||
|
||
function closeDrawer() {
|
||
document.getElementById('clientDrawer').classList.remove('open');
|
||
drawerScope = null;
|
||
}
|
||
|
||
const cardStyle = 'background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; display:flex; justify-content:space-between; align-items:center; gap: 10px;';
|
||
const pad2 = n => String(n).padStart(2, '0');
|
||
const leaseTitle = d => ({ '1h': '1 小时', '8h': '8 小时', today: '当天有效', tunnel: '通道开启期间' }[d] || '2 小时');
|
||
const fmtDT = v => {
|
||
const d = new Date(v);
|
||
const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||
return d.toDateString() === new Date().toDateString() ? `${hm}:${pad2(d.getSeconds())}` : `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${hm}`;
|
||
};
|
||
|
||
function renderLeases(key) {
|
||
const info = scopeInfo(key);
|
||
if (!info) return { pending: '', auth: '' };
|
||
const { leases, pending, invites } = info;
|
||
const k = escapeHtml(key);
|
||
let html = '';
|
||
|
||
if (pending.length) {
|
||
html += `<b style="font-size:13px; display:block; margin: 4px 0 10px; color:#fbbf24;">待审批 (${pending.length})</b>`;
|
||
html += pending.map(r => `
|
||
<div style="${cardStyle}">
|
||
<div><b>${escapeHtml(r.name)}</b> <code style="color:#38bdf8;">${escapeHtml(r.ip)}</code>
|
||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">申请于 ${fmtDT(r.at)} · 批准后可访问 ${leaseTitle((invites.find(i => i.code === r.invite) || {}).lease_duration)}</div></div>
|
||
<div class="drawer-row-actions">
|
||
<button class="btn-secondary btn-sm" onclick="decideRequest('${k}', '${escapeHtml(r.id)}', false)">拒绝</button>
|
||
<button class="btn-primary btn-sm" onclick="decideRequest('${k}', '${escapeHtml(r.id)}', true)">批准</button>
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
const pendingHtml = html;
|
||
html = '';
|
||
|
||
html += `
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin: 14px 0 10px;">
|
||
<b style="font-size:13px;">已授权设备 (${leases.length})</b>
|
||
<div style="display:flex; gap:6px;">
|
||
${info.enabled ? `<button class="btn-primary btn-sm" onclick="openInviteModal('${k}')">新建邀请</button>` : ''}
|
||
${leases.length ? `<button class="btn-danger btn-sm" onclick="revokeLease('${k}', '')">全部撤销</button>` : ''}
|
||
</div>
|
||
</div>`;
|
||
if (info.group) {
|
||
html += '<div style="color: var(--text-muted); font-size: 12px; margin: -4px 0 10px;">组内所有地址共用邀请与授权:同事授权一次即可访问整组,计时从首次连上任一地址开始。</div>';
|
||
}
|
||
html += leases.length === 0
|
||
? '<div style="color: var(--text-muted); font-size: 12px; margin-bottom: 14px;">暂无。点击“新建邀请”把链接发给同事,对方打开并填写姓名后即可访问。</div>'
|
||
: leases.map(l => {
|
||
const mins = Math.max(0, Math.round((new Date(l.expires_at) - Date.now()) / 60000));
|
||
return `
|
||
<div style="${cardStyle}">
|
||
<div><b>${escapeHtml(l.name || '未署名')}</b> <code style="color: #34d399;">${escapeHtml(l.ip)}</code>
|
||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${l.awaiting_connect
|
||
? `已授权,尚未连接 · 首次连接后可访问 ${leaseTitle(l.duration)} · 请在 ${fmtDT(l.expires_at)} 前连接`
|
||
: `${l.started_at ? '首次连接 ' + fmtDT(l.started_at) + ' · ' : ''}有效期至 ${fmtDT(l.expires_at)} · 剩余约 ${mins} 分钟`}</div></div>
|
||
<button class="btn-danger btn-sm" onclick="revokeLease('${k}', '${escapeHtml(l.ip)}')">撤销</button>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
html += `<b style="font-size:13px; display:block; margin: 8px 0 10px;">有效的邀请链接 (${invites.length})</b>`;
|
||
html += invites.length === 0
|
||
? '<div style="color: var(--text-muted); font-size: 12px; margin-bottom: 14px;">暂无有效的邀请链接</div>'
|
||
: invites.map(inv => {
|
||
const used = inv.uses || [];
|
||
const usage = inv.max_uses > 0 ? `${used.length}/${inv.max_uses} 台` : `${used.length} 台(不限)`;
|
||
const opts = `授权后可访问 ${leaseTitle(inv.lease_duration)}${inv.require_approval ? ' · <span style="color:#fbbf24;">需审批</span>' : ''}`;
|
||
return `
|
||
<div style="${cardStyle}">
|
||
<div><b>${inv.note ? '给 ' + escapeHtml(inv.note) : '未备注的邀请'}</b>
|
||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${opts}<br>链接有效至 ${fmtDT(inv.expires_at)} · 已使用 ${usage}${used.length ? '(' + used.map(u => escapeHtml(u.name)).join('、') + ')' : ''}</div></div>
|
||
<div class="drawer-row-actions">
|
||
<button class="btn-secondary btn-sm" onclick="copyInviteShare('${k}', '${escapeHtml(inv.code)}')">复制文案</button>
|
||
<button class="btn-danger btn-sm" onclick="revokeInvite('${k}', '${escapeHtml(inv.code)}')">作废</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
return { pending: pendingHtml, auth: html + '<div style="height: 10px;"></div>' };
|
||
}
|
||
|
||
async function decideRequest(key, requestId, approve) {
|
||
const res = await fetch(`${scopeBase(key)}/requests/${encodeURIComponent(requestId)}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ approve })
|
||
});
|
||
if (!res.ok) alert('操作失败: ' + await apiError(res));
|
||
}
|
||
|
||
async function revokeInvite(key, code) {
|
||
if (!confirm('作废该邀请链接?通过它获得的访问权限也会被收回。')) return;
|
||
const res = await fetch(`${scopeBase(key)}/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
|
||
if (!res.ok) alert('作废失败: ' + await apiError(res));
|
||
}
|
||
|
||
async function copyText(text) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
return true;
|
||
} catch (e) {
|
||
prompt('复制以下内容:', text);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function copyInviteShare(key, code) {
|
||
const res = await fetch(`${scopeBase(key)}/invites/${encodeURIComponent(code)}/share`);
|
||
if (!res.ok) { alert('获取失败: ' + await apiError(res)); return; }
|
||
const data = await res.json();
|
||
if (await copyText(data.share_text)) alert('分享文案已复制');
|
||
}
|
||
|
||
// Invite modal(通道或通道组)
|
||
let inviteScope = null;
|
||
function openInviteModal(key, autoGenerate) {
|
||
if (!key.includes(':')) key = 't:' + key;
|
||
const info = scopeInfo(key);
|
||
inviteScope = key;
|
||
document.getElementById('inviteTitle').innerText = info
|
||
? (info.group ? `邀请访问「${info.name}」(${info.members.filter(t => t.enabled).length} 个地址)` : `邀请访问「${info.name}」`)
|
||
: '邀请访问';
|
||
resetInviteModal();
|
||
document.getElementById('inviteModal').classList.add('open');
|
||
if (autoGenerate) generateInvite();
|
||
}
|
||
|
||
function closeInviteModal() {
|
||
document.getElementById('inviteModal').classList.remove('open');
|
||
inviteScope = null;
|
||
}
|
||
|
||
function resetInviteModal() {
|
||
document.getElementById('inviteNote').value = '';
|
||
document.getElementById('inviteLease').value = '2h';
|
||
document.getElementById('inviteApproval').checked = false;
|
||
document.getElementById('inviteForm').style.display = 'block';
|
||
document.getElementById('inviteResult').style.display = 'none';
|
||
}
|
||
|
||
async function generateInvite() {
|
||
if (!inviteScope) return;
|
||
const res = await fetch(`${scopeBase(inviteScope)}/invites`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
note: document.getElementById('inviteNote').value.trim(),
|
||
valid_minutes: parseInt(document.getElementById('inviteValid').value, 10),
|
||
max_uses: parseInt(document.getElementById('inviteMaxUses').value, 10),
|
||
lease_duration: document.getElementById('inviteLease').value,
|
||
require_approval: document.getElementById('inviteApproval').checked
|
||
})
|
||
});
|
||
if (!res.ok) { alert('生成失败: ' + await apiError(res)); return; }
|
||
const data = await res.json();
|
||
const box = document.getElementById('inviteShareText');
|
||
box.value = data.share_text;
|
||
box.rows = Math.min(16, Math.max(8, data.share_text.split('\n').length + 2));
|
||
document.getElementById('inviteCopyText').onclick = async () => { if (await copyText(data.share_text)) document.getElementById('inviteCopyText').innerText = '已复制'; };
|
||
document.getElementById('inviteCopyLink').onclick = async () => { if (await copyText(data.link)) document.getElementById('inviteCopyLink').innerText = '已复制'; };
|
||
document.getElementById('inviteCopyText').innerText = '复制分享文案';
|
||
document.getElementById('inviteCopyLink').innerText = '仅复制链接';
|
||
document.getElementById('inviteForm').style.display = 'none';
|
||
document.getElementById('inviteResult').style.display = 'block';
|
||
}
|
||
|
||
async function revokeLease(key, ip) {
|
||
if (!confirm(ip ? `撤销 ${ip} 的访问权限?该设备的连接会被立即断开。` : '撤销全部已授权设备?')) return;
|
||
const res = await fetch(`${scopeBase(key)}/leases/revoke`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ip })
|
||
});
|
||
if (!res.ok) alert('撤销失败: ' + await apiError(res));
|
||
}
|
||
|
||
function renderDrawerClients() {
|
||
if (!drawerScope) return;
|
||
const info = scopeInfo(drawerScope);
|
||
if (!info) return;
|
||
const memberIds = new Set(info.members.map(t => t.id));
|
||
const targetOf = new Map(info.members.map(t => [t.id, t.target]));
|
||
const list = ((latestMetrics && latestMetrics.active_conns) || []).filter(c => memberIds.has(c.tunnel_id));
|
||
const container = document.getElementById('drawerClientsList');
|
||
const parts = renderLeases(drawerScope);
|
||
|
||
if (list.length === 0) {
|
||
container.innerHTML = parts.pending + '<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (0)</b><div style="text-align: center; color: var(--text-muted); padding: 16px 0 6px; font-size: 13px;">当前暂无客户端连接</div>' + parts.auth;
|
||
return;
|
||
}
|
||
|
||
// 同一 IP 的连接合并展示
|
||
const groups = {};
|
||
list.forEach(c => {
|
||
const m = /^\[(.+)\]:\d+$/.exec(c.client_ip) || /^([^:]+):\d+$/.exec(c.client_ip);
|
||
const ip = m ? m[1] : c.client_ip;
|
||
(groups[ip] = groups[ip] || []).push(c);
|
||
});
|
||
const leaseNames = new Map(info.leases.map(l => [l.ip, l.name || '']));
|
||
const sum = (arr, k) => arr.reduce((a, c) => a + (c[k] || 0), 0);
|
||
const ips = Object.keys(groups).sort((x, y) => groups[y].length - groups[x].length || x.localeCompare(y));
|
||
|
||
container.innerHTML = parts.pending + `<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (${ips.length} 台 · ${list.length} 连接)</b>` + ips.map(ip => {
|
||
const conns = groups[ip];
|
||
const first = conns.map(c => new Date(c.connected_at)).sort((x, y) => x - y)[0];
|
||
const ids = conns.map(c => c.conn_id);
|
||
const targets = [...new Set(conns.map(c => targetOf.get(c.tunnel_id)))].filter(Boolean);
|
||
return `
|
||
<div style="background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 8px; padding: 14px; margin-bottom: 12px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<div>
|
||
<code style="font-size: 14px; color: #38bdf8; font-weight: 600;">${escapeHtml(ip)}</code>
|
||
<span style="font-size: 11px; color: var(--text-muted); margin-left: 6px;">${conns.length} 个连接</span>
|
||
${leaseNames.has(ip) ? `<span style="font-size: 11px; color: #34d399; margin-left: 6px;">已授权${leaseNames.get(ip) ? ' · ' + escapeHtml(leaseNames.get(ip)) : ''}</span>` : ''}
|
||
</div>
|
||
<button class="btn-danger btn-sm" onclick='killConns(${escapeHtml(JSON.stringify(ids))})'>${conns.length > 1 ? '全部断开' : '断开踢出'}</button>
|
||
</div>
|
||
<div style="font-size: 12px; color: var(--text-muted); line-height: 1.6;">
|
||
<div>最早接入: ${first ? fmtDT(first) : '-'}</div>
|
||
${info.group ? `<div>访问目标: <code>${targets.map(escapeHtml).join('、')}</code></div>` : ''}
|
||
<div>传输流量: 上行 ${formatBytes(sum(conns, 'bytes_in'))} / 下行 ${formatBytes(sum(conns, 'bytes_out'))}</div>
|
||
<div>当前速率: ↑ ${formatRate(sum(conns, 'rate_in'))} / ↓ ${formatRate(sum(conns, 'rate_out'))}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('') + parts.auth;
|
||
}
|
||
|
||
// ---- 通道组卡片 ----
|
||
const sumOf = (arr, f) => arr.reduce((a, x) => a + (f(x) || 0), 0);
|
||
|
||
function groupStatus(members) {
|
||
const on = members.filter(t => t.enabled);
|
||
if (!members.length) return ['badge-disabled', '暂无通道'];
|
||
if (!on.length) return ['badge-disabled', '已暂停'];
|
||
if (on.some(t => (t.metrics || {}).status === 'error')) return ['badge-error', '部分异常'];
|
||
if (on.some(t => (t.metrics || {}).status === 'offline')) return ['badge-syncing', '等待连接服务器'];
|
||
if (on.length < members.length) return ['badge-syncing', `${on.length}/${members.length} 运行中`];
|
||
return ['badge-online', '运行中'];
|
||
}
|
||
|
||
function groupAccessPill(members) {
|
||
if (!members.length) return '';
|
||
if (members.some(t => (t.whitelist || []).includes('*'))) return '<span class="access-pill all">⚠️ 含全部放通 *</span>';
|
||
const kinds = new Set(members.map(t => [...(t.whitelist || [])].sort().join(',')));
|
||
if (kinds.size > 1) return '<span class="access-pill fixed">成员访问方式不同</span>';
|
||
return (members[0].whitelist || []).length === 0 ? '<span class="access-pill">🔒 仅授权链接</span>' : '<span class="access-pill fixed">白名单 + 授权链接</span>';
|
||
}
|
||
|
||
function portRangeText(members) {
|
||
const ports = members.map(t => t.listen_port).sort((a, b) => a - b);
|
||
if (!ports.length) return '';
|
||
if (ports.length === 1) return String(ports[0]);
|
||
return ports[ports.length - 1] - ports[0] + 1 === ports.length ? `${ports[0]}–${ports[ports.length - 1]}` : ports.join(', ');
|
||
}
|
||
|
||
function groupLifecycleHtml(g, members) {
|
||
const on = members.filter(t => t.enabled);
|
||
const parts = [];
|
||
if (on.length) {
|
||
const min = arr => arr.filter(Boolean).sort()[0];
|
||
parts.push(`<span>开启 ${fmtDT(min(on.map(t => t.started_at)))}</span>`);
|
||
const first = min(on.map(t => t.first_connected_at));
|
||
parts.push(`<span>${first ? '首次连接 ' + fmtDT(first) : '尚无连接'}</span>`);
|
||
parts.push(`<span class="strong" title="组内连接在线时间最长的一个地址的累计时长">实际连接 ${fmtSecs(Math.max(...on.map(t => t.activity ? t.activity.connected_sec : (t.connected_sec || 0))))}</span>`);
|
||
const acts = on.map(t => t.activity).filter(Boolean);
|
||
if (acts.some(a => a.in_use)) {
|
||
parts.push('<span class="ok" title="组内有在线连接、有效授权、待审批申请或可用的邀请链接">● 使用中</span>');
|
||
} else if (acts.length) {
|
||
const act = acts.filter(a => a.auto_close_at).sort((a, b) => a.auto_close_at.localeCompare(b.auto_close_at))[0];
|
||
if (act) {
|
||
const left = -ago(act.auto_close_at);
|
||
parts.push(`<span class="${left < 300 ? 'warn' : ''}">已闲置 ${fmtSecs(ago(act.idle_since))},${fmtSecs(left)} 后整组自动关闭</span>`);
|
||
} else {
|
||
parts.push(`<span>已闲置 ${fmtSecs(ago(acts[0].idle_since))}(不自动关闭)</span>`);
|
||
}
|
||
}
|
||
const exp = min(on.map(t => t.expires_at));
|
||
if (exp) parts.push(`<span>最长开启至 ${fmtDT(exp)}</span>`);
|
||
} else if (members.length) {
|
||
if (members[0].close_reason) parts.push(`<span>关闭原因:${escapeHtml(members[0].close_reason)}</span>`);
|
||
const idle = members[0].effective_idle_close_minutes ?? 30;
|
||
parts.push(`<span>闲置自动关闭:${idle > 0 ? fmtMinutes(idle) : '关'}</span>`);
|
||
}
|
||
const m = g.metrics || {}, key = escapeHtml('g:' + g.id);
|
||
if ((m.pending || []).length) parts.push(`<span class="chip orange" onclick="openDrawer('${key}')">🔔 待审批 ${m.pending.length}</span>`);
|
||
if ((m.dynamic_leases || []).length) parts.push(`<span class="chip green" onclick="openDrawer('${key}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`);
|
||
return parts.join('');
|
||
}
|
||
|
||
function memberRowHtml(g, t) {
|
||
const m = t.metrics || {}, id = escapeHtml(t.id);
|
||
const dot = !t.enabled ? 'off' : (m.status === 'online' ? 'on' : (m.status === 'error' ? 'err' : 'wait'));
|
||
const own = (m.dynamic_leases || []).length + (m.pending || []).length;
|
||
// 批量创建的成员按“组名 (主机)”自动命名,不重复显示;加入组的已有通道保留原名
|
||
const custom = !t.name.startsWith(g.name + ' (');
|
||
return `
|
||
<div class="member-row ${t.enabled ? '' : 'off'}">
|
||
<span class="mdot ${dot}" title="${t.enabled ? escapeHtml(m.error || '运行中') : '已关闭'}"></span>
|
||
<span class="member-route"><span class="route-endpoint">${escapeHtml(publicHost)}:${t.listen_port}</span><span class="route-arrow">➔</span><span class="route-target">${escapeHtml(t.target)}</span></span>
|
||
${custom ? `<span class="member-name">${escapeHtml(t.name)}</span>` : ''}
|
||
${own ? `<span class="chip green" onclick="openDrawer('t:${id}')">单独授权 ${own}</span>` : ''}
|
||
<span class="member-meta">${m.active_conns ? m.active_conns + ' 连接 · ' : ''}${t.enabled ? `↑ ${formatRate(m.rate_in || 0)} ↓ ${formatRate(m.rate_out || 0)}` : '已关闭'}</span>
|
||
<label class="switch mini" title="${t.enabled ? '单独关闭这个地址' : '单独开启这个地址'}">
|
||
<input type="checkbox" ${t.enabled ? 'checked' : ''} onchange="toggleTunnel('${id}', this.checked)">
|
||
<span class="slider"></span>
|
||
</label>
|
||
<span class="member-actions">
|
||
<button class="icon-btn" title="单独邀请这个地址" ${t.enabled ? '' : 'disabled'} onclick="openInviteModal('t:${id}')">📨</button>
|
||
<button class="icon-btn" title="连接与授权" onclick="openDrawer('t:${id}')">👥</button>
|
||
<button class="icon-btn" title="编辑" onclick="editTunnel('${id}')">✏️</button>
|
||
<button class="icon-btn" title="移出通道组" onclick="moveOutOfGroup('${id}')">⤴️</button>
|
||
<button class="icon-btn" title="删除" onclick="deleteTunnel('${id}')">🗑️</button>
|
||
</span>
|
||
</div>`;
|
||
}
|
||
|
||
function groupCardHtml(g, members, shown) {
|
||
const on = members.filter(t => t.enabled), gid = escapeHtml(g.id), key = escapeHtml('g:' + g.id);
|
||
const [badgeClass, badgeText] = groupStatus(members);
|
||
const rateIn = sumOf(on, t => (t.metrics || {}).rate_in), rateOut = sumOf(on, t => (t.metrics || {}).rate_out);
|
||
const session = sumOf(on, t => ((t.metrics || {}).bytes_in || 0) + ((t.metrics || {}).bytes_out || 0));
|
||
const history = sumOf(members, t => (t.historical_bytes_in || 0) + (t.historical_bytes_out || 0)) + session;
|
||
const s = g.rate_history, peak = s ? Math.max(0, ...s.down, ...s.up) : 0;
|
||
const toggle = `<label class="switch mini" title="${on.length ? '关闭整组' : '开启整组'}">
|
||
<input type="checkbox" ${on.length ? 'checked' : ''} ${members.length ? '' : 'disabled'} onchange="toggleGroup('${gid}', this.checked)">
|
||
<span class="slider"></span>
|
||
</label>`;
|
||
const side = on.length ? `<div class="tunnel-side with-chart">
|
||
<div class="side-top">
|
||
<div class="traffic-legend">
|
||
<span><i style="background:${DOWN_COLOR}"></i>↓ ${formatRate(rateOut)}</span>
|
||
<span><i style="background:${UP_COLOR}"></i>↑ ${formatRate(rateIn)}</span>
|
||
</div>
|
||
${toggle}
|
||
</div>
|
||
<div class="traffic-panel">
|
||
<div class="traffic-chart">${peak ? sparkSVG(s, 260, 46, 'g' + gid) : '<div class="empty">近 10 分钟无流量</div>'}</div>
|
||
<div class="traffic-foot"><span>近 10 分钟</span><span>${peak ? '峰值 ' + formatRate(peak) : ''}</span></div>
|
||
</div>
|
||
</div>` : `<div class="tunnel-side"><div class="side-top">${toggle}</div></div>`;
|
||
const hidden = members.length - shown.length;
|
||
return `
|
||
<div class="tunnel-card group-card ${on.length || !members.length ? '' : 'disabled'}">
|
||
<div class="tunnel-body">
|
||
<div class="tunnel-info">
|
||
<div class="tunnel-title-row">
|
||
<span class="group-icon" title="通道组">▤</span>
|
||
<span class="tunnel-name">${escapeHtml(g.name)}</span>
|
||
<span class="badge ${badgeClass}">${badgeText}</span>
|
||
${groupAccessPill(members)}
|
||
</div>
|
||
<div class="group-summary">
|
||
<span>${members.length} 个地址</span>
|
||
${members.length ? `<span class="route-endpoint">${escapeHtml(publicHost)}:${portRangeText(members)}</span>
|
||
<button class="btn-secondary btn-sm" onclick="copyGroupAddresses('${gid}')">复制全部地址</button>` : ''}
|
||
</div>
|
||
<div class="lifecycle-row">${groupLifecycleHtml(g, members)}</div>
|
||
</div>
|
||
${side}
|
||
</div>
|
||
<div class="member-list">
|
||
${shown.map(t => memberRowHtml(g, t)).join('')}
|
||
${hidden > 0 ? `<div class="member-hint">已按搜索 / 筛选隐藏 ${hidden} 个地址</div>` : ''}
|
||
${!members.length ? '<div class="member-hint">组内暂无通道,点击“编辑”添加目标或把已有通道加入该组</div>' : ''}
|
||
</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();
|
||
}
|
||
|
||
// ---- 新建 / 编辑通道组 ----
|
||
let groupPreviewTimer = null, groupPreviewOK = false;
|
||
|
||
function openGroupModal(id) {
|
||
const g = id ? currentGroups.find(x => x.id === id) : null;
|
||
document.getElementById('groupModalTitle').innerText = g ? '编辑通道组' : '新建通道组';
|
||
document.getElementById('groupSaveBtn').innerText = g ? '保存' : '创建通道组';
|
||
document.getElementById('groupTargetsLabel').innerText = g ? '追加目标(可选)' : '目标主机与端口';
|
||
document.getElementById('groupId').value = g ? g.id : '';
|
||
document.getElementById('groupName').value = g ? g.name : '';
|
||
document.getElementById('groupOwner').value = g ? (g.owner || '') : '';
|
||
document.getElementById('groupHosts').value = '';
|
||
document.getElementById('groupPorts').value = '';
|
||
document.getElementById('groupIdleClose').value = '30';
|
||
document.getElementById('groupMaxDuration').value = '0';
|
||
document.getElementById('groupAccessMode').value = 'link';
|
||
document.getElementById('groupWhitelist').value = '';
|
||
onGroupAccessModeChange();
|
||
const free = currentTunnels.filter(t => !t.group_id);
|
||
document.getElementById('groupExistingWrap').style.display = free.length ? 'block' : 'none';
|
||
document.getElementById('groupExistingLabel').innerText = g ? '把已有通道加入该组(可选)' : '或加入已有通道(可选)';
|
||
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('');
|
||
renderGroupPreview(null);
|
||
document.getElementById('groupModal').classList.add('open');
|
||
}
|
||
|
||
function closeGroupModal() {
|
||
document.getElementById('groupModal').classList.remove('open');
|
||
}
|
||
|
||
function onGroupAccessModeChange() {
|
||
const mode = document.getElementById('groupAccessMode').value;
|
||
document.getElementById('groupWhitelist').style.display = mode === 'specific' ? 'block' : 'none';
|
||
}
|
||
|
||
function renderGroupPreview(result) {
|
||
const box = document.getElementById('groupPreview');
|
||
const hasHosts = document.getElementById('groupHosts').value.trim() !== '';
|
||
document.getElementById('groupNewSettings').style.display = hasHosts ? 'block' : 'none';
|
||
groupPreviewOK = !hasHosts;
|
||
if (!hasHosts || !result) {
|
||
box.innerHTML = '每个 主机 × 端口 会创建一个通道,并分配一段连续的访问端口';
|
||
box.style.color = '';
|
||
return;
|
||
}
|
||
if (result.error) {
|
||
box.innerText = result.error;
|
||
box.style.color = 'var(--danger)';
|
||
return;
|
||
}
|
||
groupPreviewOK = result.members.length > 0;
|
||
box.style.color = '';
|
||
const shown = result.members.slice(0, 6);
|
||
box.innerHTML = shown.map(m => `<div class="preview-row"><span class="route-endpoint">${escapeHtml(publicHost)}:${m.listen_port}</span> <span class="route-arrow">➔</span> <span class="route-target">${escapeHtml(m.target)}</span></div>`).join('')
|
||
+ `<div>${result.members.length > shown.length ? '… ' : ''}共 ${result.members.length} 个地址</div>`;
|
||
}
|
||
|
||
function scheduleGroupPreview() {
|
||
clearTimeout(groupPreviewTimer);
|
||
groupPreviewTimer = setTimeout(async () => {
|
||
const hosts = document.getElementById('groupHosts').value;
|
||
if (!hosts.trim()) { renderGroupPreview(null); return; }
|
||
try {
|
||
const res = await fetch('/api/groups/preview', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: document.getElementById('groupName').value.trim() || '通道组', hosts, ports: document.getElementById('groupPorts').value })
|
||
});
|
||
renderGroupPreview(res.ok ? await res.json() : { error: await apiError(res) });
|
||
} catch (e) {
|
||
renderGroupPreview({ error: '预览失败:' + e.message });
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
async function saveGroup(e) {
|
||
e.preventDefault();
|
||
const id = document.getElementById('groupId').value;
|
||
const hosts = document.getElementById('groupHosts').value.trim();
|
||
const tunnelIds = [...document.querySelectorAll('#groupExisting input:checked')].map(i => i.value);
|
||
if (!id && !hosts && !tunnelIds.length) { alert('请填写目标主机与端口,或选择要加入的已有通道'); return; }
|
||
if (hosts && !groupPreviewOK) { alert('请先修正目标主机与端口'); return; }
|
||
const mode = document.getElementById('groupAccessMode').value;
|
||
let whitelist = [];
|
||
if (mode === 'all') whitelist = ['*'];
|
||
if (mode === 'specific') {
|
||
whitelist = document.getElementById('groupWhitelist').value.split(',').map(s => s.trim()).filter(Boolean);
|
||
if (hosts && !whitelist.length) { alert('请填写允许访问的 IP 或网段,或选择“仅授权链接”'); return; }
|
||
}
|
||
const payload = {
|
||
name: document.getElementById('groupName').value.trim(),
|
||
owner: document.getElementById('groupOwner').value.trim(),
|
||
hosts, ports: hosts ? document.getElementById('groupPorts').value : '',
|
||
tunnel_ids: tunnelIds, whitelist,
|
||
idle_close_minutes: parseInt(document.getElementById('groupIdleClose').value, 10),
|
||
duration_minutes: parseInt(document.getElementById('groupMaxDuration').value, 10),
|
||
};
|
||
const res = await fetch(id ? `/api/groups/${encodeURIComponent(id)}` : '/api/groups', {
|
||
method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
|
||
});
|
||
if (!res.ok) { alert('保存失败: ' + await apiError(res)); return; }
|
||
const data = await res.json();
|
||
closeGroupModal();
|
||
await fetchTunnels();
|
||
// 新建通道组后直接打开整组的邀请设置
|
||
if (!id && mode !== 'all' && data.group) setTimeout(() => openInviteModal('g:' + data.group.id, false), 800);
|
||
}
|
||
|
||
// ---- 删除 / 解散通道组 ----
|
||
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();
|
||
}
|
||
|
||
function fillTunnelGroupSelect(selected) {
|
||
const wrap = document.getElementById('tunnelGroupWrap');
|
||
wrap.style.display = currentGroups.length ? 'block' : 'none';
|
||
document.getElementById('tunnelGroup').innerHTML = '<option value="">不属于任何组</option>' +
|
||
currentGroups.map(g => `<option value="${escapeHtml(g.id)}" ${g.id === selected ? 'selected' : ''}>${escapeHtml(g.name)}</option>`).join('');
|
||
}
|
||
|
||
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>
|