feat: 目标改为动态列表、访问端口全自动;菜单栏合并通道组;接入记录可按地址查看
添加通道 - 目标服务改成列表:每行一台主机,多个端口用逗号隔开(也支持 8080-8082、主机范围); 「添加主机」沿用上一行的端口,粘贴一串 IP / 主机:端口会自动拆成多行 - 新建时不再手动指定对外端口,统一自动分配;预览里列出每个地址分到的端口, 连通性测试并发进行,结果标在每个地址后面 - 超过一个地址自动放进以名称命名的新通道组(也可选加入已有组),只有一个地址时为不分组; 编辑通道仍保留端口栏和「新建通道组」 - 网页面板同步:同样的目标列表、自动端口与通道组规则 菜单栏 - 同一个通道组合并为一行「组名(N 个地址)」,子菜单里整组开关、逐个地址开关 连接与授权(通道组) - 当前在线、历史接入台账增加「按设备 / 按地址」切换:按地址列出组内每个地址 有几台设备在用、谁访问过;按设备的汇总行显示访问了组内哪些地址,并带上授权姓名 - 网页面板的在线连接同样可以按地址查看
This commit is contained in:
1 parent
5b355fb759
commit
659e69f8f7
6 files changed
+851
-240
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user