fix(admin): 局域网大屏卡片按「地址类别」判定 + 三层兜底 + 手填入口(dev_0.5.0)
## 判断失误的修正(主人现场实测出来的) 主人现场是 **169.254 自动配置网段**(没有 DHCP,设备靠链路本地地址互访), 平板实测能访问 `http://169.254.79.190:19540/display`;而卡片一开始把链路本地地址 **排除**了,于是显示「未检测到局域网地址」。 根因:`usableLanIPv4` 排除了 `IsLinkLocalUnicast()`,理由写的是「169.254 说明没拿到 IP」 ——那是**对网络健不健康的猜测**,不是**地址能不能用的事实**。 现改为按**地址类别**判定(`ipv4Scope`,全部用标准库谓词,**代码里没有任何网段字面量**): 只排除「作为对端访问目标没有意义」的四类(回环 / 未指定 / 组播 / 广播), 链路本地与私网、全局一样进候选,只**排在最后**并带说明 「自动配置地址:本网段没有 DHCP,同网段设备可以互相访问」。 新增**反硬编码护栏用例**:函数体内不得出现 `x.x.x.x` 字面量,且必须用满 6 个谓词。 顺带修掉新用例抓出的真 bug:`ipv4Scope(nil)` 曾落进 default 被当成「可用的 global」, 而 IPv6 的 `To4()` 正是 nil → 「只有 IPv6 的网卡」会被误判成有可用地址。 ## 三层兜底(离线现场必须拿得到地址) 1. **严格轮**:接口 up 且非回环; 2. **宽松轮**:严格轮为空时不再要求 up 位(某些驱动/虚拟网卡读不到),命中标 `confidence: low`; 3. **默认路由探测**:两轮皆空时用 `net.Dial("udp4", …)` 问出本机源地址 (UDP connect 只查路由表、不握手不发包,离线安全;TCP 会等 SYN 超时,不能用),标 `confidence: route`。 接口同时回 `scope`(private/global/link-local)与 `skipped`(被跳过的网卡 + 原因, code 收敛为 no-address / only-ipv6 / not-up / loopback——link-local 不再是「被跳过」的理由)。 ## 手填入口(最后一道保险) 自动检测不到、或不是现场要用的网段时,操作员把 `ipconfig` 里的地址填进卡片, 立刻生成链接 + 二维码;排在第一条、带「手动」徽标、可删除、localStorage 持久化、 四条就地校验文案(空/含空格/不像 IP/重复),IPv6 也支持。 ## 自检工具 `debug/诊断/局域网地址自检.exe` + 说明:只读、不写文件、不需管理员, 打印系统给 Go 看的网卡列表(含类别)、默认路由探测结果与可用大屏地址。 (正是它把现场定性成 169.254 网段的。) ## 真浏览器验证(四场景,非源码断言) 链接本地地址出链接+二维码+说明句 / 空态列出被跳过网卡并折叠(5 条 + 等共 N 块)/ low 与 route 两种来源提示各占一行互不顶掉 / 手填地址排第一并刷新不丢 —— 全部通过,0 页面错误。 ## 文档 使用手册 §9、部署指南 7.5/7.9、上线检查清单(**防火墙放行入站 19540** 与 「另一台设备能打开局域网大屏」两条新增)、常见问题 Q101、升级说明 dev_0.5.0; devdocs 05/07/13 同步(含这次误判的来龙去脉与「Get-NetAdapter 显示 Up ≠ 有 IP」)。 **没有数据迁移**,旧赛事包照常导入。 自检:服务端 13 包 ×2;前端 vue-tsc + 680 例 ×2 + build;Console 20 文件 + 68 例。
This commit is contained in:
1 parent
9ef68c2589
commit
c97d3150c9
18 files changed
+2425
-79
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scoreboard-console",
|
"name": "scoreboard-console",
|
||||||
"productName": "Scoreboard Console",
|
"productName": "Scoreboard Console",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "AskaEth",
|
"author": "AskaEth",
|
||||||
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
|
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
|
|
||||||
- [ ] Windows 10/11 x64,接市电(建议接 UPS)
|
- [ ] Windows 10/11 x64,接市电(建议接 UPS)
|
||||||
- [ ] 端口 **19540** 未被占用:`netstat -ano | findstr 19540`
|
- [ ] 端口 **19540** 未被占用:`netstat -ano | findstr 19540`
|
||||||
|
- [ ] **防火墙已放行入站 19540**(局域网设备才能连上):在**管理员** cmd 里执行
|
||||||
|
`netsh advfirewall firewall add rule name="Scoreboard 19540" dir=in action=allow protocol=TCP localport=19540`
|
||||||
|
—— 安装包是**按用户安装、不申请管理员提权**,改不了防火墙,这一步必须手动做一次;临时「关闭防火墙」只用于**验证**是不是它挡的,验证完要恢复;正式开赛前请用上面这条命令放行,不要一直关着防火墙(改过端口就把 `19540` 换成实际端口)
|
||||||
- [ ] 数据/配置目录可写:默认 `%LOCALAPPDATA%\ScoreboardConsole\`(数据在 `data\`,配置与规则是 `console\config.json`、`config.yaml`、`rules\`;都在安装目录之外,升级/卸载都不会删),需要放行杀毒/防火墙
|
- [ ] 数据/配置目录可写:默认 `%LOCALAPPDATA%\ScoreboardConsole\`(数据在 `data\`,配置与规则是 `console\config.json`、`config.yaml`、`rules\`;都在安装目录之外,升级/卸载都不会删),需要放行杀毒/防火墙
|
||||||
- [ ] 大屏通过 HDMI 接在**同一台机器**上(系统显示模式设为「扩展」)
|
- [ ] 大屏通过 HDMI 接在**同一台机器**上(系统显示模式设为「扩展」)
|
||||||
- [ ] 显示器数量确认:只接一块屏时大屏只能窗口化(按 F11 可切全屏);要每屏一个 kiosk 全屏窗口就得接第二块屏(见部署指南 7.3、7.5)
|
- [ ] 显示器数量确认:只接一块屏时大屏只能窗口化(按 F11 可切全屏);要每屏一个 kiosk 全屏窗口就得接第二块屏(见部署指南 7.3、7.5)
|
||||||
@@ -57,6 +60,7 @@
|
|||||||
- [ ] **调试标识已隐藏**:多屏 kiosk 全屏画面上不该出现「实例 … · 屏 …」;窗口化窗口(单屏)里显示这一行属正常,按 F11 全屏后它与自绘标题栏一起消失(版本号角标已移除,任何画面都不该出现)
|
- [ ] **调试标识已隐藏**:多屏 kiosk 全屏画面上不该出现「实例 … · 屏 …」;窗口化窗口(单屏)里显示这一行属正常,按 F11 全屏后它与自绘标题栏一起消失(版本号角标已移除,任何画面都不该出现)
|
||||||
- [ ] **显示器菜单可用**:在服务管理窗口里点「打开服务端管理面板」,进「大屏控制 → 显示器」,能看到每块屏;副屏点「创建全屏窗口」能重新开出来(单屏 / 主显示器只能窗口化,属正常)
|
- [ ] **显示器菜单可用**:在服务管理窗口里点「打开服务端管理面板」,进「大屏控制 → 显示器」,能看到每块屏;副屏点「创建全屏窗口」能重新开出来(单屏 / 主显示器只能窗口化,属正常)
|
||||||
- [ ] **在线大屏与逐屏切页**:同页右侧「在线大屏」列表能看到本机每块屏的在线实例(局域网设备也已在这一列表里,标记为「局域网浏览器」),每行显示当前所在页;用某一行自己的下拉 +「切到此页」能把那一块屏 / 那台设备单独切走,其余屏不受影响(离线实例的控件为灰色,属正常)
|
- [ ] **在线大屏与逐屏切页**:同页右侧「在线大屏」列表能看到本机每块屏的在线实例(局域网设备也已在这一列表里,标记为「局域网浏览器」),每行显示当前所在页;用某一行自己的下拉 +「切到此页」能把那一块屏 / 那台设备单独切走,其余屏不受影响(离线实例的控件为灰色,属正常)
|
||||||
|
- [ ] **局域网大屏能在别的设备打开**:用另一台设备(平板 / 手机)浏览器打开 `http://<主机IP>:19540/display` 能出画面,或直接扫「大屏控制 → 局域网大屏」卡片第一个地址的**二维码**;打不开时先看卡片列出的地址与提示,再按常见问题「大屏控制页显示未检测到局域网地址怎么办?」逐条排查(若卡片地址是 `169.254.x.x`,说明本网段没有 DHCP,同网段的设备照样能连)
|
||||||
- [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」
|
- [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」
|
||||||
- [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏)
|
- [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏)
|
||||||
- [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布 → 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布)
|
- [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布 → 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布)
|
||||||
|
|||||||
+9
-1
@@ -430,7 +430,15 @@
|
|||||||
|
|
||||||
- 卡片右上角徽标显示「N 个可用地址」;每个地址一行,**点链接**直接在新标签页打开,点旁边的**复制**按钮把完整地址(形如 `http://192.168.1.5:19540/display`)复制到剪贴板,按钮会短暂变成「已复制」;
|
- 卡片右上角徽标显示「N 个可用地址」;每个地址一行,**点链接**直接在新标签页打开,点旁边的**复制**按钮把完整地址(形如 `http://192.168.1.5:19540/display`)复制到剪贴板,按钮会短暂变成「已复制」;
|
||||||
- **第一个地址带一个二维码**(下方注明「手机扫码直接打开」),手机相机一扫即可打开,省去手敲长地址;
|
- **第一个地址带一个二维码**(下方注明「手机扫码直接打开」),手机相机一扫即可打开,省去手敲长地址;
|
||||||
- **空态**:显示「未检测到局域网地址(可能是没插网线 / 只有回环)。」——意思是主机当前没有别的设备能用上的地址(网线没插、网卡没起来,或只剩本机回环地址),先在 Windows 网络设置里确认主机已连上局域网;如果显示的是「服务端没有下发大屏端口,暂时拼不出能在别的设备打开的地址。」,那是服务端异常,重启 Console / 服务端后再看;
|
- **地址分三类**,卡片会照实把可用地址都列出来(排除的只有回环、未指定、组播、广播这四类对本机之外没有意义的地址):
|
||||||
|
- **内网地址**(`192.168.x.x`、`10.x.x.x`、`172.16–31.x.x` 这类):路由器 / 交换机分配或手工设好的地址,也是最常见的现场用法,**排在最前**;
|
||||||
|
- **全局地址**:不在上面这些网段里的地址(运营商专线等),能用就照常列出来,只是**排在私网地址之后**;
|
||||||
|
- **自动配置地址**(`169.254` 开头):说明**本网段没有 DHCP**、地址是设备自己配的——在**独立小网络**(没有路由器、设备直连或只接一台交换机)里这是**正常用法**,同网段设备可以互相访问。这类地址**排在最后**,旁边有一句「自动配置地址:本网段没有 DHCP,同网段设备可以互相访问」;
|
||||||
|
- **两种「不那么确定」的来源会各带一句提示,照常能用**(链接与二维码照给,只是说清这条是怎么来的,打不开时换下一条):
|
||||||
|
- 网卡状态读不到(驱动或虚拟网卡)时:「网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条」;
|
||||||
|
- 按系统默认路由推断出来时:「这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条」;
|
||||||
|
- **手动添加地址**:自动检测不到、或者列出来的不是你要的那个网段时,用卡片下方的「手动添加地址」(说明「自动检测不到时,把 ipconfig 里的 IPv4 地址填进来」)把 `ipconfig` 里那个地址填进去、点「添加」——**立刻出链接和二维码**。手填的**排在第一条**、带「手动」徽标,右边多一个「删除」可以撤掉;它记在浏览器本地(localStorage),**刷新不丢**,最多记 10 个。填错不会没反应,而是就地红字说明:没填「请先填一个 IP 地址」、带空格「地址里不能有空格,请重新复制一次 ipconfig 里的地址」、不像地址「这不像一个 IP 地址,请填 ipconfig 里的 IPv4 地址」、重复「这个地址已经在列表里了」;
|
||||||
|
- **空态**:会**列出检查过哪些网卡、各自为什么被跳过**,例如「没找到可用的局域网地址。已检查:以太网(接口未连接)、WLAN(只有 IPv6 地址)。」——原因有「**接口未连接**」「**只有 IPv6 地址**」「**接口没有地址**」「**回环接口**」四种,最多列 5 条,网卡更多时其余折叠成「**等共 N 块网卡**」。老版本服务端不返回这份清单时,显示的仍是「未检测到局域网地址(可能是没插网线 / 只有回环)。」;如果显示的是「服务端没有下发大屏端口,暂时拼不出能在别的设备打开的地址。」,那是服务端异常,重启 Console / 服务端后再看;
|
||||||
- 卡片底部注明「地址由服务端下发的端口与路径拼成;平板上打开后不必登录,大屏本身是只读的」——**平板 / 电视那边不用登录**,只有看这张卡片(也就是本管理面板)才需要身份;
|
- 卡片底部注明「地址由服务端下发的端口与路径拼成;平板上打开后不必登录,大屏本身是只读的」——**平板 / 电视那边不用登录**,只有看这张卡片(也就是本管理面板)才需要身份;
|
||||||
- **权限**:这张卡片需要**录入员(recorder)及以上**登录才看得到,只读访客(guest)看到的是「只有登录后的录入员 / 管理员能查看局域网地址,当前为只读访客。」;账号权限不足时提示「当前账号没有查看权限,请先登录(该接口需要录入员及以上)。」;
|
- **权限**:这张卡片需要**录入员(recorder)及以上**登录才看得到,只读访客(guest)看到的是「只有登录后的录入员 / 管理员能查看局域网地址,当前为只读访客。」;账号权限不足时提示「当前账号没有查看权限,请先登录(该接口需要录入员及以上)。」;
|
||||||
- 复制失败(浏览器不允许自动复制)时会提示「复制失败 / 浏览器不允许自动复制,请手动选中地址复制」,手动选中地址复制即可。
|
- 复制失败(浏览器不允许自动复制)时会提示「复制失败 / 浏览器不允许自动复制,请手动选中地址复制」,手动选中地址复制即可。
|
||||||
|
|||||||
+53
-1
@@ -6,7 +6,59 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## dev_0.4.0(当前版本)
|
## dev_0.5.0(当前版本)
|
||||||
|
|
||||||
|
### 一句话
|
||||||
|
|
||||||
|
**没有数据迁移,旧赛事项目包照常导入**:本版改的是「大屏控制 → 局域网大屏」卡片的取证逻辑——地址改为**按地址类别**判定(`169.254` 自动配置地址不再被排除),并新增**宽松兜底 / 默认路由推断 / 手动添加地址**三层,空态会说明被跳过的网卡与原因。以前卡片会把「自动配置地址」当成没有可用地址,现已修正。
|
||||||
|
|
||||||
|
### ① 局域网大屏卡片改为按地址类别判定
|
||||||
|
|
||||||
|
- **以前**:只取「接口 Up 且非回环」的网卡,并把**链路本地地址(`169.254.x.x`)也排除**,理由写的是「DHCP 没拿到地址才自配的,说明网络不健康」——那是对网络健不健康的**猜测**,不是地址能不能用的**事实**。
|
||||||
|
- **现场形态**:有机器就在 **`169.254` 自动配置网段**(本网段没有 DHCP,设备靠链路本地地址互访),平板实测能访问 `http://169.254.79.190:19540/display`,网络与防火墙全通;而卡片却因为那条猜测显示「未检测到局域网地址」——**那是误判**。
|
||||||
|
- **现在**:只排除四类「作为对端访问目标没有意义」的地址——**回环、未指定(`0.0.0.0`)、组播、广播**;**链路本地(自动配置)地址与私网、全局地址一样进候选**,只是**排在最后**并带一句说明「自动配置地址:本网段没有 DHCP,同网段设备可以互相访问」。判定全部走标准库谓词,**不抄一份网段字面量到代码里**(抄错或漏一段就会变成「明明有地址却说没有」,正是这次要修的毛病)。
|
||||||
|
|
||||||
|
### ② 新增三层兜底(宽松兜底 / 默认路由推断 / 手动添加地址)
|
||||||
|
|
||||||
|
| 层次 | 触发时机 | 卡片表现 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 严格轮 | 正常机器(网卡 up 位读得到) | 地址照常列出,不加提示 |
|
||||||
|
| **宽松兜底** | 严格轮一无所获时(Windows 上部分驱动 / 虚拟网卡**读不到 up 位**) | 不再要求 up 位,把地址捡回来并提示「网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条」 |
|
||||||
|
| **默认路由推断** | 前两轮都为空时 | 问操作系统「去局域网走哪块网卡」(UDP Dial:只查路由表、**不发一个包**),地址提示「这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条」 |
|
||||||
|
| **手动添加地址** | 以上都没给出你要的网段时 | 卡片下方「手动添加地址」填入 `ipconfig` 里的 IPv4 地址,点「添加」立刻出链接与二维码;手填的**排第一条**、带「手动」徽标、可「删除」,记在浏览器本地**刷新不丢**(最多 10 个) |
|
||||||
|
|
||||||
|
严格轮有结果就绝不跑宽松轮,前两轮任意一轮有结果就绝不跑路由探测——只有真的一个地址都拿不到时才会多花那几次本地 UDP connect,正常机器上一次都不会发生。
|
||||||
|
|
||||||
|
### ③ 空态会说明被跳过的网卡与原因
|
||||||
|
|
||||||
|
- 空态从一句「未检测到局域网地址(可能是没插网线 / 只有回环)。」改成逐条列出**检查过、但没贡献地址**的网卡与原因,例如「没找到可用的局域网地址。已检查:以太网(接口未连接)、WLAN(只有 IPv6 地址)。」;四种原因是「接口未连接」「只有 IPv6 地址」「接口没有地址」「回环接口」,**最多列 5 条**,网卡更多时其余折叠成「**等共 N 块网卡**」;
|
||||||
|
- 老服务端不返回这份清单时,显示的仍是原来那句,行为与升级前完全一致。
|
||||||
|
|
||||||
|
### ④ 没有数据迁移
|
||||||
|
|
||||||
|
- 本版**没有任何数据库迁移**,也没有设置项迁移;旧赛事项目包照常导入、切换、导出,已有榜单数字与升级前一致。
|
||||||
|
|
||||||
|
### ⑤ 升级后 3 分钟核对
|
||||||
|
|
||||||
|
1. 启动 Console,打开 `logs\console.log`:没有意外报错;正常应有一行「局域网地址:…(网卡名)」,一个地址都没有时会是一条带原因的 WARN,方便直接看出该修哪张网卡;
|
||||||
|
2. 用录入员及以上账号进「大屏控制」页,确认「局域网大屏」卡片列出地址;只有 `169.254.x.x` 的机器上应能看到它、并带「自动配置地址」那句说明;
|
||||||
|
3. 用平板 / 手机扫第一个地址的二维码,确认能打开大屏;
|
||||||
|
4. 自动检测为空时试一次「手动添加地址」(填 `ipconfig` 里的地址),确认立刻出链接与二维码;
|
||||||
|
5. 局域网设备连不上的机器,按《部署指南》7.5 的命令放行入站 **19540**;
|
||||||
|
6. 用《上线检查清单》过一遍。
|
||||||
|
|
||||||
|
### ⑥ 详细说明在哪
|
||||||
|
|
||||||
|
| 想了解 | 看 |
|
||||||
|
| --- | --- |
|
||||||
|
| 三类地址的含义、手动添加地址 | 《使用手册》第 9 节 |
|
||||||
|
| 防火墙放行与排障常识 | 《部署指南》7.5、7.9 |
|
||||||
|
| 空态原因与自检工具 | 《常见问题》Q101 |
|
||||||
|
| 接口字段与判定顺序 | `devdocs/05` 大屏控制与运维 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## dev_0.4.0
|
||||||
|
|
||||||
### 一句话
|
### 一句话
|
||||||
|
|
||||||
|
|||||||
@@ -679,3 +679,13 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
|||||||
新录的这条会成为该小项该级别的**当前纪录**,原来那条自动变成非当前(仍留在库里)。纪录库的 **「来源」列**因此有了两种值:**历史** = 赛前手工登记的旧资料,**本届** = 本场赛事批准破纪录时自动写入的(以前没有历史录入入口,实际只会看到「本届」)。
|
新录的这条会成为该小项该级别的**当前纪录**,原来那条自动变成非当前(仍留在库里)。纪录库的 **「来源」列**因此有了两种值:**历史** = 赛前手工登记的旧资料,**本届** = 本场赛事批准破纪录时自动写入的(以前没有历史录入入口,实际只会看到「本届」)。
|
||||||
|
|
||||||
**删除**:每行右端有「删除」(需**录入员及以上**)。确认框会说明「若它是当前纪录,会恢复上一条」,并注明「删除只影响纪录库,不会改动任何已发布的成绩与积分」;删除不会触发重算。
|
**删除**:每行右端有「删除」(需**录入员及以上**)。确认框会说明「若它是当前纪录,会恢复上一条」,并注明「删除只影响纪录库,不会改动任何已发布的成绩与积分」;删除不会触发重算。
|
||||||
|
|
||||||
|
**Q101:大屏控制页显示「未检测到局域网地址」怎么办?**
|
||||||
|
|
||||||
|
先说结论:这多半不是故障,而是主机此刻没有「别的设备能连上」的地址被系统枚举出来。按下面顺序排查:
|
||||||
|
|
||||||
|
1. **先看卡片列出的「已检查」原因**:新版空态会逐条列出检查过的网卡与各自被跳过的原因,例如「没找到可用的局域网地址。已检查:以太网(接口未连接)、WLAN(只有 IPv6 地址)。」——原因有「**接口未连接**」「**只有 IPv6 地址**」「**接口没有地址**」「**回环接口**」四种;最多列 5 条,网卡更多时其余折叠成「**等共 N 块网卡**」。照着原因去修那一张网卡(插好网线 / 给它一个 IPv4 地址 / 看看 DHCP)。如果看到的仍是旧句「未检测到局域网地址(可能是没插网线 / 只有回环)。」,说明这台机器上的前后端还没更新到最新版,按下面几步照样能排查;
|
||||||
|
2. **`ipconfig` 确认有没有 IPv4 地址**:在 cmd / PowerShell 里跑 `ipconfig`。注意 **Windows 里「网线插着、网卡显示 Up」不等于拿到了 IP**;如果只看到 `169.254.x.x`,说明**本网段没有 DHCP**,地址是设备自配的——这在**独立小网络**(没有路由器、设备直连或只接一台交换机)里是**正常用法,不是故障**,同网段的平板 / 手机照样能访问;
|
||||||
|
3. **手动添加地址**:自动检测不到、或者列出来的不是你要的那个网段时,把 `ipconfig` 里那条 IPv4 地址填进卡片下方的「手动添加地址」再点「添加」——立刻出链接和二维码(排第一条、带「手动」徽标、刷新不丢,最多 10 个);
|
||||||
|
4. **放行防火墙入站 19540**:地址对了、别的设备还是打不开,多半是主机防火墙挡了。在**管理员** cmd 里执行 `netsh advfirewall firewall add rule name="Scoreboard 19540" dir=in action=allow protocol=TCP localport=19540`(改过端口就换成实际端口);临时关闭防火墙只用于**验证**是不是它挡的,验证完要恢复;
|
||||||
|
5. **还不行就跑自检**:运行 `debug\诊断\局域网地址自检.exe`,把它的输出发回给我们。
|
||||||
@@ -388,6 +388,31 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da
|
|||||||
|
|
||||||
副屏的全屏窗口被误关时,用这里的「创建全屏窗口」就能恢复,不必重启 Console。
|
副屏的全屏窗口被误关时,用这里的「创建全屏窗口」就能恢复,不必重启 Console。
|
||||||
|
|
||||||
|
**「局域网大屏」卡片:怎么拿到地址(两条路)**
|
||||||
|
|
||||||
|
1. **卡片自动检测**:「大屏控制 → 局域网大屏」卡片会列出主机上可用的地址,直接点开或扫码;第一个地址(也就是二维码那一条)就是现场最可能要用的;
|
||||||
|
2. **`ipconfig` 手填**:自动检测不到、或者列出来的不是你那张网卡时,在管理员 cmd / PowerShell 里跑 `ipconfig`,把对应网卡的 **IPv4 地址**抄进卡片下方的「手动添加地址」再点「添加」——手填的排在第一条、带「手动」徽标,记在浏览器本地,刷新不丢(最多 10 个,可「删除」)。
|
||||||
|
|
||||||
|
**三类地址的含义**(卡片按类别判定,只排除回环 / 未指定 / 组播 / 广播这四类对本机之外没有意义的地址):
|
||||||
|
|
||||||
|
| 类别 | 形如 | 现场含义 | 排序 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 内网地址 | `192.168.x.x`、`10.x.x.x`、`172.16–31.x.x` | 路由器 / 交换机分配或手工设的地址,最常用的现场形态 | 最前 |
|
||||||
|
| 全局地址 | 其它网段(运营商专线等) | 能用就照常列出,但多半要经中间设备才通,不一定平板能直连 | 居中 |
|
||||||
|
| **自动配置地址** | **`169.254.x.x`** | **本网段没有 DHCP**,地址是设备自己配的;在独立小网络(没路由器、设备直连或只接交换机)里是**正常用法**,同网段设备可互相访问 | 最后 |
|
||||||
|
|
||||||
|
另外两种来源会各带一句提示、**照常能用**:网卡状态读不到时提示「网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条」;按系统默认路由推断出来时提示「这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条」。
|
||||||
|
|
||||||
|
**防火墙必须放行入站 19540**(默认端口,改过端口就换成实际端口)。在**管理员** cmd 里跑:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
netsh advfirewall firewall add rule name="Scoreboard 19540" dir=in action=allow protocol=TCP localport=19540
|
||||||
|
```
|
||||||
|
|
||||||
|
为什么安装包不能替你加这条:安装包是**免管理员的按用户安装**(默认装到当前用户目录),**不申请管理员提权**,所以改不了系统防火墙规则;这一步必须由现场用管理员身份手动做一次。临时关掉防火墙只能用来**验证**「是不是防火墙挡的」,验证完要恢复;正式开赛前一定用上面这条命令放行,而不是一直关着防火墙。
|
||||||
|
|
||||||
|
**排障常识**:Windows 里「网线插着、网卡显示 Up」**不等于拿到了 IP**;`ipconfig` 里看到 `169.254.x.x` 说明**本网段没有 DHCP**(地址是设备自配的)——这在**独立小网络**里是正常用法,**不是故障**,同网段的平板 / 手机照样能访问。
|
||||||
|
|
||||||
### 7.6 公告
|
### 7.6 公告
|
||||||
|
|
||||||
管理端「大屏控制 → 公告」推送后:
|
管理端「大屏控制 → 公告」推送后:
|
||||||
@@ -421,6 +446,7 @@ http://<主机IP>:19540/display
|
|||||||
|
|
||||||
- 这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接**与**二维码**(第一个地址),别的设备扫码或在浏览器里粘贴即可打开(该卡片需录入员及以上登录才看得到);
|
- 这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接**与**二维码**(第一个地址),别的设备扫码或在浏览器里粘贴即可打开(该卡片需录入员及以上登录才看得到);
|
||||||
- 需要主机设置固定 IP,且设备与主机在同一局域网;
|
- 需要主机设置固定 IP,且设备与主机在同一局域网;
|
||||||
|
- **主机防火墙要放行入站 19540**(默认端口):安装包是按用户安装、不申请管理员提权,改不了防火墙规则,需要在**管理员** cmd 里手动执行一次,命令与说明见 7.5;
|
||||||
- 安卓电视一般常亮,无需额外防熄屏;
|
- 安卓电视一般常亮,无需额外防熄屏;
|
||||||
- 浏览器方案**不受** Console 的 kiosk、防熄屏、选屏控制,字体与动画表现以实际设备为准;
|
- 浏览器方案**不受** Console 的 kiosk、防熄屏、选屏控制,字体与动画表现以实际设备为准;
|
||||||
- 现场以 Console + HDMI 为主方案,浏览器访问作为备用。
|
- 现场以 Console + HDMI 为主方案,浏览器访问作为备用。
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 大屏控制页要把「当前设备的局域网地址」显示成可点链接 + 二维码:面板自己往往是从
|
// 大屏控制页要把「当前设备的局域网地址」显示成可点链接 + 二维码:面板自己往往是从
|
||||||
@@ -26,14 +28,70 @@ type lanAddress struct {
|
|||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Interface string `json:"interface"`
|
Interface string `json:"interface"`
|
||||||
Family string `json:"family"`
|
Family string `json:"family"`
|
||||||
|
// Confidence 说明这条地址是怎么来的、有多可信:
|
||||||
|
// - high:严格轮从网卡枚举里挑出来的(接口 up 位读得到);
|
||||||
|
// - low :宽松轮兜底捡回来的(网卡 up 位读不到,见 lanAddresses);
|
||||||
|
// - route:网卡枚举一分钱都没给,靠系统默认路由推断出来的源地址(第三层来源)。
|
||||||
|
// 老版本前端会直接忽略这个字段,加上不会破坏契约。
|
||||||
|
Confidence string `json:"confidence"`
|
||||||
|
// Scope 说明这条地址属于哪一类(private / global / link-local,见 ipv4Scope)。
|
||||||
|
// 与 Confidence 正交:scope 说的是「这是什么地址」,confidence 说的是「怎么找到的」。
|
||||||
|
// 前端据此给链路本地地址补一句说明,而不是自己去猜 IP 前缀。
|
||||||
|
Scope string `json:"scope"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 地址置信度取值。low 只在宽松轮出现,route 只在第三层(默认路由探测)出现。
|
||||||
|
const (
|
||||||
|
confidenceHigh = "high"
|
||||||
|
confidenceLow = "low"
|
||||||
|
confidenceRoute = "route"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 地址类别取值(lanAddress.Scope)。
|
||||||
|
const (
|
||||||
|
scopePrivate = "private"
|
||||||
|
scopeGlobal = "global"
|
||||||
|
scopeLinkLocal = "link-local"
|
||||||
|
)
|
||||||
|
|
||||||
|
// routeInterfaceLabel 是路由探测那条地址的 Interface 取值。
|
||||||
|
//
|
||||||
|
// 它不是任何一张网卡的名字:这条地址压根不是从网卡枚举来的,写一个假的网卡名会让排查的人
|
||||||
|
// 去系统里找一张不存在的网卡。写清来源,接口本身就能自解释(前端不展示这个字段)。
|
||||||
|
const routeInterfaceLabel = "默认路由"
|
||||||
|
|
||||||
|
// skippedInterface 解释「一张网卡为什么没贡献地址」。
|
||||||
|
//
|
||||||
|
// 它只用于把空态换成有信息量的话(前端按 code 映射中文),**不参与地址列表的生成**:
|
||||||
|
// 淘汰谁、留下谁完全由 lanAddresses 的两轮筛选决定,这里是从同一份快照上得出的旁白。
|
||||||
|
// 两者若互相影响(例如「被这里标了 not-up 的地址就不列」),空态原因和地址列表就会打架。
|
||||||
|
type skippedInterface struct {
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Addresses []string `json:"addresses"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过原因。取值固定,前端与启动日志各有一份中文映射。
|
||||||
|
//
|
||||||
|
// 一张网卡可能同时命中多个原因(例如既没置 up、又只有 IPv6 地址),此时只给最能解释
|
||||||
|
// 问题的那一个。优先级见 skipCode:no-address > only-ipv6 > not-up > loopback。
|
||||||
|
//
|
||||||
|
// 注意这里**没有** link-local:链路本地地址现在会被收下(见 ipv4Scope),
|
||||||
|
// 它不再是「被跳过」的理由。一张 down 掉、只配了链路本地地址的网卡,真实原因是 not-up。
|
||||||
|
const (
|
||||||
|
skipNoAddress = "no-address"
|
||||||
|
skipOnlyIPv6 = "only-ipv6"
|
||||||
|
skipNotUp = "not-up"
|
||||||
|
skipLoopback = "loopback"
|
||||||
|
)
|
||||||
|
|
||||||
// networkInfo 是 GET /api/system/network 的响应体。
|
// networkInfo 是 GET /api/system/network 的响应体。
|
||||||
type networkInfo struct {
|
type networkInfo struct {
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
DisplayPath string `json:"displayPath"`
|
DisplayPath string `json:"displayPath"`
|
||||||
AdminPath string `json:"adminPath"`
|
AdminPath string `json:"adminPath"`
|
||||||
Addresses []lanAddress `json:"addresses"`
|
Addresses []lanAddress `json:"addresses"`
|
||||||
|
Skipped []skippedInterface `json:"skipped"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ifaceSnapshot 是 net.Interface 里本接口用得上的那部分。
|
// ifaceSnapshot 是 net.Interface 里本接口用得上的那部分。
|
||||||
@@ -47,13 +105,12 @@ type ifaceSnapshot struct {
|
|||||||
Addrs []net.Addr
|
Addrs []net.Addr
|
||||||
}
|
}
|
||||||
|
|
||||||
// snapshotInterfaces 读一次本机网卡。读不到(极罕见)时返回空切片:
|
// readInterfaces 读一次本机网卡。读不到(极罕见)时回错误,由调用方决定怎么记:
|
||||||
// 前端据此显示「未检测到局域网地址」,比报错更接近事实——服务本身是好的。
|
// HTTP 路径要降级成空列表(前端显示空态),启动日志那边要换成一条 WARN。
|
||||||
func (s *server) snapshotInterfaces() []ifaceSnapshot {
|
func readInterfaces() ([]ifaceSnapshot, error) {
|
||||||
list, err := net.Interfaces()
|
list, err := net.Interfaces()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("枚举网卡失败,大屏控制页将显示「未检测到局域网地址」", "err", err)
|
return nil, err
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
out := make([]ifaceSnapshot, 0, len(list))
|
out := make([]ifaceSnapshot, 0, len(list))
|
||||||
for _, iface := range list {
|
for _, iface := range list {
|
||||||
@@ -64,12 +121,173 @@ func (s *server) snapshotInterfaces() []ifaceSnapshot {
|
|||||||
}
|
}
|
||||||
out = append(out, ifaceSnapshot{Name: iface.Name, Flags: iface.Flags, Addrs: addrs})
|
out = append(out, ifaceSnapshot{Name: iface.Name, Flags: iface.Flags, Addrs: addrs})
|
||||||
}
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotInterfaces 读一次本机网卡,失败时只记日志并回空切片:
|
||||||
|
// 前端据此显示「未检测到局域网地址」,比报错更接近事实——服务本身是好的。
|
||||||
|
func (s *server) snapshotInterfaces() []ifaceSnapshot {
|
||||||
|
list, err := readInterfaces()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("枚举网卡失败,大屏控制页将显示「未检测到局域网地址」", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialProbe 与 net.Dial 同形(返回值收窄到本文件真正用到的两个方法),测试注入假实现用。
|
||||||
|
type dialProbe func(network, address string) (localAddrConn, error)
|
||||||
|
|
||||||
|
// localAddrConn 是探测只需要的那一点能力:问出本地地址,然后关掉。
|
||||||
|
type localAddrConn interface {
|
||||||
|
LocalAddr() net.Addr
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeProbe 是第三层来源的可注入形式:回一组「系统默认路由用的源地址」。
|
||||||
|
// nil 表示这一层不跑(启动日志那条路径就是这样:它跟服务启动在同一个 goroutine 上,
|
||||||
|
// 不该为了记一行日志去碰网络栈)。
|
||||||
|
type routeProbe func() []net.IP
|
||||||
|
|
||||||
|
// routeProbeTargets 是默认路由探测的目标,顺序即尝试顺序。
|
||||||
|
//
|
||||||
|
// 内网网关放在前面:现场要的正是「去局域网走哪块网卡」。末尾两个公网地址是兜底——
|
||||||
|
// 机器没配默认网关(只有一条去公网的路由,或走的是手机热点 / 4G 网卡)时,问公网地址一样能
|
||||||
|
// 让操作系统给出源地址,而那台机器的局域网地址通常就在同一张网卡上。
|
||||||
|
// 端口随便写(9 / 53):UDP 下这两个数只用于构造目标地址,一个包都不会发出去。
|
||||||
|
var routeProbeTargets = []string{
|
||||||
|
"192.168.0.1:9",
|
||||||
|
"192.168.1.1:9",
|
||||||
|
"10.0.0.1:9",
|
||||||
|
"172.16.0.1:9",
|
||||||
|
"8.8.8.8:53",
|
||||||
|
"223.5.5.5:53",
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultDialProbe 是生产环境的 dialProbe,就是 net.Dial 本身。
|
||||||
|
func defaultDialProbe(network, address string) (localAddrConn, error) {
|
||||||
|
return net.Dial(network, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeDefaultRoute 是生产环境的 routeProbe:拿 UDP Dial 去问操作系统。
|
||||||
|
func probeDefaultRoute() []net.IP {
|
||||||
|
return probeRouteSourceIPs(defaultDialProbe, routeProbeTargets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeRouteSourceIPs 让操作系统自己回答「去这些目标会走哪块网卡」,取 Dial 的本地地址。
|
||||||
|
//
|
||||||
|
// 为什么 UDP 的 Dial 在完全离线的现场也安全(这是这一层敢放在请求路径里的唯一理由):
|
||||||
|
// UDP 是「无连接」的,connect() 只做两件本地事——查一次路由表、把本地地址绑好,**不握手、
|
||||||
|
// 不发任何数据包**,所以对端在不在、网通不通都不影响它立刻返回;失败也是本地立刻失败
|
||||||
|
// (没有去那个网段的路由时直接回 ENETUNREACH)。换成 TCP 就得等 SYN 超时,没网的现场会把
|
||||||
|
// 这次请求拖住好几秒。也正因为它不发包,端口号在这里只是占位,写什么都一样。
|
||||||
|
//
|
||||||
|
// 单个目标失败就跳过:没有默认路由的机器会把 6 个目标各试一次,各自立刻失败,代价可忽略。
|
||||||
|
func probeRouteSourceIPs(dial dialProbe, targets []string) []net.IP {
|
||||||
|
if dial == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]net.IP, 0, len(targets))
|
||||||
|
seen := make(map[string]struct{}, len(targets))
|
||||||
|
for _, target := range targets {
|
||||||
|
conn, err := dial("udp4", target)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if addr := conn.LocalAddr(); addr != nil {
|
||||||
|
if host, _, err := net.SplitHostPort(addr.String()); err == nil {
|
||||||
|
// 多个目标通常落在同一张网卡上:按地址去重,前端别看到重复的一条。
|
||||||
|
if ip4 := net.ParseIP(host).To4(); ip4 != nil {
|
||||||
|
if _, dup := seen[ip4.String()]; !dup {
|
||||||
|
seen[ip4.String()] = struct{}{}
|
||||||
|
out = append(out, ip4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// lanAddresses 从网卡快照里挑出可用的局域网 IPv4 地址,按「现场最可能用的那个」排在最前。
|
// routeAddresses 跑第三层:探测 + 过滤,产出可直接下发的地址。
|
||||||
|
// 过滤口径与前两轮完全一致(ipv4Scope):探测回回环 / 未指定 / 组播 / 广播照样丢掉
|
||||||
|
// ——「系统说会走这块网卡」不等于「这个地址别的设备连得上」,比如只有回环路由的开发机。
|
||||||
|
// 链路本地与私网一样照收(scope 如实标出来),主人现场正是靠链路本地地址互访的那种网络。
|
||||||
|
func routeAddresses(probe routeProbe) []lanAddress {
|
||||||
|
// 一开始就分配好:探测没得用、或探测回空时也必须给非 nil 空切片,
|
||||||
|
// 否则 JSON 会下发 null,前端分不清「没有地址」和「服务端没给这个字段」。
|
||||||
|
out := make([]lanAddress, 0, 2)
|
||||||
|
if probe == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, ip := range probe() {
|
||||||
|
ip4 := ip.To4()
|
||||||
|
if ip4 == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scope, usable := ipv4Scope(ip4)
|
||||||
|
if !usable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := ip4.String()
|
||||||
|
if _, dup := seen[key]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, lanAddress{
|
||||||
|
IP: key,
|
||||||
|
Interface: routeInterfaceLabel,
|
||||||
|
Family: "ipv4",
|
||||||
|
Confidence: confidenceRoute,
|
||||||
|
Scope: scope,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sortLanAddresses(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// lanAddresses 从网卡快照里挑出可用的局域网 IPv4 地址,按「现场最可能用的那个」排在最前;
|
||||||
|
// 同时给出被淘汰网卡的原因(只用于解释空态,不参与挑地址)。
|
||||||
|
//
|
||||||
|
// 三层来源,逐层降级,前一层有结果就绝不跑后一层:
|
||||||
|
// 1. 严格轮:接口置了 up 位的网卡(正常机器都走这里);
|
||||||
|
// 2. 宽松轮:不要求 up 位(Windows 上 up 位读不到时的兜底);
|
||||||
|
// 3. 默认路由探测:网卡枚举一份地址都没给时,直接问操作系统「去局域网走哪块网卡」。
|
||||||
|
//
|
||||||
|
// 为什么要有宽松轮:
|
||||||
|
// 严格轮要求接口置了 up 位——正常情况下这是对的,网线没插的接口确实连不上。但 Windows 上
|
||||||
|
// 这个位映射自 IfOperStatusUp,部分驱动 / 虚拟网卡 / 特定连接方式下**读不到 up 位**(接口
|
||||||
|
// 实际是通的,标志位却是 0)。只要机器上有一张这样的网卡,整台机器就会被判成「一个可用地址
|
||||||
|
// 都没有」,卡片只给一句没有信息量的空话——而局域网明明是好的。
|
||||||
|
// 所以严格轮一无所获时再跑一轮不要求 up 位的宽松轮:只是把「可能能用」的地址捡回来并标成
|
||||||
|
// low 交给现场判断,比把所有地址都藏起来强得多(真连不上,现场换一条就是了)。
|
||||||
|
// 严格轮有结果就绝不跑宽松轮:宁可少给几条,也不要让一张下线的虚拟网卡混进列表里。
|
||||||
|
//
|
||||||
|
// 为什么还要第三层(probe):
|
||||||
|
// 现场出现过单块 Intel 网卡(Windows 里 Get-NetAdapter 明明白白是 Up)两轮都筛不出东西的机器
|
||||||
|
// ——接口枚举这条路在那台机器上就是不通。此时让操作系统自己回答「去局域网会走哪块网卡」是唯一
|
||||||
|
// 还能拿到地址的办法,代价只是几次本地的 UDP connect(不发包,见 probeRouteSourceIPs)。
|
||||||
|
// **只有前两轮都为空才会走到这里**:正常机器上连一次 Dial 都不会发生,这条路径也就不可能
|
||||||
|
// 拖慢正常的请求,更不会在启动时打网络栈(启动日志传的就是 nil)。
|
||||||
|
//
|
||||||
// 结果永远非 nil:没有任何可用地址时返回空切片,前端才知道这是「没有」而不是「没拿到字段」。
|
// 结果永远非 nil:没有任何可用地址时返回空切片,前端才知道这是「没有」而不是「没拿到字段」。
|
||||||
func lanAddresses(ifaces []ifaceSnapshot) []lanAddress {
|
func lanAddresses(ifaces []ifaceSnapshot, probe routeProbe) (addresses []lanAddress, skipped []skippedInterface) {
|
||||||
|
strict := collectLanAddresses(ifaces, usableInterface, confidenceHigh)
|
||||||
|
if len(strict) > 0 {
|
||||||
|
return strict, skippedInterfaces(ifaces, strict)
|
||||||
|
}
|
||||||
|
lenient := collectLanAddresses(ifaces, fallbackInterface, confidenceLow)
|
||||||
|
if len(lenient) > 0 {
|
||||||
|
return lenient, skippedInterfaces(ifaces, lenient)
|
||||||
|
}
|
||||||
|
routed := routeAddresses(probe)
|
||||||
|
return routed, skippedInterfaces(ifaces, routed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectLanAddresses 跑一轮筛选:keep 决定哪些网卡参与本轮,confidence 打在挑出来的地址上。
|
||||||
|
func collectLanAddresses(ifaces []ifaceSnapshot, keep func(net.Flags) bool, confidence string) []lanAddress {
|
||||||
// 候选先带上 4 字节形式:去重、排序用得到,且避免在比较器里反复做 To4。
|
// 候选先带上 4 字节形式:去重、排序用得到,且避免在比较器里反复做 To4。
|
||||||
type candidate struct {
|
type candidate struct {
|
||||||
entry lanAddress
|
entry lanAddress
|
||||||
@@ -78,12 +296,16 @@ func lanAddresses(ifaces []ifaceSnapshot) []lanAddress {
|
|||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
cands := make([]candidate, 0, len(ifaces))
|
cands := make([]candidate, 0, len(ifaces))
|
||||||
for _, iface := range ifaces {
|
for _, iface := range ifaces {
|
||||||
if !usableInterface(iface.Flags) {
|
if !keep(iface.Flags) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, raw := range iface.Addrs {
|
for _, raw := range iface.Addrs {
|
||||||
ip4 := addrIP(raw).To4()
|
ip4 := addrIP(raw).To4()
|
||||||
if ip4 == nil || !usableLanIPv4(ip4) {
|
if ip4 == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scope, usable := ipv4Scope(ip4)
|
||||||
|
if !usable {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := ip4.String()
|
key := ip4.String()
|
||||||
@@ -94,23 +316,20 @@ func lanAddresses(ifaces []ifaceSnapshot) []lanAddress {
|
|||||||
}
|
}
|
||||||
seen[key] = struct{}{}
|
seen[key] = struct{}{}
|
||||||
cands = append(cands, candidate{
|
cands = append(cands, candidate{
|
||||||
entry: lanAddress{IP: key, Interface: iface.Name, Family: "ipv4"},
|
entry: lanAddress{
|
||||||
ip4: ip4,
|
IP: key,
|
||||||
|
Interface: iface.Name,
|
||||||
|
Family: "ipv4",
|
||||||
|
Confidence: confidence,
|
||||||
|
Scope: scope,
|
||||||
|
},
|
||||||
|
ip4: ip4,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(cands, func(i, j int) bool {
|
sort.Slice(cands, func(i, j int) bool {
|
||||||
a, b := cands[i], cands[j]
|
return lanLess(cands[i].entry, cands[i].ip4, cands[j].entry, cands[j].ip4)
|
||||||
if ra, rb := lanRank(a.ip4), lanRank(b.ip4); ra != rb {
|
|
||||||
return ra < rb
|
|
||||||
}
|
|
||||||
// 同档按地址数值排序:同一台机器每次刷新都要给出同样的顺序,
|
|
||||||
// 否则列表第一个地址会跳来跳去,现场刚记住的链接下次就变了。
|
|
||||||
if c := bytes.Compare(a.ip4, b.ip4); c != 0 {
|
|
||||||
return c < 0
|
|
||||||
}
|
|
||||||
return a.entry.Interface < b.entry.Interface
|
|
||||||
})
|
})
|
||||||
|
|
||||||
out := make([]lanAddress, 0, len(cands))
|
out := make([]lanAddress, 0, len(cands))
|
||||||
@@ -120,7 +339,130 @@ func lanAddresses(ifaces []ifaceSnapshot) []lanAddress {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// usableInterface 判断一张网卡能不能贡献地址:
|
// lanLess 是地址排序的比较器:档位(越像现场在用的内网段越靠前)→ 地址数值 → 接口名。
|
||||||
|
// 网卡枚举与路由探测两条来源共用它,两边的顺序才是一致的、可预期的。
|
||||||
|
//
|
||||||
|
// 同档按地址数值排序:同一台机器每次刷新都要给出同样的顺序,
|
||||||
|
// 否则列表第一个地址会跳来跳去,现场刚记住的链接下次就变了。
|
||||||
|
func lanLess(a lanAddress, aIP net.IP, b lanAddress, bIP net.IP) bool {
|
||||||
|
if ra, rb := lanRank(aIP), lanRank(bIP); ra != rb {
|
||||||
|
return ra < rb
|
||||||
|
}
|
||||||
|
if c := bytes.Compare(aIP, bIP); c != 0 {
|
||||||
|
return c < 0
|
||||||
|
}
|
||||||
|
return a.Interface < b.Interface
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortLanAddresses 按同一套比较器就地排序。
|
||||||
|
// 这里重新解析一次 IP 文本:调用点(路由探测)手里只有字符串形式的地址,
|
||||||
|
// 而它的 IP 都是 net.ParseIP + ipv4Scope 校验过的,To4 一定拿得到。
|
||||||
|
func sortLanAddresses(list []lanAddress) {
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
a, b := list[i], list[j]
|
||||||
|
return lanLess(a, net.ParseIP(a.IP).To4(), b, net.ParseIP(b.IP).To4())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// skippedInterfaces 逐张说明「它为什么没贡献地址」,供前端把空态换成有信息量的话。
|
||||||
|
// addresses 是本次真正下发的那一份(严格轮或宽松轮的结果),同一张网卡只出现一次。
|
||||||
|
//
|
||||||
|
// 有一张地址被别的网卡抢先拿走(同一地址配在桥接 / 双网卡上,去重只留了先出现的那个)
|
||||||
|
// 的网卡不算被跳过:这不是网卡的问题,报出来只会让现场去查一张没毛病的网卡。
|
||||||
|
// 反过来,接口没置 up 却配着可用地址的网卡**要**报出来(code = not-up)——这正是主人这台
|
||||||
|
// Windows 机器最可能的现场:地址是好的,只有 up 位读不到。
|
||||||
|
func skippedInterfaces(ifaces []ifaceSnapshot, addresses []lanAddress) []skippedInterface {
|
||||||
|
contributed := make(map[string]struct{}, len(addresses))
|
||||||
|
for _, a := range addresses {
|
||||||
|
contributed[a.Interface] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]skippedInterface, 0, len(ifaces))
|
||||||
|
seen := make(map[string]struct{}, len(ifaces))
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
if _, dup := seen[iface.Name]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[iface.Name] = struct{}{}
|
||||||
|
if _, ok := contributed[iface.Name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if iface.Flags&net.FlagUp != 0 && hasEligibleIPv4(iface) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, skippedInterface{
|
||||||
|
Interface: iface.Name,
|
||||||
|
Code: skipCode(iface),
|
||||||
|
Addresses: addrStrings(iface.Addrs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasEligibleIPv4 判断一张网卡有没有「两轮里都可能被挑走」的地址(宽松轮的口径)。
|
||||||
|
func hasEligibleIPv4(iface ifaceSnapshot) bool {
|
||||||
|
if iface.Flags&net.FlagLoopback != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, raw := range iface.Addrs {
|
||||||
|
if ip4 := addrIP(raw).To4(); ip4 != nil {
|
||||||
|
if _, usable := ipv4Scope(ip4); usable {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipCode 给出「这张网卡为什么没贡献地址」,只取最能解释问题的那一个。
|
||||||
|
//
|
||||||
|
// 优先级(高到低):no-address > only-ipv6 > not-up > loopback。
|
||||||
|
// 之所以把地址层面的原因排在接口 up 位之前:现场要看到的是「怎么才能弄好」——
|
||||||
|
// 「接口没有地址」「只有 IPv6 地址」是能直接动手改的(插网线 / 换个网段 / 看看 DHCP),
|
||||||
|
// 而「接口未连接」在 Windows 上还可能是 up 位读不到(见 lanAddresses 的宽松轮)。
|
||||||
|
//
|
||||||
|
// 这里不再有 link-local:链路本地地址会被收下(见 ipv4Scope),不再是跳过的理由;
|
||||||
|
// 一张 down 掉、只配了链路本地地址的网卡,真实原因是 not-up。
|
||||||
|
func skipCode(iface ifaceSnapshot) string {
|
||||||
|
var hasAddr, hasIPv4, hasIPv6, hasLoopbackAddr bool
|
||||||
|
for _, raw := range iface.Addrs {
|
||||||
|
hasAddr = true
|
||||||
|
ip := addrIP(raw)
|
||||||
|
if ip == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
hasIPv4 = true
|
||||||
|
hasLoopbackAddr = hasLoopbackAddr || ip4.IsLoopback()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasIPv6 = hasIPv6 || ip.To16() != nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !hasAddr:
|
||||||
|
return skipNoAddress
|
||||||
|
case !hasIPv4 && hasIPv6:
|
||||||
|
return skipOnlyIPv6
|
||||||
|
case iface.Flags&net.FlagUp == 0:
|
||||||
|
return skipNotUp
|
||||||
|
case iface.Flags&net.FlagLoopback != 0, hasLoopbackAddr:
|
||||||
|
return skipLoopback
|
||||||
|
default:
|
||||||
|
// 只剩未指定(0.0.0.0)或组播这类地址:网卡在,但一个能当链接用的地址都没有。
|
||||||
|
return skipNoAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addrStrings 把网卡地址原样写成 CIDR 文本(如 `10.0.0.10/24`),给 skipped 用。
|
||||||
|
// 返回非 nil 空切片,保证 JSON 里是 [] 而不是 null。
|
||||||
|
func addrStrings(addrs []net.Addr) []string {
|
||||||
|
out := make([]string, 0, len(addrs))
|
||||||
|
for _, raw := range addrs {
|
||||||
|
out = append(out, raw.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// usableInterface 判断一张网卡能不能贡献地址(严格轮):
|
||||||
// - 必须 UP —— 网线没插、接口被 down 掉时,配置还在但包根本发不出去;
|
// - 必须 UP —— 网线没插、接口被 down 掉时,配置还在但包根本发不出去;
|
||||||
// - 排除回环接口 —— lo 上的 127.x 对别的设备毫无意义(面板自己就是从 127.0.0.1 打开的,
|
// - 排除回环接口 —— lo 上的 127.x 对别的设备毫无意义(面板自己就是从 127.0.0.1 打开的,
|
||||||
// 真把它显示出来会误导现场照抄一个只有本机能打开的地址)。
|
// 真把它显示出来会误导现场照抄一个只有本机能打开的地址)。
|
||||||
@@ -128,18 +470,46 @@ func usableInterface(flags net.Flags) bool {
|
|||||||
return flags&net.FlagUp != 0 && flags&net.FlagLoopback == 0
|
return flags&net.FlagUp != 0 && flags&net.FlagLoopback == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// usableLanIPv4 判断一个 IPv4 地址能不能给局域网其他设备用。
|
// fallbackInterface 是严格轮一无所获时的兜底条件:只排除回环接口,不再要求 up 位。
|
||||||
// 逐条排除的都是「拿得到、连不上」的地址:
|
// 放宽的只有「读不到 up 位」这一种情况,回环接口在宽松轮里同样没有意义。
|
||||||
// - 127.0.0.0/8 回环:只有本机能访问;
|
func fallbackInterface(flags net.Flags) bool {
|
||||||
// - 169.254.0.0/16 link-local:DHCP 没拿到地址时系统自配的,同网段设备通常也到不了;
|
return flags&net.FlagLoopback == 0
|
||||||
// - 0.0.0.0:未指定地址,拼不成 URL;
|
}
|
||||||
// - 224.0.0.0/4 组播:不是单播目的地址。
|
|
||||||
func usableLanIPv4(ip4 net.IP) bool {
|
// ipv4Scope 判断一个 IPv4 地址属于哪一类,第二返回值表示它能不能作为候选地址下发。
|
||||||
|
//
|
||||||
|
// 只排除四类「作为对端访问目标没有意义」的地址:
|
||||||
|
// - 回环(net.IP.IsLoopback):只有本机访问得到;
|
||||||
|
// - 未指定(IsUnspecified):0.0.0.0 拼不成 URL;
|
||||||
|
// - 组播(IsMulticast):组播地址不是单播目的地;
|
||||||
|
// - 广播(等于 net.IPv4bcast):同理。
|
||||||
|
//
|
||||||
|
// **一句话规则:除了这四类,本机持有的、可能被同网段设备访问的 IPv4 一律进候选。**
|
||||||
|
// 判定全部走标准库谓词,不写网段字面量:网段划分本身是标准库的知识,抄一份到代码里,
|
||||||
|
// 抄错或漏掉一段就会变成「明明有地址却说没有」——正是这次要修的那个毛病。
|
||||||
|
//
|
||||||
|
// 这里**曾经**把链路本地地址(IsLinkLocalUnicast)也当成「不能用」排除掉,理由写的是
|
||||||
|
// 「DHCP 没拿到地址才自配的,说明网络不健康」。那是对网络健不健康的**猜测**,不是地址能不能用的
|
||||||
|
// **事实**:主人现场就是无 DHCP 的独立内网,设备之间靠链路本地地址互访,平板实测能打开
|
||||||
|
// http://<链路本地地址>:端口/display,网络与防火墙全通,而卡片却因为这条猜测说「未检测到
|
||||||
|
// 局域网地址」。判断错了,所以改成按类别判定:链路本地照收,只是排在最后(见 lanRank)。
|
||||||
|
func ipv4Scope(ip4 net.IP) (scope string, usable bool) {
|
||||||
|
// 传进来的必须是 4 字节形式(各调用点都先做了 To4)。这里显式挡一次 nil:
|
||||||
|
// 谓词对 nil 全返回 false,落到 default 分支就会被当成「可用的 global 地址」——
|
||||||
|
// IPv6 的 To4() 正好是 nil,那会让「只有 IPv6 的网卡」被误判成有候选地址。
|
||||||
|
if ip4.To4() == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case ip4.IsLoopback(), ip4.IsUnspecified(), ip4.IsLinkLocalUnicast(), ip4.IsMulticast():
|
case ip4.IsLoopback(), ip4.IsUnspecified(), ip4.IsMulticast(), ip4.Equal(net.IPv4bcast):
|
||||||
return false
|
return "", false
|
||||||
|
case ip4.IsLinkLocalUnicast():
|
||||||
|
return scopeLinkLocal, true
|
||||||
|
case ip4.IsPrivate():
|
||||||
|
return scopePrivate, true
|
||||||
|
default:
|
||||||
|
return scopeGlobal, true
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// lanRank 是排序档位(越小越靠前):现场「网线 + WiFi 双网卡」时,排第一的应该是他们真正在用的那个。
|
// lanRank 是排序档位(越小越靠前):现场「网线 + WiFi 双网卡」时,排第一的应该是他们真正在用的那个。
|
||||||
@@ -150,9 +520,17 @@ func usableLanIPv4(ip4 net.IP) bool {
|
|||||||
// - 2:172.16.0.0/12 —— RFC1918 三段里最少见的,多是虚拟化 / 容器网桥(docker 默认就用它),
|
// - 2:172.16.0.0/12 —— RFC1918 三段里最少见的,多是虚拟化 / 容器网桥(docker 默认就用它),
|
||||||
// 真要拿它给平板用也得排在直连网段之后;
|
// 真要拿它给平板用也得排在直连网段之后;
|
||||||
// - 3:其余(公网地址、运营商专线地址等)—— 能用就留着,但不给第一顺位:
|
// - 3:其余(公网地址、运营商专线地址等)—— 能用就留着,但不给第一顺位:
|
||||||
// 现场多半是靠某个中间设备才通的,未必是平板能直连的那个。
|
// 现场多半是靠某个中间设备才通的,未必是平板能直连的那个;
|
||||||
|
// - 4:链路本地 —— 永远排最后。
|
||||||
|
//
|
||||||
|
// 为什么链路本地排最后:它只在「目标设备也用链路本地地址」时才连得上(无 DHCP 的独立内网
|
||||||
|
// 正是这种情况),适用范围天然比配过 DHCP / 静态地址的网段窄。而排序第一位会决定现场
|
||||||
|
// 「照抄哪一条」和二维码给谁,所以让适用面更广的私网 / 全局地址先来;真只有链路本地可用时,
|
||||||
|
// 它照样是列表第一条(前面的档位都是空的)。
|
||||||
func lanRank(ip4 net.IP) int {
|
func lanRank(ip4 net.IP) int {
|
||||||
switch {
|
switch {
|
||||||
|
case ip4.IsLinkLocalUnicast():
|
||||||
|
return 4
|
||||||
case ip4[0] == 192 && ip4[1] == 168:
|
case ip4[0] == 192 && ip4[1] == 168:
|
||||||
return 0
|
return 0
|
||||||
case ip4[0] == 10:
|
case ip4[0] == 10:
|
||||||
@@ -178,10 +556,84 @@ func addrIP(addr net.Addr) net.IP {
|
|||||||
// systemNetwork 是 GET /api/system/network:大屏控制页用它拼出平板能直接打开的链接。
|
// systemNetwork 是 GET /api/system/network:大屏控制页用它拼出平板能直接打开的链接。
|
||||||
// 端口取服务端实际监听的那个(见 server.port),不在这里另写一个数字。
|
// 端口取服务端实际监听的那个(见 server.port),不在这里另写一个数字。
|
||||||
func (s *server) systemNetwork(w http.ResponseWriter, r *http.Request) (any, error) {
|
func (s *server) systemNetwork(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
// 第三层(默认路由探测)只在这个接口里开:它是按请求跑的,前两轮为空才会真的 Dial。
|
||||||
|
addresses, skipped := lanAddresses(s.snapshotInterfaces(), probeDefaultRoute)
|
||||||
return networkInfo{
|
return networkInfo{
|
||||||
Port: s.port,
|
Port: s.port,
|
||||||
DisplayPath: displayPagePath,
|
DisplayPath: displayPagePath,
|
||||||
AdminPath: adminPagePath,
|
AdminPath: adminPagePath,
|
||||||
Addresses: lanAddresses(s.snapshotInterfaces()),
|
Addresses: addresses,
|
||||||
|
Skipped: skipped,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LogLanAddresses 启动时枚举一次网卡,把大屏控制页会用到的局域网地址写进日志。
|
||||||
|
//
|
||||||
|
// 现场排障用:卡片显示空态时,现场第一件事就是看这行日志——服务端可能跑在另一台机器上,
|
||||||
|
// 而日志里能直接看出「枚举到了什么、为什么一个都没用」,不必再让主人去装工具查网卡。
|
||||||
|
// 一个地址都没有时降级为 WARN,并带上 skipped 的原因摘要(中文,能直接看懂)。
|
||||||
|
func LogLanAddresses(logger *slog.Logger) {
|
||||||
|
ifaces, err := readInterfaces()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("枚举网卡失败,大屏控制页将显示「未检测到局域网地址」", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logLanAddresses(logger, ifaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logLanAddresses 是 LogLanAddresses 的纯逻辑部分:快照从外面给,日志分支才好钉住。
|
||||||
|
//
|
||||||
|
// probe 传 nil:启动路径**不做默认路由探测**。它跟服务启动在同一个 goroutine 上,为一行日志
|
||||||
|
// 去碰网络栈(哪怕 UDP 不阻塞)不值得;也免得日志里的地址和卡片上真正下发的来源不一致
|
||||||
|
// ——卡片是按请求探测的,那时网卡状态可能已经变了。日志只负责如实说「枚举到了什么」。
|
||||||
|
func logLanAddresses(logger *slog.Logger, ifaces []ifaceSnapshot) {
|
||||||
|
addresses, skipped := lanAddresses(ifaces, nil)
|
||||||
|
if len(addresses) == 0 {
|
||||||
|
// 一个地址都没有:这就是现场来问「卡片为什么是空的」时要找的那条日志,
|
||||||
|
// 所以原因摘要必须和 WARN 一起出现,不能只在别处留个 code。
|
||||||
|
summary := skipSummary(skipped)
|
||||||
|
if summary == "" {
|
||||||
|
summary = "没有枚举到任何网卡"
|
||||||
|
}
|
||||||
|
logger.Warn("未找到可用的局域网地址:大屏控制页只能显示空态", "已检查", summary)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Info("局域网地址:" + addressSummary(addresses))
|
||||||
|
}
|
||||||
|
|
||||||
|
// addressSummary 拼「10.0.0.10(以太网)、192.168.0.100(ZeroTier)」这样一行日志消息。
|
||||||
|
func addressSummary(addresses []lanAddress) string {
|
||||||
|
parts := make([]string, 0, len(addresses))
|
||||||
|
for _, a := range addresses {
|
||||||
|
parts = append(parts, a.IP+"("+a.Interface+")")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "、")
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipSummary 把 skipped 摊成「以太网(接口未连接)、WLAN(只有 IPv6 地址)」。
|
||||||
|
// 没有可报的网卡时返回空串,由调用方补一句「没有枚举到任何网卡」。
|
||||||
|
func skipSummary(skipped []skippedInterface) string {
|
||||||
|
parts := make([]string, 0, len(skipped))
|
||||||
|
for _, s := range skipped {
|
||||||
|
parts = append(parts, s.Interface+"("+skipReasonLabel(s.Code)+")")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "、")
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipReasonLabel 把跳过原因翻成日志里能直接看懂的中文。
|
||||||
|
// 前端另有一份自己的映射(服务端只下发 code,卡片文案由前端定):日志面向运维、
|
||||||
|
// 卡片面向现场操作员,措辞不必一模一样,但两份都得覆盖全部 code。
|
||||||
|
func skipReasonLabel(code string) string {
|
||||||
|
switch code {
|
||||||
|
case skipNoAddress:
|
||||||
|
return "接口没有地址"
|
||||||
|
case skipOnlyIPv6:
|
||||||
|
return "只有 IPv6 地址"
|
||||||
|
case skipNotUp:
|
||||||
|
return "接口未连接"
|
||||||
|
case skipLoopback:
|
||||||
|
return "回环接口"
|
||||||
|
default:
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -29,13 +34,50 @@ func lanIPs(got []lanAddress) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只回「平板真的连得上」的 IPv4:回环、link-local、未指定、组播、IPv6、down 掉的接口全要滤掉。
|
func lanConfidences(got []lanAddress) []string {
|
||||||
|
out := make([]string, 0, len(got))
|
||||||
|
for _, a := range got {
|
||||||
|
out = append(out, a.Confidence)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipTexts 把探测回来的 net.IP 摊成文本,方便和字面量比对。
|
||||||
|
func ipTexts(got []net.IP) []string {
|
||||||
|
out := make([]string, 0, len(got))
|
||||||
|
for _, ip := range got {
|
||||||
|
out = append(out, ip.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func lanScopes(got []lanAddress) []string {
|
||||||
|
out := make([]string, 0, len(got))
|
||||||
|
for _, a := range got {
|
||||||
|
out = append(out, a.Scope)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipCodes 把 skipped 摊成「接口名:code」,用例里一眼就能看出优先级与去重对不对。
|
||||||
|
func skipCodes(skipped []skippedInterface) []string {
|
||||||
|
out := make([]string, 0, len(skipped))
|
||||||
|
for _, s := range skipped {
|
||||||
|
out = append(out, s.Interface+":"+s.Code)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只回「平板真的连得上」的 IPv4:回环、未指定、组播、广播、IPv6、down 掉的接口全要滤掉。
|
||||||
// 这条用例钉住的是现场最容易踩的坑——面板从 127.0.0.1 打开,顺手把回环地址显示出去,
|
// 这条用例钉住的是现场最容易踩的坑——面板从 127.0.0.1 打开,顺手把回环地址显示出去,
|
||||||
// 现场照着抄到平板上就是打不开。
|
// 现场照着抄到平板上就是打不开。
|
||||||
|
// 链路本地地址(169 那一段)现在**要**收下:它是无 DHCP 内网里唯一能用的地址(见 ipv4Scope),
|
||||||
|
// 但排在私网地址之后。
|
||||||
|
// 这里 down 掉的 eth1 配着可用地址却不出现,是因为严格轮已经有结果、宽松轮根本不会跑。
|
||||||
func TestLanAddressesFiltersUnusable(t *testing.T) {
|
func TestLanAddressesFiltersUnusable(t *testing.T) {
|
||||||
ifaces := []ifaceSnapshot{
|
ifaces := []ifaceSnapshot{
|
||||||
lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8", "::1/128"),
|
lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8", "::1/128"),
|
||||||
// 网线插着但 DHCP 没拿到地址:系统自配 169.254.x,同网段设备通常也到不了。
|
// 只有 IPv6 链路地址 + 自动配置的 IPv4:自动配置那条是候选(排在私网之后)。
|
||||||
lanIf("eth0", net.FlagUp, "169.254.10.5/16", "fe80::a00:27ff:fe4e:66a1/64"),
|
lanIf("eth0", net.FlagUp, "169.254.10.5/16", "fe80::a00:27ff:fe4e:66a1/64"),
|
||||||
// 接口 down 掉:配置还在,包发不出去。
|
// 接口 down 掉:配置还在,包发不出去。
|
||||||
lanIf("eth1", 0, "192.168.5.5/24"),
|
lanIf("eth1", 0, "192.168.5.5/24"),
|
||||||
@@ -48,8 +90,8 @@ func TestLanAddressesFiltersUnusable(t *testing.T) {
|
|||||||
lanIf("wlan0", net.FlagUp, "2400:3200::1/64", "10.1.2.3/8"),
|
lanIf("wlan0", net.FlagUp, "2400:3200::1/64", "10.1.2.3/8"),
|
||||||
}
|
}
|
||||||
|
|
||||||
want := []string{"10.1.2.3", "172.20.0.1"}
|
want := []string{"10.1.2.3", "172.20.0.1", "169.254.10.5"}
|
||||||
got := lanAddresses(ifaces)
|
got, _ := lanAddresses(ifaces, nil)
|
||||||
if !reflect.DeepEqual(lanIPs(got), want) {
|
if !reflect.DeepEqual(lanIPs(got), want) {
|
||||||
t.Fatalf("可用地址应为 %v,得到 %v", want, lanIPs(got))
|
t.Fatalf("可用地址应为 %v,得到 %v", want, lanIPs(got))
|
||||||
}
|
}
|
||||||
@@ -58,36 +100,517 @@ func TestLanAddressesFiltersUnusable(t *testing.T) {
|
|||||||
if a.Family != "ipv4" {
|
if a.Family != "ipv4" {
|
||||||
t.Fatalf("family 应为 ipv4,得到 %q(%s)", a.Family, a.IP)
|
t.Fatalf("family 应为 ipv4,得到 %q(%s)", a.Family, a.IP)
|
||||||
}
|
}
|
||||||
|
if a.Scope == "" {
|
||||||
|
t.Fatalf("scope 必须下发(前端据此给链路本地地址加说明),%s 是空的", a.IP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 严格轮命中时一律 high:这些地址都来自「接口确实 up」的网卡,不需要给现场任何警告。
|
||||||
|
if c := lanConfidences(got); !reflect.DeepEqual(c, []string{"high", "high", "high"}) {
|
||||||
|
t.Fatalf("严格轮命中的地址应全部 high,得到 %v", c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 一个地址都挑不出来时必须回空切片而不是 nil:JSON 里要是 null,前端分不清
|
// 一个地址都挑不出来时必须回空切片而不是 nil:JSON 里要是 null,前端分不清
|
||||||
// 「没有局域网地址」和「服务端没给这个字段」。
|
// 「没有局域网地址」和「服务端没给这个字段」。skipped 同理。
|
||||||
func TestLanAddressesEmptyResultIsNonNil(t *testing.T) {
|
func TestLanAddressesEmptyResultIsNonNil(t *testing.T) {
|
||||||
cases := map[string][]ifaceSnapshot{
|
cases := map[string][]ifaceSnapshot{
|
||||||
"没有网卡": nil,
|
"没有网卡": nil,
|
||||||
"只有回环": {lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8")},
|
"只有回环": {lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8")},
|
||||||
"只有 IPv6": {lanIf("eth0", net.FlagUp, "fe80::1/64")},
|
"只有 IPv6": {
|
||||||
"接口全 down": {lanIf("eth0", 0, "192.168.1.23/24")},
|
lanIf("eth0", net.FlagUp, "fe80::1/64"),
|
||||||
|
},
|
||||||
|
// 未指定 / 组播 / 广播这些「当访问目标没意义」的地址,宽松轮也不放行。
|
||||||
|
"只有未指定与组播": {lanIf("eth0", net.FlagUp, "0.0.0.0/32", "224.0.0.1/4")},
|
||||||
|
"接口全 down 且只有回环": {lanIf("eth0", 0, "127.0.0.1/8")},
|
||||||
}
|
}
|
||||||
for name, ifaces := range cases {
|
for name, ifaces := range cases {
|
||||||
got := lanAddresses(ifaces)
|
got, skipped := lanAddresses(ifaces, nil)
|
||||||
if got == nil {
|
if got == nil {
|
||||||
t.Fatalf("%s:应回空切片而不是 nil", name)
|
t.Fatalf("%s:应回空切片而不是 nil", name)
|
||||||
}
|
}
|
||||||
if len(got) != 0 {
|
if len(got) != 0 {
|
||||||
t.Fatalf("%s:不该有地址,得到 %v", name, lanIPs(got))
|
t.Fatalf("%s:不该有地址,得到 %v", name, lanIPs(got))
|
||||||
}
|
}
|
||||||
raw, err := json.Marshal(networkInfo{Port: 1, Addresses: got})
|
if skipped == nil {
|
||||||
|
t.Fatalf("%s:skipped 应回空切片而不是 nil", name)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(networkInfo{Port: 1, Addresses: got, Skipped: skipped})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("%s:序列化失败 %v", name, err)
|
t.Fatalf("%s:序列化失败 %v", name, err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(raw), `"addresses":[]`) {
|
if !strings.Contains(string(raw), `"addresses":[]`) || !strings.Contains(string(raw), `"skipped":[`) {
|
||||||
t.Fatalf("%s:addresses 应序列化为 [],得到 %s", name, raw)
|
t.Fatalf("%s:addresses 应是 [],skipped 应是数组而不是 null,得到 %s", name, raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 严格轮有结果时绝不跑宽松轮:那张 down 掉却配着 10.0.0.10 的网卡不能混进地址列表,
|
||||||
|
// 哪怕它「看起来能用」——机器上还有一张正常的网卡,现场要的是确定能连的那条。
|
||||||
|
// 同时钉住契约里那份 skipped 样例的形状:没置 up 的网卡带着可用地址、另一张只有 IPv6。
|
||||||
|
func TestLanAddressesStrictRoundWins(t *testing.T) {
|
||||||
|
ifaces := []ifaceSnapshot{
|
||||||
|
lanIf("以太网", net.FlagUp, "192.168.1.23/24"),
|
||||||
|
lanIf("WLAN", 0, "10.0.0.10/24"),
|
||||||
|
lanIf("蓝牙网络连接", net.FlagUp, "169.254.11.239/16"),
|
||||||
|
}
|
||||||
|
|
||||||
|
got, skipped := lanAddresses(ifaces, nil)
|
||||||
|
// 自动配置地址是候选、但排在私网地址之后:现场照抄第一条时不会先抄到适用范围更窄的那条。
|
||||||
|
if want := []string{"192.168.1.23", "169.254.11.239"}; !reflect.DeepEqual(lanIPs(got), want) {
|
||||||
|
t.Fatalf("严格轮应回 %v,得到 %v", want, lanIPs(got))
|
||||||
|
}
|
||||||
|
if c := lanConfidences(got); !reflect.DeepEqual(c, []string{"high", "high"}) {
|
||||||
|
t.Fatalf("严格轮命中不应出现 low,得到 %v", c)
|
||||||
|
}
|
||||||
|
if s := lanScopes(got); !reflect.DeepEqual(s, []string{"private", "link-local"}) {
|
||||||
|
t.Fatalf("scope 应为 [private link-local],得到 %v", s)
|
||||||
|
}
|
||||||
|
// 只有 WLAN 真的没贡献地址(它 down 掉了,严格轮不会跑宽松轮兜底)。
|
||||||
|
if want := []string{"WLAN:not-up"}; !reflect.DeepEqual(skipCodes(skipped), want) {
|
||||||
|
t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped))
|
||||||
|
}
|
||||||
|
// 没置 up 却配着可用地址的网卡要把地址原样报出来:现场一眼就能看出「地址是好的、只是读不到状态」。
|
||||||
|
if want := []string{"10.0.0.10/24"}; !reflect.DeepEqual(skipped[0].Addresses, want) {
|
||||||
|
t.Fatalf("skipped 应带上网卡上的原始地址 %v,得到 %v", want, skipped[0].Addresses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这张用例就是主人现场那个坑:整机只看得到「up 位读不到」的网卡(Windows 的驱动 / 虚拟网卡
|
||||||
|
// 会把 IfOperStatusUp 读成 0)。严格轮一无所获时必须跑宽松轮,把地址标成 low 交给现场,
|
||||||
|
// 而不是回一句「未检测到局域网地址」。
|
||||||
|
func TestLanAddressesFallsBackToLowConfidence(t *testing.T) {
|
||||||
|
ifaces := []ifaceSnapshot{
|
||||||
|
lanIf("以太网", 0, "10.0.0.10/24"),
|
||||||
|
lanIf("ZeroTier One", 0, "192.168.0.100/24"),
|
||||||
|
lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"),
|
||||||
|
}
|
||||||
|
|
||||||
|
got, skipped := lanAddresses(ifaces, nil)
|
||||||
|
// 排序档位照旧:192.168 段排最前。
|
||||||
|
if want := []string{"192.168.0.100", "10.0.0.10"}; !reflect.DeepEqual(lanIPs(got), want) {
|
||||||
|
t.Fatalf("宽松轮应回 %v,得到 %v", want, lanIPs(got))
|
||||||
|
}
|
||||||
|
if c := lanConfidences(got); !reflect.DeepEqual(c, []string{"low", "low"}) {
|
||||||
|
t.Fatalf("宽松轮挑出的地址必须标 low,得到 %v", c)
|
||||||
|
}
|
||||||
|
// 地址本身仍然要可用:family、接口名、scope 一条都不能少。
|
||||||
|
for _, a := range got {
|
||||||
|
if a.Family != "ipv4" || a.Interface == "" || a.Scope == "" {
|
||||||
|
t.Fatalf("宽松轮地址字段不完整: %+v", a)
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(a.IP)
|
||||||
|
if ip == nil || ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() {
|
||||||
|
t.Fatalf("宽松轮不该放行不可用地址: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 真的贡献了地址的网卡不算「被跳过」,只留下那张回环的。
|
||||||
|
if want := []string{"lo:loopback"}; !reflect.DeepEqual(skipCodes(skipped), want) {
|
||||||
|
t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 宽松轮放宽的只有「up 位」这一条:回环、未指定、组播这些「当访问目标没意义」的地址
|
||||||
|
// 在宽松轮里照样不算候选(链路本地则照收,见 TestLanAddressesCollectsLinkLocal)。
|
||||||
|
func TestLanAddressesLenientKeepsAddressFilters(t *testing.T) {
|
||||||
|
ifaces := []ifaceSnapshot{
|
||||||
|
lanIf("以太网", 0, "0.0.0.0/32", "224.0.0.1/4", "255.255.255.255/32", "127.0.0.1/8"),
|
||||||
|
}
|
||||||
|
|
||||||
|
got, skipped := lanAddresses(ifaces, nil)
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("宽松轮也不该放行回环 / 未指定 / 组播 / 广播,得到 %v", lanIPs(got))
|
||||||
|
}
|
||||||
|
// 这张网卡一个候选地址都没有,原因按优先级给:接口没置 up 排在回环之前。
|
||||||
|
if want := []string{"以太网:not-up"}; !reflect.DeepEqual(skipCodes(skipped), want) {
|
||||||
|
t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主人现场那条规则:**up 的网卡上只配了自动配置地址(链路本地)也要收下**。
|
||||||
|
// 那是无 DHCP 的独立内网里唯一能用的地址,平板实测打得开;曾经因为一句「没拿到 DHCP
|
||||||
|
// 说明网络不健康」的猜测把它滤掉,卡片就报「未检测到局域网地址」。
|
||||||
|
func TestLanAddressesCollectsLinkLocal(t *testing.T) {
|
||||||
|
ifaces := []ifaceSnapshot{
|
||||||
|
// 现场那台机器:以太网 up,只有 IPv6 链路地址 + 自动配置 IPv4。
|
||||||
|
lanIf("以太网", net.FlagUp, "fe80::b551:79ff:fe1a:2b3c/64", "169.254.79.190/16"),
|
||||||
|
}
|
||||||
|
|
||||||
|
got, skipped := lanAddresses(ifaces, nil)
|
||||||
|
if want := []string{"169.254.79.190"}; !reflect.DeepEqual(lanIPs(got), want) {
|
||||||
|
t.Fatalf("链路本地地址应进 addresses,得到 %v", lanIPs(got))
|
||||||
|
}
|
||||||
|
if s := lanScopes(got); !reflect.DeepEqual(s, []string{"link-local"}) {
|
||||||
|
t.Fatalf("scope 应为 link-local,得到 %v", s)
|
||||||
|
}
|
||||||
|
if c := lanConfidences(got); !reflect.DeepEqual(c, []string{"high"}) {
|
||||||
|
t.Fatalf("up 的网卡上的地址应是 high,得到 %v", c)
|
||||||
|
}
|
||||||
|
// 它贡献了地址,就不该出现在 skipped 里(link-local 已经不是跳过理由了)。
|
||||||
|
if got := skipCodes(skipped); len(got) != 0 {
|
||||||
|
t.Fatalf("skipped 不该提到这张贡献了地址的网卡,得到 %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// down 的网卡上只有自动配置地址时,原因必须是 not-up、**不是** link-local
|
||||||
|
// (链路本地已经不算「跳过理由」了,真实原因是接口没起来)。
|
||||||
|
// 这里另给一张正常的网卡,让严格轮拿到结果——否则宽松轮会像兜底别的地址一样把它捡回来。
|
||||||
|
func TestLanAddressesDownLinkLocalReportsNotUp(t *testing.T) {
|
||||||
|
ifaces := []ifaceSnapshot{
|
||||||
|
lanIf("WLAN", net.FlagUp, "192.168.1.23/24"),
|
||||||
|
lanIf("以太网", 0, "169.254.79.190/16", "fe80::1/64"),
|
||||||
|
}
|
||||||
|
|
||||||
|
got, skipped := lanAddresses(ifaces, nil)
|
||||||
|
if want := []string{"192.168.1.23"}; !reflect.DeepEqual(lanIPs(got), want) {
|
||||||
|
t.Fatalf("严格轮有结果时只应回 %v,得到 %v", want, lanIPs(got))
|
||||||
|
}
|
||||||
|
if want := []string{"以太网:not-up"}; !reflect.DeepEqual(skipCodes(skipped), want) {
|
||||||
|
t.Fatalf("skipped 应为 %v(不是 link-local),得到 %v", want, skipCodes(skipped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反面:整机只剩一张「读不到 up 位」的网卡、而它只有自动配置地址时,宽松轮照样兜底把它捡回来
|
||||||
|
// (这正是主人现场那台机器在 up 位读不到时的形态)。宽松轮放宽的只有 up 位这一条,
|
||||||
|
// 与地址类别无关;捡回来的地址标 low,前端会额外说明来源。
|
||||||
|
func TestLanAddressesLenientCollectsLinkLocalToo(t *testing.T) {
|
||||||
|
ifaces := []ifaceSnapshot{
|
||||||
|
lanIf("以太网", 0, "169.254.79.190/16", "fe80::1/64"),
|
||||||
|
lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"),
|
||||||
|
}
|
||||||
|
|
||||||
|
got, skipped := lanAddresses(ifaces, nil)
|
||||||
|
if want := []string{"169.254.79.190"}; !reflect.DeepEqual(lanIPs(got), want) {
|
||||||
|
t.Fatalf("宽松轮应兜底回 %v,得到 %v", want, lanIPs(got))
|
||||||
|
}
|
||||||
|
if c := lanConfidences(got); !reflect.DeepEqual(c, []string{"low"}) {
|
||||||
|
t.Fatalf("兜底捡回来的地址应标 low,得到 %v", c)
|
||||||
|
}
|
||||||
|
if s := lanScopes(got); !reflect.DeepEqual(s, []string{"link-local"}) {
|
||||||
|
t.Fatalf("scope 应为 link-local,得到 %v", s)
|
||||||
|
}
|
||||||
|
if want := []string{"lo:loopback"}; !reflect.DeepEqual(skipCodes(skipped), want) {
|
||||||
|
t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序:同一台机器上私网地址在前、链路本地在后(链接与二维码给第一条,必须给适用范围更广的)。
|
||||||
|
func TestLanAddressesRanksLinkLocalLast(t *testing.T) {
|
||||||
|
ifaces := []ifaceSnapshot{
|
||||||
|
lanIf("以太网", net.FlagUp, "169.254.79.190/16"),
|
||||||
|
lanIf("WLAN", net.FlagUp, "192.168.1.23/24"),
|
||||||
|
lanIf("ZeroTier", net.FlagUp, "10.0.0.10/24"),
|
||||||
|
lanIf("docker0", net.FlagUp, "172.20.0.1/16"),
|
||||||
|
lanIf("运营商专线", net.FlagUp, "203.0.113.9/24"),
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := lanAddresses(ifaces, nil)
|
||||||
|
want := []string{"192.168.1.23", "10.0.0.10", "172.20.0.1", "203.0.113.9", "169.254.79.190"}
|
||||||
|
if !reflect.DeepEqual(lanIPs(got), want) {
|
||||||
|
t.Fatalf("地址顺序应为 %v(链路本地最后),得到 %v", want, lanIPs(got))
|
||||||
|
}
|
||||||
|
if s := lanScopes(got); !reflect.DeepEqual(s, []string{"private", "private", "private", "global", "link-local"}) {
|
||||||
|
t.Fatalf("scope 顺序不对: %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 第三层来源:默认路由探测 ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 测试里一次真 Dial 都不做:Dial 通过 scriptedRouteProbe 注入假实现。
|
||||||
|
|
||||||
|
// fakeLocalAddr 是「认不出主机:端口」的本地地址(真机上 pipe / 抽象套接字会是这样)。
|
||||||
|
type fakeLocalAddr struct{ text string }
|
||||||
|
|
||||||
|
func (a fakeLocalAddr) Network() string { return "udp" }
|
||||||
|
func (a fakeLocalAddr) String() string { return a.text }
|
||||||
|
|
||||||
|
// fakeConn 是探测里唯一用到的两点能力:LocalAddr 与 Close。
|
||||||
|
type fakeConn struct {
|
||||||
|
local net.Addr
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeConn) LocalAddr() net.Addr { return c.local }
|
||||||
|
func (c *fakeConn) Close() error {
|
||||||
|
c.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scriptedRouteProbe 造一个「假 Dial + 调用记录」的生产级 routeProbe:
|
||||||
|
// 走的是真的 probeRouteSourceIPs 与真的目标表,只有 Dial 被换掉。
|
||||||
|
// script 里没有的目标 = 没有到那个网段的路由(Dial 直接失败);值为空串 = 本地地址解析不出来。
|
||||||
|
func scriptedRouteProbe(calls *[]string, conns *[]*fakeConn, script map[string]string) routeProbe {
|
||||||
|
dial := func(network, address string) (localAddrConn, error) {
|
||||||
|
*calls = append(*calls, network+" "+address)
|
||||||
|
source, ok := script[address]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("network is unreachable")
|
||||||
|
}
|
||||||
|
conn := &fakeConn{}
|
||||||
|
if source == "" {
|
||||||
|
conn.local = fakeLocalAddr{text: "pipe"}
|
||||||
|
} else {
|
||||||
|
conn.local = &net.UDPAddr{IP: net.ParseIP(source), Port: 51820}
|
||||||
|
}
|
||||||
|
*conns = append(*conns, conn)
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
return func() []net.IP { return probeRouteSourceIPs(dial, routeProbeTargets) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测目标必须都是「主机:端口」且是 UDP 能消化的形状:写错了 Dial 会直接失败,
|
||||||
|
// 现场那一层保险就静默失灵(而它只在别的路都断了时才被用到,根本没人会发现)。
|
||||||
|
func TestRouteProbeTargetsAreUsable(t *testing.T) {
|
||||||
|
want := []string{"192.168.0.1:9", "192.168.1.1:9", "10.0.0.1:9", "172.16.0.1:9", "8.8.8.8:53", "223.5.5.5:53"}
|
||||||
|
if !reflect.DeepEqual(routeProbeTargets, want) {
|
||||||
|
t.Fatalf("探测目标应为 %v,得到 %v", want, routeProbeTargets)
|
||||||
|
}
|
||||||
|
for _, target := range routeProbeTargets {
|
||||||
|
host, port, err := net.SplitHostPort(target)
|
||||||
|
if err != nil || net.ParseIP(host).To4() == nil || port == "" {
|
||||||
|
t.Fatalf("目标 %q 不是合法的 udp4 目标: %v", target, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测本身只负责「问操作系统 + 去重 + 关掉连接」,地址可用性过滤在 routeAddresses 里做:
|
||||||
|
// 这一层如实回报系统说了什么(比如只有回环路由的开发机会老实回 127.0.0.1),
|
||||||
|
// 拿不拿给现场用是下一层的事。反面的本地地址(解析不出主机)不能让整层崩掉。
|
||||||
|
func TestProbeRouteSourceIPsDedupsAndClosesConns(t *testing.T) {
|
||||||
|
var calls []string
|
||||||
|
var conns []*fakeConn
|
||||||
|
// 第 1、2 个目标都落在 192.168.1.23(去重);第 3、4 个给回环 / link-local(这一层照收,
|
||||||
|
// 由 routeAddresses 滤掉);第 5 个本地地址解析不出来;第 6 个没有路由。
|
||||||
|
probe := scriptedRouteProbe(&calls, &conns, map[string]string{
|
||||||
|
"192.168.0.1:9": "192.168.1.23",
|
||||||
|
"192.168.1.1:9": "192.168.1.23",
|
||||||
|
"10.0.0.1:9": "127.0.0.1",
|
||||||
|
"172.16.0.1:9": "169.254.10.5",
|
||||||
|
"223.5.5.5:53": "",
|
||||||
|
// 8.8.8.8:53 不在脚本里 = 没有路由
|
||||||
|
})
|
||||||
|
|
||||||
|
got := probe()
|
||||||
|
want := []string{"192.168.1.23", "127.0.0.1", "169.254.10.5"}
|
||||||
|
if !reflect.DeepEqual(ipTexts(got), want) {
|
||||||
|
t.Fatalf("探测应回 %v(去重、保留系统原话),得到 %v", want, ipTexts(got))
|
||||||
|
}
|
||||||
|
if len(calls) != len(routeProbeTargets) {
|
||||||
|
t.Fatalf("每个目标都该试一次(共 %d 次),得到 %v", len(routeProbeTargets), calls)
|
||||||
|
}
|
||||||
|
for _, call := range calls {
|
||||||
|
if !strings.HasPrefix(call, "udp4 ") {
|
||||||
|
t.Fatalf("探测必须是 udp4(TCP 在离线现场会等超时),得到 %q", call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 拿到的连接必须关掉:现场一次请求最多开 6 个套接字,漏关就是慢性句柄泄漏。
|
||||||
|
for i, conn := range conns {
|
||||||
|
if !conn.closed {
|
||||||
|
t.Fatalf("第 %d 个探测连接没有关闭", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有默认路由(离线 / 只有回环)时探测回空,不能把「问不出来」变成错误或 panic。
|
||||||
|
func TestProbeRouteSourceIPsNoRoute(t *testing.T) {
|
||||||
|
var calls []string
|
||||||
|
var conns []*fakeConn
|
||||||
|
probe := scriptedRouteProbe(&calls, &conns, nil)
|
||||||
|
|
||||||
|
if got := probe(); len(got) != 0 {
|
||||||
|
t.Fatalf("没有路由时应回空,得到 %v", ipTexts(got))
|
||||||
|
}
|
||||||
|
if len(calls) != len(routeProbeTargets) {
|
||||||
|
t.Fatalf("每个目标都该试一次,得到 %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前三层都为空、但路由探测命中时:返回 confidence = route 的地址(第三层来源)。
|
||||||
|
// 这正是那台单块 Intel 网卡的 Windows:枚举一条都给不出,只能问操作系统要。
|
||||||
|
// (这里的网卡快照要真的「什么都没有」:只配 IPv6 的 up 网卡 + 回环,才轮得到第三层。)
|
||||||
|
func TestLanAddressesRouteFallback(t *testing.T) {
|
||||||
|
ifaces := []ifaceSnapshot{
|
||||||
|
lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"),
|
||||||
|
lanIf("以太网", net.FlagUp, "fe80::1/64"),
|
||||||
|
}
|
||||||
|
var calls []string
|
||||||
|
var conns []*fakeConn
|
||||||
|
probe := scriptedRouteProbe(&calls, &conns, map[string]string{"192.168.0.1:9": "192.168.5.9"})
|
||||||
|
|
||||||
|
got, skipped := lanAddresses(ifaces, probe)
|
||||||
|
if want := []string{"192.168.5.9"}; !reflect.DeepEqual(lanIPs(got), want) {
|
||||||
|
t.Fatalf("路由探测命中时应回 %v,得到 %v", want, lanIPs(got))
|
||||||
|
}
|
||||||
|
if c := lanConfidences(got); !reflect.DeepEqual(c, []string{"route"}) {
|
||||||
|
t.Fatalf("探测来的地址应标 route,得到 %v", c)
|
||||||
|
}
|
||||||
|
if s := lanScopes(got); !reflect.DeepEqual(s, []string{"private"}) {
|
||||||
|
t.Fatalf("探测来的地址也要有 scope,得到 %v", s)
|
||||||
|
}
|
||||||
|
if got[0].Family != "ipv4" || got[0].Interface != routeInterfaceLabel {
|
||||||
|
t.Fatalf("探测来的地址字段不对: %+v", got[0])
|
||||||
|
}
|
||||||
|
// 网卡那两条原因照旧给出来:地址是路由给的,不代表这些网卡没被跳过。
|
||||||
|
if want := []string{"lo:loopback", "以太网:only-ipv6"}; !reflect.DeepEqual(skipCodes(skipped), want) {
|
||||||
|
t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测回来的地址也要过同一套类别判定:只有回环路由的开发机上,系统会老实回答 127.0.0.1,
|
||||||
|
// 那种地址给平板毫无意义,必须滤掉后仍然回空;而未指定 / 组播同理。
|
||||||
|
// 反过来,探测回来的链路本地地址**要留下**(它是无 DHCP 内网里唯一能用的那种地址)。
|
||||||
|
func TestLanAddressesRouteFallbackKeepsAddressFilters(t *testing.T) {
|
||||||
|
ifaces := []ifaceSnapshot{
|
||||||
|
lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"),
|
||||||
|
}
|
||||||
|
var calls []string
|
||||||
|
var conns []*fakeConn
|
||||||
|
probe := scriptedRouteProbe(&calls, &conns, map[string]string{
|
||||||
|
"192.168.0.1:9": "127.0.0.1",
|
||||||
|
"10.0.0.1:9": "0.0.0.0",
|
||||||
|
"8.8.8.8:53": "224.0.0.1",
|
||||||
|
})
|
||||||
|
|
||||||
|
got, skipped := lanAddresses(ifaces, probe)
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("回环 / 未指定 / 组播的探测结果都该被滤掉,得到 %v", lanIPs(got))
|
||||||
|
}
|
||||||
|
if skipped == nil {
|
||||||
|
t.Fatal("skipped 应回空切片而不是 nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 链路本地的探测结果留下,并如实标 scope。
|
||||||
|
var linkLocalCalls []string
|
||||||
|
var linkLocalConns []*fakeConn
|
||||||
|
linkLocalProbe := scriptedRouteProbe(&linkLocalCalls, &linkLocalConns, map[string]string{"192.168.0.1:9": "169.254.7.7"})
|
||||||
|
|
||||||
|
got, _ = lanAddresses(ifaces, linkLocalProbe)
|
||||||
|
if want := []string{"169.254.7.7"}; !reflect.DeepEqual(lanIPs(got), want) {
|
||||||
|
t.Fatalf("探测回来的链路本地地址应留下,得到 %v", lanIPs(got))
|
||||||
|
}
|
||||||
|
if s := lanScopes(got); !reflect.DeepEqual(s, []string{"link-local"}) {
|
||||||
|
t.Fatalf("scope 应为 link-local,得到 %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测失败(机器没有默认路由)时:仍然回空数组、不报错,skipped 照旧把原因说清楚。
|
||||||
|
func TestLanAddressesRouteProbeFailureKeepsEmptyResult(t *testing.T) {
|
||||||
|
ifaces := []ifaceSnapshot{
|
||||||
|
lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"),
|
||||||
|
lanIf("WLAN", net.FlagUp, "fe80::1/64"),
|
||||||
|
}
|
||||||
|
var calls []string
|
||||||
|
var conns []*fakeConn
|
||||||
|
probe := scriptedRouteProbe(&calls, &conns, nil)
|
||||||
|
|
||||||
|
got, skipped := lanAddresses(ifaces, probe)
|
||||||
|
if got == nil || len(got) != 0 {
|
||||||
|
t.Fatalf("探测失败时应回空切片而不是 nil / 地址,得到 %#v", got)
|
||||||
|
}
|
||||||
|
if len(calls) != len(routeProbeTargets) {
|
||||||
|
t.Fatalf("探测失败也该把目标试完,得到 %v", calls)
|
||||||
|
}
|
||||||
|
if want := []string{"lo:loopback", "WLAN:only-ipv6"}; !reflect.DeepEqual(skipCodes(skipped), want) {
|
||||||
|
t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前两轮任一有结果时,第三层一次 Dial 都不能发生:正常机器不该为了「保险」去碰网络栈
|
||||||
|
// (现场可能完全离线,而这层保险只有在枚举确实给不出任何东西时才有意义)。
|
||||||
|
// 这里数的是真 Dial 的次数,不是「探测函数被调了几次」——脚本故意给一个能命中的源地址,
|
||||||
|
// 真去探测的话在 calls 里一定看得见。
|
||||||
|
func TestLanAddressesDoesNotProbeWhenEnumerated(t *testing.T) {
|
||||||
|
var calls []string
|
||||||
|
var conns []*fakeConn
|
||||||
|
probe := scriptedRouteProbe(&calls, &conns, map[string]string{"192.168.0.1:9": "192.168.5.9"})
|
||||||
|
|
||||||
|
// 严格轮命中。
|
||||||
|
strictGot, _ := lanAddresses([]ifaceSnapshot{lanIf("以太网", net.FlagUp, "192.168.1.23/24")}, probe)
|
||||||
|
if len(calls) != 0 {
|
||||||
|
t.Fatalf("严格轮命中时一次 Dial 都不该发生,实际 %d 次: %v", len(calls), calls)
|
||||||
|
}
|
||||||
|
if c := lanConfidences(strictGot); !reflect.DeepEqual(c, []string{"high"}) {
|
||||||
|
t.Fatalf("严格轮的地址应标 high,得到 %v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 宽松轮命中(接口没置 up,但地址可用)。
|
||||||
|
lenientGot, _ := lanAddresses([]ifaceSnapshot{lanIf("以太网", 0, "10.0.0.10/24")}, probe)
|
||||||
|
if len(calls) != 0 {
|
||||||
|
t.Fatalf("宽松轮命中时一次 Dial 都不该发生,实际 %d 次: %v", len(calls), calls)
|
||||||
|
}
|
||||||
|
if c := lanConfidences(lenientGot); !reflect.DeepEqual(c, []string{"low"}) {
|
||||||
|
t.Fatalf("宽松轮的地址应标 low,得到 %v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前两轮都为空:这时候才轮到第三层,而且会把探测目标试一遍。
|
||||||
|
routeGot, _ := lanAddresses([]ifaceSnapshot{lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8")}, probe)
|
||||||
|
if len(calls) != len(routeProbeTargets) {
|
||||||
|
t.Fatalf("前两轮为空时该把 %d 个目标试一遍,实际 %v", len(routeProbeTargets), calls)
|
||||||
|
}
|
||||||
|
if c := lanConfidences(routeGot); !reflect.DeepEqual(c, []string{"route"}) {
|
||||||
|
t.Fatalf("探测来的地址应标 route,得到 %v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动日志那条路径绝不能探测:它跟服务启动在同一个 goroutine 上,为一行日志去碰网络栈不值得。
|
||||||
|
// Go 里没有别的办法钉住「调用点传了什么」,所以直接读源码断言。
|
||||||
|
func TestStartupLogNeverProbes(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("network.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读 network.go 失败: %v", err)
|
||||||
|
}
|
||||||
|
source := string(raw)
|
||||||
|
if !strings.Contains(source, "lanAddresses(ifaces, nil)") {
|
||||||
|
t.Fatal("启动日志必须传 nil 探测(只做枚举):lanAddresses(ifaces, nil) 不见了")
|
||||||
|
}
|
||||||
|
if !strings.Contains(source, "lanAddresses(s.snapshotInterfaces(), probeDefaultRoute)") {
|
||||||
|
t.Fatal("GET /api/system/network 才该带上默认路由探测:systemNetwork 里的调用点不对")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipped 的 code 优先级与去重:一张网卡同时命中多个原因时只给最能解释问题的那一个
|
||||||
|
// (no-address > only-ipv6 > not-up > loopback),且同一接口只出现一次。
|
||||||
|
// 注意链路本地不再是原因:只配了链路本地地址的 up 网卡会贡献地址,down 的网卡报 not-up。
|
||||||
|
func TestSkippedInterfacesPriorityAndDedup(t *testing.T) {
|
||||||
|
ifaces := []ifaceSnapshot{
|
||||||
|
// 回环接口:最低优先级,只有它自己命中时才轮得到。
|
||||||
|
lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"),
|
||||||
|
// 只有链路本地地址的 down 网卡:原因是 not-up(链路本地已经不算跳过理由)。
|
||||||
|
lanIf("WLAN", 0, "169.254.11.239/16"),
|
||||||
|
// 只有 IPv6:即使 up 位也读不到,仍给 only-ipv6。
|
||||||
|
lanIf("vEthernet (WSL)", 0, "fe80::a00:27ff:fe4e:66a1/64"),
|
||||||
|
// 一个地址都没有:最高优先级。
|
||||||
|
lanIf("蓝牙网络连接", net.FlagUp),
|
||||||
|
// 非回环网卡上只配了回环地址:按地址本身也得报回环。
|
||||||
|
lanIf("eth2", net.FlagUp, "127.0.0.5/8"),
|
||||||
|
// 同一张网卡出现两次(快照可能来自两个来源):只能报一条。
|
||||||
|
lanIf("WLAN", 0, "169.254.11.239/16"),
|
||||||
|
// 有一张正常的网卡,让严格轮拿到结果——否则宽松轮会把全 down 的现场整个改写。
|
||||||
|
lanIf("以太网", net.FlagUp, "192.168.1.23/24"),
|
||||||
|
}
|
||||||
|
|
||||||
|
got, skipped := lanAddresses(ifaces, nil)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("只该有以太网那条地址,得到 %v", lanIPs(got))
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"lo:loopback",
|
||||||
|
"WLAN:not-up",
|
||||||
|
"vEthernet (WSL):only-ipv6",
|
||||||
|
"蓝牙网络连接:no-address",
|
||||||
|
"eth2:loopback",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(skipCodes(skipped), want) {
|
||||||
|
t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped))
|
||||||
|
}
|
||||||
|
// addresses 要原样带出来(CIDR 文本),前端虽然不用,但排查时能直接看到配了什么。
|
||||||
|
if !reflect.DeepEqual(skipped[1].Addresses, []string{"169.254.11.239/16"}) {
|
||||||
|
t.Fatalf("skipped 应带上网卡上的原始地址,得到 %v", skipped[1].Addresses)
|
||||||
|
}
|
||||||
|
if skipped[3].Addresses == nil {
|
||||||
|
t.Fatal("没有地址的接口也应回空切片而不是 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 同一地址配在多张网卡上(桥接、双网卡同网段)时只留一条,且保留先出现的那张网卡名;
|
// 同一地址配在多张网卡上(桥接、双网卡同网段)时只留一条,且保留先出现的那张网卡名;
|
||||||
// 顺序按「像不像常见内网段」分档,同档再按地址数值升序——否则列表每次刷新都在跳,
|
// 顺序按「像不像常见内网段」分档,同档再按地址数值升序——否则列表每次刷新都在跳,
|
||||||
// 现场刚记住的链接下次就不是第一条了。
|
// 现场刚记住的链接下次就不是第一条了。
|
||||||
@@ -100,7 +623,7 @@ func TestLanAddressesDedupAndOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
want := []string{"192.168.1.9", "192.168.1.23", "192.168.1.100", "10.0.0.4", "172.20.1.5", "8.8.8.8"}
|
want := []string{"192.168.1.9", "192.168.1.23", "192.168.1.100", "10.0.0.4", "172.20.1.5", "8.8.8.8"}
|
||||||
got := lanAddresses(ifaces)
|
got, skipped := lanAddresses(ifaces, nil)
|
||||||
if !reflect.DeepEqual(lanIPs(got), want) {
|
if !reflect.DeepEqual(lanIPs(got), want) {
|
||||||
t.Fatalf("地址顺序应为 %v,得到 %v", want, lanIPs(got))
|
t.Fatalf("地址顺序应为 %v,得到 %v", want, lanIPs(got))
|
||||||
}
|
}
|
||||||
@@ -108,15 +631,111 @@ func TestLanAddressesDedupAndOrder(t *testing.T) {
|
|||||||
if got[1].Interface != "eth0" {
|
if got[1].Interface != "eth0" {
|
||||||
t.Fatalf("去重后应保留先出现的接口名 eth0,得到 %q", got[1].Interface)
|
t.Fatalf("去重后应保留先出现的接口名 eth0,得到 %q", got[1].Interface)
|
||||||
}
|
}
|
||||||
|
// br0 的地址只是被去重拿走了,网卡本身没毛病:不能出现在「被跳过的网卡」里。
|
||||||
|
if len(skipped) != 0 {
|
||||||
|
t.Fatalf("三张网卡都贡献或只是地址重复,skipped 应为空,得到 %v", skipCodes(skipped))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序档位本身:192.168/16 → 10/8 → 172.16-31/12 → 其余,越靠前越是现场真在用的那条。
|
// skipped 的每个 code 都要能翻成中文日志:这张表是启动日志 WARN 的全部依据,
|
||||||
|
// 漏一个就会在现场最需要解释的时候打出一个英文 code。
|
||||||
|
// 集合本身收敛成 4 个:链路本地不再是跳过理由(它现在是候选地址)。
|
||||||
|
func TestSkipReasonLabelCoversEveryCode(t *testing.T) {
|
||||||
|
labels := map[string]string{}
|
||||||
|
for _, code := range []string{skipNoAddress, skipOnlyIPv6, skipNotUp, skipLoopback} {
|
||||||
|
label := skipReasonLabel(code)
|
||||||
|
if label == "" || label == code {
|
||||||
|
t.Fatalf("code %q 没有中文说明", code)
|
||||||
|
}
|
||||||
|
labels[code] = label
|
||||||
|
}
|
||||||
|
if len(labels) != 4 {
|
||||||
|
t.Fatalf("应该有 4 个 code,得到 %d 个", len(labels))
|
||||||
|
}
|
||||||
|
// 链路本地已经不算原因,日志里也不该再有这个说法。
|
||||||
|
if skipReasonLabel("link-local") == "只有自动配置地址 169.254.x" {
|
||||||
|
t.Fatal("链路本地不该再作为跳过原因出现在日志里")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动日志的两种分支:有地址时 INFO 一行「局域网地址:IP(网卡)」,
|
||||||
|
// 一个都没有时 WARN 并把 skipped 的原因摘要带上——现场排障就靠这一条。
|
||||||
|
func TestLogLanAddressesBranches(t *testing.T) {
|
||||||
|
t.Run("有地址记 INFO", func(t *testing.T) {
|
||||||
|
out := captureNetworkLogs(t, []ifaceSnapshot{
|
||||||
|
lanIf("以太网", net.FlagUp, "192.168.0.100/24"),
|
||||||
|
lanIf("ZeroTier One", net.FlagUp, "10.0.0.10/8"),
|
||||||
|
lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"),
|
||||||
|
})
|
||||||
|
if !strings.Contains(out, "level=INFO") {
|
||||||
|
t.Fatalf("有地址时应记 INFO,得到 %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "局域网地址:192.168.0.100(以太网)、10.0.0.10(ZeroTier One)") {
|
||||||
|
t.Fatalf("INFO 里应带上地址与网卡名,得到 %s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "level=WARN") {
|
||||||
|
t.Fatalf("有地址时不该同时有 WARN,得到 %s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 主人现场那台机器:唯一能用的就是自动配置地址。日志走的是同一套筛选,必须照常打出来
|
||||||
|
// (不在日志里另写一套判断,否则又会出现「卡片有、日志没有」这种两处打架)。
|
||||||
|
t.Run("自动配置地址照常打出来", func(t *testing.T) {
|
||||||
|
out := captureNetworkLogs(t, []ifaceSnapshot{
|
||||||
|
lanIf("以太网", net.FlagUp, "fe80::b551:79ff:fe1a:2b3c/64", "169.254.79.190/16"),
|
||||||
|
})
|
||||||
|
if !strings.Contains(out, "level=INFO") {
|
||||||
|
t.Fatalf("有自动配置地址时应记 INFO 而不是 WARN,得到 %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "局域网地址:169.254.79.190(以太网)") {
|
||||||
|
t.Fatalf("INFO 里应带上自动配置地址,得到 %s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("没有地址记 WARN 并带上原因", func(t *testing.T) {
|
||||||
|
out := captureNetworkLogs(t, []ifaceSnapshot{
|
||||||
|
lanIf("以太网", net.FlagUp, "fe80::1/64"),
|
||||||
|
lanIf("WLAN", net.FlagUp, "0.0.0.0/32", "224.0.0.1/4"),
|
||||||
|
})
|
||||||
|
if !strings.Contains(out, "level=WARN") {
|
||||||
|
t.Fatalf("没有地址时应记 WARN,得到 %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "未找到可用的局域网地址") {
|
||||||
|
t.Fatalf("WARN 应说清是没有可用地址,得到 %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "以太网(只有 IPv6 地址)") ||
|
||||||
|
!strings.Contains(out, "WLAN(接口没有地址)") {
|
||||||
|
t.Fatalf("WARN 应带上 skipped 的中文原因摘要,得到 %s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "level=INFO") {
|
||||||
|
t.Fatalf("没有地址时不该有 INFO,得到 %s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("一张网卡都没枚举到", func(t *testing.T) {
|
||||||
|
out := captureNetworkLogs(t, nil)
|
||||||
|
if !strings.Contains(out, "level=WARN") || !strings.Contains(out, "没有枚举到任何网卡") {
|
||||||
|
t.Fatalf("没有网卡时也该给 WARN 与说明,得到 %s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureNetworkLogs 跑一遍 logLanAddresses 并把结构化日志收成文本。
|
||||||
|
func captureNetworkLogs(t *testing.T, ifaces []ifaceSnapshot) string {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logLanAddresses(slog.New(slog.NewTextHandler(&buf, nil)), ifaces)
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序档位本身:192.168/16 → 10/8 → 172.16-31/12 → 其余 → 链路本地,越靠前越是现场真在用的那条。
|
||||||
func TestLanRankPrefersCommonLanSegments(t *testing.T) {
|
func TestLanRankPrefersCommonLanSegments(t *testing.T) {
|
||||||
order := []string{
|
order := []string{
|
||||||
"192.168.0.1", "192.168.255.254", // 路由器出厂段
|
"192.168.0.1", "192.168.255.254", // 路由器出厂段
|
||||||
"10.0.0.1", "10.255.255.254", // 企业内网 / 热点
|
"10.0.0.1", "10.255.255.254", // 企业内网 / 热点
|
||||||
"172.16.0.1", "172.31.255.254", // 最少见,多是虚拟网桥
|
"172.16.0.1", "172.31.255.254", // 最少见,多是虚拟网桥
|
||||||
"172.15.0.1", "172.32.0.1", "203.0.113.9", // 172.16/12 之外
|
"172.15.0.1", "172.32.0.1", "203.0.113.9", // 172.16/12 之外
|
||||||
|
"169.254.0.1", "169.254.255.254", // 自动配置段永远最后
|
||||||
}
|
}
|
||||||
for i := 1; i < len(order); i++ {
|
for i := 1; i < len(order); i++ {
|
||||||
prev, curr := net.ParseIP(order[i-1]).To4(), net.ParseIP(order[i]).To4()
|
prev, curr := net.ParseIP(order[i-1]).To4(), net.ParseIP(order[i]).To4()
|
||||||
@@ -127,6 +746,121 @@ func TestLanRankPrefersCommonLanSegments(t *testing.T) {
|
|||||||
if lanRank(net.ParseIP("172.15.0.1").To4()) != 3 || lanRank(net.ParseIP("172.32.0.1").To4()) != 3 {
|
if lanRank(net.ParseIP("172.15.0.1").To4()) != 3 || lanRank(net.ParseIP("172.32.0.1").To4()) != 3 {
|
||||||
t.Fatal("172.16/12 的两侧边界不该被当成常见内网段")
|
t.Fatal("172.16/12 的两侧边界不该被当成常见内网段")
|
||||||
}
|
}
|
||||||
|
// 链路本地必须严格排在所有别的类别之后(排序第一位会决定现场照抄哪条、二维码给谁)。
|
||||||
|
for _, ip := range []string{"192.168.0.1", "10.0.0.1", "172.16.0.1", "203.0.113.9"} {
|
||||||
|
if lanRank(net.ParseIP(ip).To4()) >= lanRank(net.ParseIP("169.254.1.1").To4()) {
|
||||||
|
t.Fatalf("%s 应排在链路本地之前", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 地址分类(ipv4Scope)───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 分类函数的边界:只排除「作为对端访问目标没有意义」的四类。
|
||||||
|
// 判定必须走标准库谓词——网段知识抄一份到代码里,抄错一段就是「明明有地址却说没有」。
|
||||||
|
func TestIPv4ScopeClassifies(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ip string
|
||||||
|
scope string
|
||||||
|
usable bool
|
||||||
|
}{
|
||||||
|
{"127.0.0.1", "", false}, // 回环
|
||||||
|
{"127.255.255.254", "", false}, // 回环段的另一端
|
||||||
|
{"0.0.0.0", "", false}, // 未指定
|
||||||
|
{"224.0.0.1", "", false}, // 组播
|
||||||
|
{"239.255.255.250", "", false}, // 组播段另一端(SSDP)
|
||||||
|
{"255.255.255.255", "", false}, // 广播
|
||||||
|
{"169.254.1.2", "link-local", true}, // 自动配置段
|
||||||
|
{"169.254.255.254", "link-local", true},
|
||||||
|
{"192.168.1.1", "private", true}, // RFC1918 三段
|
||||||
|
{"10.0.0.1", "private", true},
|
||||||
|
{"172.16.0.1", "private", true},
|
||||||
|
{"172.31.255.254", "private", true},
|
||||||
|
{"8.8.8.8", "global", true}, // 其余都是 global
|
||||||
|
{"203.0.113.9", "global", true},
|
||||||
|
{"172.32.0.1", "global", true}, // 172.16/12 的边界之外
|
||||||
|
{"100.64.0.1", "global", true}, // 运营商级 NAT:标准库不算 private,照收
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
scope, usable := ipv4Scope(net.ParseIP(c.ip).To4())
|
||||||
|
if scope != c.scope || usable != c.usable {
|
||||||
|
t.Fatalf("%s 应为 (%q,%v),得到 (%q,%v)", c.ip, c.scope, c.usable, scope, usable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 认不出的输入(To4 之后仍为 nil)不能 panic。
|
||||||
|
if _, usable := ipv4Scope(nil); usable {
|
||||||
|
t.Fatal("nil 地址不该被当成可用")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反硬编码护栏(主人明确要求):分类不能用网段字面量,只能调标准库谓词。
|
||||||
|
// 断言方式:把 network.go 每行的注释部分去掉(注释里写解释是允许的,代码里写死才是判断),
|
||||||
|
// 再找那种「写死一段网段」的痕迹。
|
||||||
|
// 探测目标表里的网关地址(routeProbeTargets)是「猜网关」而不是「判地址类别」,不在约束内。
|
||||||
|
func TestScopeHasNoHardcodedSubnet(t *testing.T) {
|
||||||
|
source := codeWithoutComments(t, "network.go")
|
||||||
|
|
||||||
|
// 关键:链路本地段绝不能在代码里以字面量出现(那正是这次被修掉的写法)。
|
||||||
|
if strings.Contains(source, "169.254") {
|
||||||
|
t.Fatal("代码里出现了链路本地的网段字面量:类别判定必须用 net.IP 的谓词(IsLinkLocalUnicast 等)")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := functionBody(t, source, "func ipv4Scope(")
|
||||||
|
if body == "" {
|
||||||
|
t.Fatal("找不到 ipv4Scope 的函数体:分类函数改名了?这条护栏要跟着改")
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`\d+\.\d+\.\d+\.\d+`).MatchString(body) {
|
||||||
|
t.Fatal("ipv4Scope 里出现了 IP 字面量:分类只能调标准库谓词,不能写死网段")
|
||||||
|
}
|
||||||
|
for _, predicate := range []string{"IsLoopback", "IsUnspecified", "IsMulticast", "IPv4bcast", "IsLinkLocalUnicast", "IsPrivate"} {
|
||||||
|
if !strings.Contains(body, predicate) {
|
||||||
|
t.Fatalf("分类函数里缺少谓词 %s:边界会漏判", predicate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// codeWithoutComments 去掉源码每行的注释部分(含整行注释与行尾注释),只留代码。
|
||||||
|
func codeWithoutComments(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读 %s 失败: %v", path, err)
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
for _, line := range strings.Split(string(raw), "\n") {
|
||||||
|
if idx := strings.Index(line, "//"); idx >= 0 {
|
||||||
|
line = line[:idx]
|
||||||
|
}
|
||||||
|
out.WriteString(line)
|
||||||
|
out.WriteString("\n")
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// functionBody 抠出以 signature 开头那个函数的函数体(含花括号),找不到回空串。
|
||||||
|
func functionBody(t *testing.T, source, signature string) string {
|
||||||
|
t.Helper()
|
||||||
|
start := strings.Index(source, signature)
|
||||||
|
if start < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
open := strings.Index(source[start:], "{")
|
||||||
|
if open < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
depth := 0
|
||||||
|
for i := start + open; i < len(source); i++ {
|
||||||
|
switch source[i] {
|
||||||
|
case '{':
|
||||||
|
depth++
|
||||||
|
case '}':
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return source[start+open : i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 网卡地址实际都是 *net.IPNet,但兜底分支也要能取到 IP,否则换平台(如某些 BSD/虚拟网卡)
|
// 网卡地址实际都是 *net.IPNet,但兜底分支也要能取到 IP,否则换平台(如某些 BSD/虚拟网卡)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func TestSystemNetworkShape(t *testing.T) {
|
|||||||
if err := json.Unmarshal(env2.Data, &body); err != nil {
|
if err := json.Unmarshal(env2.Data, &body); err != nil {
|
||||||
t.Fatalf("解析 network: %v (%s)", err, env2.Data)
|
t.Fatalf("解析 network: %v (%s)", err, env2.Data)
|
||||||
}
|
}
|
||||||
assertKeys(t, "network", body, []string{"port", "displayPath", "adminPath", "addresses"})
|
assertKeys(t, "network", body, []string{"port", "displayPath", "adminPath", "addresses", "skipped"})
|
||||||
|
|
||||||
if got, ok := body["port"].(float64); !ok || int(got) != 18080 {
|
if got, ok := body["port"].(float64); !ok || int(got) != 18080 {
|
||||||
t.Fatalf("port 应回注入的实际监听端口 18080,得到 %#v", body["port"])
|
t.Fatalf("port 应回注入的实际监听端口 18080,得到 %#v", body["port"])
|
||||||
@@ -64,7 +64,8 @@ func TestSystemNetworkShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// addresses 必须是数组(没有地址时是 [] 而不是 null),每条都带 ip/interface/family 且是 IPv4。
|
// addresses 必须是数组(没有地址时是 [] 而不是 null),
|
||||||
|
// 每条都带 ip/interface/family/confidence/scope 且是 IPv4。
|
||||||
raw, ok := body["addresses"].([]any)
|
raw, ok := body["addresses"].([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("addresses 应是数组,得到 %#v", body["addresses"])
|
t.Fatalf("addresses 应是数组,得到 %#v", body["addresses"])
|
||||||
@@ -74,17 +75,49 @@ func TestSystemNetworkShape(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("地址项应是对象,得到 %#v", item)
|
t.Fatalf("地址项应是对象,得到 %#v", item)
|
||||||
}
|
}
|
||||||
assertKeys(t, "addresses[]", entry, []string{"ip", "interface", "family"})
|
assertKeys(t, "addresses[]", entry, []string{"ip", "interface", "family", "confidence", "scope"})
|
||||||
if entry["family"] != "ipv4" {
|
if entry["family"] != "ipv4" {
|
||||||
t.Fatalf("family 应为 ipv4,得到 %#v", entry["family"])
|
t.Fatalf("family 应为 ipv4,得到 %#v", entry["family"])
|
||||||
}
|
}
|
||||||
|
if c, _ := entry["confidence"].(string); c != "high" && c != "low" && c != "route" {
|
||||||
|
t.Fatalf("confidence 应是 high / low / route,得到 %#v", entry["confidence"])
|
||||||
|
}
|
||||||
|
if s, _ := entry["scope"].(string); s != "private" && s != "global" && s != "link-local" {
|
||||||
|
t.Fatalf("scope 应是 private / global / link-local,得到 %#v", entry["scope"])
|
||||||
|
}
|
||||||
ip := net.ParseIP(entry["ip"].(string))
|
ip := net.ParseIP(entry["ip"].(string))
|
||||||
if ip == nil || ip.To4() == nil {
|
if ip == nil || ip.To4() == nil {
|
||||||
t.Fatalf("ip 应是 IPv4 文本,得到 %#v", entry["ip"])
|
t.Fatalf("ip 应是 IPv4 文本,得到 %#v", entry["ip"])
|
||||||
}
|
}
|
||||||
// 回环与 link-local 是「拿得到、连不上」的地址,绝不能出现在给平板用的列表里。
|
// 回环 / 未指定 / 组播 / 广播是「拿得到、连不上」的地址,绝不能出现在给平板用的列表里。
|
||||||
if ip.IsLoopback() || ip.IsLinkLocalUnicast() {
|
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.Equal(net.IPv4bcast) {
|
||||||
t.Fatalf("不该出现回环 / link-local 地址: %#v", entry)
|
t.Fatalf("不该出现回环 / 未指定 / 组播 / 广播地址: %#v", entry)
|
||||||
|
}
|
||||||
|
// 链路本地**是**候选(无 DHCP 的独立内网里它是唯一能用的地址),但必须如实标 scope。
|
||||||
|
if ip.IsLinkLocalUnicast() && entry["scope"] != "link-local" {
|
||||||
|
t.Fatalf("链路本地地址的 scope 应为 link-local,得到 %#v", entry["scope"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipped 也必须始终是数组(没有可解释的接口时是 [] 而不是 null),每条的 code 都在约定集合里。
|
||||||
|
// 它只是给前端解释空态用的旁白,地址列表不受它影响。
|
||||||
|
skipped, ok := body["skipped"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("skipped 应是数组,得到 %#v", body["skipped"])
|
||||||
|
}
|
||||||
|
// 集合里没有 link-local:链路本地现在是候选地址,不再是「被跳过」的理由。
|
||||||
|
codes := map[string]bool{"no-address": true, "only-ipv6": true, "not-up": true, "loopback": true}
|
||||||
|
for _, item := range skipped {
|
||||||
|
entry, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("skipped 项应是对象,得到 %#v", item)
|
||||||
|
}
|
||||||
|
assertKeys(t, "skipped[]", entry, []string{"interface", "code", "addresses"})
|
||||||
|
if code, _ := entry["code"].(string); !codes[code] {
|
||||||
|
t.Fatalf("skipped 的 code 不在约定集合里: %#v", entry["code"])
|
||||||
|
}
|
||||||
|
if _, ok := entry["addresses"].([]any); !ok {
|
||||||
|
t.Fatalf("skipped 的 addresses 应是数组,得到 %#v", entry["addresses"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ func main() {
|
|||||||
// 只能靠人去数据目录里翻一个没人提过的文件。
|
// 只能靠人去数据目录里翻一个没人提过的文件。
|
||||||
warnPendingRestore(logger, cfg.DataDir)
|
warnPendingRestore(logger, cfg.DataDir)
|
||||||
|
|
||||||
|
// 启动时把本机局域网地址记一条:大屏控制页在别的设备上打开,卡片显示空态时现场未必
|
||||||
|
// 拿得到服务端窗口,这行日志(有地址是 INFO、没有是带原因的 WARN)是排障的第一站。
|
||||||
|
httpapi.LogLanAddresses(logger)
|
||||||
|
|
||||||
projectName, projectSource, err := resolveProjectName(cfg.DataDir, *project)
|
projectName, projectSource, err := resolveProjectName(cfg.DataDir, *project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("解析赛事项目失败", "err", err)
|
logger.Error("解析赛事项目失败", "err", err)
|
||||||
|
|||||||
@@ -12,7 +12,15 @@ import { settingsApi } from '../components/system/systemApi'
|
|||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useEventStore } from '../stores/event'
|
import { useEventStore } from '../stores/event'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
import { copyText, lanDisplayLinks } from './lanDisplay'
|
import {
|
||||||
|
copyText,
|
||||||
|
lanDisplayLinks,
|
||||||
|
lanEmptyNote,
|
||||||
|
manualAddressStorage,
|
||||||
|
parseManualAddress,
|
||||||
|
readManualAddresses,
|
||||||
|
writeManualAddresses,
|
||||||
|
} from './lanDisplay'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 页面选项直接取大屏那份清单(`@/display/types`):规范页名与中文标签只维护一处,
|
* 页面选项直接取大屏那份清单(`@/display/types`):规范页名与中文标签只维护一处,
|
||||||
@@ -348,11 +356,61 @@ const lanGateReason = computed(() =>
|
|||||||
auth.readOnly && !data.isMock ? '只有登录后的录入员 / 管理员能查看局域网地址,当前为只读访客。' : null,
|
auth.readOnly && !data.isMock ? '只有登录后的录入员 / 管理员能查看局域网地址,当前为只读访客。' : null,
|
||||||
)
|
)
|
||||||
|
|
||||||
const lanLinks = computed(() => (lanInfo.value === null ? [] : lanDisplayLinks(lanInfo.value)))
|
/**
|
||||||
|
* 手填的局域网地址(卡片最后的兜底)。
|
||||||
|
*
|
||||||
|
* 为什么要有:自动检测这条路在个别机器上就是给的(up 位读不到、枚举不出),或者给出的
|
||||||
|
* 网段不是现场要用的那张网卡。操作员从 ipconfig 抄一个 IP 进来,是唯一不依赖检测手段的办法。
|
||||||
|
*
|
||||||
|
* 存在 localStorage 里:现场是「填一次、整个赛事都用」,切个页面或刷新就丢会让人反复抄 ipconfig。
|
||||||
|
* 存储不可用(隐私模式 / 配额满)时只当没存上,这个功能本身不请求服务端,也就不该因为存储问题挂掉。
|
||||||
|
*
|
||||||
|
* 声明在 lanLinks 之前:那是个 computed,函数体里引用到手写地址列表,先有数据源再算结果。
|
||||||
|
*/
|
||||||
|
const manualIps = ref<string[]>(readManualAddresses(manualAddressStorage()))
|
||||||
|
const manualInput = ref('')
|
||||||
|
/** 就地红字:非法输入时告诉操作员哪里不对,而不是默默什么都不发生。 */
|
||||||
|
const manualError = ref<string | null>(null)
|
||||||
|
|
||||||
|
function addManualAddress(): void {
|
||||||
|
const result = parseManualAddress(manualInput.value, manualIps.value)
|
||||||
|
if (!result.ok) {
|
||||||
|
manualError.value = result.error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manualIps.value = [...manualIps.value, result.ip]
|
||||||
|
writeManualAddresses(manualAddressStorage(), manualIps.value)
|
||||||
|
manualInput.value = ''
|
||||||
|
manualError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除只影响手填列表:自动检测出来的地址不受影响(它们不是操作员加进来的)。 */
|
||||||
|
function removeManualAddress(ip: string): void {
|
||||||
|
manualIps.value = manualIps.value.filter((item) => item !== ip)
|
||||||
|
writeManualAddresses(manualAddressStorage(), manualIps.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lanLinks = computed(() =>
|
||||||
|
lanInfo.value === null ? [] : lanDisplayLinks(lanInfo.value, manualIps.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空态文案:逐条列出服务端检查过、但没被采用的网卡与原因(最多 5 条)。
|
||||||
|
*
|
||||||
|
* `lanInfo` 为 null 时不会有东西渲染这句话(模板在前面就分流了),这里给空串只是为了让
|
||||||
|
* 类型收敛,不引入第二种「读取中」的写法。
|
||||||
|
*/
|
||||||
|
const lanEmptyText = computed(() => (lanInfo.value === null ? '' : lanEmptyNote(lanInfo.value)))
|
||||||
|
|
||||||
/** 有地址但服务端没给端口:拼不出链接,这句话要和「没插网线」分开说。 */
|
/** 有地址但服务端没给端口:拼不出链接,这句话要和「没插网线」分开说。 */
|
||||||
const lanPortMissing = computed(() => lanInfo.value !== null && lanInfo.value.port <= 0)
|
const lanPortMissing = computed(() => lanInfo.value !== null && lanInfo.value.port <= 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手填入口只在「能拼出链接」时给:端口或路径都没拿到时,填了也只会得到一条打不开的地址。
|
||||||
|
* 只读访客 / 示例数据模式不额外设限——这个功能纯在本地浏览器里跑,不请求服务端,也就没有权限问题。
|
||||||
|
*/
|
||||||
|
const lanManualEnabled = computed(() => lanInfo.value !== null && !lanPortMissing.value)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 第一个地址的小二维码。
|
* 第一个地址的小二维码。
|
||||||
*
|
*
|
||||||
@@ -927,6 +985,15 @@ async function send(
|
|||||||
<div v-for="(item, index) in lanLinks" :key="item.ip" class="lan-row">
|
<div v-for="(item, index) in lanLinks" :key="item.ip" class="lan-row">
|
||||||
<div class="lan-line">
|
<div class="lan-line">
|
||||||
<a class="lan-link" :href="item.url" target="_blank" rel="noopener">{{ item.url }}</a>
|
<a class="lan-link" :href="item.url" target="_blank" rel="noopener">{{ item.url }}</a>
|
||||||
|
<span v-if="item.manual" class="mx-badge mx-badge-neutral">手动</span>
|
||||||
|
<button
|
||||||
|
v-if="item.manual"
|
||||||
|
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
@click="removeManualAddress(item.ip)"
|
||||||
|
>
|
||||||
|
<MxIcon name="close" :size="14" /> 删除
|
||||||
|
</button>
|
||||||
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="copyLanUrl(item.url)">
|
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="copyLanUrl(item.url)">
|
||||||
<MxIcon :name="copiedUrl === item.url ? 'check' : 'copy'" :size="14" />
|
<MxIcon :name="copiedUrl === item.url ? 'check' : 'copy'" :size="14" />
|
||||||
{{ copiedUrl === item.url ? '已复制' : '复制' }}
|
{{ copiedUrl === item.url ? '已复制' : '复制' }}
|
||||||
@@ -940,10 +1007,46 @@ async function send(
|
|||||||
<div class="lan-qr-code" v-html="lanQrSvg"></div>
|
<div class="lan-qr-code" v-html="lanQrSvg"></div>
|
||||||
<div class="lan-qr-note">手机扫码直接打开</div>
|
<div class="lan-qr-note">手机扫码直接打开</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!--
|
||||||
|
来源不常规的地址(up 位读不到 / 按默认路由推断)照常给链接与二维码——它们往往
|
||||||
|
真能用——只是说清这条是怎么来的:现场遇到「扫了打不开」时才知道换下一条,
|
||||||
|
而不是以为整台机器没网。提示文案由 lanDisplay 按 confidence 给,页面不自己判断。
|
||||||
|
-->
|
||||||
|
<p v-if="item.hint !== null" class="display-note lan-uncertain">{{ item.hint }}</p>
|
||||||
|
<!--
|
||||||
|
类别说明与来源说明是两条独立的提示:自动配置地址可能同时是「兜底捡来的」,
|
||||||
|
两条都要说清,谁也不能把谁顶掉。私网 / 全局地址没有这条。
|
||||||
|
-->
|
||||||
|
<p v-if="item.scopeHint !== null" class="display-note lan-uncertain">{{ item.scopeHint }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-else class="display-note">未检测到局域网地址(可能是没插网线 / 只有回环)。</p>
|
<p v-else class="display-note">{{ lanEmptyText }}</p>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
手填入口:自动检测为空、或给出的网段不是现场要用的那张网卡时的最后一条路。
|
||||||
|
它不请求服务端(只往本地 localStorage 里记一笔),所以只读访客与示例数据模式下
|
||||||
|
一样能用;只在拿得到端口 / 路径时才出现——否则填了也拼不出一条能打开的链接。
|
||||||
|
-->
|
||||||
|
<div v-if="lanManualEnabled" class="lan-manual">
|
||||||
|
<div class="lan-manual-title">手动添加地址</div>
|
||||||
|
<p class="lan-manual-note">自动检测不到时,把 ipconfig 里的 IPv4 地址填进来</p>
|
||||||
|
<div class="lan-manual-form">
|
||||||
|
<div class="mx-input-wrap" :class="{ 'mx-input-error': manualError !== null }">
|
||||||
|
<input
|
||||||
|
v-model="manualInput"
|
||||||
|
class="mx-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="192.168.1.23"
|
||||||
|
aria-label="手动添加的 IP 地址"
|
||||||
|
@keyup.enter="addManualAddress"
|
||||||
|
@input="manualError = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" @click="addManualAddress">添加</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="manualError !== null" class="mx-helper mx-helper-error">{{ manualError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
地址由服务端下发的端口与路径拼成;平板上打开后不必登录,大屏本身是只读的。
|
地址由服务端下发的端口与路径拼成;平板上打开后不必登录,大屏本身是只读的。
|
||||||
@@ -1138,6 +1241,38 @@ async function send(
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--mx-t3);
|
color: var(--mx-t3);
|
||||||
}
|
}
|
||||||
|
/* 低置信地址的提示:贴在地址行下面,和普通说明拉开一点距离。 */
|
||||||
|
.lan-uncertain {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
/* 手填入口:和上面的地址列表隔开一点,像一个独立的小工具。 */
|
||||||
|
.lan-manual {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--mx-border);
|
||||||
|
}
|
||||||
|
.lan-manual-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
}
|
||||||
|
.lan-manual-note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.lan-manual-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
/* 输入框占满剩余宽度:IP 不长,但窄栏里也得让人看清自己填了什么。 */
|
||||||
|
.lan-manual-form .mx-input-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.textarea-wrap {
|
.textarea-wrap {
|
||||||
height: auto;
|
height: auto;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import type { NetworkAddress, NetworkInfo } from '@/shared'
|
import type {
|
||||||
|
NetworkAddress,
|
||||||
|
NetworkAddressScope,
|
||||||
|
NetworkConfidence,
|
||||||
|
NetworkInfo,
|
||||||
|
NetworkSkipCode,
|
||||||
|
NetworkSkippedInterface,
|
||||||
|
} from '@/shared'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「局域网大屏」卡片的纯逻辑。
|
* 「局域网大屏」卡片的纯逻辑。
|
||||||
@@ -13,6 +20,27 @@ export interface LanDisplayLink {
|
|||||||
ip: string
|
ip: string
|
||||||
/** 完整地址:`http://<ip>:<port><displayPath>`。 */
|
/** 完整地址:`http://<ip>:<port><displayPath>`。 */
|
||||||
url: string
|
url: string
|
||||||
|
/** 地址来源(服务端的 confidence,手填的按 high 处理)。 */
|
||||||
|
confidence: NetworkConfidence
|
||||||
|
/** 是否是操作员手填的地址(卡片上要带「手动」徽标、可删除)。 */
|
||||||
|
manual: boolean
|
||||||
|
/**
|
||||||
|
* 这条地址要额外说明的一句话(low / route 才有,high 为 null)。
|
||||||
|
*
|
||||||
|
* 文案在这里算好、模板直接用:提示与来源的对应关系只维护一处,页面里不会出现
|
||||||
|
* 「某个分支忘了提示」或「两个分支都提示」这类只有肉眼才能发现的偏差。
|
||||||
|
*/
|
||||||
|
hint: string | null
|
||||||
|
/** 地址类别(服务端给的;老服务端没这个字段时为 null)。 */
|
||||||
|
scope: NetworkAddressScope | null
|
||||||
|
/**
|
||||||
|
* 类别说明:只有自动配置地址(link-local)才需要解释一句「本网段没有 DHCP」,
|
||||||
|
* 私网 / 全局地址什么都不说。
|
||||||
|
*
|
||||||
|
* 它与 hint 是**两条互不影响的提示**:一条说「这是什么地址」,一条说「怎么找到的」,
|
||||||
|
* 两个都可以有,页面上各占一行。
|
||||||
|
*/
|
||||||
|
scopeHint: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拼一条局域网大屏地址;IPv6 要加方括号,否则端口那一段会被当成地址的一部分。 */
|
/** 拼一条局域网大屏地址;IPv6 要加方括号,否则端口那一段会被当成地址的一部分。 */
|
||||||
@@ -22,14 +50,242 @@ export function displayUrl(address: NetworkAddress, network: NetworkInfo): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把服务端返回的网络信息摊平成可点的链接。
|
* 把服务端返回的网络信息 + 操作员手填的地址摊平成可点的链接。
|
||||||
*
|
*
|
||||||
* 端口缺失(服务端异常 / 老版本接口)时给空数组:拼 `http://ip:0/display` 这种
|
* 端口缺失(服务端异常 / 老版本接口)时给空数组:拼 `http://ip:0/display` 这种
|
||||||
* 「看着像链接、点开连不上」的东西比空态更糟。
|
* 「看着像链接、点开连不上」的东西比空态更糟。手填地址同样靠这个端口拼,所以一起落空。
|
||||||
|
*
|
||||||
|
* 低置信(`confidence: 'low'` / `'route'`)的地址**照常给链接**:它们只是来源不常规
|
||||||
|
* (网卡 up 位读不到 / 是靠默认路由推断的),地址本身往往真的能用;藏起来等于把现场
|
||||||
|
* 唯一能用的那一条也拿走。
|
||||||
|
*
|
||||||
|
* 手填的排在自动检测结果**前面**:现场手填就是因为他认定「自动出来的那几条不是我要的网段」,
|
||||||
|
* 他关心的那条理应第一眼就看到、二维码也出在它上面。同一个地址既手填又被自动检测到时只留
|
||||||
|
* 手填的那条(同一个链接出现两次,现场会以为是两个不同的东西)。
|
||||||
*/
|
*/
|
||||||
export function lanDisplayLinks(network: NetworkInfo): LanDisplayLink[] {
|
export function lanDisplayLinks(network: NetworkInfo, manual: string[] = []): LanDisplayLink[] {
|
||||||
if (!Number.isFinite(network.port) || network.port <= 0) return []
|
if (!Number.isFinite(network.port) || network.port <= 0) return []
|
||||||
return network.addresses.map((address) => ({ ip: address.ip, url: displayUrl(address, network) }))
|
const manualLinks = manual.map((ip) => manualLink(ip, network))
|
||||||
|
const manualSet = new Set(manual)
|
||||||
|
const autoLinks = network.addresses
|
||||||
|
.filter((address) => !manualSet.has(address.ip))
|
||||||
|
.map((address) => {
|
||||||
|
const confidence = address.confidence ?? 'high'
|
||||||
|
const scope = address.scope ?? null
|
||||||
|
return {
|
||||||
|
ip: address.ip,
|
||||||
|
url: displayUrl(address, network),
|
||||||
|
confidence,
|
||||||
|
manual: false,
|
||||||
|
hint: confidenceHint(confidence),
|
||||||
|
scope,
|
||||||
|
scopeHint: scopeHint(scope),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return [...manualLinks, ...autoLinks]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手填地址也是普通地址:接口名留空(它不是从哪张网卡枚举出来的,写一个假网卡名只会误导)。
|
||||||
|
*
|
||||||
|
* confidence 记 high 只是形状上的统一(这个字段本是服务端给的来源判定,手填的地址压根没经过
|
||||||
|
* 服务端),真正区分手填的是 manual;也正因为它是操作员自己认定的地址,不给任何来源提示语。
|
||||||
|
* scope 同理留 null:不按 IP 前缀猜类别(猜错会给出与事实不符的说明),也就不给类别提示。
|
||||||
|
*/
|
||||||
|
function manualLink(ip: string, network: NetworkInfo): LanDisplayLink {
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
url: displayUrl({ ip, interface: '', family: ip.includes(':') ? 'ipv6' : 'ipv4' }, network),
|
||||||
|
confidence: 'high',
|
||||||
|
manual: true,
|
||||||
|
hint: null,
|
||||||
|
scope: null,
|
||||||
|
scopeHint: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 来源提示:只有「不常规的来源」才需要解释一句,正常枚举出来的(high)什么都不说。
|
||||||
|
* 现场看到提示才知道这条打不开时该换下一条,而不是以为整台机器没网。
|
||||||
|
*/
|
||||||
|
export function confidenceHint(confidence: NetworkConfidence): string | null {
|
||||||
|
if (confidence === 'low') return LAN_LOW_CONFIDENCE_HINT
|
||||||
|
if (confidence === 'route') return LAN_ROUTE_CONFIDENCE_HINT
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类别提示:只有自动配置地址要解释——它看着像「网络没配好」,实际在无 DHCP 的独立内网里
|
||||||
|
* 就是正常且唯一可用的地址,不说清现场会以为卡片又出错了。
|
||||||
|
*/
|
||||||
|
export function scopeHint(scope: NetworkAddressScope | null): string | null {
|
||||||
|
return scope === 'link-local' ? LAN_LINK_LOCAL_SCOPE_HINT : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳过原因(服务端 `skipped[].code`)的中文说明。
|
||||||
|
*
|
||||||
|
* 服务端只下发机器可读的 code:同一件事在服务端启动日志里另有一份面向运维的措辞,
|
||||||
|
* 而这里面向现场操作员。表必须覆盖全部 code(类型上就是穷尽的,漏一个编译不过)。
|
||||||
|
*/
|
||||||
|
export const NETWORK_SKIP_REASON_LABELS: Record<NetworkSkipCode, string> = {
|
||||||
|
'no-address': '接口没有地址',
|
||||||
|
'only-ipv6': '只有 IPv6 地址',
|
||||||
|
'not-up': '接口未连接',
|
||||||
|
loopback: '回环接口',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未知 code 的兜底:服务端比前端新时也要说人话,不能让原因变成空白。 */
|
||||||
|
export function skipReasonLabel(code: string): string {
|
||||||
|
return NETWORK_SKIP_REASON_LABELS[code as NetworkSkipCode] ?? `未知原因(${code})`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一条被跳过的网卡:「以太网(接口未连接)」。 */
|
||||||
|
function skippedLabel(item: NetworkSkippedInterface): string {
|
||||||
|
return `${item.interface === '' ? '未知网卡' : item.interface}(${skipReasonLabel(item.code)})`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片在没有可用地址时给的那句话(老服务端没有 skipped,只能沿用这句)。 */
|
||||||
|
export const LAN_EMPTY_FALLBACK = '未检测到局域网地址(可能是没插网线 / 只有回环)。'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 低置信地址旁的提示:链接与二维码照常给,但要说明这条是兜底来的——
|
||||||
|
* 现场遇到「扫了打不开」时才知道下一步该换哪一条,而不是以为整台机器没网。
|
||||||
|
*/
|
||||||
|
export const LAN_LOW_CONFIDENCE_HINT =
|
||||||
|
'网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由探测来的地址旁的提示:它不是从网卡枚举出来的,而是问操作系统「去局域网走哪块网卡」得到的。
|
||||||
|
* 这种地址通常就是对的(系统自己路由出来的源地址),但要先说清来源,现场打不开时才不会怀疑机器没网。
|
||||||
|
*/
|
||||||
|
export const LAN_ROUTE_CONFIDENCE_HINT =
|
||||||
|
'这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动配置地址(链路本地)旁的提示。
|
||||||
|
*
|
||||||
|
* 这种地址看着像「网络没配好」(系统自配的、没有 DHCP),但在没有 DHCP 的独立内网里它正是
|
||||||
|
* 设备之间互访用的地址——现场平板就是靠它打开的。不解释一句,现场会以为卡片又出错了,
|
||||||
|
* 或者把它当成「不能用」的地址去手填一个更糟的。
|
||||||
|
*/
|
||||||
|
export const LAN_LINK_LOCAL_SCOPE_HINT = '自动配置地址:本网段没有 DHCP,同网段设备可以互相访问'
|
||||||
|
|
||||||
|
/** 空态里最多列出几张网卡,其余的折叠成「等共 N 块网卡」。 */
|
||||||
|
export const LAN_SKIP_LIST_LIMIT = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空态文案:把服务端「检查过、但没被采用」的网卡逐条列出来。
|
||||||
|
*
|
||||||
|
* 原来只有一句「未检测到局域网地址(可能是没插网线 / 只有回环)」,而真正的原因可能
|
||||||
|
* 是「只有 IPv6」「只有 169.254」「接口没置 up」——现场拿着那句话无从下手。
|
||||||
|
* 改成逐条列原因后,主人一眼就能看出该修哪张网卡。
|
||||||
|
* 服务端没给 skipped(老版本)时退回原来那句,行为与升级前完全一致。
|
||||||
|
*
|
||||||
|
* 网卡多的机器(虚拟网卡几十张)上原因会排成长长一串,卡片放不下也没人看:只列前 5 张,
|
||||||
|
* 其余折叠成「等共 N 块网卡」——数字本身就是线索(比如 30 张说明这台机器上虚拟网卡很多)。
|
||||||
|
*/
|
||||||
|
export function lanEmptyNote(network: NetworkInfo): string {
|
||||||
|
const skipped = network.skipped ?? []
|
||||||
|
if (skipped.length === 0) return LAN_EMPTY_FALLBACK
|
||||||
|
const shown = skipped.slice(0, LAN_SKIP_LIST_LIMIT).map(skippedLabel)
|
||||||
|
const folded = skipped.length > shown.length ? `等共 ${skipped.length} 块网卡` : ''
|
||||||
|
return `没找到可用的局域网地址。已检查:${shown.join('、')}${folded}。`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手填地址在 localStorage 里的键。带前缀,免得和同域下别的页面撞。 */
|
||||||
|
export const MANUAL_LAN_STORAGE_KEY = 'scoreboard.display.manualLanAddresses'
|
||||||
|
|
||||||
|
/** 手填地址最多存几个:现场可能存着旧的;没有上限的话这条列表会一直长下去。 */
|
||||||
|
export const MAX_MANUAL_ADDRESSES = 10
|
||||||
|
|
||||||
|
/** localStorage 的最小接口;注入假实现后,node 里也能把存取与脏数据这两条路走一遍。 */
|
||||||
|
export interface ManualAddressStorage {
|
||||||
|
getItem(key: string): string | null
|
||||||
|
setItem(key: string, value: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 浏览器的 localStorage;隐私模式 / 禁用存储时**读属性本身就抛**,所以整段包在 try 里。 */
|
||||||
|
export function manualAddressStorage(): ManualAddressStorage | null {
|
||||||
|
try {
|
||||||
|
return (globalThis as { localStorage?: ManualAddressStorage }).localStorage ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读出持久化的手填地址:坏数据(不是数组 / 不是字符串 / 不是 IP / 重复)一律不认。 */
|
||||||
|
export function readManualAddresses(storage: ManualAddressStorage | null): string[] {
|
||||||
|
if (storage === null) return []
|
||||||
|
let raw: string | null
|
||||||
|
try {
|
||||||
|
raw = storage.getItem(MANUAL_LAN_STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (raw === null) return []
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
const out: string[] = []
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (typeof item !== 'string') continue
|
||||||
|
// 存下来的可能是旧版本写的、或人手工改过的内容:每条都按同一套规则重新校验,
|
||||||
|
// 免得一个坏地址变成一条点不开的链接,或者把整个列表带崩。
|
||||||
|
const result = parseManualAddress(item, out)
|
||||||
|
if (result.ok) out.push(result.ip)
|
||||||
|
}
|
||||||
|
return out.slice(0, MAX_MANUAL_ADDRESSES)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写回手填地址;写不进去(配额满 / 隐私模式)只当没存上,绝不让它影响页面。 */
|
||||||
|
export function writeManualAddresses(storage: ManualAddressStorage | null, ips: string[]): void {
|
||||||
|
if (storage === null) return
|
||||||
|
try {
|
||||||
|
storage.setItem(MANUAL_LAN_STORAGE_KEY, JSON.stringify(ips.slice(0, MAX_MANUAL_ADDRESSES)))
|
||||||
|
} catch {
|
||||||
|
// 存不下就存不下:地址这一会儿还在页面上,刷新丢了也只是要再填一次。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验结果:要么给出规整后的地址,要么给出就地红字要显示的那句话。 */
|
||||||
|
export type ManualAddressParse = { ok: true; ip: string } | { ok: false; error: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验操作员手输的地址(从 ipconfig 抄来的那串)。
|
||||||
|
*
|
||||||
|
* 规则:首尾空格先去掉——从 ipconfig 复制粘贴时几乎必然带上;中间还有空白就是抄错了。
|
||||||
|
* 只认 IPv4 点分十进制与 IPv6 字面量(IPv6 要允许:现场也可能用双栈地址,拼 URL 加方括号
|
||||||
|
* 的逻辑 displayUrl 里已经有了)。不做回环 / link-local 过滤:手填的本来就是「操作员比我清楚」
|
||||||
|
* 那一条,他真填了 127.0.0.1 也是他自己的判断,卡片不该替他改。
|
||||||
|
*/
|
||||||
|
export function parseManualAddress(raw: string, existing: string[] = []): ManualAddressParse {
|
||||||
|
const value = raw.trim()
|
||||||
|
if (value === '') return { ok: false, error: '请先填一个 IP 地址' }
|
||||||
|
if (/\s/.test(value)) return { ok: false, error: '地址里不能有空格,请重新复制一次 ipconfig 里的地址' }
|
||||||
|
const ip = canonicalIp(value)
|
||||||
|
if (ip === null) return { ok: false, error: '这不像一个 IP 地址,请填 ipconfig 里的 IPv4 地址' }
|
||||||
|
if (existing.includes(ip)) return { ok: false, error: '这个地址已经在列表里了' }
|
||||||
|
return { ok: true, ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 认得出就给出规整形式(IPv6 统一成小写压缩写法,去重才靠得住),认不出回 null。 */
|
||||||
|
function canonicalIp(value: string): string | null {
|
||||||
|
const parts = value.split('.')
|
||||||
|
if (parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) return value
|
||||||
|
if (!value.includes(':') || value.includes('%')) return null
|
||||||
|
try {
|
||||||
|
// 借 URL 解析器做 IPv6 校验:手写正则太难覆盖压缩写法(:: 的位置、内嵌 IPv4 等),
|
||||||
|
// 而浏览器/node 的 URL 只接受合法的 IPv6 字面量,顺带把大写与压缩形式规整掉。
|
||||||
|
const host = new URL(`http://[${value}]`).hostname
|
||||||
|
return host === '' ? null : host.slice(1, -1)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 复制用到的宿主能力;全部可注入,这样在 node 里也能把回退路径走一遍。 */
|
/** 复制用到的宿主能力;全部可注入,这样在 node 里也能把回退路径走一遍。 */
|
||||||
|
|||||||
@@ -30,12 +30,87 @@ const { ApiError, adminApi } = await import('../api/client')
|
|||||||
|
|
||||||
const PAGE = readFileSync(new URL('../pages/DisplayPage.vue', import.meta.url), 'utf8')
|
const PAGE = readFileSync(new URL('../pages/DisplayPage.vue', import.meta.url), 'utf8')
|
||||||
|
|
||||||
test('卡片文案齐全:标题、一句话说明、复制反馈、扫码说明、空态', () => {
|
test('卡片文案齐全:标题、一句话说明、复制反馈、扫码说明、来源提示', () => {
|
||||||
assert.match(PAGE, /局域网大屏/)
|
assert.match(PAGE, /局域网大屏/)
|
||||||
assert.match(PAGE, /在别的设备浏览器打开下面的地址,就是一块大屏/)
|
assert.match(PAGE, /在别的设备浏览器打开下面的地址,就是一块大屏/)
|
||||||
assert.match(PAGE, /手机扫码直接打开/)
|
assert.match(PAGE, /手机扫码直接打开/)
|
||||||
assert.match(PAGE, /已复制/)
|
assert.match(PAGE, /已复制/)
|
||||||
assert.match(PAGE, /未检测到局域网地址(可能是没插网线 \/ 只有回环)/)
|
// 提示原文钉在 lanDisplay.ts(只有一处),页面只负责渲染 lanDisplayLinks 给的那句话:
|
||||||
|
// 页面自己判断 confidence 就会漏掉第三种来源(route)。
|
||||||
|
assert.match(PAGE, /\{\{ item\.hint \}\}/)
|
||||||
|
assert.doesNotMatch(PAGE, /LAN_LOW_CONFIDENCE_HINT|LAN_ROUTE_CONFIDENCE_HINT/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('空态文案由 lanEmptyNote 生成(逐条列原因),不写死在页面里', () => {
|
||||||
|
assert.match(PAGE, /from '\.\/lanDisplay'/)
|
||||||
|
assert.match(PAGE, /lanEmptyNote,/)
|
||||||
|
assert.match(PAGE, /<p v-else class="display-note">\{\{ lanEmptyText \}\}<\/p>/)
|
||||||
|
assert.match(PAGE, /const lanEmptyText = computed\(/)
|
||||||
|
// 老文案属于 lanDisplay.ts(老服务端分支),页面里不该再抄一份。
|
||||||
|
assert.doesNotMatch(PAGE, /class="display-note">未检测到局域网地址/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源提示只挂在有提示的那条地址旁(high 一句都不说)', () => {
|
||||||
|
// 提示挂在 item.hint 上,而 hint 由 lanDisplay 按来源给:high → null,low / route → 各自的句子。
|
||||||
|
assert.match(PAGE, /<p v-if="item\.hint !== null" class="display-note lan-uncertain">/)
|
||||||
|
// 链接与二维码不受来源影响:up 位读不到 / 路由推断出来的地址照常渲染。
|
||||||
|
assert.match(PAGE, /<a class="lan-link" :href="item\.url" target="_blank" rel="noopener">/)
|
||||||
|
assert.match(PAGE, /v-if="index === 0 && lanQrSvg !== null"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('类别说明:只有 scopeHint 不为空的那条地址才出现(私网 / 全局没有)', () => {
|
||||||
|
assert.match(
|
||||||
|
PAGE,
|
||||||
|
/<p v-if="item\.scopeHint !== null" class="display-note lan-uncertain">\{\{ item\.scopeHint \}\}<\/p>/,
|
||||||
|
)
|
||||||
|
// 页面不自己判类别、更不按前缀猜:说明句由 lanDisplay 按服务端给的 scope 算好。
|
||||||
|
assert.doesNotMatch(PAGE, /link-local|169\.254/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源提示与类别说明是两条独立的行,不能互相顶掉', () => {
|
||||||
|
// 两条都必须是 v-if:写成 v-if + v-else 的话,一条地址明明两个提示都该给,却只会显示一个。
|
||||||
|
const hintIndex = PAGE.indexOf('{{ item.hint }}')
|
||||||
|
const scopeIndex = PAGE.indexOf('{{ item.scopeHint }}')
|
||||||
|
assert.ok(hintIndex > 0 && scopeIndex > hintIndex, '两条提示都要渲染,且各占一行')
|
||||||
|
assert.doesNotMatch(PAGE, /v-else[\s\S]{0,200}?\{\{ item\.scopeHint \}\}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('手填入口:标题、副说明、输入框、添加按钮、就地红字', () => {
|
||||||
|
assert.match(PAGE, /手动添加地址/)
|
||||||
|
assert.match(PAGE, /自动检测不到时,把 ipconfig 里的 IPv4 地址填进来/)
|
||||||
|
assert.match(PAGE, /v-model="manualInput"/)
|
||||||
|
assert.match(PAGE, /@keyup\.enter="addManualAddress"/)
|
||||||
|
assert.match(PAGE, />添加<\/button>/)
|
||||||
|
// 非法输入必须当场红字说明,而不是点了没反应。
|
||||||
|
assert.match(PAGE, /v-if="manualError !== null" class="mx-helper mx-helper-error"/)
|
||||||
|
// 输入框本身也要标红:现场一眼看出是这一格填错了。
|
||||||
|
assert.match(PAGE, /:class="\{ 'mx-input-error': manualError !== null \}"/)
|
||||||
|
// 只在拼得出链接时给入口(端口没下发时填了也白填)。
|
||||||
|
assert.match(PAGE, /v-if="lanManualEnabled" class="lan-manual"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('手填的地址带「手动」徽标、可删除,并写进 localStorage', () => {
|
||||||
|
assert.match(PAGE, /v-if="item\.manual" class="mx-badge mx-badge-neutral">手动</)
|
||||||
|
assert.match(PAGE, /@click="removeManualAddress\(item\.ip\)"/)
|
||||||
|
assert.match(PAGE, /const manualIps = ref<string\[\]>\(readManualAddresses\(manualAddressStorage\(\)\)\)/)
|
||||||
|
assert.match(PAGE, /writeManualAddresses\(manualAddressStorage\(\), manualIps\.value\)/)
|
||||||
|
// 删除只动手填列表;自动检测出来的地址不写回存储、也不受影响。
|
||||||
|
assert.match(PAGE, /manualIps\.value\.filter\(\(item\) => item !== ip\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('添加这条路真的会校验并持久化(只看「有 writeManualAddresses」会被别处的调用蒙混过去)', () => {
|
||||||
|
const addFn = /function addManualAddress\(\): void \{[\s\S]*?\n\}/.exec(PAGE)?.[0] ?? ''
|
||||||
|
assert.notEqual(addFn, '', '找不到 addManualAddress')
|
||||||
|
assert.match(addFn, /parseManualAddress\(manualInput\.value, manualIps\.value\)/)
|
||||||
|
assert.match(addFn, /writeManualAddresses\(manualAddressStorage\(\), manualIps\.value\)/)
|
||||||
|
// 校验不过必须当场返回,不能把非法值塞进列表。
|
||||||
|
assert.match(addFn, /if \(!result\.ok\) \{[\s\S]*?return/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('手填入口不依赖权限与示例数据:没有 gate / isMock 判断', () => {
|
||||||
|
const manualBlock = /<div v-if="lanManualEnabled" class="lan-manual">[\s\S]*?<\/div>\s*<\/div>/.exec(PAGE)?.[0] ?? ''
|
||||||
|
assert.notEqual(manualBlock, '')
|
||||||
|
assert.doesNotMatch(manualBlock, /lanGateReason|isMock|readOnly/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('链接本身就是可点的新窗口链接', () => {
|
test('链接本身就是可点的新窗口链接', () => {
|
||||||
@@ -43,8 +118,8 @@ test('链接本身就是可点的新窗口链接', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('页面的地址只来自 lanDisplayLinks(),不写死端口或路径', () => {
|
test('页面的地址只来自 lanDisplayLinks(),不写死端口或路径', () => {
|
||||||
assert.match(PAGE, /import \{ copyText, lanDisplayLinks \} from '\.\/lanDisplay'/)
|
assert.match(PAGE, /from '\.\/lanDisplay'/)
|
||||||
assert.match(PAGE, /lanDisplayLinks\(lanInfo\.value\)/)
|
assert.match(PAGE, /lanDisplayLinks\(lanInfo\.value, manualIps\.value\)/)
|
||||||
// 19540 一旦出现在页面里,就说明有人在拼地址时替服务端做了决定。
|
// 19540 一旦出现在页面里,就说明有人在拼地址时替服务端做了决定。
|
||||||
assert.doesNotMatch(PAGE, /19540/)
|
assert.doesNotMatch(PAGE, /19540/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import type { NetworkInfo } from '@/shared'
|
import type { NetworkConfidence, NetworkInfo, NetworkSkipCode } from '@/shared'
|
||||||
import { copyText, displayUrl, lanDisplayLinks } from '../pages/lanDisplay'
|
import {
|
||||||
|
confidenceHint,
|
||||||
|
copyText,
|
||||||
|
displayUrl,
|
||||||
|
LAN_EMPTY_FALLBACK,
|
||||||
|
LAN_LINK_LOCAL_SCOPE_HINT,
|
||||||
|
LAN_LOW_CONFIDENCE_HINT,
|
||||||
|
LAN_ROUTE_CONFIDENCE_HINT,
|
||||||
|
LAN_SKIP_LIST_LIMIT,
|
||||||
|
lanDisplayLinks,
|
||||||
|
lanEmptyNote,
|
||||||
|
MANUAL_LAN_STORAGE_KEY,
|
||||||
|
MAX_MANUAL_ADDRESSES,
|
||||||
|
NETWORK_SKIP_REASON_LABELS,
|
||||||
|
parseManualAddress,
|
||||||
|
readManualAddresses,
|
||||||
|
scopeHint,
|
||||||
|
skipReasonLabel,
|
||||||
|
writeManualAddresses,
|
||||||
|
type ManualAddressStorage,
|
||||||
|
} from '../pages/lanDisplay'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「局域网大屏」卡片的两条硬要求:
|
* 「局域网大屏」卡片的两条硬要求:
|
||||||
@@ -55,6 +75,373 @@ test('端口为 0 时给空数组,不拼 http://ip:0/display 这种点不开
|
|||||||
assert.deepEqual(lanDisplayLinks({ ...NETWORK, port: 0 }), [])
|
assert.deepEqual(lanDisplayLinks({ ...NETWORK, port: 0 }), [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('低置信地址照常生成链接(藏起来等于把现场唯一能用的那条也拿走),并带上 confidence', () => {
|
||||||
|
const info: NetworkInfo = {
|
||||||
|
...NETWORK,
|
||||||
|
addresses: [
|
||||||
|
{ ip: '192.168.1.23', interface: '以太网', family: 'ipv4', confidence: 'low' },
|
||||||
|
{ ip: '10.0.8.7', interface: 'wlan0', family: 'ipv4' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(lanDisplayLinks(info), [
|
||||||
|
{
|
||||||
|
ip: '192.168.1.23',
|
||||||
|
url: 'http://192.168.1.23:19540/display',
|
||||||
|
confidence: 'low',
|
||||||
|
manual: false,
|
||||||
|
hint: LAN_LOW_CONFIDENCE_HINT,
|
||||||
|
scope: null,
|
||||||
|
scopeHint: null,
|
||||||
|
},
|
||||||
|
// 老服务端没有 confidence / scope 字段:按 high,且不加任何提示。
|
||||||
|
{
|
||||||
|
ip: '10.0.8.7',
|
||||||
|
url: 'http://10.0.8.7:19540/display',
|
||||||
|
confidence: 'high',
|
||||||
|
manual: false,
|
||||||
|
hint: null,
|
||||||
|
scope: null,
|
||||||
|
scopeHint: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('路由推断出来的地址标 route,并给一句自己的提示', () => {
|
||||||
|
const info: NetworkInfo = {
|
||||||
|
...NETWORK,
|
||||||
|
addresses: [{ ip: '192.168.5.9', interface: '默认路由', family: 'ipv4', confidence: 'route', scope: 'private' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(lanDisplayLinks(info), [
|
||||||
|
{
|
||||||
|
ip: '192.168.5.9',
|
||||||
|
url: 'http://192.168.5.9:19540/display',
|
||||||
|
confidence: 'route',
|
||||||
|
manual: false,
|
||||||
|
hint: LAN_ROUTE_CONFIDENCE_HINT,
|
||||||
|
scope: 'private',
|
||||||
|
scopeHint: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源提示:只有不常规的来源才说话,high 一句都不多说', () => {
|
||||||
|
assert.equal(confidenceHint('high'), null)
|
||||||
|
assert.equal(confidenceHint('low'), LAN_LOW_CONFIDENCE_HINT)
|
||||||
|
assert.equal(confidenceHint('route'), LAN_ROUTE_CONFIDENCE_HINT)
|
||||||
|
// 三条文案必须互不相同:route 的地址被说成「网卡状态读不到」会把人引到完全错的方向。
|
||||||
|
assert.notEqual(LAN_ROUTE_CONFIDENCE_HINT, LAN_LOW_CONFIDENCE_HINT)
|
||||||
|
assert.equal(LAN_ROUTE_CONFIDENCE_HINT, '这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 地址类别(scope)────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('自动配置地址给一句说明,私网 / 全局 / 未知都不给', () => {
|
||||||
|
assert.equal(LAN_LINK_LOCAL_SCOPE_HINT, '自动配置地址:本网段没有 DHCP,同网段设备可以互相访问')
|
||||||
|
assert.equal(scopeHint('link-local'), LAN_LINK_LOCAL_SCOPE_HINT)
|
||||||
|
assert.equal(scopeHint('private'), null)
|
||||||
|
assert.equal(scopeHint('global'), null)
|
||||||
|
assert.equal(scopeHint(null), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('主人现场那条地址:自动配置地址照常给链接、二维码位置在第一条,并带上说明', () => {
|
||||||
|
const info: NetworkInfo = {
|
||||||
|
...NETWORK,
|
||||||
|
addresses: [
|
||||||
|
{ ip: '192.168.1.23', interface: 'WLAN', family: 'ipv4', confidence: 'high', scope: 'private' },
|
||||||
|
{ ip: '169.254.79.190', interface: '以太网', family: 'ipv4', confidence: 'high', scope: 'link-local' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = lanDisplayLinks(info)
|
||||||
|
assert.deepEqual(
|
||||||
|
links.map((link) => [link.ip, link.scope, link.scopeHint]),
|
||||||
|
[
|
||||||
|
['192.168.1.23', 'private', null],
|
||||||
|
// 私网在前、自动配置在后;后者照常是可点的链接,只是多一句说明。
|
||||||
|
['169.254.79.190', 'link-local', LAN_LINK_LOCAL_SCOPE_HINT],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert.equal(links[1]!.url, 'http://169.254.79.190:19540/display')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源提示与类别提示互不顶掉:同一条地址两条都给', () => {
|
||||||
|
const info: NetworkInfo = {
|
||||||
|
...NETWORK,
|
||||||
|
addresses: [{ ip: '169.254.79.190', interface: '以太网', family: 'ipv4', confidence: 'low', scope: 'link-local' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = lanDisplayLinks(info)[0]!
|
||||||
|
assert.equal(link.hint, LAN_LOW_CONFIDENCE_HINT)
|
||||||
|
assert.equal(link.scopeHint, LAN_LINK_LOCAL_SCOPE_HINT)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scope 认不出(老服务端)时不给类别说明,也不按前缀自己猜', () => {
|
||||||
|
const info: NetworkInfo = {
|
||||||
|
...NETWORK,
|
||||||
|
// 老服务端不返回 scope:即使地址恰好长得像自动配置地址,也不加说明(分类是服务端的知识)。
|
||||||
|
addresses: [{ ip: '169.254.79.190', interface: '以太网', family: 'ipv4', confidence: 'high' }],
|
||||||
|
}
|
||||||
|
const link = lanDisplayLinks(info)[0]!
|
||||||
|
assert.equal(link.scope, null)
|
||||||
|
assert.equal(link.scopeHint, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('低置信提示的文案只有一处', () => {
|
||||||
|
assert.equal(LAN_LOW_CONFIDENCE_HINT, '网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('空态逐条列出被跳过的网卡与原因', () => {
|
||||||
|
const info: NetworkInfo = {
|
||||||
|
...NETWORK,
|
||||||
|
addresses: [],
|
||||||
|
skipped: [
|
||||||
|
{ interface: '以太网', code: 'not-up', addresses: ['10.0.0.10/24'] },
|
||||||
|
{ interface: 'WLAN', code: 'only-ipv6', addresses: ['fe80::1/64'] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
lanEmptyNote(info),
|
||||||
|
'没找到可用的局域网地址。已检查:以太网(接口未连接)、WLAN(只有 IPv6 地址)。',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('空态:服务端没给 skipped(老版本)时退回原来那句话', () => {
|
||||||
|
assert.equal(lanEmptyNote({ ...NETWORK, addresses: [] }), LAN_EMPTY_FALLBACK)
|
||||||
|
assert.equal(lanEmptyNote({ ...NETWORK, addresses: [], skipped: [] }), LAN_EMPTY_FALLBACK)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('空态:网卡名缺失时也不能拼出「()」', () => {
|
||||||
|
const info: NetworkInfo = {
|
||||||
|
...NETWORK,
|
||||||
|
addresses: [],
|
||||||
|
skipped: [{ interface: '', code: 'no-address', addresses: [] }],
|
||||||
|
}
|
||||||
|
assert.equal(lanEmptyNote(info), '没找到可用的局域网地址。已检查:未知网卡(接口没有地址)。')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('原因映射覆盖全部 code(一个都不能漏)与未知 code 的兜底', () => {
|
||||||
|
// 集合里没有 link-local:自动配置地址现在是候选地址,不再是「被跳过」的理由。
|
||||||
|
const codes: NetworkSkipCode[] = ['no-address', 'only-ipv6', 'not-up', 'loopback']
|
||||||
|
|
||||||
|
// 表本身必须与 code 集合完全对应:多一个少一个都在这里红灯。
|
||||||
|
assert.deepEqual(Object.keys(NETWORK_SKIP_REASON_LABELS).sort(), [...codes].sort())
|
||||||
|
for (const code of codes) {
|
||||||
|
const label = skipReasonLabel(code)
|
||||||
|
assert.equal(label, NETWORK_SKIP_REASON_LABELS[code])
|
||||||
|
assert.notEqual(label.trim(), '')
|
||||||
|
// 未知 code 之外不能落到兜底话术上,否则「漏了 code」会被兜底悄悄盖住。
|
||||||
|
assert.doesNotMatch(label, /未知原因/)
|
||||||
|
}
|
||||||
|
// 服务端比前端新:认不出的 code 也要说人话,不能留空白。
|
||||||
|
assert.equal(skipReasonLabel('brand-new-code'), '未知原因(brand-new-code)')
|
||||||
|
// 链路本地不再是跳过原因:旧服务端万一还发这个 code,也不能显示成「地址不能用」那一类说法。
|
||||||
|
assert.notEqual(skipReasonLabel('link-local'), '只有自动配置地址(169.254.x)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('每条地址的置信度只可能是 high / low / route', () => {
|
||||||
|
const info: NetworkInfo = {
|
||||||
|
...NETWORK,
|
||||||
|
addresses: [{ ip: '10.0.0.4', interface: 'ZeroTier', family: 'ipv4' }],
|
||||||
|
}
|
||||||
|
const confidence: NetworkConfidence = lanDisplayLinks(info)[0]!.confidence
|
||||||
|
assert.equal(confidence, 'high')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 空态的折叠 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('空态最多列 5 张网卡,其余的折叠成「等共 N 块网卡」', () => {
|
||||||
|
const many: NetworkInfo = {
|
||||||
|
...NETWORK,
|
||||||
|
addresses: [],
|
||||||
|
skipped: Array.from({ length: 30 }, (_, index) => ({
|
||||||
|
interface: `vEthernet ${index + 1}`,
|
||||||
|
code: 'no-address' as const,
|
||||||
|
addresses: [],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = lanEmptyNote(many)
|
||||||
|
// 只有前 5 张带原因,第 6 张的接口名不能出现(否则等于没折叠)。
|
||||||
|
for (let index = 1; index <= LAN_SKIP_LIST_LIMIT; index += 1) {
|
||||||
|
assert.match(note, new RegExp(`vEthernet ${index}(接口没有地址)`))
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(note, /vEthernet 6(/)
|
||||||
|
assert.match(note, /等共 30 块网卡。$/)
|
||||||
|
assert.equal(
|
||||||
|
note,
|
||||||
|
'没找到可用的局域网地址。已检查:vEthernet 1(接口没有地址)、vEthernet 2(接口没有地址)、' +
|
||||||
|
'vEthernet 3(接口没有地址)、vEthernet 4(接口没有地址)、vEthernet 5(接口没有地址)等共 30 块网卡。',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('空态:不超过 5 张时不折叠(不出现「等共」)', () => {
|
||||||
|
const info: NetworkInfo = {
|
||||||
|
...NETWORK,
|
||||||
|
addresses: [],
|
||||||
|
skipped: Array.from({ length: LAN_SKIP_LIST_LIMIT }, (_, index) => ({
|
||||||
|
interface: `网卡${index + 1}`,
|
||||||
|
code: 'loopback' as const,
|
||||||
|
addresses: [],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(lanEmptyNote(info), /等共/)
|
||||||
|
// 恰好 5 张也要一张不漏地列出来。
|
||||||
|
assert.match(lanEmptyNote(info), /网卡5(回环接口)。$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 手填地址 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('手填地址:合法 IP 排在自动检测结果前面,带「手动」标记与链接', () => {
|
||||||
|
const links = lanDisplayLinks(NETWORK, ['192.168.9.9'])
|
||||||
|
|
||||||
|
assert.deepEqual(links, [
|
||||||
|
{
|
||||||
|
ip: '192.168.9.9',
|
||||||
|
url: 'http://192.168.9.9:19540/display',
|
||||||
|
confidence: 'high',
|
||||||
|
manual: true,
|
||||||
|
// 手填的是操作员自己的判断,不需要额外解释来源;类别也不猜(scope 为 null)。
|
||||||
|
hint: null,
|
||||||
|
scope: null,
|
||||||
|
scopeHint: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ip: '192.168.1.23',
|
||||||
|
url: 'http://192.168.1.23:19540/display',
|
||||||
|
confidence: 'high',
|
||||||
|
manual: false,
|
||||||
|
hint: null,
|
||||||
|
scope: null,
|
||||||
|
scopeHint: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ip: '10.0.8.7',
|
||||||
|
url: 'http://10.0.8.7:19540/display',
|
||||||
|
confidence: 'high',
|
||||||
|
manual: false,
|
||||||
|
hint: null,
|
||||||
|
scope: null,
|
||||||
|
scopeHint: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('手填地址:多个按添加顺序排在前面,IPv6 拼 URL 带方括号', () => {
|
||||||
|
const links = lanDisplayLinks(NETWORK, ['10.1.1.1', 'fe80::1'])
|
||||||
|
assert.deepEqual(
|
||||||
|
links.map((link) => link.ip),
|
||||||
|
['10.1.1.1', 'fe80::1', '192.168.1.23', '10.0.8.7'],
|
||||||
|
)
|
||||||
|
assert.equal(links[1]!.url, 'http://[fe80::1]:19540/display')
|
||||||
|
assert.equal(links[1]!.manual, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('手填地址:与自动检测重复时只留手填的那条(同一个链接不出现两次)', () => {
|
||||||
|
const links = lanDisplayLinks(NETWORK, ['192.168.1.23'])
|
||||||
|
assert.deepEqual(
|
||||||
|
links.map((link) => [link.ip, link.manual]),
|
||||||
|
[
|
||||||
|
['192.168.1.23', true],
|
||||||
|
['10.0.8.7', false],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('手填地址:端口没下发时连手填的也拼不出链接(不留半条能点的地址)', () => {
|
||||||
|
assert.deepEqual(lanDisplayLinks({ ...NETWORK, port: 0 }, ['192.168.9.9']), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('手填校验:合法 IPv4 / IPv6 收下,非法输入给红字且不添加', () => {
|
||||||
|
assert.deepEqual(parseManualAddress('192.168.1.23'), { ok: true, ip: '192.168.1.23' })
|
||||||
|
// 从 ipconfig 复制粘贴常带首尾空格:去掉后就合法。
|
||||||
|
assert.deepEqual(parseManualAddress(' 192.168.1.23 '), { ok: true, ip: '192.168.1.23' })
|
||||||
|
// IPv6 允许,并统一成小写压缩写法(去重才靠得住)。
|
||||||
|
assert.deepEqual(parseManualAddress('FE80::0001'), { ok: true, ip: 'fe80::1' })
|
||||||
|
assert.deepEqual(parseManualAddress('::1'), { ok: true, ip: '::1' })
|
||||||
|
|
||||||
|
const bad: Array<[string, RegExp]> = [
|
||||||
|
['', /请先填一个 IP 地址/],
|
||||||
|
[' ', /请先填一个 IP 地址/],
|
||||||
|
['192.168.1.23 10.0.0.1', /不能有空格/],
|
||||||
|
['192.168.1.2 3', /不能有空格/],
|
||||||
|
['192.168.1', /不像一个 IP 地址/],
|
||||||
|
['999.168.1.1', /不像一个 IP 地址/],
|
||||||
|
['192.168.1.256', /不像一个 IP 地址/],
|
||||||
|
['abc', /不像一个 IP 地址/],
|
||||||
|
['not:an:ip', /不像一个 IP 地址/],
|
||||||
|
['fe80::1%eth0', /不像一个 IP 地址/],
|
||||||
|
]
|
||||||
|
for (const [raw, message] of bad) {
|
||||||
|
const result = parseManualAddress(raw)
|
||||||
|
assert.equal(result.ok, false, `${raw} 不该通过校验`)
|
||||||
|
if (!result.ok) assert.match(result.error, message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('手填校验:重复的地址不添加,并说清原因', () => {
|
||||||
|
const result = parseManualAddress('10.0.0.4', ['192.168.1.23', '10.0.0.4'])
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
if (!result.ok) assert.match(result.error, /已经在列表里/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('手填持久化:localStorage 往返,坏数据一律不认', () => {
|
||||||
|
const store = fakeStorage()
|
||||||
|
|
||||||
|
assert.deepEqual(readManualAddresses(store), [])
|
||||||
|
writeManualAddresses(store, ['192.168.9.9', '10.1.1.1'])
|
||||||
|
assert.equal(store.value(MANUAL_LAN_STORAGE_KEY), '["192.168.9.9","10.1.1.1"]')
|
||||||
|
assert.deepEqual(readManualAddresses(store), ['192.168.9.9', '10.1.1.1'])
|
||||||
|
|
||||||
|
// 脏数据:不是数组 / 元素不是字符串 / 不是 IP / 重复 —— 全部丢掉,不能让页面崩。
|
||||||
|
store.setValue(MANUAL_LAN_STORAGE_KEY, '{"not":"an array"}')
|
||||||
|
assert.deepEqual(readManualAddresses(store), [])
|
||||||
|
store.setValue(MANUAL_LAN_STORAGE_KEY, '{ 这不是 json')
|
||||||
|
assert.deepEqual(readManualAddresses(store), [])
|
||||||
|
store.setValue(MANUAL_LAN_STORAGE_KEY, JSON.stringify(['192.168.1.23', 42, 'abc', ' ', '192.168.1.23']))
|
||||||
|
assert.deepEqual(readManualAddresses(store), ['192.168.1.23'])
|
||||||
|
|
||||||
|
// 超量的旧数据要在读的时候就被截断,不然键里会一直堆着几十条。
|
||||||
|
store.setValue(
|
||||||
|
MANUAL_LAN_STORAGE_KEY,
|
||||||
|
JSON.stringify(Array.from({ length: MAX_MANUAL_ADDRESSES + 5 }, (_, index) => `10.0.0.${index + 1}`)),
|
||||||
|
)
|
||||||
|
assert.equal(readManualAddresses(store).length, MAX_MANUAL_ADDRESSES)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('手填持久化:存储不可用(隐私模式 / 配额满)时静默降级,不抛异常', () => {
|
||||||
|
assert.deepEqual(readManualAddresses(null), [])
|
||||||
|
writeManualAddresses(null, ['192.168.1.23'])
|
||||||
|
|
||||||
|
const throwing: ManualAddressStorage = {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error('SecurityError')
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error('QuotaExceededError')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.deepEqual(readManualAddresses(throwing), [])
|
||||||
|
writeManualAddresses(throwing, ['192.168.1.23'])
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 内存版 localStorage:node 里没有浏览器存储,存取两条路都要能测。 */
|
||||||
|
function fakeStorage(): ManualAddressStorage & { value(key: string): string | null; setValue(key: string, raw: string): void } {
|
||||||
|
const data = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
getItem: (key) => data.get(key) ?? null,
|
||||||
|
setItem: (key, value) => {
|
||||||
|
data.set(key, value)
|
||||||
|
},
|
||||||
|
value: (key) => data.get(key) ?? null,
|
||||||
|
setValue: (key, raw) => {
|
||||||
|
data.set(key, raw)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test('复制:navigator.clipboard 不存在时走到 execCommand 回退', async () => {
|
test('复制:navigator.clipboard 不存在时走到 execCommand 回退', async () => {
|
||||||
const seen: string[] = []
|
const seen: string[] = []
|
||||||
const ok = await copyText('http://192.168.1.23:19540/display', {
|
const ok = await copyText('http://192.168.1.23:19540/display', {
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ import type {
|
|||||||
ImportConflictDecision,
|
ImportConflictDecision,
|
||||||
LiveEvents,
|
LiveEvents,
|
||||||
NetworkAddress,
|
NetworkAddress,
|
||||||
|
NetworkAddressScope,
|
||||||
NetworkInfo,
|
NetworkInfo,
|
||||||
|
NetworkSkippedInterface,
|
||||||
Page,
|
Page,
|
||||||
PageQuery,
|
PageQuery,
|
||||||
PointAdjustment,
|
PointAdjustment,
|
||||||
@@ -721,6 +723,30 @@ function readNetworkAddress(value: unknown): NetworkAddress | null {
|
|||||||
ip,
|
ip,
|
||||||
interface: readString(value, 'interface') ?? '',
|
interface: readString(value, 'interface') ?? '',
|
||||||
family: readString(value, 'family') ?? 'ipv4',
|
family: readString(value, 'family') ?? 'ipv4',
|
||||||
|
// 只认服务端契约里的 low / route 两个「需要额外提示」的值:老服务端没有这个字段
|
||||||
|
// (它的地址本来就走严格轮),值也不认识时按 high 处理——凭一个没见过的值在卡片上
|
||||||
|
// 多印一句提示,是凭空造出来的噪声。
|
||||||
|
confidence: value.confidence === 'low' || value.confidence === 'route' ? value.confidence : 'high',
|
||||||
|
// 类别同样只认契约里的三个值;认不出(老服务端)时留 undefined,卡片就不加类别说明。
|
||||||
|
// 绝不按 IP 前缀在这里补一个:分类是服务端的知识,前端猜出来的值会和它打架。
|
||||||
|
scope: readScope(value.scope),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readScope(value: unknown): NetworkAddressScope | undefined {
|
||||||
|
return value === 'private' || value === 'global' || value === 'link-local' ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化 `skipped[]` 的一条:没有 code 的行解释不了任何事,直接丢掉。 */
|
||||||
|
function readNetworkSkipped(value: unknown): NetworkSkippedInterface | null {
|
||||||
|
if (!isRecord(value)) return null
|
||||||
|
const code = readString(value, 'code')
|
||||||
|
if (code === null || code === '') return null
|
||||||
|
const rawAddresses = Array.isArray(value['addresses']) ? value['addresses'] : []
|
||||||
|
return {
|
||||||
|
interface: readString(value, 'interface') ?? '',
|
||||||
|
code,
|
||||||
|
addresses: rawAddresses.filter((item): item is string => typeof item === 'string'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,10 +756,13 @@ function readNetworkAddress(value: unknown): NetworkAddress | null {
|
|||||||
* 端口/路径是拼地址的原料,前端不替服务端猜:缺端口就留 0,由页面判断「读不到可用地址」
|
* 端口/路径是拼地址的原料,前端不替服务端猜:缺端口就留 0,由页面判断「读不到可用地址」
|
||||||
* 并给说明——编一个默认端口出来会得到一条看着对、实际打不开的链接。
|
* 并给说明——编一个默认端口出来会得到一条看着对、实际打不开的链接。
|
||||||
* addresses 不是数组时按「没检测到地址」处理,交给页面显示空态。
|
* addresses 不是数组时按「没检测到地址」处理,交给页面显示空态。
|
||||||
|
* skipped 不是数组时归一化成空数组(等价于「没有可解释的网卡」):老服务端压根不返回这个
|
||||||
|
* 字段,卡片据此退回原来那句空态文案,行为与升级前一致。
|
||||||
*/
|
*/
|
||||||
function readNetworkInfo(value: unknown): NetworkInfo {
|
function readNetworkInfo(value: unknown): NetworkInfo {
|
||||||
const port = firstNumberOrNull(isRecord(value) ? value['port'] : null)
|
const port = firstNumberOrNull(isRecord(value) ? value['port'] : null)
|
||||||
const rawAddresses = isRecord(value) && Array.isArray(value['addresses']) ? value['addresses'] : []
|
const rawAddresses = isRecord(value) && Array.isArray(value['addresses']) ? value['addresses'] : []
|
||||||
|
const rawSkipped = isRecord(value) && Array.isArray(value['skipped']) ? value['skipped'] : []
|
||||||
return {
|
return {
|
||||||
port: port !== null && port > 0 ? Math.floor(port) : 0,
|
port: port !== null && port > 0 ? Math.floor(port) : 0,
|
||||||
displayPath: normalizePath(readString(value, 'displayPath'), '/display'),
|
displayPath: normalizePath(readString(value, 'displayPath'), '/display'),
|
||||||
@@ -741,6 +770,9 @@ function readNetworkInfo(value: unknown): NetworkInfo {
|
|||||||
addresses: rawAddresses
|
addresses: rawAddresses
|
||||||
.map(readNetworkAddress)
|
.map(readNetworkAddress)
|
||||||
.filter((item): item is NetworkAddress => item !== null),
|
.filter((item): item is NetworkAddress => item !== null),
|
||||||
|
skipped: rawSkipped
|
||||||
|
.map(readNetworkSkipped)
|
||||||
|
.filter((item): item is NetworkSkippedInterface => item !== null),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ const NETWORK = {
|
|||||||
port: 19540,
|
port: 19540,
|
||||||
displayPath: '/display',
|
displayPath: '/display',
|
||||||
adminPath: '/admin',
|
adminPath: '/admin',
|
||||||
addresses: [{ ip: '192.168.1.23', interface: 'eth0', family: 'ipv4' }],
|
addresses: [{ ip: '192.168.1.23', interface: 'eth0', family: 'ipv4', confidence: 'high', scope: 'private' }],
|
||||||
|
skipped: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
function client(reply: () => Response): ApiClient {
|
function client(reply: () => Response): ApiClient {
|
||||||
@@ -53,6 +54,87 @@ test('network 打的是 /api/system/network,端口与路径原样透传', asyn
|
|||||||
assert.deepEqual(info, NETWORK)
|
assert.deepEqual(info, NETWORK)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('confidence:只认服务端契约里的 low / route,其余按 high', async () => {
|
||||||
|
const info = await client(() =>
|
||||||
|
jsonResponse({
|
||||||
|
...NETWORK,
|
||||||
|
addresses: [
|
||||||
|
{ ip: '192.168.1.23', interface: 'eth0', family: 'ipv4', confidence: 'low' },
|
||||||
|
{ ip: '192.168.5.9', interface: '默认路由', family: 'ipv4', confidence: 'route' },
|
||||||
|
// 老服务端没有这个字段、新服务端给了没见过的值:都按 high,不能凭它多印一句提示。
|
||||||
|
{ ip: '10.0.0.4', interface: 'ZeroTier', family: 'ipv4' },
|
||||||
|
{ ip: '10.0.0.5', interface: 'WSL', family: 'ipv4', confidence: 'medium' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).system.network()
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
info.addresses.map((a) => a.confidence),
|
||||||
|
['low', 'route', 'high', 'high'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('skipped:逐条归一化,缺 code 的行丢掉', async () => {
|
||||||
|
const info = await client(() =>
|
||||||
|
jsonResponse({
|
||||||
|
...NETWORK,
|
||||||
|
addresses: [],
|
||||||
|
skipped: [
|
||||||
|
{ interface: '以太网', code: 'not-up', addresses: ['10.0.0.10/24'] },
|
||||||
|
{ interface: 'WLAN', code: 'only-ipv6', addresses: ['fe80::1/64'] },
|
||||||
|
// 没有 code 的行解释不了任何事;缺 addresses 的行补空数组。
|
||||||
|
{ interface: '蓝牙网络连接' },
|
||||||
|
'x',
|
||||||
|
{ interface: 'eth9', code: 'no-address' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).system.network()
|
||||||
|
|
||||||
|
assert.deepEqual(info.skipped, [
|
||||||
|
{ interface: '以太网', code: 'not-up', addresses: ['10.0.0.10/24'] },
|
||||||
|
{ interface: 'WLAN', code: 'only-ipv6', addresses: ['fe80::1/64'] },
|
||||||
|
{ interface: 'eth9', code: 'no-address', addresses: [] },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scope:只认契约里的三个值,认不出一律留 undefined(绝不按前缀猜)', async () => {
|
||||||
|
const info = await client(() =>
|
||||||
|
jsonResponse({
|
||||||
|
...NETWORK,
|
||||||
|
addresses: [
|
||||||
|
{ ip: '192.168.1.23', interface: '以太网', family: 'ipv4', scope: 'private' },
|
||||||
|
{ ip: '203.0.113.9', interface: '专线', family: 'ipv4', scope: 'global' },
|
||||||
|
// 主人现场那条:自动配置地址,服务端如实标 link-local。
|
||||||
|
{ ip: '169.254.79.190', interface: '以太网', family: 'ipv4', scope: 'link-local' },
|
||||||
|
// 老服务端没这个字段、或给了没见过的值:留 undefined,前端不加任何类别说明
|
||||||
|
// (也不看 IP 前缀自己补一个:分类是服务端的知识)。
|
||||||
|
{ ip: '169.254.1.2', interface: '旧网卡', family: 'ipv4' },
|
||||||
|
{ ip: '10.0.0.4', interface: 'ZeroTier', family: 'ipv4', scope: 'carrier-grade' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).system.network()
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
info.addresses.map((a) => a.scope),
|
||||||
|
['private', 'global', 'link-local', undefined, undefined],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('老服务端没有 skipped 时给空数组,不影响 addresses 的解析', async () => {
|
||||||
|
const info = await client(() =>
|
||||||
|
jsonResponse({
|
||||||
|
port: 19540,
|
||||||
|
displayPath: '/display',
|
||||||
|
adminPath: '/admin',
|
||||||
|
addresses: [{ ip: '192.168.1.23', interface: 'eth0', family: 'ipv4' }],
|
||||||
|
}),
|
||||||
|
).system.network()
|
||||||
|
|
||||||
|
assert.deepEqual(info.skipped, [])
|
||||||
|
assert.equal(info.addresses[0]?.ip, '192.168.1.23')
|
||||||
|
assert.equal(info.addresses[0]?.confidence, 'high')
|
||||||
|
})
|
||||||
|
|
||||||
test('addresses 缺失或不是数组时按「没有可用地址」处理,不是解析失败', async () => {
|
test('addresses 缺失或不是数组时按「没有可用地址」处理,不是解析失败', async () => {
|
||||||
// 没插网线 / 只有回环时服务端就回空数组;接口本身是成功的。
|
// 没插网线 / 只有回环时服务端就回空数组;接口本身是成功的。
|
||||||
assert.deepEqual((await client(() => jsonResponse({ ...NETWORK, addresses: [] })).system.network()).addresses, [])
|
assert.deepEqual((await client(() => jsonResponse({ ...NETWORK, addresses: [] })).system.network()).addresses, [])
|
||||||
|
|||||||
+57
-1
@@ -673,14 +673,64 @@ export interface HealthInfo {
|
|||||||
version: string
|
version: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端对一条局域网地址的置信度(也就是它的来源):
|
||||||
|
* - `high` = 接口置了 up 位,从网卡枚举里挑出来的;
|
||||||
|
* - `low` = 网卡 up 位读不到,兜底轮捡回来的;
|
||||||
|
* - `route` = 网卡枚举一条都没给,按系统默认路由推断出来的源地址。
|
||||||
|
*/
|
||||||
|
export type NetworkConfidence = 'high' | 'low' | 'route'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条局域网地址属于哪一类(服务端按标准库谓词判定后下发):
|
||||||
|
* - `private` = 私网地址;
|
||||||
|
* - `global` = 其余(公网 / 运营商地址);
|
||||||
|
* - `link-local` = 自动配置地址(无 DHCP 的独立内网里它就是唯一能用的那种)。
|
||||||
|
*
|
||||||
|
* 前端**不按 IP 前缀自己猜**:分类是服务端的知识,两边各猜一套迟早对不上。
|
||||||
|
*/
|
||||||
|
export type NetworkAddressScope = 'private' | 'global' | 'link-local'
|
||||||
|
|
||||||
|
/** 服务端解释「一张网卡为什么没贡献地址」的原因码。 */
|
||||||
|
export type NetworkSkipCode = 'no-address' | 'only-ipv6' | 'not-up' | 'loopback'
|
||||||
|
|
||||||
/** 本机的一个局域网地址(GET /api/system/network)。 */
|
/** 本机的一个局域网地址(GET /api/system/network)。 */
|
||||||
export interface NetworkAddress {
|
export interface NetworkAddress {
|
||||||
/** 形如 `192.168.1.23`;服务端只回可用地址,回环不会被列进来。 */
|
/**
|
||||||
|
* 形如 `192.168.1.23`;服务端只排掉回环 / 未指定 / 组播 / 广播这类
|
||||||
|
* 「作为对端访问目标没有意义」的地址,其余(含自动配置地址)都会列出来。
|
||||||
|
*/
|
||||||
ip: string
|
ip: string
|
||||||
/** 网卡名,如 `eth0` / `wlan0` / Windows 的 `WLAN`。 */
|
/** 网卡名,如 `eth0` / `wlan0` / Windows 的 `WLAN`。 */
|
||||||
interface: string
|
interface: string
|
||||||
/** 地址族,当前只有 `ipv4` 会被列出(大屏地址是 IPv4 点分十进制)。 */
|
/** 地址族,当前只有 `ipv4` 会被列出(大屏地址是 IPv4 点分十进制)。 */
|
||||||
family: string
|
family: string
|
||||||
|
/**
|
||||||
|
* 置信度(来源);`low` 表示服务端读不到这张网卡的 up 位(驱动 / 虚拟网卡),
|
||||||
|
* `route` 表示它压根不是枚举来的、而是系统默认路由给的。老服务端不返回这个字段,解析时按 `high` 处理。
|
||||||
|
*/
|
||||||
|
confidence?: NetworkConfidence
|
||||||
|
/**
|
||||||
|
* 地址类别;老服务端不返回这个字段(解析后为 undefined),此时前端不加任何类别说明。
|
||||||
|
* `link-local` 会额外提示一句「本网段没有 DHCP」。
|
||||||
|
*/
|
||||||
|
scope?: NetworkAddressScope
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一张被跳过的网卡及原因(GET /api/system/network 的 `skipped[]`)。
|
||||||
|
*
|
||||||
|
* 服务端只给机器可读的 `code`,中文文案由前端映射(见 admin/pages/lanDisplay.ts)——
|
||||||
|
* 同一条信息在服务端启动日志里另有一份面向运维的措辞。
|
||||||
|
* 这个数组**只用来解释空态**,地址列表本身与它无关。
|
||||||
|
*/
|
||||||
|
export interface NetworkSkippedInterface {
|
||||||
|
/** 网卡名,如 `以太网` / `WLAN`。 */
|
||||||
|
interface: string
|
||||||
|
/** 原因码;服务端契约固定为 NetworkSkipCode 那几个,未知值由前端兜底显示。 */
|
||||||
|
code: string
|
||||||
|
/** 该网卡上的原始地址(CIDR 文本),排查时用得上。 */
|
||||||
|
addresses: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -695,6 +745,12 @@ export interface NetworkInfo {
|
|||||||
displayPath: string
|
displayPath: string
|
||||||
adminPath: string
|
adminPath: string
|
||||||
addresses: NetworkAddress[]
|
addresses: NetworkAddress[]
|
||||||
|
/**
|
||||||
|
* 被跳过的网卡与原因,前端据此把空态换成「已检查:以太网(接口未连接)…」。
|
||||||
|
*
|
||||||
|
* 声明为可选:老服务端不返回这个字段、示例数据也不填它,两种情况下卡片都要退回原空态文案。
|
||||||
|
*/
|
||||||
|
skipped?: NetworkSkippedInterface[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 数据目录 `event_projs/` 下的一个赛事项目(GET /api/projects,admin)。 */
|
/** 数据目录 `event_projs/` 下的一个赛事项目(GET /api/projects,admin)。 */
|
||||||
|
|||||||
Reference in new issue
Block a user