feat: 目标改为动态列表、访问端口全自动;菜单栏合并通道组;接入记录可按地址查看

添加通道
- 目标服务改成列表:每行一台主机,多个端口用逗号隔开(也支持 8080-8082、主机范围);
  「添加主机」沿用上一行的端口,粘贴一串 IP / 主机:端口会自动拆成多行
- 新建时不再手动指定对外端口,统一自动分配;预览里列出每个地址分到的端口,
  连通性测试并发进行,结果标在每个地址后面
- 超过一个地址自动放进以名称命名的新通道组(也可选加入已有组),只有一个地址时为不分组;
  编辑通道仍保留端口栏和「新建通道组」
- 网页面板同步:同样的目标列表、自动端口与通道组规则

菜单栏
- 同一个通道组合并为一行「组名(N 个地址)」,子菜单里整组开关、逐个地址开关

连接与授权(通道组)
- 当前在线、历史接入台账增加「按设备 / 按地址」切换:按地址列出组内每个地址
  有几台设备在用、谁访问过;按设备的汇总行显示访问了组内哪些地址,并带上授权姓名
- 网页面板的在线连接同样可以按地址查看
This commit is contained in:
DeepFal committed 2026-10-13 21:23:25 +08:00
1 parent 5b355fb759
commit 659e69f8f7
6 files changed
+851 -240

No files matched your search

