diff --git a/README.md b/README.md index 074b637..46935e4 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ PortRelay 把这件事变成三步: - 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。 - **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。 - 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。 -- **通道组**:添加通道时目标可以一次填多个(例如 `10.0.1.11-13:22, 10.0.1.11:8080`),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。 +- **通道组**:添加通道时目标是一个列表,每行一台主机、多个端口用逗号隔开(也可以直接粘贴一串 IP),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。 **通道生命周期** @@ -208,11 +208,11 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64} ### 3. 创建通道并邀请同事 -1. 点 **添加通道**,填写名称和目标地址(如 `10.0.1.16:3306`)。可以先“探测”一下本机能否连通;端口会自动推荐。 +1. 点 **添加通道**,填写名称和目标主机、端口(如 `10.0.1.16`、`3306`)。可以先测试一下本机能否连通;网关上的访问端口自动分配。 2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。 3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。 -需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,目标填 `10.0.1.11-13:22`,表单会预览端口映射并自动选上「新建通道组」,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后点 **邀请整组**。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。 +需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,每行填一台主机(端口用逗号隔开,可以直接粘贴一串 IP),表单会预览端口映射并自动放进新建的通道组,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后点 **邀请整组**。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。组的「连接与授权」可以按设备或按地址查看在线连接和接入记录。 ## 配置参考 diff --git a/README_EN.md b/README_EN.md index c36b72e..2e42079 100644 --- a/README_EN.md +++ b/README_EN.md @@ -58,7 +58,7 @@ PortRelay turns that into three steps: - When approval is required, the app shows a system notification and you approve or deny with one click. The requester's page refreshes on its own. - **The access clock starts at the first connection**, so a teammate can accept an invite now and use it later. - When access expires or is revoked, **existing connections are dropped too**. Unauthorized connections are reset (TCP RST) and logged. -- **Tunnel groups**: the add-tunnel form accepts several targets at once (for example `10.0.1.11-13:22, 10.0.1.11:8080`), creates one tunnel per address on a contiguous block of gateway ports and puts them in one group. A group shares one invite link and one set of grants: a teammate is authorized once for the whole group, and one revoke disconnects them everywhere. +- **Tunnel groups**: the add-tunnel form takes a list of targets, one host per row with comma-separated ports (a pasted list of IPs is split into rows), creates one tunnel per address on a contiguous block of gateway ports and puts them in one group. A group shares one invite link and one set of grants: a teammate is authorized once for the whole group, and one revoke disconnects them everywhere. **Tunnel lifecycle** @@ -217,11 +217,11 @@ Open ⚙️ Settings and fill in: ### 3. Create a tunnel and invite someone -1. Click **添加通道** (Add tunnel) and enter a name and a target such as `10.0.1.16:3306`. You can probe the target from your machine first, and a free port is suggested automatically. +1. Click **添加通道** (Add tunnel) and enter a name, a target host and port such as `10.0.1.16` and `3306`. You can probe the target from your machine first; the gateway port is assigned automatically. 2. Keep the default access mode, **仅授权链接** (invite-only). Click **邀请** (Invite), then choose how long access lasts, how many devices may use the link, and whether approval is required. 3. Send the generated message or QR code. Once the teammate has entered their name, they can connect their database client or SSH to `192.168.1.10:33660`. -To open several hosts at once (a set of servers, say), use the same **添加通道** (Add tunnel) form and enter `10.0.1.11-13:22` as the target. The form previews the port mapping and selects 新建通道组 (New tunnel group); idle close, bandwidth and access settings apply to every address. Then click **邀请整组** (Invite group). To add addresses later, click **添加地址** (Add address) on the group card; an existing tunnel can join a group from its edit form. +To open several hosts at once (a set of servers, say), use the same **添加通道** (Add tunnel) form with one host per row (comma-separated ports; you can paste a list of IPs). The form previews the port mapping and puts the addresses in a new tunnel group; idle close, bandwidth and access settings apply to every address. Then click **邀请整组** (Invite group). To add addresses later, click **添加地址** (Add address) on the group card; an existing tunnel can join a group from its edit form. A group's connections and history can be viewed by device or by address. ## Configuration diff --git a/cmd/portrelay-agent/web/index.html b/cmd/portrelay-agent/web/index.html index 18226ce..71d5f5f 100644 --- a/cmd/portrelay-agent/web/index.html +++ b/cmd/portrelay-agent/web/index.html @@ -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 @@
- +
@@ -451,10 +461,12 @@
- -
- - + +
主机端口
+
+
+ +
@@ -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 => ` +
+ + + ${edit ? '' : ``} +
`).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 ? ` ✓ ${escapeHtml(r.rtt)}` : ` ✗ 不通`; + }; + let caption = tunnelPlan.length === 1 ? '访问端口自动分配' : '每个地址一个通道,访问端口自动连续分配'; + if (tested.length === tunnelPlan.length && !failed.length) caption = tunnelPlan.length === 1 ? '目标连接成功' : `全部 ${tunnelPlan.length} 个目标连接成功`; + if (failed.length) caption = `${failed.length} 个目标连不上,鼠标停在 ✗ 上可查看原因`; box.style.display = 'block'; box.style.color = ''; - box.innerHTML = shown.map(m => `
${escapeHtml(publicHost)}:${m.listen_port} ➔ ${escapeHtml(m.target)}
`).join('') - + `
${tunnelPlan.length > shown.length ? '… ' : ''}共 ${tunnelPlan.length} 个地址,每个地址一个通道,访问端口自动连续分配
`; + box.innerHTML = shown.map(m => `
${escapeHtml(publicHost)}:${m.listen_port} ➔ ${escapeHtml(m.target)}${status(m)}
`).join('') + + `
${tunnelPlan.length > shown.length ? `… 共 ${tunnelPlan.length} 个地址,` : ''}${caption}
`; } function fillTunnelGroupSelect(selected) { @@ -1252,28 +1354,31 @@ (currentGroups.length ? '' : '') + currentGroups.map(g => ``).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 ? `` : ''; + const header = `在线连接 (${byAddress ? `${inUse}/${info.members.length} 个地址在用` : `${ips.length} 台 · ${list.length} 连接`})${toggle}`; - container.innerHTML = parts.pending + `在线连接 (${ips.length} 台 · ${list.length} 连接)` + 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 ` +
+
+
+ ${escapeHtml(publicHost)}:${t.listen_port} ➔ ${escapeHtml(t.target)} + ${conns.length ? `${devs.length} 台设备 · ${conns.length} 个连接` : (t.enabled ? '无连接' : '已关闭')} +
+ ${conns.length ? `` : ''} +
+ ${devs.map(ip => ` +
+ ${escapeHtml(ip)}${leaseNames.get(ip) ? ` ${escapeHtml(leaseNames.get(ip))}` : ''} + · ${byIp[ip].length} 个连接 · ↑ ${formatRate(sum(byIp[ip], 'rate_in'))} / ↓ ${formatRate(sum(byIp[ip], 'rate_out'))} + +
`).join('')} +
`; + }).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); diff --git a/docs/images/app-edit.png b/docs/images/app-edit.png index eeebb51..79268f7 100644 Binary files a/docs/images/app-edit.png and b/docs/images/app-edit.png differ diff --git a/mac-native/PortRelayApp.swift b/mac-native/PortRelayApp.swift index 7e880c2..6bef60c 100644 --- a/mac-native/PortRelayApp.swift +++ b/mac-native/PortRelayApp.swift @@ -268,6 +268,13 @@ struct ServerStatusResp: Codable { var total_rate_history: RateSeriesItem? } +/// 连通性测试结果 +struct ProbeOutcome { + let ok: Bool + /// 延迟,或失败原因 + let detail: String +} + struct ProbeResp: Codable { var target: String var reachable: Bool @@ -1044,7 +1051,7 @@ class PortRelayViewModel: ObservableObject { /// 菜单内容有变化时才需要重建(避免每秒重建打断已展开的菜单) func menuNeedsRebuild() -> Bool { // 分步拼接:一长串字符串相加会让旧版编译器类型推断超时 - let tunnelSig: String = tunnels.map { "\($0.id):\($0.name):\($0.listen_port):\($0.enabled)" }.joined(separator: ",") + let tunnelSig: String = tunnels.map { "\($0.id):\($0.name):\($0.listen_port):\($0.enabled):\($0.group_id ?? "")" }.joined(separator: ",") let groupSig: String = groups.map { "\($0.id):\($0.name)" }.joined(separator: ",") let pendingSig: String = pendingRequests.map { $0.request.id }.joined(separator: ",") let sig = [String(isConnected), tunnelSig, groupSig, pendingSig].joined(separator: "|") @@ -1225,51 +1232,38 @@ class PortRelayViewModel: ObservableObject { return (true, try? JSONDecoder().decode(BatchResp.self, from: data), nil) } - /// 同时测试多个目标的连通性,全部连通才算成功 - func probeTargets(_ targets: [String]) async -> (Bool, String) { - if targets.count == 1 { return await probeTarget(target: targets[0]) } - var failed: [String: String] = [:] - await withTaskGroup(of: (String, Bool, String).self) { group in - for t in targets { - group.addTask { - let (ok, msg) = await self.probeTarget(target: t) - return (t, ok, msg) - } - } - for await (t, ok, msg) in group where !ok { - failed[t] = msg - } - } - guard let first = targets.first(where: { failed[$0] != nil }) else { - return (true, "全部 \(targets.count) 个目标连接成功") - } - let more = failed.count > 1 ? "(共 \(failed.count) 个失败)" : "" - return (false, "\(targets.count - failed.count)/\(targets.count) 连接成功;\(first) \(failed[first] ?? "")\(more)") - } - - func probeTarget(target: String) async -> (Bool, String) { - guard let url = URL(string: "http://127.0.0.1:33080/api/probe") else { return (false, "无效地址") } + /// 测试一个目标的连通性;detail 为延迟或失败原因 + func probe(_ target: String) async -> ProbeOutcome { + guard let url = URL(string: "http://127.0.0.1:33080/api/probe") else { return ProbeOutcome(ok: false, detail: "无效地址") } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") - let body = ["target": target] - req.httpBody = try? JSONSerialization.data(withJSONObject: body) - + req.httpBody = try? JSONSerialization.data(withJSONObject: ["target": target]) do { let (data, _) = try await URLSession.shared.data(for: req) - if let res = try? JSONDecoder().decode(ProbeResp.self, from: data) { - if res.reachable { - return (true, "连接成功 (\(res.rtt))") - } else { - return (false, "连接失败: \(res.error ?? "超时")") - } + guard let res = try? JSONDecoder().decode(ProbeResp.self, from: data) else { + return ProbeOutcome(ok: false, detail: "解析失败") } - return (false, "解析失败") + return res.reachable ? ProbeOutcome(ok: true, detail: res.rtt) : ProbeOutcome(ok: false, detail: res.error ?? "超时") } catch { - return (false, error.localizedDescription) + return ProbeOutcome(ok: false, detail: error.localizedDescription) } } + /// 同时测试多个目标 + func probeAll(_ targets: [String]) async -> [String: ProbeOutcome] { + var out: [String: ProbeOutcome] = [:] + await withTaskGroup(of: (String, ProbeOutcome).self) { group in + for t in targets { + group.addTask { (t, await self.probe(t)) } + } + for await (t, r) in group { + out[t] = r + } + } + return out + } + func recommendPort() async -> Int { if isPreview { return 33662 } guard let url = URL(string: "http://127.0.0.1:33080/api/recommend-port") else { return 33660 } @@ -2808,12 +2802,17 @@ struct ClientDetailsSheet: View { @State private var expandedIPs: Set = [] @State private var expandedHistoryIPs: Set = [] @State private var isRevoking: Bool = false + /// 通道组:在线连接与接入记录按设备(来源 IP)或按组内地址查看 + @State private var byAddress: Bool = false + @State private var collapsedOnlineAddrs: Set = [] + @State private var expandedHistoryAddrs: Set = [] - init(vm: PortRelayViewModel, scope: AccessScope, initialTab: Int = 0, isPresented: Binding) { + init(vm: PortRelayViewModel, scope: AccessScope, initialTab: Int = 0, byAddress: Bool = false, isPresented: Binding) { _vm = ObservedObject(wrappedValue: vm) self.scope = scope _isPresented = isPresented _selectedTab = State(initialValue: initialTab) + _byAddress = State(initialValue: byAddress) } init(vm: PortRelayViewModel, tunnel: TunnelItem, initialTab: Int = 0, isPresented: Binding) { @@ -2837,12 +2836,45 @@ struct ClientDetailsSheet: View { return "\(vm.publicHost):\(t.listen_port) → \(t.target)" } - /// 通道组:这台设备连到了组内哪些地址 - func groupTargetsSummary(_ conns: [ActiveClientInfo]) -> String { + /// 通道组:设备访问了组内哪些地址(" · 访问 10.0.1.11、10.0.1.12");组内端口都相同时只写主机 + func addressesSummary(_ tunnelIDs: Set) -> String { guard scope.isGroup else { return "" } - let targets = Set(conns.compactMap { targetLabel($0.tunnel_id) }) - if targets.count == 1, let only = targets.first { return " · \(only)" } - return " · 连到 \(targets.count) 个地址" + let used = members.filter { tunnelIDs.contains($0.id) } + guard !used.isEmpty else { return "" } + let samePort = Set(members.map { portOf($0.target) }).count == 1 + let labels: [String] = used.map { samePort ? hostOf($0.target) : $0.target } + if labels.count > 3 { return " · 访问 \(labels.count) 个地址" } + return " · 访问 " + labels.joined(separator: "、") + } + + /// 有在线连接 / 接入记录的组内地址数 + var addressesOnline: Int { Set(activeClientsForTunnel.map { $0.tunnel_id }).intersection(memberIDs).count } + var addressesWithHistory: Int { Set(historyRecords.map { $0.tunnel_id }).intersection(memberIDs).count } + + var dimensionSummary: String { + if selectedTab == 0 { + return byAddress ? "\(addressesOnline)/\(members.count) 个地址正在使用" + : "\(clientGroups.count) 台设备 · \(activeClientsForTunnel.count) 个连接" + } + return byAddress ? "\(addressesWithHistory)/\(members.count) 个地址有接入记录" + : "\(historyGroups.count) 台设备 · \(historyRecords.count) 次连接" + } + + /// 通道组的在线 / 接入记录可以按设备或按地址看 + var dimensionBar: some View { + HStack(spacing: 8) { + Text(verbatim: dimensionSummary) + .font(.system(size: 11)) + .foregroundColor(.secondary) + Spacer() + Picker("", selection: $byAddress) { + Text("按设备").tag(false) + Text("按地址").tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + } } /// 通道组里每个连接经过的目标("→ 10.0.1.11:22") @@ -2902,6 +2934,10 @@ struct ClientDetailsSheet: View { .pickerStyle(.segmented) .padding(.vertical, 4) + if scope.isGroup && selectedTab != 2 { + dimensionBar + } + Divider() // Tab Content @@ -2920,8 +2956,14 @@ struct ClientDetailsSheet: View { } else { ScrollView { VStack(spacing: 8) { - ForEach(clientGroups) { g in - clientGroupRow(g) + if scope.isGroup && byAddress { + ForEach(members) { t in + onlineAddressRow(t) + } + } else { + ForEach(clientGroups) { g in + clientGroupRow(g) + } } } .padding(.bottom, 8) @@ -2944,8 +2986,14 @@ struct ClientDetailsSheet: View { } else { ScrollView { VStack(spacing: 8) { - ForEach(historyGroups) { g in - historyGroupRow(g) + if scope.isGroup && byAddress { + ForEach(members) { t in + historyAddressRow(t) + } + } else { + ForEach(historyGroups) { g in + historyGroupRow(g) + } } } .padding(.bottom, 8) @@ -2996,8 +3044,12 @@ struct ClientDetailsSheet: View { .lineLimit(1) .truncationMode(.middle) TagView("\(g.records.count) 次连接") + if let n = leaseName(g.ip) { + TagView("已授权 · \(n)", color: .green) + } } - Text(verbatim: "最近 \(formatDateTimeShort(g.lastSeen)) · 首次 \(formatDateTimeShort(g.firstSeen)) · 累计 \(formatSecondsToDuration(g.totalDuration))") + Text(verbatim: "最近 \(formatDateTimeShort(g.lastSeen)) · 首次 \(formatDateTimeShort(g.firstSeen)) · 累计 \(formatSecondsToDuration(g.totalDuration))" + + addressesSummary(Set(g.records.map { $0.tunnel_id }))) .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) @@ -3059,6 +3111,182 @@ struct ClientDetailsSheet: View { .rowCard() } + // MARK: 按地址(通道组) + + func addressLabel(_ t: TunnelItem) -> some View { + HStack(spacing: 6) { + Text(verbatim: "\(vm.publicHost):\(t.listen_port)") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(.accentColor) + Image(systemName: "arrow.right") + .font(.system(size: 9)) + .foregroundColor(.secondary) + Text(verbatim: t.target) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + } + .lineLimit(1) + } + + /// 设备 IP + 授权时填写的姓名 + func deviceLabel(_ ip: String) -> some View { + HStack(spacing: 6) { + Text(verbatim: ip) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + if let n = leaseName(ip) { + TagView(n, color: .green) + } + } + } + + /// 组内一个地址:此刻哪些设备连着它 + @ViewBuilder + func onlineAddressRow(_ t: TunnelItem) -> some View { + let conns: [ActiveClientInfo] = activeClientsForTunnel.filter { $0.tunnel_id == t.id } + let devices: [ClientIPGroup] = Dictionary(grouping: conns) { hostOf($0.client_ip) } + .map { ClientIPGroup(ip: $0.key, conns: $0.value) } + .sorted { $0.ip < $1.ip } + let all = ClientIPGroup(ip: t.id, conns: conns) + let expanded = !conns.isEmpty && !collapsedOnlineAddrs.contains(t.id) + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + chevron(expanded, visible: !conns.isEmpty) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + addressLabel(t) + TagView(conns.isEmpty ? "无连接" : "\(devices.count) 台设备 · \(conns.count) 个连接") + } + Text(verbatim: conns.isEmpty ? (t.enabled ? "暂无设备连接" : "已关闭") + : "最早接入 \(formatDateTimeShort(all.firstConnectedAt)) · ↑ \(formatBytes(all.bytesIn)) ↓ \(formatBytes(all.bytesOut))") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .lineLimit(1) + } + .layoutPriority(1) + Spacer(minLength: 8) + if !conns.isEmpty { + RateText(up: all.rateIn, down: all.rateOut) + Button("全部断开", role: .destructive) { + Task { await vm.killClients(connIds: conns.map { $0.conn_id }) } + } + .controlSize(.small) + .frame(width: 76, alignment: .trailing) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + .onTapGesture { if !conns.isEmpty { toggle(t.id, in: &collapsedOnlineAddrs) } } + + if expanded { + Divider().padding(.leading, 32).padding(.trailing, 12) + VStack(spacing: 0) { + ForEach(devices) { d in + HStack(spacing: 8) { + Color.clear.frame(width: 12) + deviceLabel(d.ip) + .frame(width: 200, alignment: .leading) + Text(verbatim: "\(d.conns.count) 个连接 · 接入 \(formatTimeHHmm(d.firstConnectedAt))") + .font(.system(size: 11)) + .foregroundColor(.secondary) + Spacer(minLength: 8) + RateText(up: d.rateIn, down: d.rateOut, size: 10) + Button("断开") { + Task { await vm.killClients(connIds: d.conns.map { $0.conn_id }) } + } + .buttonStyle(.borderless) + .foregroundColor(.red) + .font(.system(size: 11)) + .frame(width: 76, alignment: .trailing) + } + .lineLimit(1) + .padding(.horizontal, 12) + .padding(.vertical, 5) + } + } + .padding(.vertical, 4) + } + } + .rowCard() + .opacity(conns.isEmpty ? 0.6 : 1) + } + + /// 组内一个地址:谁、什么时候访问过它 + @ViewBuilder + func historyAddressRow(_ t: TunnelItem) -> some View { + let records: [ClientHistoryRecord] = historyRecords.filter { $0.tunnel_id == t.id }.sorted { $0.connected_at > $1.connected_at } + let summary = HistoryIPGroup(ip: t.id, records: records) + let devices = Set(records.map { hostOf($0.client_ip) }).count + let expanded = !records.isEmpty && isExpanded(t.id, in: expandedHistoryAddrs, groupCount: addressesWithHistory) + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + chevron(expanded, visible: !records.isEmpty) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + addressLabel(t) + TagView(records.isEmpty ? "暂无记录" : "\(devices) 台设备 · \(records.count) 次连接") + } + if !records.isEmpty { + Text(verbatim: "最近 \(formatDateTimeShort(summary.lastSeen)) · 首次 \(formatDateTimeShort(summary.firstSeen)) · 累计 \(formatSecondsToDuration(summary.totalDuration))") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + .layoutPriority(1) + Spacer(minLength: 8) + if !records.isEmpty { + VStack(alignment: .trailing, spacing: 3) { + Text(verbatim: formatBytes(summary.bytesIn + summary.bytesOut)) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + Text(verbatim: "↑ \(formatBytes(summary.bytesIn)) ↓ \(formatBytes(summary.bytesOut))") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) + } + .lineLimit(1) + .frame(width: 190, alignment: .trailing) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + .onTapGesture { if !records.isEmpty { toggle(t.id, in: &expandedHistoryAddrs) } } + + if expanded { + Divider().padding(.leading, 32).padding(.trailing, 12) + VStack(spacing: 0) { + ForEach(records) { rec in + HStack(spacing: 8) { + Color.clear.frame(width: 12) + deviceLabel(hostOf(rec.client_ip)) + .frame(width: 200, alignment: .leading) + Text(verbatim: "\(formatDateTimeShort(rec.connected_at)) – \(formatTimeHHmm(rec.disconnected_at))") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + .frame(width: 180, alignment: .leading) + Text(verbatim: formatSecondsToDuration(rec.duration_sec)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + Spacer(minLength: 8) + Text(verbatim: "↑ \(formatBytes(rec.bytes_in)) ↓ \(formatBytes(rec.bytes_out))") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) + .frame(width: 190, alignment: .trailing) + } + .lineLimit(1) + .padding(.horizontal, 12) + .padding(.vertical, 5) + } + } + .padding(.vertical, 4) + } + } + .rowCard() + .opacity(records.isEmpty ? 0.6 : 1) + } + func isLeased(_ ip: String) -> Bool { leases.contains { $0.ip == ip } } @@ -3088,7 +3316,7 @@ struct ClientDetailsSheet: View { } } Text(verbatim: "最早接入 \(formatDateTimeShort(g.firstConnectedAt)) · ↑ \(formatBytes(g.bytesIn)) ↓ \(formatBytes(g.bytesOut))" - + groupTargetsSummary(g.conns)) + + addressesSummary(Set(g.conns.map { $0.tunnel_id }))) .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) @@ -3480,6 +3708,68 @@ struct DiagnosticsSheet: View { // MARK: - Edit / Create Sheet (With Owner & Duration TTL) +/// 添加通道表单里的一行目标:一台主机 + 一个或多个端口("22, 3306"、"8080-8082")。 +/// 主机格也接受逗号分隔的几台、末段范围(10.0.1.11-13)或自带端口的 主机:端口。 +struct TargetRow: Identifiable, Equatable { + let id = UUID() + var host: String = "" + var ports: String = "" + + static let separators: Set = [" ", ",", ",", "、", ";", ";", "\n", "\r", "\t"] + + static func items(_ s: String) -> [String] { + s.split(whereSeparator: { separators.contains($0) }).map(String.init) + } + + /// "10.0.1.11:22" / "[fd00::1]:22" → (主机, 端口);不带端口(含裸 IPv6)返回 nil + static func splitHostPort(_ item: String) -> (String, String)? { + if item.hasPrefix("["), let close = item.firstIndex(of: "]") { + let rest = item[item.index(after: close)...] + guard rest.hasPrefix(":"), rest.count > 1 else { return nil } + return (String(item[item.index(after: item.startIndex).. [TargetRow] { + var out: [TargetRow] = [] + for item in items(text) { + let (host, port) = splitHostPort(item) ?? (item, "") + if let i = out.firstIndex(where: { $0.host == host }) { + if !port.isEmpty && !items(out[i].ports).contains(port) { + out[i].ports = out[i].ports.isEmpty ? port : out[i].ports + ", " + port + } + } else { + out.append(TargetRow(host: host, ports: port.isEmpty ? defaultPorts : port)) + } + } + return out + } + + /// 拼成 "主机:端口, …" 交给服务端展开;还没填完整的行先跳过 + static func compose(_ rows: [TargetRow]) -> String { + var out: [String] = [] + for r in rows { + let ports = items(r.ports) + for h in items(r.host) { + if splitHostPort(h) != nil { + out.append(h) + continue + } + guard !ports.isEmpty else { continue } + let host = h.contains(":") && !h.hasPrefix("[") ? "[\(h)]" : h + for p in ports { + out.append("\(host):\(p)") + } + } + } + return out.joined(separator: ", ") + } +} + struct TunnelEditSheet: View { @ObservedObject var vm: PortRelayViewModel let tunnel: TunnelItem? @@ -3487,7 +3777,7 @@ struct TunnelEditSheet: View { @State private var name: String @State private var owner: String - @State private var target: String + @State private var rows: [TargetRow] @State private var portString: String @State private var durationMinutes: Int @State private var idleCloseMinutes: Int @@ -3496,14 +3786,15 @@ struct TunnelEditSheet: View { @State private var accessMode: AccessMode @State private var groupChoice: GroupChoice @State private var newGroupName: String = "" - /// 填了多个目标时将创建的通道(服务端预览) + /// 将创建的通道(服务端预览,含自动分配的访问端口) @State private var plan: [PlannedTunnel] = [] @State private var planError: String? = nil - @State private var probeMessage: String = "" - @State private var probeOK: Bool = false + /// 连通性测试结果,按目标 + @State private var probes: [String: ProbeOutcome] = [:] @State private var isProbing: Bool = false @State private var isSaving: Bool = false @State private var errorMessage: String? = nil + @FocusState private var focusedRow: UUID? /// 新建完成后打开邀请:独立通道,或新建的通道组。加入已有组时不需要——组的授权自动覆盖新地址 var onCreated: ((AccessScope) -> Void)? = nil @@ -3512,7 +3803,7 @@ struct TunnelEditSheet: View { case linkOnly, specific, all } - /// 通道组选项:不分组、新建、或加入已有组 + /// 通道组选项。新建时多个地址自动新建组(以名称为组名);编辑时可以新建组并移入 enum GroupChoice: Hashable { case none case new @@ -3524,7 +3815,7 @@ struct TunnelEditSheet: View { /// 表单字段在创建时就用通道数据填好,不依赖 onAppear(避免第一帧显示空表单)。 /// groupId:新建时默认加入的通道组(通道组卡片上的「添加地址」) init(vm: PortRelayViewModel, tunnel: TunnelItem?, isPresented: Binding, groupId: String? = nil, - draftName: String = "", draftTarget: String = "", onCreated: ((AccessScope) -> Void)? = nil) { + draftName: String = "", draftRows: [(String, String)] = [], onCreated: ((AccessScope) -> Void)? = nil) { _vm = ObservedObject(wrappedValue: vm) self.tunnel = tunnel self.onCreated = onCreated @@ -3534,7 +3825,12 @@ struct TunnelEditSheet: View { _accessMode = State(initialValue: mode) _name = State(initialValue: tunnel?.name ?? draftName) _owner = State(initialValue: tunnel.map { $0.owner ?? "" } ?? NSUserName()) - _target = State(initialValue: tunnel?.target ?? draftTarget) + var initialRows = draftRows.map { TargetRow(host: $0.0, ports: $0.1) } + if let t = tunnel { + let (h, p) = TargetRow.splitHostPort(t.target) ?? (t.target, "") + initialRows = [TargetRow(host: h, ports: p)] + } + _rows = State(initialValue: initialRows.isEmpty ? [TargetRow()] : initialRows) _portString = State(initialValue: tunnel.map { "\($0.listen_port)" } ?? "") // 最长开启时长:新建默认不限制(主要依靠闲置自动关闭);duration_minutes 为 0 时 JSON 中会省略 _durationMinutes = State(initialValue: tunnel.map { $0.duration_minutes ?? 0 } ?? 0) @@ -3547,47 +3843,42 @@ struct TunnelEditSheet: View { // MARK: Derived state - /// 可能填了多个目标(逗号 / 空格分隔,或含范围 "10.0.1.11-13:22"),需要服务端展开 - var mayBeMulti: Bool { - guard !isEdit else { return false } - let items = target.split(whereSeparator: { " ,,、;;\n\t".contains($0) }) - return items.count > 1 || target.contains("-") - } + var composedTarget: String { TargetRow.compose(rows) } - /// 确实会创建多个通道 - var isBatch: Bool { mayBeMulti && plan.count > 1 } + /// 会创建多个通道(自动放进通道组) + var isBatch: Bool { !isEdit && plan.count > 1 } - /// 新建通道组的名称:没填时与通道名称相同 + var trimmedName: String { name.trimmingCharacters(in: .whitespaces) } + + /// 新建通道组的名称:新建时就是名称;编辑时可另填,没填时与通道名称相同 var resolvedGroupName: String { let n = newGroupName.trimmingCharacters(in: .whitespaces) - return n.isEmpty ? name.trimmingCharacters(in: .whitespaces) : n + return isEdit && !n.isEmpty ? n : trimmedName } - /// 选中的通道组名称(不分组为 nil) - var chosenGroupName: String? { - switch groupChoice { - case .none: return nil - case .new: return newGroupName.trimmingCharacters(in: .whitespaces) - case .existing(let id): return vm.group(id)?.name - } + var existingGroupName: String? { + if case .existing(let id) = groupChoice { return vm.group(id)?.name } + return nil + } + + var nameTitle: String { + isBatch && groupChoice == .new ? "通道组名称" : "通道名称" } var namePlaceholder: String { - if !isEdit, let g = chosenGroupName, !g.isEmpty { + if !isEdit, let g = existingGroupName { return "可不填,默认「\(g) (主机)」" } return isBatch ? "例如:订单服务" : "例如:MySQL 实习库" } var canSave: Bool { - if isSaving || target.trimmingCharacters(in: .whitespaces).isEmpty { return false } - if mayBeMulti && planError != nil { return false } - let hasName = !name.trimmingCharacters(in: .whitespaces).isEmpty - switch groupChoice { - case .none: return hasName - case .new: return !resolvedGroupName.isEmpty && (hasName || !isEdit) - case .existing: return hasName || !isEdit + if isSaving { return false } + if isEdit { + return !trimmedName.isEmpty && !composedTarget.isEmpty && (groupChoice != .new || !resolvedGroupName.isEmpty) } + if plan.isEmpty || planError != nil { return false } + return !trimmedName.isEmpty || existingGroupName != nil } func makeForm(port: Int, whitelist: [String]) -> TunnelForm { @@ -3598,8 +3889,8 @@ struct TunnelEditSheet: View { case .existing(let id): groupId = id } return TunnelForm( - name: name.trimmingCharacters(in: .whitespaces), owner: owner.trimmingCharacters(in: .whitespaces), - target: target.trimmingCharacters(in: .whitespacesAndNewlines), port: port, whitelist: whitelist, + name: trimmedName, owner: owner.trimmingCharacters(in: .whitespaces), + target: composedTarget, port: port, whitelist: whitelist, durationMinutes: durationMinutes, idleCloseMinutes: idleCloseMinutes, maxBandwidthMB: maxBandwidthMB, groupId: groupId, newGroup: newGroup) } @@ -3612,7 +3903,7 @@ struct TunnelEditSheet: View { .font(.system(size: 15, weight: .bold)) HStack(spacing: 12) { - field("通道名称") { + field(nameTitle) { TextField(namePlaceholder, text: $name) .textFieldStyle(.roundedBorder) } @@ -3624,7 +3915,7 @@ struct TunnelEditSheet: View { targetSection - if !isBatch { + if isEdit { field("对外监听端口 (范围 33001-34000)") { HStack { TextField("33660", text: $portString) @@ -3660,54 +3951,147 @@ struct TunnelEditSheet: View { .padding(.top, 4) } .padding(20) - .frame(width: 490) - .onAppear { - if tunnel == nil { - Task { - let p = await vm.recommendPort() - if portString.isEmpty { portString = "\(p)" } - } - } - } - .task(id: "\(target)|\(name)|\(newGroupName)|\(groupChoice)") { await refreshPlan() } + .frame(width: 520) + .task(id: "\(composedTarget)|\(trimmedName)|\(groupChoice)") { await refreshPlan() } + .onChange(of: composedTarget) { _ in probes = [:] } .onChange(of: isBatch) { batch in - // 多个目标必须放进同一个通道组 + // 多个地址放进同一个通道组;只剩一个地址时回到不分组 if batch && groupChoice == .none { groupChoice = .new } + if !batch && !isEdit && groupChoice == .new { groupChoice = .none } } } - // MARK: Sections + // MARK: Target list var targetSection: some View { - VStack(alignment: .leading, spacing: 4) { - Text(isEdit ? "目标服务 (内网主机 IP:端口)" : "目标服务 (内网主机 IP:端口,可填多个)") + VStack(alignment: .leading, spacing: 6) { + Text(isEdit ? "目标服务 (内网主机与端口)" : "目标服务(每行一台主机,多个端口用逗号隔开)") .font(.system(size: 12)) .foregroundColor(.secondary) - HStack { - TextField(isEdit ? "例如 10.0.0.10:3306" : "例如 10.0.0.10:3306;多台主机写 10.0.1.11-13:22", text: $target) - .textFieldStyle(.roundedBorder) - Button(isProbing ? "测试中" : "连通性测试") { probe() } - .disabled(target.trimmingCharacters(in: .whitespaces).isEmpty || isProbing || (mayBeMulti && planError != nil)) - .controlSize(.small) + if rows.count > 1 || !isEdit { + HStack(spacing: 8) { + Text("主机").frame(maxWidth: .infinity, alignment: .leading) + Text("端口").frame(width: 150, alignment: .leading) + Color.clear.frame(width: 18, height: 1) + } + .font(.system(size: 11)) + .foregroundColor(.secondary) } - if !probeMessage.isEmpty { - Text(verbatim: probeMessage) - .font(.system(size: 11)) - .foregroundColor(probeOK ? .green : .red) + if isEdit, let row = rows.first { + HStack(spacing: 8) { + targetRow(row) + probeButton + } + } else { + ForEach(rows) { row in + targetRow(row) + } + HStack(spacing: 10) { + Button { + addRow() + } label: { + Label("添加主机", systemImage: "plus") + .font(.system(size: 12)) + } + .buttonStyle(.borderless) + .help("可以一次粘贴多个 IP(每行一个,或用逗号分隔),会自动拆成多行") + Spacer() + probeButton + } } - if mayBeMulti, let err = planError { + if isEdit { + if let r = probes[composedTarget] { + Text(verbatim: r.ok ? "连接成功 (\(r.detail))" : "连接失败: \(r.detail)") + .font(.system(size: 11)) + .foregroundColor(r.ok ? .green : .red) + } + } else if let err = planError { Text(verbatim: err) .font(.system(size: 11)) .foregroundColor(.red) - } else if isBatch { + } else if !plan.isEmpty { planList } } } - /// 多个目标时:每个地址分到的访问端口 + var probeButton: some View { + Button(isProbing ? "测试中" : "连通性测试") { probe() } + .disabled(composedTarget.isEmpty || isProbing || (!isEdit && (plan.isEmpty || planError != nil))) + .controlSize(.small) + } + + func targetRow(_ row: TargetRow) -> some View { + HStack(spacing: 8) { + TextField(isEdit ? "例如 10.0.0.10" : "例如 10.0.1.11,可粘贴多个", text: hostBinding(row.id)) + .textFieldStyle(.roundedBorder) + .focused($focusedRow, equals: row.id) + TextField(isEdit ? "3306" : "22, 3306", text: portsBinding(row.id)) + .textFieldStyle(.roundedBorder) + .frame(width: 150) + if !isEdit { + Button { + removeRow(row.id) + } label: { + Image(systemName: "minus.circle") + .foregroundColor(.secondary) + } + .buttonStyle(.borderless) + .frame(width: 18) + .disabled(rows.count == 1) + .opacity(rows.count == 1 ? 0.3 : 1) + .help("删除这一行") + } + } + } + + func hostBinding(_ id: UUID) -> Binding { + Binding( + get: { rows.first(where: { $0.id == id })?.host ?? "" }, + set: { setHost(id, $0) } + ) + } + + func portsBinding(_ id: UUID) -> Binding { + Binding( + get: { rows.first(where: { $0.id == id })?.ports ?? "" }, + set: { v in + if let i = rows.firstIndex(where: { $0.id == id }) { rows[i].ports = v } + } + ) + } + + /// 一次粘贴进多个主机(或 主机:端口)时拆成多行,其余情况照常输入 + func setHost(_ id: UUID, _ value: String) { + guard let i = rows.firstIndex(where: { $0.id == id }) else { return } + let pasted = value.count - rows[i].host.count > 1 + if !isEdit && pasted && (TargetRow.items(value).count > 1 || TargetRow.splitHostPort(value) != nil) { + let parsed = TargetRow.parse(value, defaultPorts: rows[i].ports) + if !parsed.isEmpty { + rows.replaceSubrange(i...i, with: parsed) + return + } + } + rows[i].host = value + } + + /// 新的一行沿用上一行的端口(同一组机器通常开同样的端口) + func addRow() { + let row = TargetRow(host: "", ports: rows.last?.ports ?? "") + rows.append(row) + focusedRow = row.id + } + + func removeRow(_ id: UUID) { + guard rows.count > 1 else { return } + rows.removeAll { $0.id == id } + } + + /// 每个地址分到的访问端口,以及连通性测试结果 var planList: some View { - let shown = plan.prefix(6) + let shown = plan.prefix(8) + let failed = plan.filter { probes[$0.target]?.ok == false }.count + let tested = plan.filter { probes[$0.target] != nil }.count return VStack(alignment: .leading, spacing: 3) { ForEach(Array(shown)) { m in HStack(spacing: 6) { @@ -3720,17 +4104,39 @@ struct TunnelEditSheet: View { Text(verbatim: m.target) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) + Spacer(minLength: 6) + if let r = probes[m.target] { + Label(r.ok ? r.detail : "不通", systemImage: r.ok ? "checkmark.circle.fill" : "xmark.circle.fill") + .font(.system(size: 10)) + .foregroundColor(r.ok ? .green : .red) + .help(r.ok ? "连接成功" : "连接失败:\(r.detail)") + } } } - Text(verbatim: (plan.count > shown.count ? "… " : "") + "共 \(plan.count) 个地址,每个地址一个通道,访问端口自动连续分配") + Text(verbatim: planCaption(failed: failed, tested: tested, hidden: plan.count - shown.count)) .font(.system(size: 11)) - .foregroundColor(.secondary) + .foregroundColor(failed > 0 ? .red : .secondary) } .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(0.04))) } + func planCaption(failed: Int, tested: Int, hidden: Int) -> String { + var parts: [String] = [] + if hidden > 0 { parts.append("… 共 \(plan.count) 个地址") } + if tested == plan.count && failed == 0 { + parts.append(plan.count == 1 ? "目标连接成功" : "全部 \(plan.count) 个目标连接成功") + } else if failed > 0 { + parts.append("\(failed) 个目标连不上,鼠标停在 ✗ 上可查看原因") + } else { + parts.append(plan.count == 1 ? "访问端口自动分配" : "每个地址一个通道,访问端口自动连续分配") + } + return parts.joined(separator: ",") + } + + // MARK: Sections + var groupSection: some View { VStack(alignment: .leading, spacing: 4) { Text("通道组") @@ -3741,7 +4147,9 @@ struct TunnelEditSheet: View { if !isBatch { Text("不分组").tag(GroupChoice.none) } - Text("新建通道组").tag(GroupChoice.new) + if isBatch || isEdit { + Text("新建通道组").tag(GroupChoice.new) + } if !vm.groups.isEmpty { Divider() ForEach(vm.groups) { g in @@ -3751,23 +4159,31 @@ struct TunnelEditSheet: View { } .labelsHidden() .fixedSize() - if groupChoice == .new { - TextField(name.trimmingCharacters(in: .whitespaces).isEmpty ? "通道组名称" : "组名,默认「\(name.trimmingCharacters(in: .whitespaces))」", text: $newGroupName) + if isEdit && groupChoice == .new { + TextField(trimmedName.isEmpty ? "通道组名称" : "组名,默认「\(trimmedName)」", text: $newGroupName) .textFieldStyle(.roundedBorder) } } - if isBatch { - Text("多个地址会放进同一个通道组:共用邀请和授权,同事授权一次即可访问全部地址") - .font(.system(size: 11)) - .foregroundColor(.secondary) - } else if groupChoice != .none { - Text("组内通道共用邀请和授权,同事授权一次即可访问组内全部地址") + if let hint = groupHint { + Text(verbatim: hint) .font(.system(size: 11)) .foregroundColor(.secondary) } } } + var groupHint: String? { + switch groupChoice { + case .none: + return nil + case .new where isBatch: + let n = trimmedName.isEmpty ? "(填写上面的名称)" : "「\(trimmedName)」" + return "\(plan.count) 个地址会放进通道组\(n):共用邀请和授权,同事授权一次即可访问全部地址" + default: + return "组内通道共用邀请和授权,同事授权一次即可访问组内全部地址" + } + } + /// 自动关闭:闲置关闭(主要)+ 可选的最长开启时长 var closeSection: some View { VStack(alignment: .leading, spacing: 6) { @@ -3853,7 +4269,7 @@ struct TunnelEditSheet: View { // MARK: Actions func refreshPlan() async { - guard mayBeMulti else { + guard !isEdit, !composedTarget.isEmpty else { plan = [] planError = nil return @@ -3868,12 +4284,10 @@ struct TunnelEditSheet: View { } func probe() { - let targets = isBatch ? plan.map { $0.target } : [target.trimmingCharacters(in: .whitespaces)] + let targets = isEdit ? [composedTarget] : plan.map { $0.target } isProbing = true Task { - let (ok, msg) = await vm.probeTargets(targets) - probeOK = ok - probeMessage = msg + probes = await vm.probeAll(targets) isProbing = false } } @@ -3901,17 +4315,20 @@ struct TunnelEditSheet: View { func save() { var port = 0 - if !isBatch { + if isEdit { guard let p = Int(portString), p > 33000 && p <= 34000 else { errorMessage = "端口须在 33001-34000 范围内" return } + guard !composedTarget.contains(",") else { + errorMessage = "一个通道只对应一个目标端口;要开放更多端口,请用「添加通道」" + return + } port = p } guard let wl = whitelist() else { return } let form = makeForm(port: port, whitelist: wl) - var joinsExisting = false - if case .existing = groupChoice { joinsExisting = true } + let joinsExisting = existingGroupName != nil isSaving = true errorMessage = nil Task { @@ -4496,13 +4913,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { tunnelSection.isEnabled = false menu.addItem(tunnelSection) + // 通道组占一行(出现在它第一个成员的位置),子菜单里整组开关、逐个地址开关 + var seenGroups = Set() for t in vm.tunnels { - let check = t.enabled ? "✓ " : " " - let title = "\(check)\(t.name) (:\(t.listen_port))" - let item = NSMenuItem(title: title, action: #selector(toggleTunnelAction(_:)), keyEquivalent: "") - item.representedObject = t.id - item.target = self - menu.addItem(item) + if let g = vm.group(t.group_id) { + if seenGroups.insert(g.id).inserted { + menu.addItem(groupMenuItem(g, members: vm.members(of: g))) + } + } else { + menu.addItem(tunnelMenuItem(t, title: "\(t.name) (:\(t.listen_port))")) + } } } @@ -4526,6 +4946,37 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.statusItem?.menu = menu } + func tunnelMenuItem(_ t: TunnelItem, title: String) -> NSMenuItem { + let item = NSMenuItem(title: (t.enabled ? "✓ " : " ") + title, action: #selector(toggleTunnelAction(_:)), keyEquivalent: "") + item.representedObject = t.id + item.target = self + return item + } + + func groupMenuItem(_ g: GroupItem, members: [TunnelItem]) -> NSMenuItem { + let on = members.filter { $0.enabled }.count + let state = on > 0 && on < members.count ? ",\(on) 个开启" : "" + let item = NSMenuItem(title: (on > 0 ? "✓ " : " ") + "\(g.name)(\(members.count) 个地址\(state))", action: nil, keyEquivalent: "") + let sub = NSMenu() + let toggle = NSMenuItem(title: on > 0 ? "关闭整组" : "开启整组", action: #selector(toggleGroupAction(_:)), keyEquivalent: "") + toggle.representedObject = [g.id, on > 0 ? "off" : "on"] + toggle.target = self + sub.addItem(toggle) + sub.addItem(NSMenuItem.separator()) + for t in members { + sub.addItem(tunnelMenuItem(t, title: ":\(t.listen_port) → \(t.target)")) + } + item.submenu = sub + return item + } + + @objc func toggleGroupAction(_ sender: NSMenuItem) { + guard let v = sender.representedObject as? [String], v.count == 2 else { return } + Task { + await PortRelayViewModel.shared.toggleGroup(id: v[0], enabled: v[1] == "on") + } + } + @objc func toggleTunnelAction(_ sender: NSMenuItem) { guard let id = sender.representedObject as? String else { return } Task { diff --git a/tools/ui-snapshots/main.swift b/tools/ui-snapshots/main.swift index 8303c24..b23efb3 100644 --- a/tools/ui-snapshots/main.swift +++ b/tools/ui-snapshots/main.swift @@ -189,7 +189,10 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel { [ {"id":"h1","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.21:51000","connected_at":"\(t(-5000))","disconnected_at":"\(t(-4000))","duration_sec":1000,"bytes_in":1288490188,"bytes_out":5368709}, {"id":"h2","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.21:51001","connected_at":"\(t(-3000))","disconnected_at":"\(t(-2990))","duration_sec":10,"bytes_in":2048,"bytes_out":4096}, - {"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1} + {"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1}, + {"id":"h4","tunnel_id":"g1a","tunnel_name":"订单服务 (10.0.3.11)","port":33670,"client_ip":"192.168.1.42:50100","connected_at":"\(t(-7000))","disconnected_at":"\(t(-6400))","duration_sec":600,"bytes_in":820000,"bytes_out":5200000}, + {"id":"h5","tunnel_id":"g1b","tunnel_name":"订单服务 (10.0.3.12)","port":33671,"client_ip":"192.168.1.42:50120","connected_at":"\(t(-6800))","disconnected_at":"\(t(-6000))","duration_sec":800,"bytes_in":420000,"bytes_out":3100000}, + {"id":"h6","tunnel_id":"g1a","tunnel_name":"订单服务 (10.0.3.11)","port":33670,"client_ip":"192.168.1.66:41022","connected_at":"\(t(-90000))","disconnected_at":"\(t(-88200))","duration_sec":1800,"bytes_in":1200000,"bytes_out":9400000} ] """) vm.totalActiveConns = 8; vm.totalHistoricalConns = 1656; vm.totalRateIn = totalIn; vm.totalRateOut = totalOut @@ -241,8 +244,8 @@ MainActor.assumeIsolated { render("01b-main-min", CGSize(width: 920, height: 640), ContentView(vm: vm)) render("04-clients-history", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 1, isPresented: yes)) render("05-clients-auth", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 2, isPresented: yes)) - render("06-edit-new", CGSize(width: 490, height: 760), TunnelEditSheet(vm: vm, tunnel: nil, isPresented: yes)) - render("07-edit-existing", CGSize(width: 490, height: 760), TunnelEditSheet(vm: vm, tunnel: vm.tunnels[1], isPresented: yes)) + render("06-edit-new", CGSize(width: 520, height: 760), TunnelEditSheet(vm: vm, tunnel: nil, isPresented: yes)) + render("07-edit-existing", CGSize(width: 520, height: 760), TunnelEditSheet(vm: vm, tunnel: vm.tunnels[1], isPresented: yes)) render("08-invite-form", CGSize(width: 560, height: 400), InviteSheet(vm: vm, tunnel: t1, autoGenerate: false, isPresented: yes)) render("09-invite-result", CGSize(width: 560, height: 400), InviteSheet(vm: vm, tunnel: t1, autoGenerate: true, isPresented: yes)) render("10-settings", CGSize(width: 400, height: 320), SettingsSheet(vm: vm, isPresented: yes)) @@ -256,7 +259,7 @@ MainActor.assumeIsolated { render("readme-invite", CGSize(width: 560, height: 250), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: true, isPresented: yes)) render("readme-clients", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 0, isPresented: yes)) render("readme-access", CGSize(width: 760, height: 490), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 2, isPresented: yes)) - render("readme-edit", CGSize(width: 490, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes)) + render("readme-edit", CGSize(width: 520, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes)) let g = demo.groups[0] render("group-card", CGSize(width: 980, height: 330), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo, onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20)) @@ -264,14 +267,19 @@ MainActor.assumeIsolated { onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20)) render("group-card-narrow", CGSize(width: 720, height: 360), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo, onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20)) - render("group-add-batch", CGSize(width: 490, height: 860), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, - draftName: "订单服务", draftTarget: "10.0.3.11-13:22, 10.0.3.11-13:8080")) - render("group-add-member", CGSize(width: 490, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, groupId: g.id)) + render("group-add-batch", CGSize(width: 520, height: 900), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, + draftName: "订单服务", draftRows: [("10.0.3.11", "22, 8080"), ("10.0.3.12", "22, 8080"), ("10.0.3.13", "22")])) + render("group-add-single", CGSize(width: 520, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, + draftName: "MySQL 实习库", draftRows: [("10.0.1.16", "3306")])) + render("group-add-member", CGSize(width: 520, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, groupId: g.id)) render("group-sheet-edit", CGSize(width: 520, height: 420), GroupEditSheet(vm: demo, group: g, isPresented: yes)) render("group-invite", CGSize(width: 560, height: 300), InviteSheet(vm: demo, scope: .group(g.id), autoGenerate: true, isPresented: yes)) render("group-access", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 2, isPresented: yes)) render("group-online", CGSize(width: 760, height: 400), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 0, isPresented: yes)) - render("group-edit-member", CGSize(width: 490, height: 800), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[3], isPresented: yes)) + render("group-online-addr", CGSize(width: 760, height: 460), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 0, byAddress: true, isPresented: yes)) + render("group-history", CGSize(width: 760, height: 400), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 1, isPresented: yes)) + render("group-history-addr", CGSize(width: 760, height: 520), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 1, byAddress: true, isPresented: yes)) + render("group-edit-member", CGSize(width: 520, height: 800), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[3], isPresented: yes)) // README 动图逐帧:卡片随时间推移(每帧 = 5 秒) if only.hasPrefix("gif") {