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

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

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

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

No files matched your search

+3 -3
View File
@@ -55,7 +55,7 @@ PortRelay 把这件事变成三步:
- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。 - 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。 - **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。 - 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(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. 创建通道并邀请同事 ### 3. 创建通道并邀请同事
1. 点 **添加通道**,填写名称和目标地址(如 `10.0.1.16:3306`)。可以先“探测”一下本机能否连通;端口会自动推荐。 1. 点 **添加通道**,填写名称和目标主机、端口(如 `10.0.1.16`、`3306`)。可以先测试一下本机能否连通;网关上的访问端口自动分配。
2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。 2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。
3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。 3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,目标填 `10.0.1.11-13:22`,表单会预览端口映射并自动选上「新建通道组」,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后点 **邀请整组**。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。 需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,每行填一台主机(端口用逗号隔开,可以直接粘贴一串 IP),表单会预览端口映射并自动放进新建的通道组,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后点 **邀请整组**。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。组的「连接与授权」可以按设备或按地址查看在线连接和接入记录。
## 配置参考 ## 配置参考
+3 -3
View File
@@ -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. - 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. - **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. - 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** **Tunnel lifecycle**
@@ -217,11 +217,11 @@ Open ⚙️ Settings and fill in:
### 3. Create a tunnel and invite someone ### 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. 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`. 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 ## Configuration
+241 -89
View File
@@ -329,6 +329,16 @@
.preview-row { font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; font-size: 12px; } .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 { 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); } .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 { background: none; border: 0; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 8px 0; }
.add-member:hover { color: var(--text); } .add-member:hover { color: var(--text); }
@media (max-width: 860px) { .member-name, .member-meta { display: none; } } @media (max-width: 860px) { .member-name, .member-meta { display: none; } }
@@ -442,7 +452,7 @@
<input type="hidden" id="tunnelId"> <input type="hidden" id="tunnelId">
<div class="input-row"> <div class="input-row">
<div class="form-group" style="flex: 1;"> <div class="form-group" style="flex: 1;">
<label>通道名称 / 备注</label> <label id="tunnelNameLabel">通道名称 / 备注</label>
<input type="text" id="tunnelName" placeholder="例如:MySQL 实习库 或 财务系统 SSH" oninput="schedulePlan()"> <input type="text" id="tunnelName" placeholder="例如:MySQL 实习库 或 财务系统 SSH" oninput="schedulePlan()">
</div> </div>
<div class="form-group" style="flex: 1;"> <div class="form-group" style="flex: 1;">
@@ -451,10 +461,12 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="tunnelTargetLabel">目标地址(中转机 Mac 可直连的内网目标,可填多个)</label> <label id="tunnelTargetLabel">目标服务(每行一台主机,多个端口用逗号隔开)</label>
<div class="input-row"> <div class="target-head" id="targetHead"><span>主机</span><span>端口</span></div>
<input type="text" id="tunnelTarget" required placeholder="例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22" oninput="schedulePlan()"> <div id="targetRows"></div>
<button type="button" class="btn-secondary" onclick="probeTarget()">探测测试</button> <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>
<div class="input-hint" id="probeResult"></div> <div class="input-hint" id="probeResult"></div>
<div class="plan-box" id="tunnelPlan" style="display:none;"></div> <div class="plan-box" id="tunnelPlan" style="display:none;"></div>
@@ -1069,16 +1081,14 @@
document.getElementById('tunnelId').value = ''; document.getElementById('tunnelId').value = '';
document.getElementById('tunnelName').value = ''; document.getElementById('tunnelName').value = '';
document.getElementById('tunnelOwner').value = ''; document.getElementById('tunnelOwner').value = '';
document.getElementById('tunnelTarget').value = ''; document.getElementById('tunnelTargetLabel').innerText = '目标服务(每行一台主机,多个端口用逗号隔开)';
document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标,可填多个)'; fillTunnelGroupSelect(groupId || '');
document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22';
setAccessForm([]); setAccessForm([]);
setLifecycleForm(30, 0, 0); setLifecycleForm(30, 0, 0);
fillTunnelGroupSelect(groupId || '');
document.getElementById('probeResult').innerText = '';
resetPlan(); resetPlan();
autoRecommendPort(); setTargetRows([{ host: '', ports: '' }]);
document.getElementById('tunnelModal').classList.add('open'); document.getElementById('tunnelModal').classList.add('open');
document.querySelector('#targetRows .t-host').focus();
} }
function editTunnel(id) { function editTunnel(id) {
@@ -1088,15 +1098,14 @@
document.getElementById('tunnelId').value = t.id; document.getElementById('tunnelId').value = t.id;
document.getElementById('tunnelName').value = t.name; document.getElementById('tunnelName').value = t.name;
document.getElementById('tunnelOwner').value = t.owner || ''; document.getElementById('tunnelOwner').value = t.owner || '';
document.getElementById('tunnelTarget').value = t.target; document.getElementById('tunnelTargetLabel').innerText = '目标服务(内网主机与端口)';
document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标)'; const hp = splitHostPort(t.target) || [t.target, ''];
document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306';
document.getElementById('tunnelPort').value = t.listen_port; document.getElementById('tunnelPort').value = t.listen_port;
setAccessForm(t.whitelist || []); setAccessForm(t.whitelist || []);
setLifecycleForm(t.effective_idle_close_minutes ?? 30, t.duration_minutes || 0, t.max_bandwidth_mb || 0); setLifecycleForm(t.effective_idle_close_minutes ?? 30, t.duration_minutes || 0, t.max_bandwidth_mb || 0);
fillTunnelGroupSelect(t.group_id || ''); fillTunnelGroupSelect(t.group_id || '');
document.getElementById('probeResult').innerText = '';
resetPlan(); resetPlan();
setTargetRows([{ host: hp[0], ports: hp[1] }]);
document.getElementById('tunnelModal').classList.add('open'); 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) { async function probeOne(target) {
const res = await fetch('/api/probe', { const res = await fetch('/api/probe', {
method: 'POST', method: 'POST',
@@ -1123,71 +1222,58 @@
return res.json(); return res.json();
} }
let probeResults = {};
async function probeTarget() { async function probeTarget() {
const target = document.getElementById('tunnelTarget').value.trim(); const target = composeTarget(readTargetRows());
if (!target) { if (!target) { alert('请先填写目标主机和端口'); return; }
alert('请先输入目标地址'); const targets = isEditing() ? [target] : tunnelPlan.map(t => t.target);
return; if (!targets.length) return;
}
const targets = isBatch() ? tunnelPlan.map(t => t.target) : [target];
const hint = document.getElementById('probeResult'); const hint = document.getElementById('probeResult');
hint.innerText = '探测中...'; hint.innerText = '探测中...';
hint.style.color = 'var(--text-muted)'; hint.style.color = 'var(--text-muted)';
try { const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' }))));
const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' })))); probeResults = {};
const failed = results.map((r, i) => [targets[i], r]).filter(([, r]) => !r.reachable); targets.forEach((t, i) => { probeResults[t] = results[i]; });
if (targets.length === 1) { if (isEditing()) {
const data = results[0]; const r = results[0];
hint.innerText = data.reachable ? `✓ 连通性测试通过!延迟: ${data.rtt}` : `✗ 连通失败: ${data.error || '连接超时'}`; hint.innerText = r.reachable ? `✓ 连通性测试通过!延迟: ${r.rtt}` : `✗ 连通失败: ${r.error || '连接超时'}`;
} else if (!failed.length) { hint.style.color = r.reachable ? 'var(--success)' : 'var(--danger)';
hint.innerText = `✓ 全部 ${targets.length} 个目标连通`; return;
} 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)';
} }
hint.innerText = '';
renderPlan();
} }
// ---- 多个目标:预览将创建的通道,并放进通道组 ---- // ---- 预览将创建的通道(含自动分配的访问端口);多个地址自动放进通道组 ----
let tunnelPlan = [], tunnelPlanError = null, planTimer = null; let tunnelPlan = [], tunnelPlanError = null, planTimer = null, plannedTarget = '';
function resetPlan() { function resetPlan() {
tunnelPlan = []; tunnelPlan = [];
tunnelPlanError = null; tunnelPlanError = null;
clearTimeout(planTimer); probeResults = {};
document.getElementById('probeResult').innerText = '';
renderPlan(); 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() { function isBatch() {
return mayBeMulti() && tunnelPlan.length > 1; return !isEditing() && tunnelPlan.length > 1;
} }
// 通道组选项:'' 不分组,'__new__' 新建,其余为已有组 ID // 通道组选项:'' 不分组,'__new__' 新建,其余为已有组 ID
function groupFields() { function groupFields() {
const sel = document.getElementById('tunnelGroup').value; const sel = document.getElementById('tunnelGroup').value;
if (sel === '__new__') { if (sel !== '__new__') return { group_id: sel, new_group: '' };
const name = document.getElementById('tunnelNewGroup').value.trim() || document.getElementById('tunnelName').value.trim(); const name = document.getElementById('tunnelName').value.trim();
return { group_id: '', new_group: name }; const custom = isEditing() ? document.getElementById('tunnelNewGroup').value.trim() : '';
} return { group_id: '', new_group: custom || name };
return { group_id: sel, new_group: '' };
} }
function tunnelPayload(port, whitelist) { function tunnelPayload(port, whitelist) {
return { return {
name: document.getElementById('tunnelName').value.trim(), name: document.getElementById('tunnelName').value.trim(),
owner: document.getElementById('tunnelOwner').value.trim(), owner: document.getElementById('tunnelOwner').value.trim(),
target: document.getElementById('tunnelTarget').value.trim(), target: composeTarget(readTargetRows()),
listen_port: port, listen_port: port,
whitelist, whitelist,
idle_close_minutes: parseInt(document.getElementById('tunnelIdleClose').value, 10), idle_close_minutes: parseInt(document.getElementById('tunnelIdleClose').value, 10),
@@ -1199,7 +1285,9 @@
function schedulePlan() { function schedulePlan() {
clearTimeout(planTimer); 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 () => { planTimer = setTimeout(async () => {
try { try {
const res = await fetch('/api/tunnels/batch', { const res = await fetch('/api/tunnels/batch', {
@@ -1222,28 +1310,42 @@
} }
function renderPlan() { function renderPlan() {
const batch = isBatch(); const batch = isBatch(), edit = isEditing();
const box = document.getElementById('tunnelPlan'); document.getElementById('tunnelPortWrap').style.display = edit ? 'block' : 'none';
document.getElementById('tunnelPortWrap').style.display = batch ? 'none' : 'block'; // 多个地址必须放进通道组;新建时只有一个地址就不需要新建组
// 多个目标必须放进同一个通道组
const sel = document.getElementById('tunnelGroup'); const sel = document.getElementById('tunnelGroup');
const none = sel.querySelector('option[value=""]'); const none = sel.querySelector('option[value=""]'), fresh = sel.querySelector('option[value="__new__"]');
if (none) none.disabled = batch; if (none) none.disabled = none.hidden = batch;
if (batch && sel.value === '') { sel.value = '__new__'; onTunnelGroupChange(true); } if (fresh) fresh.disabled = fresh.hidden = !batch && !edit;
if (batch && sel.value === '') sel.value = '__new__';
if (!batch && !edit && sel.value === '__new__') sel.value = '';
updateGroupHint(); updateGroupHint();
document.getElementById('tunnelSaveBtn').innerText = batch ? `创建 ${tunnelPlan.length} 个通道` : '保存通道'; 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.display = 'block';
box.style.color = 'var(--danger)'; box.style.color = 'var(--danger)';
box.innerText = tunnelPlanError; box.innerText = tunnelPlanError;
return; return;
} }
if (!batch) { box.style.display = 'none'; box.innerHTML = ''; return; } if (!tunnelPlan.length) { box.style.display = 'none'; box.innerHTML = ''; return; }
const shown = tunnelPlan.slice(0, 6); 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.display = 'block';
box.style.color = ''; 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('') 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} 个地址,每个地址一个通道,访问端口自动连续分配</div>`; + `<div>${tunnelPlan.length > shown.length ? `… 共 ${tunnelPlan.length} 个地址,` : ''}${caption}</div>`;
} }
function fillTunnelGroupSelect(selected) { function fillTunnelGroupSelect(selected) {
@@ -1252,28 +1354,31 @@
(currentGroups.length ? '<option disabled>──────────</option>' : '') + (currentGroups.length ? '<option disabled>──────────</option>' : '') +
currentGroups.map(g => `<option value="${escapeHtml(g.id)}" ${g.id === selected ? 'selected' : ''}>${escapeHtml(g.name)}</option>`).join(''); currentGroups.map(g => `<option value="${escapeHtml(g.id)}" ${g.id === selected ? 'selected' : ''}>${escapeHtml(g.name)}</option>`).join('');
document.getElementById('tunnelNewGroup').value = ''; document.getElementById('tunnelNewGroup').value = '';
onTunnelGroupChange(true); updateGroupHint();
} }
function updateGroupHint() { function updateGroupHint() {
const sel = document.getElementById('tunnelGroup').value; const sel = document.getElementById('tunnelGroup').value;
const isNew = sel === '__new__';
const name = document.getElementById('tunnelName').value.trim(); const name = document.getElementById('tunnelName').value.trim();
const newInput = document.getElementById('tunnelNewGroup'); const newInput = document.getElementById('tunnelNewGroup');
newInput.style.display = isNew ? 'block' : 'none'; newInput.style.display = sel === '__new__' && isEditing() ? 'block' : 'none';
newInput.placeholder = name ? `组名,默认「${name}」` : '通道组名称'; newInput.placeholder = name ? `组名,默认「${name}」` : '通道组名称';
document.getElementById('tunnelGroupHint').innerText = isBatch() let hint = '';
? '多个地址会放进同一个通道组:共用邀请和授权,同事授权一次即可访问全部地址' if (sel === '__new__' && isBatch()) {
: (sel ? '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址' : ''); hint = `${tunnelPlan.length} 个地址会放进通道组${name ? `「${name}」` : '(填写上面的名称)'}:共用邀请和授权,同事授权一次即可访问全部地址`;
// 放进通道组时名称可以不填,按组名命名 } else if (sel) {
const g = isNew ? newInput.value.trim() : (currentGroups.find(x => x.id === sel) || {}).name; hint = '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址';
document.getElementById('tunnelName').placeholder = (!document.getElementById('tunnelId').value && g) }
? `可不填,默认「${g} (主机)」` : '例如:MySQL 实习库 或 财务系统 SSH'; 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(); updateGroupHint();
if (!skipPlan) schedulePlan(); schedulePlan();
} }
// 闲置自动关闭 / 最长开启时长;旧配置中不在选项里的值临时加入下拉框 // 闲置自动关闭 / 最长开启时长;旧配置中不在选项里的值临时加入下拉框
@@ -1309,10 +1414,20 @@
const id = document.getElementById('tunnelId').value; const id = document.getElementById('tunnelId').value;
const batch = isBatch(); const batch = isBatch();
const sel = document.getElementById('tunnelGroup').value; const sel = document.getElementById('tunnelGroup').value;
const port = parseInt(document.getElementById('tunnelPort').value, 10); const joinsExisting = sel !== '' && sel !== '__new__';
if (!batch && !(port > 33000 && port <= 34000)) { alert('端口须在 33001-34000 范围内'); return; } const name = document.getElementById('tunnelName').value.trim();
if (mayBeMulti() && tunnelPlanError) { alert(tunnelPlanError); return; } const target = composeTarget(readTargetRows());
if (!document.getElementById('tunnelName').value.trim() && (id || !sel)) { alert('请填写通道名称'); return; } 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; } if (sel === '__new__' && !groupFields().new_group) { alert('请填写通道组名称'); return; }
const mode = document.getElementById('tunnelAccessMode').value; const mode = document.getElementById('tunnelAccessMode').value;
let whitelist = []; let whitelist = [];
@@ -1322,10 +1437,10 @@
if (whitelist.length === 0) { alert('请填写允许访问的 IP 或网段,或选择“仅授权链接”'); return; } if (whitelist.length === 0) { alert('请填写允许访问的 IP 或网段,或选择“仅授权链接”'); return; }
if (whitelist.includes('*')) { alert('如需放通全部,请选择“全部放通 *”'); return; } if (whitelist.includes('*')) { alert('如需放通全部,请选择“全部放通 *”'); return; }
} }
const payload = tunnelPayload(batch ? 0 : port, whitelist); const payload = tunnelPayload(port, whitelist);
try { try {
// 新建统一走 /api/tunnels/batch:单个目标、多个目标、新建或加入通道组都在这一个表单里 // 新建统一走 /api/tunnels/batch:一个或多个地址、新建或加入通道组都在这一个表单里,访问端口自动分配
const res = await fetch(id ? `/api/tunnels/${encodeURIComponent(id)}` : '/api/tunnels/batch', { const res = await fetch(id ? `/api/tunnels/${encodeURIComponent(id)}` : '/api/tunnels/batch', {
method: id ? 'PUT' : 'POST', method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -1339,7 +1454,6 @@
closeTunnelModal(); closeTunnelModal();
await fetchTunnels(); await fetchTunnels();
// 新建后直接生成邀请:新建的通道组邀请整组;加入已有组时组的授权自动覆盖新地址,不用再邀请 // 新建后直接生成邀请:新建的通道组邀请整组;加入已有组时组的授权自动覆盖新地址,不用再邀请
const joinsExisting = sel !== '' && sel !== '__new__';
if (!id && mode !== 'all' && !joinsExisting) { if (!id && mode !== 'all' && !joinsExisting) {
const key = data.group ? 'g:' + data.group.id : 't:' + data.tunnels[0].id; const key = data.group ? 'g:' + data.group.id : 't:' + data.tunnels[0].id;
setTimeout(() => openInviteModal(key, false), 800); setTimeout(() => openInviteModal(key, false), 800);
@@ -1598,6 +1712,14 @@
if (!res.ok) alert('撤销失败: ' + await apiError(res)); 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() { function renderDrawerClients() {
if (!drawerScope) return; if (!drawerScope) return;
const info = scopeInfo(drawerScope); const info = scopeInfo(drawerScope);
@@ -1616,15 +1738,45 @@
// 同一 IP 的连接合并展示 // 同一 IP 的连接合并展示
const groups = {}; const groups = {};
list.forEach(c => { list.forEach(c => {
const m = /^\[(.+)\]:\d+$/.exec(c.client_ip) || /^([^:]+):\d+$/.exec(c.client_ip); const ip = hostOfAddr(c.client_ip);
const ip = m ? m[1] : c.client_ip;
(groups[ip] = groups[ip] || []).push(c); (groups[ip] = groups[ip] || []).push(c);
}); });
const leaseNames = new Map(info.leases.map(l => [l.ip, l.name || ''])); 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 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 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 conns = groups[ip];
const first = conns.map(c => new Date(c.connected_at)).sort((x, y) => x - y)[0]; const first = conns.map(c => new Date(c.connected_at)).sort((x, y) => x - y)[0];
const ids = conns.map(c => c.conn_id); const ids = conns.map(c => c.conn_id);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 144 KiB

+588 -137
View File
@@ -268,6 +268,13 @@ struct ServerStatusResp: Codable {
var total_rate_history: RateSeriesItem? var total_rate_history: RateSeriesItem?
} }
/// 连通性测试结果
struct ProbeOutcome {
let ok: Bool
/// 延迟,或失败原因
let detail: String
}
struct ProbeResp: Codable { struct ProbeResp: Codable {
var target: String var target: String
var reachable: Bool var reachable: Bool
@@ -1044,7 +1051,7 @@ class PortRelayViewModel: ObservableObject {
/// 菜单内容有变化时才需要重建(避免每秒重建打断已展开的菜单) /// 菜单内容有变化时才需要重建(避免每秒重建打断已展开的菜单)
func menuNeedsRebuild() -> Bool { 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 groupSig: String = groups.map { "\($0.id):\($0.name)" }.joined(separator: ",")
let pendingSig: String = pendingRequests.map { $0.request.id }.joined(separator: ",") let pendingSig: String = pendingRequests.map { $0.request.id }.joined(separator: ",")
let sig = [String(isConnected), tunnelSig, groupSig, pendingSig].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) return (true, try? JSONDecoder().decode(BatchResp.self, from: data), nil)
} }
/// 同时测试多个目标的连通性,全部连通才算成功 /// 测试一个目标的连通性;detail 为延迟或失败原因
func probeTargets(_ targets: [String]) async -> (Bool, String) { func probe(_ target: String) async -> ProbeOutcome {
if targets.count == 1 { return await probeTarget(target: targets[0]) } guard let url = URL(string: "http://127.0.0.1:33080/api/probe") else { return ProbeOutcome(ok: false, detail: "无效地址") }
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, "无效地址") }
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.httpMethod = "POST" req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ["target": target] req.httpBody = try? JSONSerialization.data(withJSONObject: ["target": target])
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
do { do {
let (data, _) = try await URLSession.shared.data(for: req) let (data, _) = try await URLSession.shared.data(for: req)
if let res = try? JSONDecoder().decode(ProbeResp.self, from: data) { guard let res = try? JSONDecoder().decode(ProbeResp.self, from: data) else {
if res.reachable { return ProbeOutcome(ok: false, detail: "解析失败")
return (true, "连接成功 (\(res.rtt))")
} else {
return (false, "连接失败: \(res.error ?? "超时")")
}
} }
return (false, "解析失败") return res.reachable ? ProbeOutcome(ok: true, detail: res.rtt) : ProbeOutcome(ok: false, detail: res.error ?? "超时")
} catch { } 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 { func recommendPort() async -> Int {
if isPreview { return 33662 } if isPreview { return 33662 }
guard let url = URL(string: "http://127.0.0.1:33080/api/recommend-port") else { return 33660 } 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<String> = [] @State private var expandedIPs: Set<String> = []
@State private var expandedHistoryIPs: Set<String> = [] @State private var expandedHistoryIPs: Set<String> = []
@State private var isRevoking: Bool = false @State private var isRevoking: Bool = false
/// 通道组:在线连接与接入记录按设备(来源 IP)或按组内地址查看
@State private var byAddress: Bool = false
@State private var collapsedOnlineAddrs: Set<String> = []
@State private var expandedHistoryAddrs: Set<String> = []
init(vm: PortRelayViewModel, scope: AccessScope, initialTab: Int = 0, isPresented: Binding<Bool>) { init(vm: PortRelayViewModel, scope: AccessScope, initialTab: Int = 0, byAddress: Bool = false, isPresented: Binding<Bool>) {
_vm = ObservedObject(wrappedValue: vm) _vm = ObservedObject(wrappedValue: vm)
self.scope = scope self.scope = scope
_isPresented = isPresented _isPresented = isPresented
_selectedTab = State(initialValue: initialTab) _selectedTab = State(initialValue: initialTab)
_byAddress = State(initialValue: byAddress)
} }
init(vm: PortRelayViewModel, tunnel: TunnelItem, initialTab: Int = 0, isPresented: Binding<Bool>) { init(vm: PortRelayViewModel, tunnel: TunnelItem, initialTab: Int = 0, isPresented: Binding<Bool>) {
@@ -2837,12 +2836,45 @@ struct ClientDetailsSheet: View {
return "\(vm.publicHost):\(t.listen_port) → \(t.target)" return "\(vm.publicHost):\(t.listen_port) → \(t.target)"
} }
/// 通道组:这台设备连到了组内哪些地址 /// 通道组:设备访问了组内哪些地址(" · 访问 10.0.1.11、10.0.1.12");组内端口都相同时只写主机
func groupTargetsSummary(_ conns: [ActiveClientInfo]) -> String { func addressesSummary(_ tunnelIDs: Set<String>) -> String {
guard scope.isGroup else { return "" } guard scope.isGroup else { return "" }
let targets = Set(conns.compactMap { targetLabel($0.tunnel_id) }) let used = members.filter { tunnelIDs.contains($0.id) }
if targets.count == 1, let only = targets.first { return " · \(only)" } guard !used.isEmpty else { return "" }
return " · 连到 \(targets.count) 个地址" 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") /// 通道组里每个连接经过的目标("→ 10.0.1.11:22")
@@ -2902,6 +2934,10 @@ struct ClientDetailsSheet: View {
.pickerStyle(.segmented) .pickerStyle(.segmented)
.padding(.vertical, 4) .padding(.vertical, 4)
if scope.isGroup && selectedTab != 2 {
dimensionBar
}
Divider() Divider()
// Tab Content // Tab Content
@@ -2920,8 +2956,14 @@ struct ClientDetailsSheet: View {
} else { } else {
ScrollView { ScrollView {
VStack(spacing: 8) { VStack(spacing: 8) {
ForEach(clientGroups) { g in if scope.isGroup && byAddress {
clientGroupRow(g) ForEach(members) { t in
onlineAddressRow(t)
}
} else {
ForEach(clientGroups) { g in
clientGroupRow(g)
}
} }
} }
.padding(.bottom, 8) .padding(.bottom, 8)
@@ -2944,8 +2986,14 @@ struct ClientDetailsSheet: View {
} else { } else {
ScrollView { ScrollView {
VStack(spacing: 8) { VStack(spacing: 8) {
ForEach(historyGroups) { g in if scope.isGroup && byAddress {
historyGroupRow(g) ForEach(members) { t in
historyAddressRow(t)
}
} else {
ForEach(historyGroups) { g in
historyGroupRow(g)
}
} }
} }
.padding(.bottom, 8) .padding(.bottom, 8)
@@ -2996,8 +3044,12 @@ struct ClientDetailsSheet: View {
.lineLimit(1) .lineLimit(1)
.truncationMode(.middle) .truncationMode(.middle)
TagView("\(g.records.count) 次连接") 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)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
@@ -3059,6 +3111,182 @@ struct ClientDetailsSheet: View {
.rowCard() .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 { func isLeased(_ ip: String) -> Bool {
leases.contains { $0.ip == ip } leases.contains { $0.ip == ip }
} }
@@ -3088,7 +3316,7 @@ struct ClientDetailsSheet: View {
} }
} }
Text(verbatim: "最早接入 \(formatDateTimeShort(g.firstConnectedAt)) · ↑ \(formatBytes(g.bytesIn)) ↓ \(formatBytes(g.bytesOut))" 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)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
@@ -3480,6 +3708,68 @@ struct DiagnosticsSheet: View {
// MARK: - Edit / Create Sheet (With Owner & Duration TTL) // 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<Character> = [" ", ",", ",", "、", ";", ";", "\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)..<close]), String(rest.dropFirst()))
}
let parts = item.split(separator: ":", omittingEmptySubsequences: false)
guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { return nil }
return (String(parts[0]), String(parts[1]))
}
/// 粘贴进来的一串目标拆成多行:每项是 "主机" 或 "主机:端口",同一主机的端口并到一行
static func parse(_ text: String, defaultPorts: String) -> [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 { struct TunnelEditSheet: View {
@ObservedObject var vm: PortRelayViewModel @ObservedObject var vm: PortRelayViewModel
let tunnel: TunnelItem? let tunnel: TunnelItem?
@@ -3487,7 +3777,7 @@ struct TunnelEditSheet: View {
@State private var name: String @State private var name: String
@State private var owner: String @State private var owner: String
@State private var target: String @State private var rows: [TargetRow]
@State private var portString: String @State private var portString: String
@State private var durationMinutes: Int @State private var durationMinutes: Int
@State private var idleCloseMinutes: Int @State private var idleCloseMinutes: Int
@@ -3496,14 +3786,15 @@ struct TunnelEditSheet: View {
@State private var accessMode: AccessMode @State private var accessMode: AccessMode
@State private var groupChoice: GroupChoice @State private var groupChoice: GroupChoice
@State private var newGroupName: String = "" @State private var newGroupName: String = ""
/// 填了多个目标时将创建的通道(服务端预览) /// 将创建的通道(服务端预览,含自动分配的访问端口)
@State private var plan: [PlannedTunnel] = [] @State private var plan: [PlannedTunnel] = []
@State private var planError: String? = nil @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 isProbing: Bool = false
@State private var isSaving: Bool = false @State private var isSaving: Bool = false
@State private var errorMessage: String? = nil @State private var errorMessage: String? = nil
@FocusState private var focusedRow: UUID?
/// 新建完成后打开邀请:独立通道,或新建的通道组。加入已有组时不需要——组的授权自动覆盖新地址 /// 新建完成后打开邀请:独立通道,或新建的通道组。加入已有组时不需要——组的授权自动覆盖新地址
var onCreated: ((AccessScope) -> Void)? = nil var onCreated: ((AccessScope) -> Void)? = nil
@@ -3512,7 +3803,7 @@ struct TunnelEditSheet: View {
case linkOnly, specific, all case linkOnly, specific, all
} }
/// 通道组选项:不分组、新建、或加入已有组 /// 通道组选项。新建时多个地址自动新建组(以名称为组名);编辑时可以新建组并移入
enum GroupChoice: Hashable { enum GroupChoice: Hashable {
case none case none
case new case new
@@ -3524,7 +3815,7 @@ struct TunnelEditSheet: View {
/// 表单字段在创建时就用通道数据填好,不依赖 onAppear(避免第一帧显示空表单)。 /// 表单字段在创建时就用通道数据填好,不依赖 onAppear(避免第一帧显示空表单)。
/// groupId:新建时默认加入的通道组(通道组卡片上的「添加地址」) /// groupId:新建时默认加入的通道组(通道组卡片上的「添加地址」)
init(vm: PortRelayViewModel, tunnel: TunnelItem?, isPresented: Binding<Bool>, groupId: String? = nil, init(vm: PortRelayViewModel, tunnel: TunnelItem?, isPresented: Binding<Bool>, groupId: String? = nil,
draftName: String = "", draftTarget: String = "", onCreated: ((AccessScope) -> Void)? = nil) { draftName: String = "", draftRows: [(String, String)] = [], onCreated: ((AccessScope) -> Void)? = nil) {
_vm = ObservedObject(wrappedValue: vm) _vm = ObservedObject(wrappedValue: vm)
self.tunnel = tunnel self.tunnel = tunnel
self.onCreated = onCreated self.onCreated = onCreated
@@ -3534,7 +3825,12 @@ struct TunnelEditSheet: View {
_accessMode = State(initialValue: mode) _accessMode = State(initialValue: mode)
_name = State(initialValue: tunnel?.name ?? draftName) _name = State(initialValue: tunnel?.name ?? draftName)
_owner = State(initialValue: tunnel.map { $0.owner ?? "" } ?? NSUserName()) _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)" } ?? "") _portString = State(initialValue: tunnel.map { "\($0.listen_port)" } ?? "")
// 最长开启时长:新建默认不限制(主要依靠闲置自动关闭);duration_minutes 为 0 时 JSON 中会省略 // 最长开启时长:新建默认不限制(主要依靠闲置自动关闭);duration_minutes 为 0 时 JSON 中会省略
_durationMinutes = State(initialValue: tunnel.map { $0.duration_minutes ?? 0 } ?? 0) _durationMinutes = State(initialValue: tunnel.map { $0.duration_minutes ?? 0 } ?? 0)
@@ -3547,47 +3843,42 @@ struct TunnelEditSheet: View {
// MARK: Derived state // MARK: Derived state
/// 可能填了多个目标(逗号 / 空格分隔,或含范围 "10.0.1.11-13:22"),需要服务端展开 var composedTarget: String { TargetRow.compose(rows) }
var mayBeMulti: Bool {
guard !isEdit else { return false }
let items = target.split(whereSeparator: { " ,,、;;\n\t".contains($0) })
return items.count > 1 || target.contains("-")
}
/// 确实会创建多个通道 /// 会创建多个通道(自动放进通道组)
var isBatch: Bool { mayBeMulti && plan.count > 1 } var isBatch: Bool { !isEdit && plan.count > 1 }
/// 新建通道组的名称:没填时与通道名称相同 var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
/// 新建通道组的名称:新建时就是名称;编辑时可另填,没填时与通道名称相同
var resolvedGroupName: String { var resolvedGroupName: String {
let n = newGroupName.trimmingCharacters(in: .whitespaces) let n = newGroupName.trimmingCharacters(in: .whitespaces)
return n.isEmpty ? name.trimmingCharacters(in: .whitespaces) : n return isEdit && !n.isEmpty ? n : trimmedName
} }
/// 选中的通道组名称(不分组为 nil) var existingGroupName: String? {
var chosenGroupName: String? { if case .existing(let id) = groupChoice { return vm.group(id)?.name }
switch groupChoice { return nil
case .none: return nil }
case .new: return newGroupName.trimmingCharacters(in: .whitespaces)
case .existing(let id): return vm.group(id)?.name var nameTitle: String {
} isBatch && groupChoice == .new ? "通道组名称" : "通道名称"
} }
var namePlaceholder: String { var namePlaceholder: String {
if !isEdit, let g = chosenGroupName, !g.isEmpty { if !isEdit, let g = existingGroupName {
return "可不填,默认「\(g) (主机)」" return "可不填,默认「\(g) (主机)」"
} }
return isBatch ? "例如:订单服务" : "例如:MySQL 实习库" return isBatch ? "例如:订单服务" : "例如:MySQL 实习库"
} }
var canSave: Bool { var canSave: Bool {
if isSaving || target.trimmingCharacters(in: .whitespaces).isEmpty { return false } if isSaving { return false }
if mayBeMulti && planError != nil { return false } if isEdit {
let hasName = !name.trimmingCharacters(in: .whitespaces).isEmpty return !trimmedName.isEmpty && !composedTarget.isEmpty && (groupChoice != .new || !resolvedGroupName.isEmpty)
switch groupChoice {
case .none: return hasName
case .new: return !resolvedGroupName.isEmpty && (hasName || !isEdit)
case .existing: return hasName || !isEdit
} }
if plan.isEmpty || planError != nil { return false }
return !trimmedName.isEmpty || existingGroupName != nil
} }
func makeForm(port: Int, whitelist: [String]) -> TunnelForm { func makeForm(port: Int, whitelist: [String]) -> TunnelForm {
@@ -3598,8 +3889,8 @@ struct TunnelEditSheet: View {
case .existing(let id): groupId = id case .existing(let id): groupId = id
} }
return TunnelForm( return TunnelForm(
name: name.trimmingCharacters(in: .whitespaces), owner: owner.trimmingCharacters(in: .whitespaces), name: trimmedName, owner: owner.trimmingCharacters(in: .whitespaces),
target: target.trimmingCharacters(in: .whitespacesAndNewlines), port: port, whitelist: whitelist, target: composedTarget, port: port, whitelist: whitelist,
durationMinutes: durationMinutes, idleCloseMinutes: idleCloseMinutes, maxBandwidthMB: maxBandwidthMB, durationMinutes: durationMinutes, idleCloseMinutes: idleCloseMinutes, maxBandwidthMB: maxBandwidthMB,
groupId: groupId, newGroup: newGroup) groupId: groupId, newGroup: newGroup)
} }
@@ -3612,7 +3903,7 @@ struct TunnelEditSheet: View {
.font(.system(size: 15, weight: .bold)) .font(.system(size: 15, weight: .bold))
HStack(spacing: 12) { HStack(spacing: 12) {
field("通道名称") { field(nameTitle) {
TextField(namePlaceholder, text: $name) TextField(namePlaceholder, text: $name)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
@@ -3624,7 +3915,7 @@ struct TunnelEditSheet: View {
targetSection targetSection
if !isBatch { if isEdit {
field("对外监听端口 (范围 33001-34000)") { field("对外监听端口 (范围 33001-34000)") {
HStack { HStack {
TextField("33660", text: $portString) TextField("33660", text: $portString)
@@ -3660,54 +3951,147 @@ struct TunnelEditSheet: View {
.padding(.top, 4) .padding(.top, 4)
} }
.padding(20) .padding(20)
.frame(width: 490) .frame(width: 520)
.onAppear { .task(id: "\(composedTarget)|\(trimmedName)|\(groupChoice)") { await refreshPlan() }
if tunnel == nil { .onChange(of: composedTarget) { _ in probes = [:] }
Task {
let p = await vm.recommendPort()
if portString.isEmpty { portString = "\(p)" }
}
}
}
.task(id: "\(target)|\(name)|\(newGroupName)|\(groupChoice)") { await refreshPlan() }
.onChange(of: isBatch) { batch in .onChange(of: isBatch) { batch in
// 多个目标必须放进同一个通道组 // 多个地址放进同一个通道组;只剩一个地址时回到不分组
if batch && groupChoice == .none { groupChoice = .new } if batch && groupChoice == .none { groupChoice = .new }
if !batch && !isEdit && groupChoice == .new { groupChoice = .none }
} }
} }
// MARK: Sections // MARK: Target list
var targetSection: some View { var targetSection: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 6) {
Text(isEdit ? "目标服务 (内网主机 IP:端口)" : "目标服务 (内网主机 IP:端口,可填多个)") Text(isEdit ? "目标服务 (内网主机与端口)" : "目标服务(每行一台主机,多个端口用逗号隔开)")
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundColor(.secondary) .foregroundColor(.secondary)
HStack { if rows.count > 1 || !isEdit {
TextField(isEdit ? "例如 10.0.0.10:3306" : "例如 10.0.0.10:3306;多台主机写 10.0.1.11-13:22", text: $target) HStack(spacing: 8) {
.textFieldStyle(.roundedBorder) Text("主机").frame(maxWidth: .infinity, alignment: .leading)
Button(isProbing ? "测试中" : "连通性测试") { probe() } Text("端口").frame(width: 150, alignment: .leading)
.disabled(target.trimmingCharacters(in: .whitespaces).isEmpty || isProbing || (mayBeMulti && planError != nil)) Color.clear.frame(width: 18, height: 1)
.controlSize(.small) }
.font(.system(size: 11))
.foregroundColor(.secondary)
} }
if !probeMessage.isEmpty { if isEdit, let row = rows.first {
Text(verbatim: probeMessage) HStack(spacing: 8) {
.font(.system(size: 11)) targetRow(row)
.foregroundColor(probeOK ? .green : .red) 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) Text(verbatim: err)
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.red) .foregroundColor(.red)
} else if isBatch { } else if !plan.isEmpty {
planList 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<String> {
Binding(
get: { rows.first(where: { $0.id == id })?.host ?? "" },
set: { setHost(id, $0) }
)
}
func portsBinding(_ id: UUID) -> Binding<String> {
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 { 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) { return VStack(alignment: .leading, spacing: 3) {
ForEach(Array(shown)) { m in ForEach(Array(shown)) { m in
HStack(spacing: 6) { HStack(spacing: 6) {
@@ -3720,17 +4104,39 @@ struct TunnelEditSheet: View {
Text(verbatim: m.target) Text(verbatim: m.target)
.font(.system(size: 11, design: .monospaced)) .font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary) .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)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(failed > 0 ? .red : .secondary)
} }
.padding(8) .padding(8)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(0.04))) .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 { var groupSection: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("通道组") Text("通道组")
@@ -3741,7 +4147,9 @@ struct TunnelEditSheet: View {
if !isBatch { if !isBatch {
Text("不分组").tag(GroupChoice.none) Text("不分组").tag(GroupChoice.none)
} }
Text("新建通道组").tag(GroupChoice.new) if isBatch || isEdit {
Text("新建通道组").tag(GroupChoice.new)
}
if !vm.groups.isEmpty { if !vm.groups.isEmpty {
Divider() Divider()
ForEach(vm.groups) { g in ForEach(vm.groups) { g in
@@ -3751,23 +4159,31 @@ struct TunnelEditSheet: View {
} }
.labelsHidden() .labelsHidden()
.fixedSize() .fixedSize()
if groupChoice == .new { if isEdit && groupChoice == .new {
TextField(name.trimmingCharacters(in: .whitespaces).isEmpty ? "通道组名称" : "组名,默认「\(name.trimmingCharacters(in: .whitespaces))」", text: $newGroupName) TextField(trimmedName.isEmpty ? "通道组名称" : "组名,默认「\(trimmedName)」", text: $newGroupName)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
} }
if isBatch { if let hint = groupHint {
Text("多个地址会放进同一个通道组:共用邀请和授权,同事授权一次即可访问全部地址") Text(verbatim: hint)
.font(.system(size: 11))
.foregroundColor(.secondary)
} else if groupChoice != .none {
Text("组内通道共用邀请和授权,同事授权一次即可访问组内全部地址")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .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 { var closeSection: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -3853,7 +4269,7 @@ struct TunnelEditSheet: View {
// MARK: Actions // MARK: Actions
func refreshPlan() async { func refreshPlan() async {
guard mayBeMulti else { guard !isEdit, !composedTarget.isEmpty else {
plan = [] plan = []
planError = nil planError = nil
return return
@@ -3868,12 +4284,10 @@ struct TunnelEditSheet: View {
} }
func probe() { func probe() {
let targets = isBatch ? plan.map { $0.target } : [target.trimmingCharacters(in: .whitespaces)] let targets = isEdit ? [composedTarget] : plan.map { $0.target }
isProbing = true isProbing = true
Task { Task {
let (ok, msg) = await vm.probeTargets(targets) probes = await vm.probeAll(targets)
probeOK = ok
probeMessage = msg
isProbing = false isProbing = false
} }
} }
@@ -3901,17 +4315,20 @@ struct TunnelEditSheet: View {
func save() { func save() {
var port = 0 var port = 0
if !isBatch { if isEdit {
guard let p = Int(portString), p > 33000 && p <= 34000 else { guard let p = Int(portString), p > 33000 && p <= 34000 else {
errorMessage = "端口须在 33001-34000 范围内" errorMessage = "端口须在 33001-34000 范围内"
return return
} }
guard !composedTarget.contains(",") else {
errorMessage = "一个通道只对应一个目标端口;要开放更多端口,请用「添加通道」"
return
}
port = p port = p
} }
guard let wl = whitelist() else { return } guard let wl = whitelist() else { return }
let form = makeForm(port: port, whitelist: wl) let form = makeForm(port: port, whitelist: wl)
var joinsExisting = false let joinsExisting = existingGroupName != nil
if case .existing = groupChoice { joinsExisting = true }
isSaving = true isSaving = true
errorMessage = nil errorMessage = nil
Task { Task {
@@ -4496,13 +4913,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
tunnelSection.isEnabled = false tunnelSection.isEnabled = false
menu.addItem(tunnelSection) menu.addItem(tunnelSection)
// 通道组占一行(出现在它第一个成员的位置),子菜单里整组开关、逐个地址开关
var seenGroups = Set<String>()
for t in vm.tunnels { for t in vm.tunnels {
let check = t.enabled ? "✓ " : " " if let g = vm.group(t.group_id) {
let title = "\(check)\(t.name) (:\(t.listen_port))" if seenGroups.insert(g.id).inserted {
let item = NSMenuItem(title: title, action: #selector(toggleTunnelAction(_:)), keyEquivalent: "") menu.addItem(groupMenuItem(g, members: vm.members(of: g)))
item.representedObject = t.id }
item.target = self } else {
menu.addItem(item) menu.addItem(tunnelMenuItem(t, title: "\(t.name) (:\(t.listen_port))"))
}
} }
} }
@@ -4526,6 +4946,37 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
self.statusItem?.menu = menu 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) { @objc func toggleTunnelAction(_ sender: NSMenuItem) {
guard let id = sender.representedObject as? String else { return } guard let id = sender.representedObject as? String else { return }
Task { Task {
+16 -8
View File
@@ -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":"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":"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 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("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("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("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("06-edit-new", CGSize(width: 520, 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("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("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("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)) 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-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-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-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] 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, 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)) 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)) 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, 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)) 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, render("group-add-batch", CGSize(width: 520, height: 900), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes,
draftName: "订单服务", draftTarget: "10.0.3.11-13:22, 10.0.3.11-13:8080")) draftName: "订单服务", draftRows: [("10.0.3.11", "22, 8080"), ("10.0.3.12", "22, 8080"), ("10.0.3.13", "22")]))
render("group-add-member", CGSize(width: 490, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, groupId: g.id)) 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-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-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-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-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 秒) // README 动图逐帧:卡片随时间推移(每帧 = 5 秒)
if only.hasPrefix("gif") { if only.hasPrefix("gif") {