+241 -89
View File
@@ -329,6 +329,16 @@
.preview-row { font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
.plan-box { margin-top: 8px; padding: 8px 10px; border-radius: 6px; background: rgba(148, 163, 184, 0.08); color: var(--text-muted); font-size: 12px; line-height: 1.7; }
.plan-box .route-endpoint { color: var(--accent, #38bdf8); }
.target-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.target-row input { min-width: 0; }
.target-row .t-ports { flex: 0 0 150px; }
.target-head { display: flex; gap: 8px; font-size: 11px; color: var(--text-muted); margin-bottom: 4px; }
.target-head span:first-child { flex: 1; }
.target-head span:last-child { flex: 0 0 182px; }
.plan-box .ok { color: var(--success); } .plan-box .bad { color: var(--danger); }
.dim-toggle { display: inline-flex; border: 1px solid var(--card-border); border-radius: 6px; overflow: hidden; float: right; }
.dim-toggle button { background: none; border: 0; color: var(--text-muted); font-size: 11px; padding: 2px 10px; cursor: pointer; }
.dim-toggle button.on { background: rgba(56, 189, 248, 0.18); color: var(--text); }
.add-member { background: none; border: 0; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 8px 0; }
.add-member:hover { color: var(--text); }
@media (max-width: 860px) { .member-name, .member-meta { display: none; } }
@@ -442,7 +452,7 @@
<input type="hidden" id="tunnelId">
<div class="input-row">
<div class="form-group" style="flex: 1;">
<label>通道名称 / 备注</label>
<label id="tunnelNameLabel">通道名称 / 备注</label>
<input type="text" id="tunnelName" placeholder="例如:MySQL 实习库 或 财务系统 SSH" oninput="schedulePlan()">
</div>
<div class="form-group" style="flex: 1;">
@@ -451,10 +461,12 @@
</div>
</div>
<div class="form-group">
<label id="tunnelTargetLabel">目标地址(中转机 Mac 可直连的内网目标,可填多个)</label>
<div class="input-row">
<input type="text" id="tunnelTarget" required placeholder="例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22" oninput="schedulePlan()">
<button type="button" class="btn-secondary" onclick="probeTarget()">探测测试</button>
<label id="tunnelTargetLabel">目标服务(每行一台主机,多个端口用逗号隔开)</label>
<div class="target-head" id="targetHead"><span>主机</span><span>端口</span></div>
<div id="targetRows"></div>
<div class="input-row" style="justify-content: space-between; align-items: center;">
<button type="button" class="add-member" id="addRowBtn" onclick="addTargetRow()" title="可以一次粘贴多个 IP(每行一个,或用逗号分隔),会自动拆成多行">+ 添加主机</button>
<button type="button" class="btn-secondary btn-sm" onclick="probeTarget()">探测测试</button>
</div>
<div class="input-hint" id="probeResult"></div>
<div class="plan-box" id="tunnelPlan" style="display:none;"></div>
@@ -1069,16 +1081,14 @@
document.getElementById('tunnelId').value = '';
document.getElementById('tunnelName').value = '';
document.getElementById('tunnelOwner').value = '';
document.getElementById('tunnelTarget').value = '';
document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标,可填多个)';
document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22';
document.getElementById('tunnelTargetLabel').innerText = '目标服务(每行一台主机,多个端口用逗号隔开)';
fillTunnelGroupSelect(groupId || '');
setAccessForm([]);
setLifecycleForm(30, 0, 0);
fillTunnelGroupSelect(groupId || '');
document.getElementById('probeResult').innerText = '';
resetPlan();
autoRecommendPort();
setTargetRows([{ host: '', ports: '' }]);
document.getElementById('tunnelModal').classList.add('open');
document.querySelector('#targetRows .t-host').focus();
}
function editTunnel(id) {
@@ -1088,15 +1098,14 @@
document.getElementById('tunnelId').value = t.id;
document.getElementById('tunnelName').value = t.name;
document.getElementById('tunnelOwner').value = t.owner || '';
document.getElementById('tunnelTarget').value = t.target;
document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标)';
document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306';
document.getElementById('tunnelTargetLabel').innerText = '目标服务(内网主机与端口)';
const hp = splitHostPort(t.target) || [t.target, ''];
document.getElementById('tunnelPort').value = t.listen_port;
setAccessForm(t.whitelist || []);
setLifecycleForm(t.effective_idle_close_minutes ?? 30, t.duration_minutes || 0, t.max_bandwidth_mb || 0);
fillTunnelGroupSelect(t.group_id || '');
document.getElementById('probeResult').innerText = '';
resetPlan();
setTargetRows([{ host: hp[0], ports: hp[1] }]);
document.getElementById('tunnelModal').classList.add('open');
}
@@ -1114,6 +1123,96 @@
}
}
// ---- 目标列表:每行一台主机 + 一个或多个端口 ----
const isEditing = () => !!document.getElementById('tunnelId').value;
const listItems = s => s.split(/[\s,,、;;]+/).filter(Boolean);
// "10.0.1.11:22" / "[fd00::1]:22" → [主机, 端口];不带端口(含裸 IPv6)返回 null
function splitHostPort(item) {
const m = /^\[(.+)\]:(.+)$/.exec(item);
if (m) return [m[1], m[2]];
const parts = item.split(':');
return parts.length === 2 && parts[0] && parts[1] ? parts : null;
}
// 粘贴进来的一串目标拆成多行:每项是 "主机" 或 "主机:端口",同一主机的端口并到一行
function parseTargets(text, defaultPorts) {
const out = [];
for (const item of listItems(text)) {
const [host, port] = splitHostPort(item) || [item, ''];
const row = out.find(r => r.host === host);
if (!row) out.push({ host, ports: port || defaultPorts });
else if (port && !listItems(row.ports).includes(port)) row.ports = row.ports ? `${row.ports}, ${port}` : port;
}
return out;
}
// 拼成 "主机:端口, …" 交给服务端展开;还没填完整的行先跳过
function composeTarget(rows) {
const out = [];
for (const r of rows) {
const ports = listItems(r.ports);
for (const h of listItems(r.host)) {
if (splitHostPort(h)) { out.push(h); continue; }
if (!ports.length) continue;
const host = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
ports.forEach(p => out.push(`${host}:${p}`));
}
}
return out.join(', ');
}
function readTargetRows() {
return [...document.querySelectorAll('#targetRows .target-row')].map(r => ({
host: r.querySelector('.t-host').value.trim(), ports: r.querySelector('.t-ports').value.trim()
}));
}
function setTargetRows(rows) {
const edit = isEditing();
document.getElementById('targetRows').innerHTML = rows.map(r => `
<div class="target-row">
<input type="text" class="t-host" value="${escapeHtml(r.host)}" placeholder="${edit ? '例如 10.0.0.10' : '例如 10.0.1.11,可粘贴多个'}" oninput="schedulePlan()" onpaste="onHostPaste(event)">
<input type="text" class="t-ports" value="${escapeHtml(r.ports)}" placeholder="${edit ? '3306' : '22, 3306'}" oninput="schedulePlan()">
${edit ? '' : `<button type="button" class="icon-btn" onclick="removeTargetRow(this)" title="删除这一行" ${rows.length === 1 ? 'disabled' : ''}>⊖</button>`}
</div>`).join('');
document.getElementById('addRowBtn').style.display = edit ? 'none' : '';
document.getElementById('targetHead').style.display = edit ? 'none' : 'flex';
schedulePlan();
}
// 新的一行沿用上一行的端口(同一组机器通常开同样的端口)
function addTargetRow() {
const rows = readTargetRows();
rows.push({ host: '', ports: rows.length ? rows[rows.length - 1].ports : '' });
setTargetRows(rows);
const hosts = document.querySelectorAll('#targetRows .t-host');
hosts[hosts.length - 1].focus();
}
function removeTargetRow(btn) {
const all = [...document.querySelectorAll('#targetRows .target-row')];
const rows = readTargetRows();
rows.splice(all.indexOf(btn.closest('.target-row')), 1);
setTargetRows(rows.length ? rows : [{ host: '', ports: '' }]);
}
// 一次粘贴多个主机(或 主机:端口)时拆成多行
function onHostPaste(e) {
if (isEditing()) return;
const text = (e.clipboardData || window.clipboardData).getData('text');
if (listItems(text).length < 2 && !splitHostPort(text.trim())) return;
e.preventDefault();
const all = [...document.querySelectorAll('#targetRows .target-row')];
const idx = all.indexOf(e.target.closest('.target-row'));
const rows = readTargetRows();
const parsed = parseTargets(text, rows[idx].ports);
// 粘贴到空行时替换这一行,否则插在它后面
if (rows[idx].host) rows.splice(idx + 1, 0, ...parsed);
else rows.splice(idx, 1, ...parsed);
setTargetRows(rows);
}
async function probeOne(target) {
const res = await fetch('/api/probe', {
method: 'POST',
@@ -1123,71 +1222,58 @@
return res.json();
}
let probeResults = {};
async function probeTarget() {
const target = document.getElementById('tunnelTarget').value.trim();
if (!target) {
alert('请先输入目标地址');
return;
}
const targets = isBatch() ? tunnelPlan.map(t => t.target) : [target];
const target = composeTarget(readTargetRows());
if (!target) { alert('请先填写目标主机和端口'); return; }
const targets = isEditing() ? [target] : tunnelPlan.map(t => t.target);
if (!targets.length) return;
const hint = document.getElementById('probeResult');
hint.innerText = '探测中...';
hint.style.color = 'var(--text-muted)';
try {
const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' }))));
const failed = results.map((r, i) => [targets[i], r]).filter(([, r]) => !r.reachable);
if (targets.length === 1) {
const data = results[0];
hint.innerText = data.reachable ? `✓ 连通性测试通过!延迟: ${data.rtt}` : `✗ 连通失败: ${data.error || '连接超时'}`;
} else if (!failed.length) {
hint.innerText = `✓ 全部 ${targets.length} 个目标连通`;
} else {
const [t, r] = failed[0];
hint.innerText = `✗ ${targets.length - failed.length}/${targets.length} 连通;${t} 连通失败: ${r.error || '连接超时'}` + (failed.length > 1 ? `(共 ${failed.length} 个失败)` : '');
}
hint.style.color = failed.length ? 'var(--danger)' : 'var(--success)';
} catch (e) {
hint.innerText = '探测请求失败';
hint.style.color = 'var(--danger)';
const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' }))));
probeResults = {};
targets.forEach((t, i) => { probeResults[t] = results[i]; });
if (isEditing()) {
const r = results[0];
hint.innerText = r.reachable ? `✓ 连通性测试通过!延迟: ${r.rtt}` : `✗ 连通失败: ${r.error || '连接超时'}`;
hint.style.color = r.reachable ? 'var(--success)' : 'var(--danger)';
return;
}
hint.innerText = '';
renderPlan();
}
// ---- 多个目标:预览将创建的通道,并放进通道组 ----
let tunnelPlan = [], tunnelPlanError = null, planTimer = null;
// ---- 预览将创建的通道(含自动分配的访问端口);多个地址自动放进通道组 ----
let tunnelPlan = [], tunnelPlanError = null, planTimer = null, plannedTarget = '';
function resetPlan() {
tunnelPlan = [];
tunnelPlanError = null;
clearTimeout(planTimer);
probeResults = {};
document.getElementById('probeResult').innerText = '';
renderPlan();
}
// 可能填了多个目标(逗号 / 空格分隔,或含范围 "10.0.1.11-13:22"),需要服务端展开
function mayBeMulti() {
if (document.getElementById('tunnelId').value) return false;
const target = document.getElementById('tunnelTarget').value.trim();
return target.split(/[\s,,、;;]+/).filter(Boolean).length > 1 || target.includes('-');
}
function isBatch() {
return mayBeMulti() && tunnelPlan.length > 1;
return !isEditing() && tunnelPlan.length > 1;
}
// 通道组选项:'' 不分组,'__new__' 新建,其余为已有组 ID
function groupFields() {
const sel = document.getElementById('tunnelGroup').value;
if (sel === '__new__') {
const name = document.getElementById('tunnelNewGroup').value.trim() || document.getElementById('tunnelName').value.trim();
return { group_id: '', new_group: name };
}
return { group_id: sel, new_group: '' };
if (sel !== '__new__') return { group_id: sel, new_group: '' };
const name = document.getElementById('tunnelName').value.trim();
const custom = isEditing() ? document.getElementById('tunnelNewGroup').value.trim() : '';
return { group_id: '', new_group: custom || name };
}
function tunnelPayload(port, whitelist) {
return {
name: document.getElementById('tunnelName').value.trim(),
owner: document.getElementById('tunnelOwner').value.trim(),
target: document.getElementById('tunnelTarget').value.trim(),
target: composeTarget(readTargetRows()),
listen_port: port,
whitelist,
idle_close_minutes: parseInt(document.getElementById('tunnelIdleClose').value, 10),
@@ -1199,7 +1285,9 @@
function schedulePlan() {
clearTimeout(planTimer);
if (!mayBeMulti()) { tunnelPlan = []; tunnelPlanError = null; renderPlan(); return; }
const target = composeTarget(readTargetRows());
if (target !== plannedTarget) { probeResults = {}; plannedTarget = target; }
if (isEditing() || !target) { tunnelPlan = []; tunnelPlanError = null; renderPlan(); return; }
planTimer = setTimeout(async () => {
try {
const res = await fetch('/api/tunnels/batch', {
@@ -1222,28 +1310,42 @@
}
function renderPlan() {
const batch = isBatch();
const box = document.getElementById('tunnelPlan');
document.getElementById('tunnelPortWrap').style.display = batch ? 'none' : 'block';
// 多个目标必须放进同一个通道组
const batch = isBatch(), edit = isEditing();
document.getElementById('tunnelPortWrap').style.display = edit ? 'block' : 'none';
// 多个地址必须放进通道组;新建时只有一个地址就不需要新建组
const sel = document.getElementById('tunnelGroup');
const none = sel.querySelector('option[value=""]');
if (none) none.disabled = batch;
if (batch && sel.value === '') { sel.value = '__new__'; onTunnelGroupChange(true); }
const none = sel.querySelector('option[value=""]'), fresh = sel.querySelector('option[value="__new__"]');
if (none) none.disabled = none.hidden = batch;
if (fresh) fresh.disabled = fresh.hidden = !batch && !edit;
if (batch && sel.value === '') sel.value = '__new__';
if (!batch && !edit && sel.value === '__new__') sel.value = '';
updateGroupHint();
document.getElementById('tunnelSaveBtn').innerText = batch ? `创建 ${tunnelPlan.length} 个通道` : '保存通道';
if (mayBeMulti() && tunnelPlanError) {
document.getElementById('tunnelNameLabel').innerText = batch && sel.value === '__new__' ? '通道组名称' : '通道名称 / 备注';
const box = document.getElementById('tunnelPlan');
if (tunnelPlanError) {
box.style.display = 'block';
box.style.color = 'var(--danger)';
box.innerText = tunnelPlanError;
return;
}
if (!batch) { box.style.display = 'none'; box.innerHTML = ''; return; }
const shown = tunnelPlan.slice(0, 6);
if (!tunnelPlan.length) { box.style.display = 'none'; box.innerHTML = ''; return; }
const shown = tunnelPlan.slice(0, 8);
const tested = tunnelPlan.filter(m => probeResults[m.target]);
const failed = tested.filter(m => !probeResults[m.target].reachable);
const status = m => {
const r = probeResults[m.target];
if (!r) return '';
return r.reachable ? ` <span class="ok">✓ ${escapeHtml(r.rtt)}</span>` : ` <span class="bad" title="${escapeHtml(r.error || '连接超时')}">✗ 不通</span>`;
};
let caption = tunnelPlan.length === 1 ? '访问端口自动分配' : '每个地址一个通道,访问端口自动连续分配';
if (tested.length === tunnelPlan.length && !failed.length) caption = tunnelPlan.length === 1 ? '目标连接成功' : `全部 ${tunnelPlan.length} 个目标连接成功`;
if (failed.length) caption = `<span class="bad">${failed.length} 个目标连不上,鼠标停在 ✗ 上可查看原因</span>`;
box.style.display = 'block';
box.style.color = '';
box.innerHTML = shown.map(m => `<div class="preview-row"><span class="route-endpoint">${escapeHtml(publicHost)}:${m.listen_port}</span> <span class="route-arrow">➔</span> <span class="route-target">${escapeHtml(m.target)}</span></div>`).join('')
+ `<div>${tunnelPlan.length > shown.length ? '… ' : ''}共 ${tunnelPlan.length} 个地址,每个地址一个通道,访问端口自动连续分配</div>`;
box.innerHTML = shown.map(m => `<div class="preview-row"><span class="route-endpoint">${escapeHtml(publicHost)}:${m.listen_port}</span> <span class="route-arrow">➔</span> <span class="route-target">${escapeHtml(m.target)}</span>${status(m)}</div>`).join('')
+ `<div>${tunnelPlan.length > shown.length ? `… 共 ${tunnelPlan.length} 个地址,` : ''}${caption}</div>`;
}
function fillTunnelGroupSelect(selected) {
@@ -1252,28 +1354,31 @@
(currentGroups.length ? '<option disabled>──────────</option>' : '') +
currentGroups.map(g => `<option value="${escapeHtml(g.id)}" ${g.id === selected ? 'selected' : ''}>${escapeHtml(g.name)}</option>`).join('');
document.getElementById('tunnelNewGroup').value = '';
onTunnelGroupChange(true);
updateGroupHint();
}
function updateGroupHint() {
const sel = document.getElementById('tunnelGroup').value;
const isNew = sel === '__new__';
const name = document.getElementById('tunnelName').value.trim();
const newInput = document.getElementById('tunnelNewGroup');
newInput.style.display = isNew ? 'block' : 'none';
newInput.style.display = sel === '__new__' && isEditing() ? 'block' : 'none';
newInput.placeholder = name ? `组名,默认「${name}」` : '通道组名称';
document.getElementById('tunnelGroupHint').innerText = isBatch()
? '多个地址会放进同一个通道组:共用邀请和授权,同事授权一次即可访问全部地址'
: (sel ? '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址' : '');
// 放进通道组时名称可以不填,按组名命名
const g = isNew ? newInput.value.trim() : (currentGroups.find(x => x.id === sel) || {}).name;
document.getElementById('tunnelName').placeholder = (!document.getElementById('tunnelId').value && g)
? `可不填,默认「${g} (主机)」` : '例如:MySQL 实习库 或 财务系统 SSH';
let hint = '';
if (sel === '__new__' && isBatch()) {
hint = `${tunnelPlan.length} 个地址会放进通道组${name ? `「${name}」` : '(填写上面的名称)'}:共用邀请和授权,同事授权一次即可访问全部地址`;
} else if (sel) {
hint = '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址';
}
document.getElementById('tunnelGroupHint').innerText = hint;
// 加入已有组时名称可以不填,按组名命名
const g = (currentGroups.find(x => x.id === sel) || {}).name;
document.getElementById('tunnelName').placeholder = !isEditing() && g ? `可不填,默认「${g} (主机)」`
: (isBatch() ? '例如:订单服务' : '例如:MySQL 实习库 或 财务系统 SSH');
}
function onTunnelGroupChange(skipPlan) {
function onTunnelGroupChange() {
updateGroupHint();
if (!skipPlan) schedulePlan();
schedulePlan();
}
// 闲置自动关闭 / 最长开启时长;旧配置中不在选项里的值临时加入下拉框
@@ -1309,10 +1414,20 @@
const id = document.getElementById('tunnelId').value;
const batch = isBatch();
const sel = document.getElementById('tunnelGroup').value;
const port = parseInt(document.getElementById('tunnelPort').value, 10);
if (!batch && !(port > 33000 && port <= 34000)) { alert('端口须在 33001-34000 范围内'); return; }
if (mayBeMulti() && tunnelPlanError) { alert(tunnelPlanError); return; }
if (!document.getElementById('tunnelName').value.trim() && (id || !sel)) { alert('请填写通道名称'); return; }
const joinsExisting = sel !== '' && sel !== '__new__';
const name = document.getElementById('tunnelName').value.trim();
const target = composeTarget(readTargetRows());
let port = 0;
if (id) {
port = parseInt(document.getElementById('tunnelPort').value, 10);
if (!(port > 33000 && port <= 34000)) { alert('端口须在 33001-34000 范围内'); return; }
if (!target) { alert('请填写目标主机和端口'); return; }
if (target.includes(',')) { alert('一个通道只对应一个目标端口;要开放更多端口,请用「新建代理通道」'); return; }
} else {
if (tunnelPlanError) { alert(tunnelPlanError); return; }
if (!tunnelPlan.length) { alert('请填写目标主机和端口'); return; }
}
if (!name && (id || !joinsExisting)) { alert(batch ? '请填写通道组名称' : '请填写通道名称'); return; }
if (sel === '__new__' && !groupFields().new_group) { alert('请填写通道组名称'); return; }
const mode = document.getElementById('tunnelAccessMode').value;
let whitelist = [];
@@ -1322,10 +1437,10 @@
if (whitelist.length === 0) { alert('请填写允许访问的 IP 或网段,或选择“仅授权链接”'); return; }
if (whitelist.includes('*')) { alert('如需放通全部,请选择“全部放通 *”'); return; }
}
const payload = tunnelPayload(batch ? 0 : port, whitelist);
const payload = tunnelPayload(port, whitelist);
try {
// 新建统一走 /api/tunnels/batch:单个目标、多个目标、新建或加入通道组都在这一个表单里
// 新建统一走 /api/tunnels/batch:一个或多个地址、新建或加入通道组都在这一个表单里,访问端口自动分配
const res = await fetch(id ? `/api/tunnels/${encodeURIComponent(id)}` : '/api/tunnels/batch', {
method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1339,7 +1454,6 @@
closeTunnelModal();
await fetchTunnels();
// 新建后直接生成邀请:新建的通道组邀请整组;加入已有组时组的授权自动覆盖新地址,不用再邀请
const joinsExisting = sel !== '' && sel !== '__new__';
if (!id && mode !== 'all' && !joinsExisting) {
const key = data.group ? 'g:' + data.group.id : 't:' + data.tunnels[0].id;
setTimeout(() => openInviteModal(key, false), 800);
@@ -1598,6 +1712,14 @@
if (!res.ok) alert('撤销失败: ' + await apiError(res));
}
// 通道组的在线连接可以按设备(来源 IP)或按组内地址查看
let drawerByAddress = false;
function setDrawerDimension(v) {
drawerByAddress = v;
renderDrawerClients();
}
const hostOfAddr = a => { const m = /^\[(.+)\]:\d+$/.exec(a) || /^([^:]+):\d+$/.exec(a); return m ? m[1] : a; };
function renderDrawerClients() {
if (!drawerScope) return;
const info = scopeInfo(drawerScope);
@@ -1616,15 +1738,45 @@
// 同一 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;
const ip = hostOfAddr(c.client_ip);
(groups[ip] = groups[ip] || []).push(c);
});
const leaseNames = new Map(info.leases.map(l => [l.ip, l.name || '']));
const sum = (arr, k) => arr.reduce((a, c) => a + (c[k] || 0), 0);
const ips = Object.keys(groups).sort((x, y) => groups[y].length - groups[x].length || x.localeCompare(y));
const byAddress = info.group && drawerByAddress;
const inUse = new Set(list.map(c => c.tunnel_id)).size;
const toggle = info.group ? `<span class="dim-toggle"><button type="button" class="${byAddress ? '' : 'on'}" onclick="setDrawerDimension(false)">按设备</button><button type="button" class="${byAddress ? 'on' : ''}" onclick="setDrawerDimension(true)">按地址</button></span>` : '';
const header = `<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (${byAddress ? `${inUse}/${info.members.length} 个地址在用` : `${ips.length} 台 · ${list.length} 连接`})${toggle}</b>`;
container.innerHTML = parts.pending + `<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (${ips.length} 台 · ${list.length} 连接)</b>` + ips.map(ip => {
if (byAddress) {
container.innerHTML = parts.pending + header + info.members.map(t => {
const conns = list.filter(c => c.tunnel_id === t.id);
const byIp = {};
conns.forEach(c => { const ip = hostOfAddr(c.client_ip); (byIp[ip] = byIp[ip] || []).push(c); });
const devs = Object.keys(byIp).sort();
const kill = cs => escapeHtml(JSON.stringify(cs.map(c => c.conn_id)));
return `
<div style="background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; ${conns.length ? '' : 'opacity: 0.55;'}">
<div style="display: flex; justify-content: space-between; align-items: center; gap: 8px;">
<div style="font-size: 13px;">
<code style="color: #38bdf8; font-weight: 600;">${escapeHtml(publicHost)}:${t.listen_port}</code> <span class="route-arrow">➔</span> <code>${escapeHtml(t.target)}</code>
<span style="font-size: 11px; color: var(--text-muted); margin-left: 6px;">${conns.length ? `${devs.length} 台设备 · ${conns.length} 个连接` : (t.enabled ? '无连接' : '已关闭')}</span>
</div>
${conns.length ? `<button class="btn-danger btn-sm" onclick='killConns(${kill(conns)})'>全部断开</button>` : ''}
</div>
${devs.map(ip => `
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--text-muted); padding: 6px 0 0;">
<span><code style="color: var(--text);">${escapeHtml(ip)}</code>${leaseNames.get(ip) ? ` <span style="color: #34d399;">${escapeHtml(leaseNames.get(ip))}</span>` : ''}
· ${byIp[ip].length} 个连接 · ↑ ${formatRate(sum(byIp[ip], 'rate_in'))} / ↓ ${formatRate(sum(byIp[ip], 'rate_out'))}</span>
<button class="btn-secondary btn-sm" onclick='killConns(${kill(byIp[ip])})'>断开</button>
</div>`).join('')}
</div>`;
}).join('') + parts.auth;
return;
}
container.innerHTML = parts.pending + header + ips.map(ip => {
const conns = groups[ip];
const first = conns.map(c => new Date(c.connected_at)).sort((x, y) => x - y)[0];
const ids = conns.map(c => c.conn_id);