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