网络连接:右侧栏的新工具,列出 Linux 主机的 TCP、UDP 连接和监听端口
- 卡片显示协议、状态、地址、进程和用户,可搜索、按协议和状态筛选 - 打开或切换终端时读取一次,点刷新再读;系统监控和它共用终端里执行命令的部分 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a0436caf6
commit
0d455e02f5
19 files changed
+2368
-60
No files matched your search
@@ -10,7 +10,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP
|
|||||||
|
|
||||||
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。
|
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。
|
||||||
|
|
||||||
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
|
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
|
||||||
|
|
||||||
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
|
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
|||||||
|
|
||||||
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。
|
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。
|
||||||
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
|
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
|
||||||
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、SSH、SFTP 和工作区都直接用这些类型。
|
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)、`exec.rs`(右侧栏的工具共用的 `ExecTarget`:在哪个远程终端的连接上执行命令,和轮询回答的 `exec_answer`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、SSH、SFTP 和工作区都直接用这些类型。
|
||||||
- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `HostLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒(`ssh::LATENCY_INTERVAL`)用 `ssh::round_trip`(`latency.rs`:`Handle::send_ping()`,即 `keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`(`Latency` / `LatencyLevel` 在 `connection.rs`,画法是 `shared::LatencyLabel`)。SFTP 标签同样显示:工作线程另起一个 `measure_latency` 任务,新连接一建好就量、之后每 5 秒量一次,传输进行中也照量(ping 排在数据后面,读数随之升高),结果是 `SftpEvent::Latency`;`ExplorerPanel` 在 `Connecting` / `Disconnected` 时清掉,只在已连接时由 `title_suffix` 显示。这个任务只在一次 ping 期间攥着 `Arc<SftpClient>`,并且每 250 ms 看一眼共享槽位,客户端被换掉或丢掉(断开、断线)就立刻放手,所以「断开」照样马上关掉连接(同下面「SFTP 空闲时也要发现断网」的约束)。
|
- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `HostLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒(`ssh::LATENCY_INTERVAL`)用 `ssh::round_trip`(`latency.rs`:`Handle::send_ping()`,即 `keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`(`Latency` / `LatencyLevel` 在 `connection.rs`,画法是 `shared::LatencyLabel`)。SFTP 标签同样显示:工作线程另起一个 `measure_latency` 任务,新连接一建好就量、之后每 5 秒量一次,传输进行中也照量(ping 排在数据后面,读数随之升高),结果是 `SftpEvent::Latency`;`ExplorerPanel` 在 `Connecting` / `Disconnected` 时清掉,只在已连接时由 `title_suffix` 显示。这个任务只在一次 ping 期间攥着 `Arc<SftpClient>`,并且每 250 ms 看一眼共享槽位,客户端被换掉或丢掉(断开、断线)就立刻放手,所以「断开」照样马上关掉连接(同下面「SFTP 空闲时也要发现断网」的约束)。
|
||||||
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是主机自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
|
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是主机自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
|
||||||
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。
|
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。
|
||||||
@@ -57,7 +57,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
|||||||
- `forward_panel.rs`(侧栏里的转发列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车)、`forward_dialog.rs`(`ForwardForm` 与 `open_forward_dialog`)、`diagram.rs`(对话框里的示意图 `ForwardDiagram`,以及 `stops` / `explain` / `purpose` 等纯函数)。
|
- `forward_panel.rs`(侧栏里的转发列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车)、`forward_dialog.rs`(`ForwardForm` 与 `open_forward_dialog`)、`diagram.rs`(对话框里的示意图 `ForwardDiagram`,以及 `stops` / `explain` / `purpose` 等纯函数)。
|
||||||
- `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。
|
- `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。
|
||||||
- `credential/` — 凭据的界面,模型在 `host/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `host::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `HostStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。
|
- `credential/` — 凭据的界面,模型在 `host/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `host::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `HostStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。
|
||||||
- `monitor/` — 系统监控,右侧栏的第一个真工具,只读远程 Linux 主机:`linux.rs`(在终端自己的连接上执行的命令 `COMMAND` 和解析 `parse`)、`model.rs`(读数、`Tracker` 算比率、格式化)、`monitor_panel.rs`(`MonitorPanel` 与 `MonitorTarget`:轮询读数、五张卡片),见下面「系统监控(`monitor/`)是纯解析加一个面板」。依赖 `terminal`(`TerminalView::exec`),不依赖 `workspace`。
|
- `monitor/` — 系统监控,右侧栏的第一个真工具,只读远程 Linux 主机:`linux.rs`(在终端自己的连接上执行的命令 `COMMAND` 和解析 `parse`)、`model.rs`(读数、`Tracker` 算比率、格式化)、`monitor_panel.rs`(`MonitorPanel`:轮询读数、五张卡片),见下面「系统监控(`monitor/`)是纯解析加一个面板」。依赖 `terminal`(`TerminalView::exec`、`ExecTarget`),不依赖 `workspace`。
|
||||||
|
- `netstat/` — 网络连接,右侧栏的第二个真工具,只读远程 Linux 主机:`linux.rs`(命令 `command()` 和解析 `parse`)、`model.rs`(`Socket` / `SocketState` / `Role`;`Table::new` 合并同一地址的几行、定角色、排序、计数,`Table::filtered` 按 `Filter` 搜索和筛选,都是纯函数)、`netstat_panel.rs`(`NetstatPanel`:读取、搜索框和两个下拉、`uniform_list` 画的卡片),见下面「网络连接(`netstat/`)读一次,按需刷新」。依赖 `terminal`,不依赖 `workspace` 和 `monitor`。
|
||||||
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
|
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
|
||||||
- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
|
- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
|
||||||
- `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。
|
- `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。
|
||||||
@@ -109,9 +110,10 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
|||||||
- **本地 / 动态转发先监听再登录,监听器跨重连保留。** 端口被占用在任何问答之前就失败(`a_taken_local_port_fails_before_anyone_is_asked_anything`);重连期间端口不会被别人占走,这段时间进来的连接由 `turning_away` 直接关掉。停止时要 `handle.disconnect`:只丢句柄不够,每个通道流都攥着一个发送端,连接会一直开着;`JoinSet::shutdown` 在 runtime 里等连接任务结束,因为 `ChannelStream` 在 drop 时 `tokio::spawn`。
|
- **本地 / 动态转发先监听再登录,监听器跨重连保留。** 端口被占用在任何问答之前就失败(`a_taken_local_port_fails_before_anyone_is_asked_anything`);重连期间端口不会被别人占走,这段时间进来的连接由 `turning_away` 直接关掉。停止时要 `handle.disconnect`:只丢句柄不够,每个通道流都攥着一个发送端,连接会一直开着;`JoinSet::shutdown` 在 runtime 里等连接任务结束,因为 `ChannelStream` 在 drop 时 `tokio::spawn`。
|
||||||
- **首次登录可以问,重连不问。** 第一次用交互式 `SshPrompts`;断线后的重连用 `.non_interactive()` 而且陌生主机直接答否,后台的转发因此不会自己弹框。重连只对网络类错误重试(`ssh::is_network_error`,要显式认 `russh::Error::IO`,它是 transparent 的、链上没有单独的 `io::Error`),缺密码、主机密钥问题立即以「连接中断,自动重连失败:…」停止。远程转发在重连时 `tcpip_forward` 被拒也算可重试:服务器可能还替刚死的那条连接占着端口。
|
- **首次登录可以问,重连不问。** 第一次用交互式 `SshPrompts`;断线后的重连用 `.non_interactive()` 而且陌生主机直接答否,后台的转发因此不会自己弹框。重连只对网络类错误重试(`ssh::is_network_error`,要显式认 `russh::Error::IO`,它是 transparent 的、链上没有单独的 `io::Error`),缺密码、主机密钥问题立即以「连接中断,自动重连失败:…」停止。远程转发在重连时 `tcpip_forward` 被拒也算可重试:服务器可能还替刚死的那条连接占着端口。
|
||||||
- **停止中的运行占着自己的位置。** 本地监听端口要等旧的工作线程真的结束才空出来,所以 `ForwardManager` 的 `Link::stopping` 在发出 `Stop` 后保留这条链接直到收到终结事件:`Some(false)` 对外显示为已停止,`Some(true)` 表示结束后立刻再启动(重启,或停止途中又点了启动)。直接另起一个工作线程会撞上「端口已被占用」。
|
- **停止中的运行占着自己的位置。** 本地监听端口要等旧的工作线程真的结束才空出来,所以 `ForwardManager` 的 `Link::stopping` 在发出 `Stop` 后保留这条链接直到收到终结事件:`Some(false)` 对外显示为已停止,`Some(true)` 表示结束后立刻再启动(重启,或停止途中又点了启动)。直接另起一个工作线程会撞上「端口已被占用」。
|
||||||
- **右侧栏是右侧 Dock 的一个面板,切换画在 Dock 外面,两者都只跟着 SSH 终端出现。** 右侧 Dock 里只有 `workspace::ToolSidebar`,持有当前工具 `ToolKind`、它服务的终端 `ToolTerminal { id: RemoteTerminalId, host, view: WeakEntity<TerminalView> }` 和 Dock 是否开着(`set_terminal(terminal, shown)`;根元素的 id 跟着终端,是 `("tool-sidebar", 终端 id)`),标题是工具名,理由同左边的 `Sidebar`(换面板会把焦点带走、两个面板必然画出标签栏)。做好的工具是功能模块的实体,由 `ToolSidebar` 持有、隐藏时也活着(`MonitorPanel`),`sync_tools` 把终端交给它、并且只让正显示的那个工具跑(Dock 开着且当前工具是它);没做的工具画占位。工具切换 `render_tool_switch` 由工作区画在 `DockArea` 右边(`h_flex` 里 `DockArea` 占 `flex_1().min_w_0()`),因为收起的 Dock 整个不画。**当前标签只经 `Workspace::set_active_tab` 改**(四种标签的 `Activated` / `Closed` 订阅都是 `subscribe_in`,要拿到 `window`):它记下标签再 `sync_tool_sidebar`,`tool_terminal(cx)` 是「当前标签是还在的远程终端」,右侧 Dock 开着 = `tool_sidebar_wanted`(用户最后一次开 / 关的意愿)且有远程终端在前;切换按钮列只在有远程终端在前时画,只画这台主机能用的工具(`offered_tools`:`ToolKind::works_on(host.os)`,系统监控要求 `HostOs::is_linux` 或还没探测出系统),当前工具这台用不了时右侧栏也收起(意愿不变);系统探测的结果在连接后才回来,所以工作区 `observe_in` 主机 store 时也 `sync_tool_sidebar`。新加会改当前标签的地方要走 `set_active_tab`,直接赋值 `active_tab` 右侧栏就不跟了。没有远程终端在前时 `ToggleTool` / `ToggleToolSidebar` 什么也不做,也不改意愿(`the_tool_sidebar_goes_with_ssh_terminals_only` 守着);`ToggleTool` 对这台主机用不了的工具也不做;`ToggleToolSidebar` 在右侧栏收着时打开上次的工具,那个工具这台用不了就换成第一个能用的(`the_monitor_is_not_offered_on_a_host_known_not_to_run_linux`)。切换按钮是 `toggled` 的开关(`checked()` 就是当前显示的工具),`tooltip_placement(Placement::Left)` 朝窗口里弹;按钮按下不抢焦点(gpui-kit 的 `Button` 在 mouse down 时 `prevent_default`),终端一直接着键盘,派发走工作区的 focus handle(同标题栏)。**右侧 Dock 不可折叠**:可折叠的 Dock 会在中间区的标签栏末尾多出 gpui-kit 自己的折叠按钮(`toggle-dock:Right`),和切换重复;而不可折叠的 Dock `toggle_dock` 拒绝关闭,所以 `set_right_dock_open` 只在关闭的那一下把它设成可折叠、关完再设回去(`the_tool_switch_shows_a_tool_and_hides_it_again` 查标签栏里没有那个按钮)。**最窄 320 px(`TOOL_SIDEBAR_WIDTH`,也是打开时的宽度)**:Dock 没有单独设最小宽度的接口,`DockContext` 的拖动回调是私有字段、包不了,所以工作区 `observe_in` DockArea,拖动每一步它都会通知,`hold_tool_sidebar_width` 发现比 320 px 窄就在同一轮更新里 `set_dock_size` 设回去,画出来的帧不会更窄(`the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens` 用鼠标事件拖 `resize-handle-right`:拖动条横跨 Dock 左边缘左右各 4 px,按在边缘左侧会被中间的终端挡住,要按在边缘右侧;Dock 按指针的绝对位置算宽度)。收起时焦点要是在右侧栏里,`Workspace::focus_center` 把它交还给最近显示的中间标签(中间区为空时给开始页),否则所有快捷键失去派发路径;换工具时焦点在旧工具里就先交给 `ToolSidebar` 自己。`ToolSidebar` 观察 store,主机改名跟着变。
|
- **右侧栏是右侧 Dock 的一个面板,切换画在 Dock 外面,两者都只跟着 SSH 终端出现。** 右侧 Dock 里只有 `workspace::ToolSidebar`,持有当前工具 `ToolKind`、它服务的终端 `ToolTerminal { id: RemoteTerminalId, host, view: WeakEntity<TerminalView> }` 和 Dock 是否开着(`set_terminal(terminal, shown)`;根元素的 id 跟着终端,是 `("tool-sidebar", 终端 id)`),标题是工具名,理由同左边的 `Sidebar`(换面板会把焦点带走、两个面板必然画出标签栏)。做好的工具是功能模块的实体,由 `ToolSidebar` 持有、隐藏时也活着(`MonitorPanel`、`NetstatPanel`),`sync_tools` 把终端交给它、并且只让正显示的那个工具跑(Dock 开着且当前工具是它);没做的工具画占位。工具切换 `render_tool_switch` 由工作区画在 `DockArea` 右边(`h_flex` 里 `DockArea` 占 `flex_1().min_w_0()`),因为收起的 Dock 整个不画。**当前标签只经 `Workspace::set_active_tab` 改**(四种标签的 `Activated` / `Closed` 订阅都是 `subscribe_in`,要拿到 `window`):它记下标签再 `sync_tool_sidebar`,`tool_terminal(cx)` 是「当前标签是还在的远程终端」,右侧 Dock 开着 = `tool_sidebar_wanted`(用户最后一次开 / 关的意愿)且有远程终端在前;切换按钮列只在有远程终端在前时画,只画这台主机能用的工具(`offered_tools`:`ToolKind::works_on(host.os)`,系统监控要求 `HostOs::is_linux` 或还没探测出系统),当前工具这台用不了时右侧栏也收起(意愿不变);系统探测的结果在连接后才回来,所以工作区 `observe_in` 主机 store 时也 `sync_tool_sidebar`。新加会改当前标签的地方要走 `set_active_tab`,直接赋值 `active_tab` 右侧栏就不跟了。没有远程终端在前时 `ToggleTool` / `ToggleToolSidebar` 什么也不做,也不改意愿(`the_tool_sidebar_goes_with_ssh_terminals_only` 守着);`ToggleTool` 对这台主机用不了的工具也不做;`ToggleToolSidebar` 在右侧栏收着时打开上次的工具,那个工具这台用不了就换成第一个能用的(`the_monitor_is_not_offered_on_a_host_known_not_to_run_linux`)。切换按钮是 `toggled` 的开关(`checked()` 就是当前显示的工具),`tooltip_placement(Placement::Left)` 朝窗口里弹;按钮按下不抢焦点(gpui-kit 的 `Button` 在 mouse down 时 `prevent_default`),终端一直接着键盘,派发走工作区的 focus handle(同标题栏)。**右侧 Dock 不可折叠**:可折叠的 Dock 会在中间区的标签栏末尾多出 gpui-kit 自己的折叠按钮(`toggle-dock:Right`),和切换重复;而不可折叠的 Dock `toggle_dock` 拒绝关闭,所以 `set_right_dock_open` 只在关闭的那一下把它设成可折叠、关完再设回去(`the_tool_switch_shows_a_tool_and_hides_it_again` 查标签栏里没有那个按钮)。**最窄 320 px(`TOOL_SIDEBAR_WIDTH`,也是打开时的宽度)**:Dock 没有单独设最小宽度的接口,`DockContext` 的拖动回调是私有字段、包不了,所以工作区 `observe_in` DockArea,拖动每一步它都会通知,`hold_tool_sidebar_width` 发现比 320 px 窄就在同一轮更新里 `set_dock_size` 设回去,画出来的帧不会更窄(`the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens` 用鼠标事件拖 `resize-handle-right`:拖动条横跨 Dock 左边缘左右各 4 px,按在边缘左侧会被中间的终端挡住,要按在边缘右侧;Dock 按指针的绝对位置算宽度)。收起时焦点要是在右侧栏里,`Workspace::focus_center` 把它交还给最近显示的中间标签(中间区为空时给开始页),否则所有快捷键失去派发路径;换工具时焦点在旧工具里就先交给 `ToolSidebar` 自己。`ToolSidebar` 观察 store,主机改名跟着变。
|
||||||
- **右侧栏的工具在终端自己的连接上执行命令,不另起登录。** `TerminalTransportCommand::Exec(ExecRequest { command, reply })`:SSH 传输在主循环里另开 exec 通道执行(和系统探测、量延迟同一个循环),**一次一条**(`exec_queue` 排队,免得往服务器上堆通道),10 秒(`EXEC_TIMEOUT`)没结束就答「命令超时」,只收标准输出、最多 1 MB,`ChannelMsg::Failure` 答「服务器拒绝执行命令」;回答走 `reply`(`std::sync::mpsc`,`ExecResult = Result<String, String>`)。本地 PTY 和其他传输丢掉请求,问的一方看到通道断开。`TerminalEngine::exec` / `TerminalView::exec` 只在 shell 运行中(`TerminalLifecycle::Running`)才发,否则返回 `None`。回答来自传输线程,所以工具**轮询** `try_recv`(每 50 ms),不在 GPUI 任务里直接等它(同「禁止后台线程直接唤醒 GPUI 前台任务」)。命令要能经过任何登录 shell(bash、zsh、fish、csh):写成一行 `sh -c '…'`,脚本里不出现单引号和 `!`(`the_command_survives_any_login_shell` 守着)。协议测试 `commands_beside_the_shell_run_on_the_terminals_own_connection` 查按顺序回答、不再登录。
|
- **右侧栏的工具在终端自己的连接上执行命令,不另起登录。** `TerminalTransportCommand::Exec(ExecRequest { command, reply })`:SSH 传输在主循环里另开 exec 通道执行(和系统探测、量延迟同一个循环),**一次一条**(`exec_queue` 排队,免得往服务器上堆通道),10 秒(`EXEC_TIMEOUT`)没结束就答「命令超时」,只收标准输出、最多 1 MB,`ChannelMsg::Failure` 答「服务器拒绝执行命令」;回答走 `reply`(`std::sync::mpsc`,`ExecResult = Result<String, String>`)。本地 PTY 和其他传输丢掉请求,问的一方看到通道断开。`TerminalEngine::exec` / `TerminalView::exec` 只在 shell 运行中(`TerminalLifecycle::Running`)才发,否则返回 `None`。回答来自传输线程,所以工具**轮询** `try_recv`(每 50 ms,`terminal::exec_answer`,两个工具共用;它们的目标是 `terminal::ExecTarget`),不在 GPUI 任务里直接等它(同「禁止后台线程直接唤醒 GPUI 前台任务」)。命令要能经过任何登录 shell(bash、zsh、fish、csh):写成一行 `sh -c '…'`,脚本里不出现单引号和 `!`(`the_command_survives_any_login_shell` 守着)。协议测试 `commands_beside_the_shell_run_on_the_terminals_own_connection` 查按顺序回答、不再登录。
|
||||||
- **系统监控(`monitor/`)是纯解析加一个面板。** `linux.rs` 是命令 `command(Parts { system, disks })`(每部分以 `@@名称` 行开头:`SYSTEM` 是 `uname`、`/etc/os-release` 和 CPU 型号,每次连接一次;`LOAD` 每次都有,只用 `read` / `case` / `echo` 这些内建命令读 `/proc/uptime`、`/proc/stat`(读到第一行不是 `cpu` 就停,后面很长的 `intr` 行不读)、`/proc/meminfo`(读到 `SwapFree` 就停)、`/proc/net/dev` 和 `/proc/net/route`(找默认路由的网卡);`DISKS` 是 `df`)和纯函数 `parse`(→ `Parsed::Reading(Box<Reading>)` 或 `Unsupported(uname -s)`;没有 `/proc/stat` 的读数就算不支持,cmd.exe 什么都不打印也落在这里;`system` 和 `disks` 只在这次读数要了时才有)。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`、把路径换过去,用本机的 `sh` 真跑脚本再解析,守着内建命令读到的和 `cat` / `grep` 一样;`every_command_is_valid_sh` 对四种组合跑 `sh -n`。`model.rs` 是读数 `Reading`(CPU 的 busy / idle 节拍、内存、网卡的累计字节,要了时才有的系统信息和磁盘)、`Tracker`(和上一次读数比出 `Snapshot`:CPU 百分比、每核百分比、网速;第一次、相隔超过 10 秒、计数变小(重启)都不出比率)和格式化(`format_bytes` 按 1024、两位小数,`format_rate`、`format_percent`、`format_uptime`)、`Load::of`(70 / 90 分档)。`MonitorPanel` 按终端 id 存每台的 `Tracker`、最近一次 `Snapshot`、系统信息(连同读它时终端的连接代次 `TerminalView::generation`,重连可能连到另一台机器,代次变了就重读)和磁盘(连同读它的时刻,满 30 秒 `DISK_INTERVAL` 再读),切回来马上有数;每次读数前 `HostState::parts_due` 决定要哪几部分;`set_target(target, active)` 变了就重启读数任务(丢掉任务就停);时间用 `cx.background_executor().now()`,所以测试时钟下比率也对。卡片是 gpui-kit 的 `GroupBox::fill()`,环是 `ProgressCircle`,条是 `Progress`,会变的数字用等宽字体(`numeric`)免得跳动。一行放几根竖条按上一帧量到的宽度算(同路径标签:竖条那一行里的 `canvas` 在 prepaint 记下宽度,变了就 `on_next_frame` 通知面板重画;`bars_per_row` 是纯函数,竖条 0.375 rem、间隔 0.125 rem),还没量到之前那一行限高一行、`overflow_hidden`,不会先闪出几行。展开 / 折叠按钮(`unfold_button`:ghost xsmall,文字后面跟 ⌄ / ⌃,`toggled`)派发 `ToggleMonitorDetail(MonitorDetail::{Cores, Interfaces})`,工作区经 `ToolSidebar::toggle_monitor_detail` 转给 `MonitorPanel::toggle`(一个命令一个处理器,同 SFTP 队列的展开);按钮派发在工作区的 focus handle 上(`MonitorPanel::new(dispatch)`),终端没焦点也能用。主网卡的挑选是纯函数 `Snapshot::main_interface`(`busy_interfaces` 是有过流量、不是 `lo` 的那些)。面板的滚动用 `overflow_y_scrollbar()`(见下面「滚动条挂在不滚动的外层上」)。
|
- **系统监控(`monitor/`)是纯解析加一个面板。** `linux.rs` 是命令 `command(Parts { system, disks })`(每部分以 `@@名称` 行开头:`SYSTEM` 是 `uname`、`/etc/os-release` 和 CPU 型号,每次连接一次;`LOAD` 每次都有,只用 `read` / `case` / `echo` 这些内建命令读 `/proc/uptime`、`/proc/stat`(读到第一行不是 `cpu` 就停,后面很长的 `intr` 行不读)、`/proc/meminfo`(读到 `SwapFree` 就停)、`/proc/net/dev` 和 `/proc/net/route`(找默认路由的网卡);`DISKS` 是 `df`)和纯函数 `parse`(→ `Parsed::Reading(Box<Reading>)` 或 `Unsupported(uname -s)`;没有 `/proc/stat` 的读数就算不支持,cmd.exe 什么都不打印也落在这里;`system` 和 `disks` 只在这次读数要了时才有)。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`、把路径换过去,用本机的 `sh` 真跑脚本再解析,守着内建命令读到的和 `cat` / `grep` 一样;`every_command_is_valid_sh` 对四种组合跑 `sh -n`。`model.rs` 是读数 `Reading`(CPU 的 busy / idle 节拍、内存、网卡的累计字节,要了时才有的系统信息和磁盘)、`Tracker`(和上一次读数比出 `Snapshot`:CPU 百分比、每核百分比、网速;第一次、相隔超过 10 秒、计数变小(重启)都不出比率)和格式化(`format_bytes` 按 1024、两位小数,`format_rate`、`format_percent`、`format_uptime`)、`Load::of`(70 / 90 分档)。`MonitorPanel` 按终端 id 存每台的 `Tracker`、最近一次 `Snapshot`、系统信息(连同读它时终端的连接代次 `TerminalView::generation`,重连可能连到另一台机器,代次变了就重读)和磁盘(连同读它的时刻,满 30 秒 `DISK_INTERVAL` 再读),切回来马上有数;每次读数前 `HostState::parts_due` 决定要哪几部分;`set_target(target, active)` 变了就重启读数任务(丢掉任务就停);时间用 `cx.background_executor().now()`,所以测试时钟下比率也对。卡片是 gpui-kit 的 `GroupBox::fill()`,环是 `ProgressCircle`,条是 `Progress`,会变的数字用等宽字体(`numeric`)免得跳动。一行放几根竖条按上一帧量到的宽度算(同路径标签:竖条那一行里的 `canvas` 在 prepaint 记下宽度,变了就 `on_next_frame` 通知面板重画;`bars_per_row` 是纯函数,竖条 0.375 rem、间隔 0.125 rem),还没量到之前那一行限高一行、`overflow_hidden`,不会先闪出几行。展开 / 折叠按钮(`unfold_button`:ghost xsmall,文字后面跟 ⌄ / ⌃,`toggled`)派发 `ToggleMonitorDetail(MonitorDetail::{Cores, Interfaces})`,工作区经 `ToolSidebar::toggle_monitor_detail` 转给 `MonitorPanel::toggle`(一个命令一个处理器,同 SFTP 队列的展开);按钮派发在工作区的 focus handle 上(`MonitorPanel::new(dispatch)`),终端没焦点也能用。主网卡的挑选是纯函数 `Snapshot::main_interface`(`busy_interfaces` 是有过流量、不是 `lo` 的那些)。面板的滚动用 `overflow_y_scrollbar()`(见下面「滚动条挂在不滚动的外层上」)。
|
||||||
|
- **网络连接(`netstat/`)读一次,按需刷新。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:先 `export PATH=$PATH:/usr/sbin:/sbin`(Debian 上普通用户的 PATH 里没有 sbin,有的发行版把 `ss` 放在那里),`@@uid` 是 `id -u`(是不是 root),`@@users` 用内建命令读 `/etc/passwd` 得到「uid 名称」,然后有 `ss` 就 `ss -tuanpe | awk …`:awk 跳过表头、只印前 `LIMIT`(3000)行、在 `@@counts` 下按「协议 状态」数**全部**的,所以列表被截断时计数仍然准确(`ss` 先列 UDP 和监听的,截掉的只会是连接);没有 `ss` 就对 `/proc/net/{tcp,tcp6,udp,udp6}` 中存在的每个文件跑一个 awk(`-v p=$f`),印出地址、对端、状态、uid、inode,同样截断和计数。awk 程序按原样写在常量里,由 `double_quoted` 转义成 sh 的双引号串(`$`、`"`);不能用 `!`(csh 会展开,所以写 `$1 == "Netid" { next }` 而不是 `!=`),也不能出现 `\\`(fish 在单引号里把它读成一个反斜杠),`the_command_survives_any_login_shell` 守着。`ss` 省略 `uid:0`,所以有 inode、没有 uid 的是 root 的;`ino:0` 的没有主人(TIME_WAIT),用户留空。进程名可能带空格,所以 `parse_ss` 按位置跳过前 6 个字段再找 `users:((…))`,老版本 `ss` 的 `("name",pid,fd)` 也认。`/proc` 的地址是内存里的 32 位字(小端),IPv6 是四个这样的字,`proc_address` 照 `ss` 的写法输出(端口 0 写 `*`,IPv6 加方括号)。`the_script_reads_ss_or_else_proc` 在临时目录里摆好假的 `/proc/net`、`/etc/passwd` 和一个假 `ss` 脚本,用本机的 `sh` 和 awk 真跑两条路。面板:`set_target` 变了(换终端、显示 / 隐藏、换工具)就重读,之外只有刷新按钮读(派发 `RefreshConnections`,工作区经 `ToolSidebar::refresh_connections` 转给 `NetstatPanel::refresh`);搜索框和两个下拉是面板自己的状态(同左侧列表的搜索,不是动作),改了就滚回顶上。列表用 `uniform_list`,它按第一项的高度排所有项,所以**每张卡片的行数必须一样**(没有进程也画「进程 —」那一行),改卡片时别加只有部分卡片才有的行;滚动条挂在不滚动的 `netstat-list` 上。
|
||||||
|
|
||||||
- **侧栏是一个面板、三个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `HostPanel`、`ForwardPanel` 和 `CredentialPanel` 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。
|
- **侧栏是一个面板、三个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `HostPanel`、`ForwardPanel` 和 `CredentialPanel` 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。
|
||||||
- **转发列表的命令经工作区的 focus handle 派发。** 行上的启停按钮、工具栏的「+」、空状态的按钮都用 `target.dispatch_action`(同标题栏和开始页)。用 `window.dispatch_action` 的话,回答完主机密钥的对话框之后焦点是空的,点「停止」什么也不发生(`a_forward_question_is_answered_in_a_dialog_and_declining_stops_the_forward` 守着)。右键行时面板先自己取得焦点,菜单项才有派发路径。行按钮的 `on_click` 要 `cx.stop_propagation()`,否则双击它会连行的双击一起触发。
|
- **转发列表的命令经工作区的 focus handle 派发。** 行上的启停按钮、工具栏的「+」、空状态的按钮都用 `target.dispatch_action`(同标题栏和开始页)。用 `window.dispatch_action` 的话,回答完主机密钥的对话框之后焦点是空的,点「停止」什么也不发生(`a_forward_question_is_answered_in_a_dialog_and_declining_stops_the_forward` 守着)。右键行时面板先自己取得焦点,菜单项才有派发路径。行按钮的 `on_click` 要 `cx.stop_propagation()`,否则双击它会连行的双击一起触发。
|
||||||
@@ -183,4 +185,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
|||||||
|
|
||||||
## 测试约定
|
## 测试约定
|
||||||
|
|
||||||
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的补充 `host-tooltip-note` / `credential-tooltip-note`(`aria_label` 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checkedLine truncated
|
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`netstat`(网络连接)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的补充 `host-tooltip-note` / `credential-tooltip-note`(`aria_label` 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hostsLine truncated
|
||||||
+6
-2
@@ -135,9 +135,13 @@ shellrs download <ID> <远程路径> <本地路径>
|
|||||||
- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。
|
- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。
|
||||||
- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。
|
- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。
|
||||||
|
|
||||||
## 右侧栏与系统监控
|
## 右侧栏:网络连接与系统监控
|
||||||
|
|
||||||
当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。目前只有系统监控可用,其余工具即将推出。已知不是 Linux 的主机(macOS、Windows、BSD)不显示系统监控的图标。
|
当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、网络连接、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。目前网络连接和系统监控可用,其余工具即将推出。已知不是 Linux 的主机(macOS、Windows、BSD)不显示这两个工具的图标。
|
||||||
|
|
||||||
|
**网络连接**列出远程 Linux 主机上所有的 TCP、UDP 连接和监听端口,每条一张卡片:协议、状态、本地和远端地址,占用它的进程和 PID,以及所属用户。右上角写明这是「监听端口」「入站连接」(连到本机监听的端口)还是「出站连接」。顶部是总数、监听端口数和已连接数;可以按地址、端口、状态、PID、进程名或用户搜索,也可以按协议和状态筛选。监听的排在最前,然后是已连接的,TIME_WAIT 等正在关闭的排在最后;同一个端口被多个进程共同监听(如 nginx 的多个 worker)时合成一张卡片。
|
||||||
|
|
||||||
|
列表在打开网络连接或切换终端时读取一次,之后不会自己变化,点右上角的刷新按钮重新读取。数据通过这个终端已有的 SSH 连接读取,不需要再次登录,用的是主机上的 `ss` 命令;没有 `ss` 的主机(如 BusyBox)改为读取 `/proc/net`,这时看不到进程;不是以 root 登录时只能看到自己的进程。这两种情况列表顶部都会写明。连接超过 3000 条时只列出前 3000 条(监听端口总在其中),总数仍然准确。
|
||||||
|
|
||||||
**系统监控**显示远程 Linux 主机的:
|
**系统监控**显示远程 Linux 主机的:
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -39,6 +39,8 @@ gpui_kit::actions!(
|
|||||||
ToggleHostPanel,
|
ToggleHostPanel,
|
||||||
/// Show or hide the right sidebar, whichever tool it is showing.
|
/// Show or hide the right sidebar, whichever tool it is showing.
|
||||||
ToggleToolSidebar,
|
ToggleToolSidebar,
|
||||||
|
/// Read the network connections of the SSH terminal's host again.
|
||||||
|
RefreshConnections,
|
||||||
/// Show the host list in the left dock.
|
/// Show the host list in the left dock.
|
||||||
ShowHosts,
|
ShowHosts,
|
||||||
/// Show the port-forwarding list in the left dock.
|
/// Show the port-forwarding list in the left dock.
|
||||||
@@ -231,14 +233,16 @@ pub enum ToolKind {
|
|||||||
Snippets,
|
Snippets,
|
||||||
History,
|
History,
|
||||||
Docker,
|
Docker,
|
||||||
|
Connections,
|
||||||
Monitor,
|
Monitor,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolKind {
|
impl ToolKind {
|
||||||
pub const ALL: [ToolKind; 4] = [
|
pub const ALL: [ToolKind; 5] = [
|
||||||
ToolKind::Snippets,
|
ToolKind::Snippets,
|
||||||
ToolKind::History,
|
ToolKind::History,
|
||||||
ToolKind::Docker,
|
ToolKind::Docker,
|
||||||
|
ToolKind::Connections,
|
||||||
ToolKind::Monitor,
|
ToolKind::Monitor,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -52,7 +52,11 @@ icon_assets!(
|
|||||||
RotateCcwClock,
|
RotateCcwClock,
|
||||||
Container,
|
Container,
|
||||||
Activity,
|
Activity,
|
||||||
Monitor
|
Monitor,
|
||||||
|
Network,
|
||||||
|
RadioTower,
|
||||||
|
ArrowDownLeft,
|
||||||
|
ArrowUpRight
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -3,9 +3,9 @@
|
|||||||
//! Modules are organized by capability so they can become crates later:
|
//! Modules are organized by capability so they can become crates later:
|
||||||
//! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP
|
//! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP
|
||||||
//! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential`
|
//! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential`
|
||||||
//! (凭据), `monitor` (系统监控), `secrets` (系统钥匙串), `settings` (设置),
|
//! (凭据), `monitor` (系统监控), `netstat` (网络连接), `secrets` (系统钥匙串),
|
||||||
//! `cli` (外部 CLI), `update` (在线升级), `connection` 与 `shared` (跨模块共用),
|
//! `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `connection` 与
|
||||||
//! `workspace` (窗口壳), `app` (动作、快捷键、资源).
|
//! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源).
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
@@ -15,6 +15,7 @@ pub mod explorer;
|
|||||||
pub mod forward;
|
pub mod forward;
|
||||||
pub mod host;
|
pub mod host;
|
||||||
pub mod monitor;
|
pub mod monitor;
|
||||||
|
pub mod netstat;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod sftp;
|
pub mod sftp;
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ mod linux;
|
|||||||
mod model;
|
mod model;
|
||||||
mod monitor_panel;
|
mod monitor_panel;
|
||||||
|
|
||||||
pub use monitor_panel::{MonitorPanel, MonitorTarget};
|
pub use monitor_panel::MonitorPanel;
|
||||||
|
|
||||||
/// A part of the system monitor that is folded away until asked for.
|
/// A part of the system monitor that is folded away until asked for.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use gpui_kit::component::{
|
use gpui_kit::component::{
|
||||||
@@ -25,7 +24,7 @@ use super::model::{
|
|||||||
format_bytes, format_percent, format_rate, format_uptime,
|
format_bytes, format_percent, format_rate, format_uptime,
|
||||||
};
|
};
|
||||||
use crate::app::{CatalogIcon, ToggleMonitorDetail};
|
use crate::app::{CatalogIcon, ToggleMonitorDetail};
|
||||||
use crate::terminal::{ExecResult, RemoteTerminalId, TerminalView};
|
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
|
||||||
|
|
||||||
/// Time between readings of the load. The second comes sooner: the CPU's
|
/// Time between readings of the load. The second comes sooner: the CPU's
|
||||||
/// load and the network's rates need two, and the panel should not sit on
|
/// load and the network's rates need two, and the panel should not sit on
|
||||||
@@ -35,18 +34,6 @@ const SECOND_READING: Duration = Duration::from_secs(1);
|
|||||||
/// Disks are read this seldom: they fill slowly, and `df` is the slowest
|
/// Disks are read this seldom: they fill slowly, and `df` is the slowest
|
||||||
/// part of a reading.
|
/// part of a reading.
|
||||||
const DISK_INTERVAL: Duration = Duration::from_secs(30);
|
const DISK_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
/// How often a reading on its way is looked for. The answer comes from the
|
|
||||||
/// terminal's transport thread, which must not wake the UI itself.
|
|
||||||
const POLL: Duration = Duration::from_millis(50);
|
|
||||||
|
|
||||||
/// The terminal whose host the monitor reads, over that terminal's own
|
|
||||||
/// connection.
|
|
||||||
#[derive(Clone, PartialEq)]
|
|
||||||
pub struct MonitorTarget {
|
|
||||||
pub terminal: RemoteTerminalId,
|
|
||||||
pub view: WeakEntity<TerminalView>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 系统监控: the CPU, memory, network and disks of the host of the SSH
|
/// 系统监控: the CPU, memory, network and disks of the host of the SSH
|
||||||
/// terminal in front, read every two seconds while the panel shows.
|
/// terminal in front, read every two seconds while the panel shows.
|
||||||
///
|
///
|
||||||
@@ -58,7 +45,7 @@ pub struct MonitorTarget {
|
|||||||
/// each terminal's host showed last is kept, so coming back to a terminal
|
/// each terminal's host showed last is kept, so coming back to a terminal
|
||||||
/// shows its numbers at once.
|
/// shows its numbers at once.
|
||||||
pub struct MonitorPanel {
|
pub struct MonitorPanel {
|
||||||
target: Option<MonitorTarget>,
|
target: Option<ExecTarget>,
|
||||||
active: bool,
|
active: bool,
|
||||||
hosts: HashMap<RemoteTerminalId, HostState>,
|
hosts: HashMap<RemoteTerminalId, HostState>,
|
||||||
/// Every core's load, not only the first row of it.
|
/// Every core's load, not only the first row of it.
|
||||||
@@ -144,12 +131,7 @@ impl MonitorPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read `target`'s host while `active`, the panel showing it.
|
/// Read `target`'s host while `active`, the panel showing it.
|
||||||
pub fn set_target(
|
pub fn set_target(&mut self, target: Option<ExecTarget>, active: bool, cx: &mut Context<Self>) {
|
||||||
&mut self,
|
|
||||||
target: Option<MonitorTarget>,
|
|
||||||
active: bool,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
if self.target == target && self.active == active {
|
if self.target == target && self.active == active {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -188,7 +170,12 @@ impl MonitorPanel {
|
|||||||
let Ok(request) = request else { break };
|
let Ok(request) = request else { break };
|
||||||
let (outcome, connection) = match request {
|
let (outcome, connection) = match request {
|
||||||
None => (Outcome::NotConnected, 0),
|
None => (Outcome::NotConnected, 0),
|
||||||
Some((reply, connection)) => (wait_for(reply, cx).await, connection),
|
Some((reply, connection)) => (
|
||||||
|
exec_answer(reply, cx)
|
||||||
|
.await
|
||||||
|
.map_or(Outcome::NotConnected, Outcome::Output),
|
||||||
|
connection,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
if this
|
if this
|
||||||
.update(cx, |this, cx| {
|
.update(cx, |this, cx| {
|
||||||
@@ -235,18 +222,6 @@ impl MonitorPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look for the answer until it comes, or until the transport drops the
|
|
||||||
/// request: the terminal went away.
|
|
||||||
async fn wait_for(reply: mpsc::Receiver<ExecResult>, cx: &mut AsyncApp) -> Outcome {
|
|
||||||
loop {
|
|
||||||
match reply.try_recv() {
|
|
||||||
Ok(result) => return Outcome::Output(result),
|
|
||||||
Err(mpsc::TryRecvError::Empty) => cx.background_executor().timer(POLL).await,
|
|
||||||
Err(mpsc::TryRecvError::Disconnected) => return Outcome::NotConnected,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for MonitorPanel {
|
impl Render for MonitorPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let host = self
|
let host = self
|
||||||
|
|||||||
@@ -0,0 +1,593 @@
|
|||||||
|
//! Reading a Linux host's sockets: one command, and the parser for what it
|
||||||
|
//! prints.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
use super::model::{Counts, Processes, Protocol, Socket, SocketProcess, SocketState, Table};
|
||||||
|
|
||||||
|
/// The most sockets a reading lists; the host still counts them all. A busy
|
||||||
|
/// server has tens of thousands, mostly in TIME_WAIT, more than a reading's
|
||||||
|
/// output may hold (`ss` writes some 200 bytes a socket). `ss` lists UDP
|
||||||
|
/// and what listens before the connections, so those always make it.
|
||||||
|
pub const LIMIT: usize = 3000;
|
||||||
|
|
||||||
|
/// `ss` lines through, the first `LIMIT` of them, and a count of every
|
||||||
|
/// protocol and state after them.
|
||||||
|
const SS_AWK: &str = "$1 == \"Netid\" { next } \
|
||||||
|
{ n[$1 \" \" $2]++; if (++r <= LIMIT) print } \
|
||||||
|
END { print \"@@counts\"; for (k in n) print n[k], k }";
|
||||||
|
|
||||||
|
/// A `/proc/net` file's address, peer, state, uid and inode, the first
|
||||||
|
/// `LIMIT` of them, and a count of every state after them.
|
||||||
|
const PROC_AWK: &str = "FNR > 1 { n[$4]++; if (++r <= LIMIT) print $2, $3, $4, $8, $10 } \
|
||||||
|
END { print \"@@counts\"; for (k in n) print n[k], p, k }";
|
||||||
|
|
||||||
|
/// What 网络连接 runs on the host for a reading: who the login is, the
|
||||||
|
/// host's user names, and `ss -tuanpe` (every TCP and UDP socket, numeric,
|
||||||
|
/// with its processes, its uid and inode). A host without `ss` (BusyBox)
|
||||||
|
/// has `/proc/net` read instead, which names no processes. Each part under
|
||||||
|
/// an `@@` line of its own.
|
||||||
|
///
|
||||||
|
/// One line, in single quotes for `sh -c`, so that whatever the login shell
|
||||||
|
/// is (bash, zsh, fish, csh) it hands the script to `sh` untouched: the
|
||||||
|
/// script has no single quote, and no `!` for csh to expand.
|
||||||
|
pub fn command() -> String {
|
||||||
|
format!(
|
||||||
|
"sh -c 'export LC_ALL=C PATH=$PATH:/usr/sbin:/sbin; \
|
||||||
|
if test -r /proc/net/tcp; then \
|
||||||
|
echo @@uid; id -u; \
|
||||||
|
echo @@users; while IFS=: read -r name x uid rest; do echo \"$uid $name\"; done < /etc/passwd; \
|
||||||
|
if command -v ss >/dev/null 2>&1; then \
|
||||||
|
echo @@ss; ss -tuanpe 2>/dev/null | awk {ss}; \
|
||||||
|
else for f in tcp tcp6 udp udp6; do if test -r /proc/net/$f; then \
|
||||||
|
echo @@$f; awk -v p=$f {proc} /proc/net/$f; fi; done; fi; \
|
||||||
|
else echo @@unsupported; uname -s; fi'",
|
||||||
|
ss = double_quoted(&SS_AWK.replace("LIMIT", &LIMIT.to_string())),
|
||||||
|
proc = double_quoted(&PROC_AWK.replace("LIMIT", &LIMIT.to_string())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` in double quotes for `sh`, which expands nothing in it.
|
||||||
|
fn double_quoted(text: &str) -> String {
|
||||||
|
let mut quoted = String::from("\"");
|
||||||
|
for character in text.chars() {
|
||||||
|
if matches!(character, '"' | '$' | '`' | '\\') {
|
||||||
|
quoted.push('\\');
|
||||||
|
}
|
||||||
|
quoted.push(character);
|
||||||
|
}
|
||||||
|
quoted.push('"');
|
||||||
|
quoted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the command's output said.
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum Parsed {
|
||||||
|
Table(Table),
|
||||||
|
/// Not a Linux host. Holds `uname -s` when the host has one: 「Darwin」.
|
||||||
|
Unsupported(Option<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(output: &str) -> Parsed {
|
||||||
|
let mut sections: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||||
|
let mut current = None;
|
||||||
|
for line in output.lines() {
|
||||||
|
if let Some(name) = line.strip_prefix("@@") {
|
||||||
|
current = Some(name.trim());
|
||||||
|
sections.entry(name.trim()).or_default();
|
||||||
|
} else if let Some(name) = current {
|
||||||
|
sections.entry(name).or_default().push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let section = |name: &str| sections.get(name).map(Vec::as_slice).unwrap_or_default();
|
||||||
|
let first = |name: &str| {
|
||||||
|
section(name)
|
||||||
|
.iter()
|
||||||
|
.map(|line| line.trim())
|
||||||
|
.find(|line| !line.is_empty())
|
||||||
|
};
|
||||||
|
|
||||||
|
if !sections.contains_key("uid") {
|
||||||
|
// A shell other than sh's (cmd.exe) prints nothing we know.
|
||||||
|
return Parsed::Unsupported(first("unsupported").map(str::to_owned));
|
||||||
|
}
|
||||||
|
let root = first("uid") == Some("0");
|
||||||
|
let users: HashMap<&str, &str> = section("users")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|line| line.trim().split_once(' '))
|
||||||
|
.collect();
|
||||||
|
let user = |uid: &str| users.get(uid).unwrap_or(&uid).to_string();
|
||||||
|
|
||||||
|
let mut counted = Counts::default();
|
||||||
|
let mut sockets = Vec::new();
|
||||||
|
let processes = if !sections.contains_key("ss") {
|
||||||
|
Processes::None
|
||||||
|
} else if root {
|
||||||
|
Processes::All
|
||||||
|
} else {
|
||||||
|
Processes::Own
|
||||||
|
};
|
||||||
|
if sections.contains_key("ss") {
|
||||||
|
sockets.extend(
|
||||||
|
section("ss")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|line| parse_ss(line, &user)),
|
||||||
|
);
|
||||||
|
for line in section("counts") {
|
||||||
|
let mut fields = line.split_whitespace();
|
||||||
|
let (Some(count), Some(netid), Some(state)) =
|
||||||
|
(fields.next(), fields.next(), fields.next())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let (Ok(count), Some(protocol)) = (count.parse(), protocol_of(netid))
|
||||||
|
&& let Some(state) = SocketState::from_ss(protocol, state)
|
||||||
|
{
|
||||||
|
counted.add(state, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for file in ["tcp", "tcp6", "udp", "udp6"] {
|
||||||
|
let Some(protocol) = protocol_of(file) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
sockets.extend(
|
||||||
|
section(file)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|line| parse_proc(protocol, line, &user)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for line in section("counts") {
|
||||||
|
let mut fields = line.split_whitespace();
|
||||||
|
let (Some(count), Some(file), Some(state)) =
|
||||||
|
(fields.next(), fields.next(), fields.next())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let (Ok(count), Some(protocol)) = (count.parse(), protocol_of(file))
|
||||||
|
&& let Some(state) = SocketState::from_proc(protocol, state)
|
||||||
|
{
|
||||||
|
counted.add(state, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Parsed::Table(Table::new(sockets, Some(counted), processes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「tcp」「udp6」.
|
||||||
|
fn protocol_of(name: &str) -> Option<Protocol> {
|
||||||
|
match name {
|
||||||
|
"tcp" | "tcp6" => Some(Protocol::Tcp),
|
||||||
|
"udp" | "udp6" => Some(Protocol::Udp),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line of `ss -tuanpe`:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=891,fd=3)) ino:21233 sk:5 <->
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `ss` leaves `uid:0` out, so a socket with an inode and no uid is
|
||||||
|
/// root's; one with inode 0 has no owner (TIME_WAIT).
|
||||||
|
fn parse_ss(line: &str, user: &impl Fn(&str) -> String) -> Option<Socket> {
|
||||||
|
let mut fields = line.split_whitespace();
|
||||||
|
let protocol = protocol_of(fields.next()?)?;
|
||||||
|
let state = SocketState::from_ss(protocol, fields.next()?)?;
|
||||||
|
let (_received, _sent) = (fields.next()?, fields.next()?);
|
||||||
|
let (local, peer) = (fields.next()?, fields.next()?);
|
||||||
|
let mut socket = Socket::new(protocol, state, local.to_owned(), peer.to_owned());
|
||||||
|
|
||||||
|
// The rest: the processes, whose names may hold spaces, then the
|
||||||
|
// details.
|
||||||
|
let rest = after_fields(line, 6);
|
||||||
|
socket.processes = parse_users(rest);
|
||||||
|
let detail = |name: &str| {
|
||||||
|
rest.split_whitespace()
|
||||||
|
.find_map(|field| field.strip_prefix(name))
|
||||||
|
};
|
||||||
|
socket.user = match detail("ino:") {
|
||||||
|
Some("0") | None => None,
|
||||||
|
Some(_) => Some(user(detail("uid:").unwrap_or("0"))),
|
||||||
|
};
|
||||||
|
Some(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `line` past its first `count` fields.
|
||||||
|
fn after_fields(line: &str, count: usize) -> &str {
|
||||||
|
let mut rest = line.trim_start();
|
||||||
|
for _ in 0..count {
|
||||||
|
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||||
|
rest = rest[end..].trim_start();
|
||||||
|
}
|
||||||
|
rest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `users:(("nginx",pid=913756,fd=6),("nginx",pid=913757,fd=6))`; an old
|
||||||
|
/// `ss` writes `("nginx",913756,6)`.
|
||||||
|
fn parse_users(rest: &str) -> Vec<SocketProcess> {
|
||||||
|
let Some(start) = rest.find("users:((") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut processes: Vec<SocketProcess> = Vec::new();
|
||||||
|
let mut remaining = &rest[start + "users:(".len()..];
|
||||||
|
while let Some(open) = remaining.strip_prefix("(\"") {
|
||||||
|
let Some((name, after)) = open.split_once("\",") else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Some((fields, after)) = after.split_once(')') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let pid = fields
|
||||||
|
.split(',')
|
||||||
|
.next()
|
||||||
|
.map(|pid| pid.trim_start_matches("pid="))
|
||||||
|
.and_then(|pid| pid.parse().ok());
|
||||||
|
if let Some(pid) = pid
|
||||||
|
&& !processes.iter().any(|process| process.pid == pid)
|
||||||
|
{
|
||||||
|
processes.push(SocketProcess {
|
||||||
|
name: name.to_owned(),
|
||||||
|
pid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
remaining = after.strip_prefix(',').unwrap_or(after);
|
||||||
|
}
|
||||||
|
processes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line of `/proc/net/tcp` cut to address, peer, state, uid and inode:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// 0100007F:0CEA 00000000:0000 0A 0 12345
|
||||||
|
/// ```
|
||||||
|
fn parse_proc(protocol: Protocol, line: &str, user: &impl Fn(&str) -> String) -> Option<Socket> {
|
||||||
|
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
let [local, peer, state, uid, inode] = fields.as_slice() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let state = SocketState::from_proc(protocol, state)?;
|
||||||
|
let mut socket = Socket::new(protocol, state, proc_address(local)?, proc_address(peer)?);
|
||||||
|
socket.user = (*inode != "0").then(|| user(uid));
|
||||||
|
Some(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「0100007F:0CEA」 to 「127.0.0.1:3306」, as `ss` writes it: the address
|
||||||
|
/// in the host's byte order, a 32-bit word at a time, the port in hex; port
|
||||||
|
/// 0 is 「*」, IPv6 in brackets.
|
||||||
|
fn proc_address(hex: &str) -> Option<String> {
|
||||||
|
let (address, port) = hex.split_once(':')?;
|
||||||
|
let port = match u16::from_str_radix(port, 16).ok()? {
|
||||||
|
0 => "*".to_string(),
|
||||||
|
port => port.to_string(),
|
||||||
|
};
|
||||||
|
let words = (0..address.len() / 8)
|
||||||
|
.map(|word| {
|
||||||
|
let word = u32::from_str_radix(address.get(word * 8..word * 8 + 8)?, 16).ok()?;
|
||||||
|
// `/proc` prints the in-memory word, which is little-endian on
|
||||||
|
// every architecture Linux servers run on.
|
||||||
|
Some(word.swap_bytes().to_be_bytes())
|
||||||
|
})
|
||||||
|
.collect::<Option<Vec<[u8; 4]>>>()?;
|
||||||
|
match words.as_slice() {
|
||||||
|
[ipv4] => Some(format!("{}:{port}", Ipv4Addr::from(*ipv4))),
|
||||||
|
[a, b, c, d] => {
|
||||||
|
let bytes: Vec<u8> = [a, b, c, d].into_iter().flatten().copied().collect();
|
||||||
|
let ipv6 = Ipv6Addr::from(<[u8; 16]>::try_from(bytes).ok()?);
|
||||||
|
Some(format!("[{ipv6}]:{port}"))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::netstat::model::Role;
|
||||||
|
|
||||||
|
const SS: &str = "\
|
||||||
|
@@uid
|
||||||
|
0
|
||||||
|
@@users
|
||||||
|
0 root
|
||||||
|
33 www-data
|
||||||
|
101 systemd-resolve
|
||||||
|
@@ss
|
||||||
|
udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:((\"systemd-resolve\",pid=600,fd=13)) uid:101 ino:20123 sk:1 cgroup:/system.slice/systemd-resolved.service <->
|
||||||
|
tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=891,fd=3)) ino:21233 sk:5 cgroup:/system.slice/ssh.service <->
|
||||||
|
tcp LISTEN 0 511 *:80 *:* users:((\"nginx\",pid=913757,fd=6),(\"nginx\",pid=913756,fd=6)) ino:30001 sk:6 v6only:0 <->
|
||||||
|
tcp LISTEN 0 511 *:80 *:* users:((\"nginx\",pid=913758,fd=6)) ino:30002 sk:7 v6only:0 <->
|
||||||
|
tcp ESTAB 0 0 10.0.0.5:22 203.0.113.7:51234 users:((\"sshd\",pid=1234,fd=4),(\"sshd\",pid=1200,fd=4)) timer:(keepalive,119min,0) ino:123456 sk:9 <->
|
||||||
|
tcp ESTAB 0 0 10.0.0.5:80 198.51.100.2:6001 users:((\"Web Content\",pid=4242,fd=9)) uid:33 ino:4444 sk:b <->
|
||||||
|
tcp TIME-WAIT 0 0 10.0.0.5:80 198.51.100.2:6000 timer:(timewait,50sec,0) ino:0 sk:a
|
||||||
|
tcp ESTAB 0 0 [::ffff:10.0.0.5]:3306 [::ffff:10.0.0.9]:39000 ino:777 sk:c uid:999 <->
|
||||||
|
@@counts
|
||||||
|
2 tcp ESTAB
|
||||||
|
1 udp UNCONN
|
||||||
|
3 tcp LISTEN
|
||||||
|
1 tcp TIME-WAIT
|
||||||
|
1 tcp ESTAB
|
||||||
|
";
|
||||||
|
|
||||||
|
fn table(output: &str) -> Table {
|
||||||
|
match parse(output) {
|
||||||
|
Parsed::Table(table) => table,
|
||||||
|
other => panic!("not a table: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ss_lines_give_the_sockets_their_processes_and_users() {
|
||||||
|
let table = table(SS);
|
||||||
|
assert_eq!(table.processes(), Processes::All);
|
||||||
|
assert!(!table.truncated());
|
||||||
|
let line = |local: &str, peer: &str| {
|
||||||
|
table
|
||||||
|
.sockets()
|
||||||
|
.iter()
|
||||||
|
.find(|socket| socket.local == local && socket.peer == peer)
|
||||||
|
.unwrap_or_else(|| panic!("no {local} {peer}"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let sshd = line("0.0.0.0:22", "0.0.0.0:*");
|
||||||
|
assert_eq!(sshd.state, SocketState::Listen);
|
||||||
|
assert_eq!(sshd.process_label().as_deref(), Some("sshd (PID 891)"));
|
||||||
|
// No uid: root's.
|
||||||
|
assert_eq!(sshd.user.as_deref(), Some("root"));
|
||||||
|
|
||||||
|
// Two lines on one port make one, with every worker.
|
||||||
|
let nginx = line("*:80", "*:*");
|
||||||
|
assert_eq!(
|
||||||
|
nginx.process_label().as_deref(),
|
||||||
|
Some("nginx (PID 913757 等 3 个)")
|
||||||
|
);
|
||||||
|
|
||||||
|
let resolver = line("127.0.0.53%lo:53", "0.0.0.0:*");
|
||||||
|
assert_eq!(resolver.protocol, Protocol::Udp);
|
||||||
|
assert_eq!(resolver.state, SocketState::Listen);
|
||||||
|
assert_eq!(resolver.user.as_deref(), Some("systemd-resolve"));
|
||||||
|
|
||||||
|
let session = line("10.0.0.5:22", "203.0.113.7:51234");
|
||||||
|
assert_eq!(session.role, Role::Inbound);
|
||||||
|
assert_eq!(session.processes.len(), 2);
|
||||||
|
|
||||||
|
// A name with a space in it.
|
||||||
|
let browser = line("10.0.0.5:80", "198.51.100.2:6001");
|
||||||
|
assert_eq!(
|
||||||
|
browser.process_label().as_deref(),
|
||||||
|
Some("Web Content (PID 4242)")
|
||||||
|
);
|
||||||
|
assert_eq!(browser.user.as_deref(), Some("www-data"));
|
||||||
|
|
||||||
|
// No process holds a socket in TIME_WAIT, and no one owns it.
|
||||||
|
let closing = line("10.0.0.5:80", "198.51.100.2:6000");
|
||||||
|
assert_eq!(closing.state, SocketState::TimeWait);
|
||||||
|
assert!(closing.processes.is_empty());
|
||||||
|
assert_eq!(closing.user, None);
|
||||||
|
|
||||||
|
// Another user's socket, seen without root: no process; a uid the
|
||||||
|
// host has no name for stays a number.
|
||||||
|
let database = line("[::ffff:10.0.0.5]:3306", "[::ffff:10.0.0.9]:39000");
|
||||||
|
assert!(database.processes.is_empty());
|
||||||
|
assert_eq!(database.user.as_deref(), Some("999"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
table.counts().summary(),
|
||||||
|
"共 7 条 · 3 个监听端口 · 3 条已连接"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_host_with_more_sockets_than_a_reading_lists_says_how_many() {
|
||||||
|
let output = SS.replace("1 tcp TIME-WAIT", "40000 tcp TIME-WAIT");
|
||||||
|
let table = table(&output);
|
||||||
|
assert!(table.truncated());
|
||||||
|
assert_eq!(table.sockets().len(), 7);
|
||||||
|
assert_eq!(
|
||||||
|
table.counts().summary(),
|
||||||
|
"共 40007 条 · 4 个监听端口 · 3 条已连接"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proc_lines_are_read_where_there_is_no_ss() {
|
||||||
|
let output = "\
|
||||||
|
@@uid
|
||||||
|
1000
|
||||||
|
@@users
|
||||||
|
0 root
|
||||||
|
1000 deploy
|
||||||
|
@@tcp
|
||||||
|
0100007F:0CEA 00000000:0000 0A 999 12345
|
||||||
|
0500000A:0016 0700A8C0:C350 01 1000 23456
|
||||||
|
0500000A:0050 0200A8C0:1770 06 0 0
|
||||||
|
@@counts
|
||||||
|
1 tcp 0A
|
||||||
|
1 tcp 01
|
||||||
|
1 tcp 06
|
||||||
|
@@tcp6
|
||||||
|
00000000000000000000000000000000:0016 00000000000000000000000000000000:0000 0A 0 777
|
||||||
|
@@counts
|
||||||
|
1 tcp6 0A
|
||||||
|
@@udp
|
||||||
|
00000000:0044 00000000:0000 07 0 888
|
||||||
|
@@counts
|
||||||
|
1 udp 07
|
||||||
|
";
|
||||||
|
let table = table(output);
|
||||||
|
assert_eq!(table.processes(), Processes::None);
|
||||||
|
assert!(!table.truncated());
|
||||||
|
let lines: Vec<(Protocol, &str, &str, Option<&str>)> = table
|
||||||
|
.sockets()
|
||||||
|
.iter()
|
||||||
|
.map(|socket| {
|
||||||
|
(
|
||||||
|
socket.protocol,
|
||||||
|
socket.local.as_str(),
|
||||||
|
socket.peer.as_str(),
|
||||||
|
socket.user.as_deref(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
lines,
|
||||||
|
[
|
||||||
|
(Protocol::Tcp, "[::]:22", "[::]:*", Some("root")),
|
||||||
|
(Protocol::Tcp, "127.0.0.1:3306", "0.0.0.0:*", Some("999")),
|
||||||
|
(Protocol::Udp, "0.0.0.0:68", "0.0.0.0:*", Some("root")),
|
||||||
|
(
|
||||||
|
Protocol::Tcp,
|
||||||
|
"10.0.0.5:22",
|
||||||
|
"192.168.0.7:50000",
|
||||||
|
Some("deploy")
|
||||||
|
),
|
||||||
|
(Protocol::Tcp, "10.0.0.5:80", "192.168.0.2:6000", None),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_host_that_is_not_linux_says_what_it_is() {
|
||||||
|
assert_eq!(
|
||||||
|
parse("@@unsupported\nDarwin\n"),
|
||||||
|
Parsed::Unsupported(Some("Darwin".into()))
|
||||||
|
);
|
||||||
|
// cmd.exe runs none of it.
|
||||||
|
assert_eq!(parse(""), Parsed::Unsupported(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_command_survives_any_login_shell() {
|
||||||
|
let command = command();
|
||||||
|
let script = command
|
||||||
|
.strip_prefix("sh -c '")
|
||||||
|
.and_then(|rest| rest.strip_suffix('\''))
|
||||||
|
.expect("one sh -c in single quotes");
|
||||||
|
assert!(!script.contains('\''));
|
||||||
|
assert!(!script.contains('!'));
|
||||||
|
assert!(!script.contains('\n'));
|
||||||
|
// fish reads 「\\」 in single quotes as one backslash.
|
||||||
|
assert!(!script.contains("\\\\"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn run(script: &str, path: &str) -> String {
|
||||||
|
let output = std::process::Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(script)
|
||||||
|
.env("PATH", path)
|
||||||
|
.output()
|
||||||
|
.expect("sh runs");
|
||||||
|
assert!(output.status.success(), "{output:?}");
|
||||||
|
String::from_utf8(output.stdout).expect("utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The script, run by this machine's `sh` with `/proc`, `/etc/passwd`
|
||||||
|
/// and `ss` stood in for, gives what the parser reads.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn the_script_reads_ss_or_else_proc() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let root = tempfile::tempdir().expect("temp dir");
|
||||||
|
let at = |path: &str| root.path().join(path);
|
||||||
|
std::fs::create_dir_all(at("proc/net")).unwrap();
|
||||||
|
std::fs::create_dir_all(at("etc")).unwrap();
|
||||||
|
std::fs::create_dir_all(at("bin")).unwrap();
|
||||||
|
std::fs::write(at("etc/passwd"), "root:x:0:0:root:/root:/bin/bash\n").unwrap();
|
||||||
|
let header = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n";
|
||||||
|
std::fs::write(
|
||||||
|
at("proc/net/tcp"),
|
||||||
|
format!(
|
||||||
|
"{header} 0: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21233 1 0000000000000000 100 0 0 10 0\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
at("proc/net/udp"),
|
||||||
|
format!(
|
||||||
|
"{header} 1: 3500007F:0035 00000000:0000 07 00000000:00000000 00:00000000 00000000 101 0 20123 2 0000000000000000 0\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ss = at("bin/ss");
|
||||||
|
std::fs::write(
|
||||||
|
&ss,
|
||||||
|
"#!/bin/sh\necho 'Netid State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess'\n\
|
||||||
|
echo 'tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=891,fd=3)) ino:21233 sk:1'\n\
|
||||||
|
echo 'tcp TIME-WAIT 0 0 10.0.0.5:22 10.0.0.9:4000 timer:(timewait,1sec,0) ino:0 sk:2'\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::set_permissions(&ss, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
|
||||||
|
let command = command().replace("/proc/", &format!("{}/", at("proc").display()));
|
||||||
|
let command = command.replace("/etc/passwd", &at("etc/passwd").display().to_string());
|
||||||
|
let script = command
|
||||||
|
.strip_prefix("sh -c '")
|
||||||
|
.and_then(|rest| rest.strip_suffix('\''))
|
||||||
|
.unwrap();
|
||||||
|
let system = std::env::var("PATH").unwrap_or_default();
|
||||||
|
|
||||||
|
let with_ss = table(&run(script, &format!("{}:{system}", at("bin").display())));
|
||||||
|
let lines: Vec<(&str, Option<String>, Option<&str>)> = with_ss
|
||||||
|
.sockets()
|
||||||
|
.iter()
|
||||||
|
.map(|socket| {
|
||||||
|
(
|
||||||
|
socket.local.as_str(),
|
||||||
|
socket.process_label(),
|
||||||
|
socket.user.as_deref(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
lines,
|
||||||
|
[
|
||||||
|
("0.0.0.0:22", Some("sshd (PID 891)".into()), Some("root")),
|
||||||
|
("10.0.0.5:22", None, None),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(!with_ss.truncated());
|
||||||
|
|
||||||
|
// Without `ss`, the files themselves; tcp6 and udp6 are missing,
|
||||||
|
// as on a host with IPv6 turned off.
|
||||||
|
let script = script.replace("command -v ss ", "command -v no-such-ss ");
|
||||||
|
let without = table(&run(&script, &system));
|
||||||
|
let lines: Vec<(Protocol, &str, Option<&str>)> = without
|
||||||
|
.sockets()
|
||||||
|
.iter()
|
||||||
|
.map(|socket| {
|
||||||
|
(
|
||||||
|
socket.protocol,
|
||||||
|
socket.local.as_str(),
|
||||||
|
socket.user.as_deref(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
lines,
|
||||||
|
[
|
||||||
|
(Protocol::Tcp, "0.0.0.0:22", Some("root")),
|
||||||
|
(Protocol::Udp, "127.0.0.53:53", Some("101")),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(without.counts().total, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn the_command_is_valid_sh() {
|
||||||
|
let command = command();
|
||||||
|
let script = command
|
||||||
|
.strip_prefix("sh -c '")
|
||||||
|
.and_then(|rest| rest.strip_suffix('\''))
|
||||||
|
.unwrap();
|
||||||
|
let status = std::process::Command::new("sh")
|
||||||
|
.args(["-n", "-c", script])
|
||||||
|
.status()
|
||||||
|
.expect("sh runs");
|
||||||
|
assert!(status.success());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//! 网络连接: the right sidebar's tool that lists the TCP and UDP sockets of
|
||||||
|
//! the host of the SSH terminal in front, with the processes holding them,
|
||||||
|
//! read over that terminal's own connection. Linux hosts only, for now.
|
||||||
|
|
||||||
|
mod linux;
|
||||||
|
mod model;
|
||||||
|
mod netstat_panel;
|
||||||
|
|
||||||
|
pub use netstat_panel::NetstatPanel;
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
//! What 网络连接 lists, and how its search and filters pick from the list.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub enum Protocol {
|
||||||
|
Tcp,
|
||||||
|
Udp,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Protocol {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Protocol::Tcp => "TCP",
|
||||||
|
Protocol::Udp => "UDP",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub enum SocketState {
|
||||||
|
/// A TCP socket taking connections, or a UDP socket bound to a port and
|
||||||
|
/// connected to no peer: either takes what comes to its port.
|
||||||
|
Listen,
|
||||||
|
Established,
|
||||||
|
SynSent,
|
||||||
|
SynReceived,
|
||||||
|
FinWait1,
|
||||||
|
FinWait2,
|
||||||
|
TimeWait,
|
||||||
|
Close,
|
||||||
|
CloseWait,
|
||||||
|
LastAck,
|
||||||
|
Closing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SocketState {
|
||||||
|
/// The state as `ss` names it: 「LISTEN」「ESTAB」「TIME-WAIT」. A UDP
|
||||||
|
/// socket connected to no peer is 「UNCONN」; a TCP one in that state
|
||||||
|
/// is bound and neither listening nor connected.
|
||||||
|
pub fn from_ss(protocol: Protocol, name: &str) -> Option<Self> {
|
||||||
|
Some(match (protocol, name) {
|
||||||
|
(Protocol::Udp, "UNCONN") => SocketState::Listen,
|
||||||
|
(Protocol::Tcp, "UNCONN") => SocketState::Close,
|
||||||
|
(_, "LISTEN") => SocketState::Listen,
|
||||||
|
(_, "ESTAB") => SocketState::Established,
|
||||||
|
(_, "SYN-SENT") => SocketState::SynSent,
|
||||||
|
(_, "SYN-RECV") => SocketState::SynReceived,
|
||||||
|
(_, "FIN-WAIT-1") => SocketState::FinWait1,
|
||||||
|
(_, "FIN-WAIT-2") => SocketState::FinWait2,
|
||||||
|
(_, "TIME-WAIT") => SocketState::TimeWait,
|
||||||
|
(_, "CLOSE-WAIT") => SocketState::CloseWait,
|
||||||
|
(_, "LAST-ACK") => SocketState::LastAck,
|
||||||
|
(_, "CLOSING") => SocketState::Closing,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state as `/proc/net/tcp` and its kin write it, in hex. UDP uses
|
||||||
|
/// only two: connected (01) and not (07).
|
||||||
|
pub fn from_proc(protocol: Protocol, hex: &str) -> Option<Self> {
|
||||||
|
Some(match (protocol, u8::from_str_radix(hex, 16).ok()?) {
|
||||||
|
(Protocol::Udp, 0x07) => SocketState::Listen,
|
||||||
|
(Protocol::Udp, 0x01) => SocketState::Established,
|
||||||
|
(Protocol::Udp, _) => return None,
|
||||||
|
(Protocol::Tcp, 0x01) => SocketState::Established,
|
||||||
|
(Protocol::Tcp, 0x02) => SocketState::SynSent,
|
||||||
|
(Protocol::Tcp, 0x03 | 0x0C) => SocketState::SynReceived,
|
||||||
|
(Protocol::Tcp, 0x04) => SocketState::FinWait1,
|
||||||
|
(Protocol::Tcp, 0x05) => SocketState::FinWait2,
|
||||||
|
(Protocol::Tcp, 0x06) => SocketState::TimeWait,
|
||||||
|
(Protocol::Tcp, 0x07) => SocketState::Close,
|
||||||
|
(Protocol::Tcp, 0x08) => SocketState::CloseWait,
|
||||||
|
(Protocol::Tcp, 0x09) => SocketState::LastAck,
|
||||||
|
(Protocol::Tcp, 0x0A) => SocketState::Listen,
|
||||||
|
(Protocol::Tcp, 0x0B) => SocketState::Closing,
|
||||||
|
(Protocol::Tcp, _) => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the list says it: 「监听」「已连接」, and the TCP name for the
|
||||||
|
/// states only the TCP-minded look for: 「TIME_WAIT」.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SocketState::Listen => "监听",
|
||||||
|
SocketState::Established => "已连接",
|
||||||
|
SocketState::SynSent => "SYN_SENT",
|
||||||
|
SocketState::SynReceived => "SYN_RECV",
|
||||||
|
SocketState::FinWait1 => "FIN_WAIT1",
|
||||||
|
SocketState::FinWait2 => "FIN_WAIT2",
|
||||||
|
SocketState::TimeWait => "TIME_WAIT",
|
||||||
|
SocketState::Close => "CLOSE",
|
||||||
|
SocketState::CloseWait => "CLOSE_WAIT",
|
||||||
|
SocketState::LastAck => "LAST_ACK",
|
||||||
|
SocketState::Closing => "CLOSING",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name netstat gives it, which a search finds as well.
|
||||||
|
fn netstat_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SocketState::Listen => "LISTEN",
|
||||||
|
SocketState::Established => "ESTABLISHED",
|
||||||
|
other => other.label(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the list puts it: what listens first, then the live
|
||||||
|
/// connections, the ones on their way out last.
|
||||||
|
fn rank(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
SocketState::Listen => 0,
|
||||||
|
SocketState::Established => 1,
|
||||||
|
SocketState::CloseWait => 2,
|
||||||
|
SocketState::SynSent | SocketState::SynReceived => 3,
|
||||||
|
SocketState::FinWait1
|
||||||
|
| SocketState::FinWait2
|
||||||
|
| SocketState::Closing
|
||||||
|
| SocketState::LastAck
|
||||||
|
| SocketState::Close => 4,
|
||||||
|
SocketState::TimeWait => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a socket is for the host.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Role {
|
||||||
|
/// Takes what comes to its port.
|
||||||
|
Listening,
|
||||||
|
/// Came in to a port something on the host listens on.
|
||||||
|
Inbound,
|
||||||
|
/// Went out from the host.
|
||||||
|
Outbound,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Role {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Role::Listening => "监听端口",
|
||||||
|
Role::Inbound => "入站连接",
|
||||||
|
Role::Outbound => "出站连接",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SocketProcess {
|
||||||
|
pub name: String,
|
||||||
|
pub pid: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of the list: a socket, or the sockets sharing one address
|
||||||
|
/// (a server's workers listening on the same port).
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Socket {
|
||||||
|
pub protocol: Protocol,
|
||||||
|
pub state: SocketState,
|
||||||
|
/// 「0.0.0.0:22」「[::1]:631」「*:5678」, as `ss` writes it.
|
||||||
|
pub local: String,
|
||||||
|
/// 「203.0.113.7:51234」, or 「0.0.0.0:*」 with no peer.
|
||||||
|
pub peer: String,
|
||||||
|
/// Empty when the login cannot see it (another user's, without root),
|
||||||
|
/// and for a socket no process holds (in TIME_WAIT).
|
||||||
|
pub processes: Vec<SocketProcess>,
|
||||||
|
/// Whose it is: the name, or the uid when the host has no name for it.
|
||||||
|
/// `None` for a socket no process holds.
|
||||||
|
pub user: Option<String>,
|
||||||
|
pub role: Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Socket {
|
||||||
|
pub fn new(protocol: Protocol, state: SocketState, local: String, peer: String) -> Self {
|
||||||
|
Self {
|
||||||
|
protocol,
|
||||||
|
state,
|
||||||
|
local,
|
||||||
|
peer,
|
||||||
|
processes: Vec::new(),
|
||||||
|
user: None,
|
||||||
|
role: Role::Outbound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The port it is bound to, `None` for 「*」.
|
||||||
|
pub fn port(&self) -> Option<u16> {
|
||||||
|
port_of(&self.local)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「sshd (PID 891)」「nginx (PID 913756 等 4 个)」, `None` with none.
|
||||||
|
pub fn process_label(&self) -> Option<String> {
|
||||||
|
let first = self.processes.first()?;
|
||||||
|
Some(match self.processes.len() {
|
||||||
|
1 => format!("{} (PID {})", first.name, first.pid),
|
||||||
|
count => format!("{} (PID {} 等 {count} 个)", first.name, first.pid),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the search looks in, lowercased.
|
||||||
|
fn haystack(&self) -> String {
|
||||||
|
let mut haystack = format!(
|
||||||
|
"{} {} {} {} {}",
|
||||||
|
self.protocol.label(),
|
||||||
|
self.state.label(),
|
||||||
|
self.state.netstat_name(),
|
||||||
|
self.local,
|
||||||
|
self.peer,
|
||||||
|
);
|
||||||
|
for process in &self.processes {
|
||||||
|
haystack.push_str(&format!(" {} {}", process.name, process.pid));
|
||||||
|
}
|
||||||
|
if let Some(user) = &self.user {
|
||||||
|
haystack.push(' ');
|
||||||
|
haystack.push_str(user);
|
||||||
|
}
|
||||||
|
haystack.to_lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn port_of(address: &str) -> Option<u16> {
|
||||||
|
address.rsplit_once(':')?.1.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many sockets there are, of all and of the two kinds the summary
|
||||||
|
/// names.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Counts {
|
||||||
|
pub total: usize,
|
||||||
|
pub listening: usize,
|
||||||
|
pub connected: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Counts {
|
||||||
|
pub fn add(&mut self, state: SocketState, count: usize) {
|
||||||
|
self.total += count;
|
||||||
|
match state {
|
||||||
|
SocketState::Listen => self.listening += count,
|
||||||
|
SocketState::Established => self.connected += count,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「共 96 条 · 15 个监听端口 · 21 条已连接」.
|
||||||
|
pub fn summary(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"共 {} 条 · {} 个监听端口 · {} 条已连接",
|
||||||
|
self.total, self.listening, self.connected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whose processes a reading names.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Processes {
|
||||||
|
/// Every socket's: the login is root.
|
||||||
|
All,
|
||||||
|
/// Only the login's own sockets'.
|
||||||
|
Own,
|
||||||
|
/// None: the host has no `ss`, and `/proc/net` names no processes.
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One reading of a host's sockets, in list order.
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub struct Table {
|
||||||
|
sockets: Vec<Socket>,
|
||||||
|
/// Lowercased, beside each socket.
|
||||||
|
haystacks: Vec<String>,
|
||||||
|
counts: Counts,
|
||||||
|
truncated: bool,
|
||||||
|
processes: Processes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Table {
|
||||||
|
/// `sockets` as the host listed them. `counted`: how many the host
|
||||||
|
/// counted, when it listed only so many; then there are more than
|
||||||
|
/// `sockets`.
|
||||||
|
pub fn new(sockets: Vec<Socket>, counted: Option<Counts>, processes: Processes) -> Self {
|
||||||
|
let listed = sockets.len();
|
||||||
|
let mut merged: Vec<Socket> = Vec::with_capacity(listed);
|
||||||
|
let mut seen: HashMap<(Protocol, SocketState, String, String), usize> = HashMap::new();
|
||||||
|
for socket in sockets {
|
||||||
|
let key = (
|
||||||
|
socket.protocol,
|
||||||
|
socket.state,
|
||||||
|
socket.local.clone(),
|
||||||
|
socket.peer.clone(),
|
||||||
|
);
|
||||||
|
match seen.get(&key) {
|
||||||
|
Some(&index) => {
|
||||||
|
let into = &mut merged[index];
|
||||||
|
for process in socket.processes {
|
||||||
|
if !into.processes.contains(&process) {
|
||||||
|
into.processes.push(process);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if into.user.is_none() {
|
||||||
|
into.user = socket.user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
seen.insert(key, merged.len());
|
||||||
|
merged.push(socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let listening: HashSet<(Protocol, u16)> = merged
|
||||||
|
.iter()
|
||||||
|
.filter(|socket| socket.state == SocketState::Listen)
|
||||||
|
.filter_map(|socket| Some((socket.protocol, socket.port()?)))
|
||||||
|
.collect();
|
||||||
|
for socket in &mut merged {
|
||||||
|
socket.role = if socket.state == SocketState::Listen {
|
||||||
|
Role::Listening
|
||||||
|
} else if socket
|
||||||
|
.port()
|
||||||
|
.is_some_and(|port| listening.contains(&(socket.protocol, port)))
|
||||||
|
{
|
||||||
|
Role::Inbound
|
||||||
|
} else {
|
||||||
|
Role::Outbound
|
||||||
|
};
|
||||||
|
}
|
||||||
|
merged.sort_by(|a, b| {
|
||||||
|
(a.state.rank(), a.protocol, a.port(), &a.local, &a.peer).cmp(&(
|
||||||
|
b.state.rank(),
|
||||||
|
b.protocol,
|
||||||
|
b.port(),
|
||||||
|
&b.local,
|
||||||
|
&b.peer,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
|
||||||
|
let truncated = counted.is_some_and(|counted| counted.total > listed);
|
||||||
|
let counts = match counted {
|
||||||
|
Some(counted) if truncated => counted,
|
||||||
|
_ => merged.iter().fold(Counts::default(), |mut counts, socket| {
|
||||||
|
counts.add(socket.state, 1);
|
||||||
|
counts
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
haystacks: merged.iter().map(Socket::haystack).collect(),
|
||||||
|
sockets: merged,
|
||||||
|
counts,
|
||||||
|
truncated,
|
||||||
|
processes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sockets(&self) -> &[Socket] {
|
||||||
|
&self.sockets
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn counts(&self) -> Counts {
|
||||||
|
self.counts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the host had more sockets than it listed.
|
||||||
|
pub fn truncated(&self) -> bool {
|
||||||
|
self.truncated
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn processes(&self) -> Processes {
|
||||||
|
self.processes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sockets `filter` lets through, by their place in `sockets()`.
|
||||||
|
pub fn filtered(&self, filter: &Filter) -> Vec<usize> {
|
||||||
|
let query = filter.query.trim().to_lowercase();
|
||||||
|
self.sockets
|
||||||
|
.iter()
|
||||||
|
.zip(&self.haystacks)
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, (socket, haystack))| {
|
||||||
|
filter.protocol.lets_through(socket.protocol)
|
||||||
|
&& filter.state.lets_through(socket.state)
|
||||||
|
&& (query.is_empty() || haystack.contains(&query))
|
||||||
|
})
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum ProtocolFilter {
|
||||||
|
#[default]
|
||||||
|
All,
|
||||||
|
Tcp,
|
||||||
|
Udp,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolFilter {
|
||||||
|
pub const ALL: [ProtocolFilter; 3] = [
|
||||||
|
ProtocolFilter::All,
|
||||||
|
ProtocolFilter::Tcp,
|
||||||
|
ProtocolFilter::Udp,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProtocolFilter::All => "全部协议",
|
||||||
|
ProtocolFilter::Tcp => "TCP",
|
||||||
|
ProtocolFilter::Udp => "UDP",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lets_through(self, protocol: Protocol) -> bool {
|
||||||
|
match self {
|
||||||
|
ProtocolFilter::All => true,
|
||||||
|
ProtocolFilter::Tcp => protocol == Protocol::Tcp,
|
||||||
|
ProtocolFilter::Udp => protocol == Protocol::Udp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum StateFilter {
|
||||||
|
#[default]
|
||||||
|
All,
|
||||||
|
Listen,
|
||||||
|
Established,
|
||||||
|
TimeWait,
|
||||||
|
CloseWait,
|
||||||
|
/// The states not named above.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StateFilter {
|
||||||
|
pub const ALL: [StateFilter; 6] = [
|
||||||
|
StateFilter::All,
|
||||||
|
StateFilter::Listen,
|
||||||
|
StateFilter::Established,
|
||||||
|
StateFilter::TimeWait,
|
||||||
|
StateFilter::CloseWait,
|
||||||
|
StateFilter::Other,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
StateFilter::All => "全部状态",
|
||||||
|
StateFilter::Listen => SocketState::Listen.label(),
|
||||||
|
StateFilter::Established => SocketState::Established.label(),
|
||||||
|
StateFilter::TimeWait => SocketState::TimeWait.label(),
|
||||||
|
StateFilter::CloseWait => SocketState::CloseWait.label(),
|
||||||
|
StateFilter::Other => "其他状态",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lets_through(self, state: SocketState) -> bool {
|
||||||
|
match self {
|
||||||
|
StateFilter::All => true,
|
||||||
|
StateFilter::Listen => state == SocketState::Listen,
|
||||||
|
StateFilter::Established => state == SocketState::Established,
|
||||||
|
StateFilter::TimeWait => state == SocketState::TimeWait,
|
||||||
|
StateFilter::CloseWait => state == SocketState::CloseWait,
|
||||||
|
StateFilter::Other => !matches!(
|
||||||
|
state,
|
||||||
|
SocketState::Listen
|
||||||
|
| SocketState::Established
|
||||||
|
| SocketState::TimeWait
|
||||||
|
| SocketState::CloseWait
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the list is narrowed to.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Filter {
|
||||||
|
pub protocol: ProtocolFilter,
|
||||||
|
pub state: StateFilter,
|
||||||
|
/// Looked for in the addresses, the state, the processes and their
|
||||||
|
/// PIDs and the user, ignoring case.
|
||||||
|
pub query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Filter {
|
||||||
|
/// Whether it narrows the list at all.
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.protocol != ProtocolFilter::All
|
||||||
|
|| self.state != StateFilter::All
|
||||||
|
|| !self.query.trim().is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn socket(protocol: Protocol, state: SocketState, local: &str, peer: &str) -> Socket {
|
||||||
|
Socket::new(protocol, state, local.into(), peer.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process(name: &str, pid: u32) -> SocketProcess {
|
||||||
|
SocketProcess {
|
||||||
|
name: name.into(),
|
||||||
|
pid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample() -> Table {
|
||||||
|
let mut sshd = socket(
|
||||||
|
Protocol::Tcp,
|
||||||
|
SocketState::Listen,
|
||||||
|
"0.0.0.0:22",
|
||||||
|
"0.0.0.0:*",
|
||||||
|
);
|
||||||
|
sshd.processes = vec![process("sshd", 891)];
|
||||||
|
sshd.user = Some("root".into());
|
||||||
|
let worker = |pid| {
|
||||||
|
let mut nginx = socket(
|
||||||
|
Protocol::Tcp,
|
||||||
|
SocketState::Listen,
|
||||||
|
"0.0.0.0:80",
|
||||||
|
"0.0.0.0:*",
|
||||||
|
);
|
||||||
|
nginx.processes = vec![process("nginx", pid)];
|
||||||
|
nginx.user = Some("nginx".into());
|
||||||
|
nginx
|
||||||
|
};
|
||||||
|
let (first, second) = (worker(100), worker(101));
|
||||||
|
Table::new(
|
||||||
|
vec![
|
||||||
|
socket(
|
||||||
|
Protocol::Tcp,
|
||||||
|
SocketState::TimeWait,
|
||||||
|
"10.0.0.5:80",
|
||||||
|
"198.51.100.2:6000",
|
||||||
|
),
|
||||||
|
socket(
|
||||||
|
Protocol::Tcp,
|
||||||
|
SocketState::Established,
|
||||||
|
"10.0.0.5:48210",
|
||||||
|
"140.82.112.4:443",
|
||||||
|
),
|
||||||
|
socket(
|
||||||
|
Protocol::Tcp,
|
||||||
|
SocketState::Established,
|
||||||
|
"10.0.0.5:22",
|
||||||
|
"203.0.113.7:51234",
|
||||||
|
),
|
||||||
|
first,
|
||||||
|
socket(
|
||||||
|
Protocol::Udp,
|
||||||
|
SocketState::Listen,
|
||||||
|
"127.0.0.53%lo:53",
|
||||||
|
"0.0.0.0:*",
|
||||||
|
),
|
||||||
|
sshd,
|
||||||
|
second,
|
||||||
|
socket(
|
||||||
|
Protocol::Tcp,
|
||||||
|
SocketState::CloseWait,
|
||||||
|
"10.0.0.5:39000",
|
||||||
|
"10.0.0.9:3306",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
Processes::All,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_listens_comes_first_and_shares_of_a_port_make_one_line() {
|
||||||
|
let table = sample();
|
||||||
|
let lines: Vec<(&str, &str, Role)> = table
|
||||||
|
.sockets()
|
||||||
|
.iter()
|
||||||
|
.map(|socket| (socket.state.label(), socket.local.as_str(), socket.role))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
lines,
|
||||||
|
[
|
||||||
|
("监听", "0.0.0.0:22", Role::Listening),
|
||||||
|
("监听", "0.0.0.0:80", Role::Listening),
|
||||||
|
("监听", "127.0.0.53%lo:53", Role::Listening),
|
||||||
|
("已连接", "10.0.0.5:22", Role::Inbound),
|
||||||
|
("已连接", "10.0.0.5:48210", Role::Outbound),
|
||||||
|
("CLOSE_WAIT", "10.0.0.5:39000", Role::Outbound),
|
||||||
|
("TIME_WAIT", "10.0.0.5:80", Role::Inbound),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let nginx = &table.sockets()[1];
|
||||||
|
assert_eq!(
|
||||||
|
nginx.process_label().as_deref(),
|
||||||
|
Some("nginx (PID 100 等 2 个)")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
table.counts(),
|
||||||
|
Counts {
|
||||||
|
total: 7,
|
||||||
|
listening: 3,
|
||||||
|
connected: 2
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(!table.truncated());
|
||||||
|
assert_eq!(
|
||||||
|
table.counts().summary(),
|
||||||
|
"共 7 条 · 3 个监听端口 · 2 条已连接"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_host_that_counted_more_than_it_listed_is_said_to_have_more() {
|
||||||
|
let counted = Counts {
|
||||||
|
total: 50_000,
|
||||||
|
listening: 12,
|
||||||
|
connected: 9_000,
|
||||||
|
};
|
||||||
|
let table = Table::new(
|
||||||
|
vec![socket(
|
||||||
|
Protocol::Tcp,
|
||||||
|
SocketState::Listen,
|
||||||
|
"0.0.0.0:22",
|
||||||
|
"0.0.0.0:*",
|
||||||
|
)],
|
||||||
|
Some(counted),
|
||||||
|
Processes::All,
|
||||||
|
);
|
||||||
|
assert!(table.truncated());
|
||||||
|
assert_eq!(table.counts(), counted);
|
||||||
|
|
||||||
|
// A count that matches the list is no news.
|
||||||
|
let table = Table::new(
|
||||||
|
vec![socket(
|
||||||
|
Protocol::Tcp,
|
||||||
|
SocketState::Listen,
|
||||||
|
"0.0.0.0:22",
|
||||||
|
"0.0.0.0:*",
|
||||||
|
)],
|
||||||
|
Some(Counts {
|
||||||
|
total: 1,
|
||||||
|
listening: 1,
|
||||||
|
connected: 0,
|
||||||
|
}),
|
||||||
|
Processes::All,
|
||||||
|
);
|
||||||
|
assert!(!table.truncated());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_search_finds_addresses_states_processes_pids_and_users() {
|
||||||
|
let table = sample();
|
||||||
|
let local = |filter: Filter| -> Vec<String> {
|
||||||
|
table
|
||||||
|
.filtered(&filter)
|
||||||
|
.into_iter()
|
||||||
|
.map(|index| table.sockets()[index].local.clone())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let query = |text: &str| Filter {
|
||||||
|
query: text.into(),
|
||||||
|
..Filter::default()
|
||||||
|
};
|
||||||
|
assert_eq!(local(query("NGINX")), ["0.0.0.0:80"]);
|
||||||
|
assert_eq!(local(query("101")), ["0.0.0.0:80"]);
|
||||||
|
assert_eq!(local(query(":22")), ["0.0.0.0:22", "10.0.0.5:22"]);
|
||||||
|
assert_eq!(
|
||||||
|
local(query("established")),
|
||||||
|
["10.0.0.5:22", "10.0.0.5:48210"]
|
||||||
|
);
|
||||||
|
assert_eq!(local(query("time_wait")), ["10.0.0.5:80"]);
|
||||||
|
assert_eq!(local(query("203.0.113")), ["10.0.0.5:22"]);
|
||||||
|
assert_eq!(local(query(" root ")), ["0.0.0.0:22"]);
|
||||||
|
assert_eq!(local(Filter::default()).len(), 7);
|
||||||
|
|
||||||
|
let udp = Filter {
|
||||||
|
protocol: ProtocolFilter::Udp,
|
||||||
|
..Filter::default()
|
||||||
|
};
|
||||||
|
assert_eq!(local(udp), ["127.0.0.53%lo:53"]);
|
||||||
|
let other = Filter {
|
||||||
|
state: StateFilter::Other,
|
||||||
|
..Filter::default()
|
||||||
|
};
|
||||||
|
assert!(local(other).is_empty());
|
||||||
|
let close_wait = Filter {
|
||||||
|
state: StateFilter::CloseWait,
|
||||||
|
..Filter::default()
|
||||||
|
};
|
||||||
|
assert!(close_wait.is_active());
|
||||||
|
assert_eq!(local(close_wait), ["10.0.0.5:39000"]);
|
||||||
|
assert!(!query(" ").is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn states_read_from_ss_and_from_proc_agree() {
|
||||||
|
for (name, hex, state) in [
|
||||||
|
("LISTEN", "0A", SocketState::Listen),
|
||||||
|
("ESTAB", "01", SocketState::Established),
|
||||||
|
("TIME-WAIT", "06", SocketState::TimeWait),
|
||||||
|
("CLOSE-WAIT", "08", SocketState::CloseWait),
|
||||||
|
("SYN-RECV", "03", SocketState::SynReceived),
|
||||||
|
("FIN-WAIT-2", "05", SocketState::FinWait2),
|
||||||
|
] {
|
||||||
|
assert_eq!(SocketState::from_ss(Protocol::Tcp, name), Some(state));
|
||||||
|
assert_eq!(SocketState::from_proc(Protocol::Tcp, hex), Some(state));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
SocketState::from_ss(Protocol::Udp, "UNCONN"),
|
||||||
|
Some(SocketState::Listen)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SocketState::from_proc(Protocol::Udp, "07"),
|
||||||
|
Some(SocketState::Listen)
|
||||||
|
);
|
||||||
|
assert_eq!(SocketState::from_ss(Protocol::Tcp, "Netid"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gpui_kit::component::{
|
||||||
|
ActiveTheme as _, Icon, IconName, IndexPath, Sizable as _,
|
||||||
|
button::{Button, ButtonVariants as _},
|
||||||
|
h_flex,
|
||||||
|
input::{Input, InputEvent, InputState},
|
||||||
|
scroll::ScrollableElement as _,
|
||||||
|
searchable_list::SearchableListItem,
|
||||||
|
select::{Select, SelectEvent, SelectState},
|
||||||
|
spinner::Spinner,
|
||||||
|
tag::Tag,
|
||||||
|
v_flex,
|
||||||
|
};
|
||||||
|
use gpui_kit::prelude::FluentBuilder as _;
|
||||||
|
use gpui_kit::*;
|
||||||
|
|
||||||
|
use super::linux::{self, Parsed};
|
||||||
|
use super::model::{
|
||||||
|
Filter, Processes, ProtocolFilter, Role, Socket, SocketState, StateFilter, Table,
|
||||||
|
};
|
||||||
|
use crate::app::{CatalogIcon, RefreshConnections};
|
||||||
|
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
|
||||||
|
|
||||||
|
/// How long a terminal not connected is left before it is asked again.
|
||||||
|
const RETRY: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// 网络连接: every TCP and UDP socket of the host of the SSH terminal in
|
||||||
|
/// front, with the processes holding them, read when the panel comes on
|
||||||
|
/// screen and again on 刷新.
|
||||||
|
///
|
||||||
|
/// A list, not a gauge, so it holds still while it is read: what changes
|
||||||
|
/// underneath shows on the next refresh, not under the pointer. Each
|
||||||
|
/// reading runs `linux::command` beside the shell, on the terminal's own
|
||||||
|
/// connection: no second login, nothing asked again. What each terminal's
|
||||||
|
/// host showed last is kept, so coming back to a terminal shows its list at
|
||||||
|
/// once while it is read again. The search and the filters stay as they
|
||||||
|
/// are from one terminal to the next.
|
||||||
|
pub struct NetstatPanel {
|
||||||
|
target: Option<ExecTarget>,
|
||||||
|
active: bool,
|
||||||
|
hosts: HashMap<RemoteTerminalId, HostState>,
|
||||||
|
/// The reading on its way; dropping it stops it.
|
||||||
|
reading: Option<Task<()>>,
|
||||||
|
/// Whether a reading is on its way.
|
||||||
|
loading: bool,
|
||||||
|
search: Entity<InputState>,
|
||||||
|
protocol: Entity<SelectState<Vec<ProtocolFilter>>>,
|
||||||
|
state: Entity<SelectState<Vec<StateFilter>>>,
|
||||||
|
filter: Filter,
|
||||||
|
scroll_handle: UniformListScrollHandle,
|
||||||
|
/// The workspace's focus handle: 刷新 dispatches on it, so it works
|
||||||
|
/// whatever is focused.
|
||||||
|
dispatch: FocusHandle,
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct HostState {
|
||||||
|
table: Option<Rc<Table>>,
|
||||||
|
problem: Option<Problem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
enum Problem {
|
||||||
|
/// The terminal is not connected, so there is no connection to read on.
|
||||||
|
NotConnected,
|
||||||
|
/// Not Linux; `uname -s` when the host said it.
|
||||||
|
Unsupported(Option<String>),
|
||||||
|
/// The command did not run, or did not finish.
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchableListItem for ProtocolFilter {
|
||||||
|
type Value = Self;
|
||||||
|
|
||||||
|
fn title(&self) -> SharedString {
|
||||||
|
self.label().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(&self) -> &Self::Value {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchableListItem for StateFilter {
|
||||||
|
type Value = Self;
|
||||||
|
|
||||||
|
fn title(&self) -> SharedString {
|
||||||
|
self.label().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(&self) -> &Self::Value {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetstatPanel {
|
||||||
|
pub fn new(dispatch: FocusHandle, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let search = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder("搜索地址、端口、状态、PID 或进程")
|
||||||
|
.clean_on_escape()
|
||||||
|
});
|
||||||
|
let protocol = cx.new(|cx| {
|
||||||
|
SelectState::new(
|
||||||
|
ProtocolFilter::ALL.to_vec(),
|
||||||
|
Some(IndexPath::new(0)),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let state = cx.new(|cx| {
|
||||||
|
SelectState::new(
|
||||||
|
StateFilter::ALL.to_vec(),
|
||||||
|
Some(IndexPath::new(0)),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let subscriptions = vec![
|
||||||
|
cx.subscribe(&search, |this, input, event: &InputEvent, cx| {
|
||||||
|
if matches!(event, InputEvent::Change) {
|
||||||
|
let query = input.read(cx).value().to_string();
|
||||||
|
this.set_filter(
|
||||||
|
Filter {
|
||||||
|
query,
|
||||||
|
..this.filter.clone()
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
cx.subscribe(
|
||||||
|
&protocol,
|
||||||
|
|this, _, event: &SelectEvent<Vec<ProtocolFilter>>, cx| {
|
||||||
|
if let SelectEvent::Confirm(Some(protocol)) = event {
|
||||||
|
this.set_filter(
|
||||||
|
Filter {
|
||||||
|
protocol: *protocol,
|
||||||
|
..this.filter.clone()
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
cx.subscribe(
|
||||||
|
&state,
|
||||||
|
|this, _, event: &SelectEvent<Vec<StateFilter>>, cx| {
|
||||||
|
if let SelectEvent::Confirm(Some(state)) = event {
|
||||||
|
this.set_filter(
|
||||||
|
Filter {
|
||||||
|
state: *state,
|
||||||
|
..this.filter.clone()
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
Self {
|
||||||
|
target: None,
|
||||||
|
active: false,
|
||||||
|
hosts: HashMap::new(),
|
||||||
|
reading: None,
|
||||||
|
loading: false,
|
||||||
|
search,
|
||||||
|
protocol,
|
||||||
|
state,
|
||||||
|
filter: Filter::default(),
|
||||||
|
scroll_handle: UniformListScrollHandle::new(),
|
||||||
|
dispatch,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `target`'s host when `active`, the panel showing it, and was
|
||||||
|
/// not before.
|
||||||
|
pub fn set_target(&mut self, target: Option<ExecTarget>, active: bool, cx: &mut Context<Self>) {
|
||||||
|
if self.target == target && self.active == active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.target = target;
|
||||||
|
self.active = active;
|
||||||
|
self.read(cx);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the host again: 刷新.
|
||||||
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.read(cx);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_filter(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||||
|
if self.filter != filter {
|
||||||
|
self.filter = filter;
|
||||||
|
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start reading the target's host over, or stop when there is nothing
|
||||||
|
/// to show. A terminal not connected is asked again every little while,
|
||||||
|
/// so its list comes once it connects.
|
||||||
|
fn read(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.reading = None;
|
||||||
|
self.loading = false;
|
||||||
|
let Some(target) = self.target.clone().filter(|_| self.active) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.loading = true;
|
||||||
|
self.reading = Some(cx.spawn(async move |this, cx| {
|
||||||
|
loop {
|
||||||
|
let reply = this.update(cx, |_, cx| {
|
||||||
|
let view = target.view.upgrade()?;
|
||||||
|
view.read(cx).exec(linux::command(), cx)
|
||||||
|
});
|
||||||
|
let Ok(reply) = reply else { return };
|
||||||
|
let answer = match reply {
|
||||||
|
Some(reply) => exec_answer(reply, cx).await,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let connected = answer.is_some();
|
||||||
|
if this
|
||||||
|
.update(cx, |this, cx| this.record(target.terminal, answer, cx))
|
||||||
|
.is_err()
|
||||||
|
|| connected
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cx.background_executor().timer(RETRY).await;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(
|
||||||
|
&mut self,
|
||||||
|
terminal: RemoteTerminalId,
|
||||||
|
answer: Option<ExecResult>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let host = self.hosts.entry(terminal).or_default();
|
||||||
|
host.problem = match answer {
|
||||||
|
None => Some(Problem::NotConnected),
|
||||||
|
Some(Err(error)) => Some(Problem::Failed(error)),
|
||||||
|
Some(Ok(output)) => match linux::parse(&output) {
|
||||||
|
Parsed::Table(table) => {
|
||||||
|
host.table = Some(Rc::new(table));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Parsed::Unsupported(system) => Some(Problem::Unsupported(system)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Not connected, it keeps asking; the spinner stops all the same.
|
||||||
|
self.loading = false;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_table(
|
||||||
|
&self,
|
||||||
|
table: Rc<Table>,
|
||||||
|
problem: Option<Problem>,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
let rows = Rc::new(table.filtered(&self.filter));
|
||||||
|
let summary = table.counts().summary();
|
||||||
|
let dispatch = self.dispatch.clone();
|
||||||
|
let header = v_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.px_3()
|
||||||
|
.pt_3()
|
||||||
|
.pb_2()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id("netstat-summary")
|
||||||
|
.test_support()
|
||||||
|
.aria_label(summary.clone())
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.truncate()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(summary),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("netstat-refresh")
|
||||||
|
.ghost()
|
||||||
|
.xsmall()
|
||||||
|
.icon(Icon::new(CatalogIcon::RefreshCw))
|
||||||
|
.loading(self.loading)
|
||||||
|
.tooltip("刷新")
|
||||||
|
.accessibility_label("刷新")
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
dispatch.dispatch_action(&RefreshConnections, window, cx)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.when_some(problem, |header, problem| {
|
||||||
|
header.child(render_problem(&problem, cx))
|
||||||
|
})
|
||||||
|
.when(table.truncated(), |header| {
|
||||||
|
header.child(note(
|
||||||
|
"netstat-truncated",
|
||||||
|
format!("连接太多,只列出了前 {} 条。", linux::LIMIT),
|
||||||
|
cx.theme().warning,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map(|header| {
|
||||||
|
let text = match table.processes() {
|
||||||
|
Processes::All => return header,
|
||||||
|
Processes::Own => "当前用户不是 root,只能看到自己的进程。",
|
||||||
|
Processes::None => "这台主机没有 ss 命令,看不到占用连接的进程。",
|
||||||
|
};
|
||||||
|
header.child(note(
|
||||||
|
"netstat-processes",
|
||||||
|
text.into(),
|
||||||
|
cx.theme().muted_foreground,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
Input::new(&self.search)
|
||||||
|
.id("netstat-search")
|
||||||
|
.small()
|
||||||
|
.cleanable(true)
|
||||||
|
.prefix(Icon::new(IconName::Search).small()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
// Each in a box of its own, or it covers the whole row.
|
||||||
|
.child(
|
||||||
|
div().flex_1().min_w_0().child(
|
||||||
|
Select::new(&self.protocol)
|
||||||
|
.id("netstat-protocol")
|
||||||
|
.small()
|
||||||
|
.accessibility_label("协议"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div().flex_1().min_w_0().child(
|
||||||
|
Select::new(&self.state)
|
||||||
|
.id("netstat-state")
|
||||||
|
.small()
|
||||||
|
.accessibility_label("状态"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.when(self.filter.is_active(), |header| {
|
||||||
|
let text = format!("筛选出 {} 条", rows.len());
|
||||||
|
header.child(
|
||||||
|
div()
|
||||||
|
.id("netstat-matches")
|
||||||
|
.test_support()
|
||||||
|
.aria_label(text.clone())
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(text),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let empty = if self.filter.is_active() {
|
||||||
|
"没有符合条件的连接"
|
||||||
|
} else {
|
||||||
|
"没有网络连接"
|
||||||
|
};
|
||||||
|
// The scrollbar goes on the list's box, which does not scroll: on
|
||||||
|
// the scrolled rows it would scroll away with them.
|
||||||
|
let list = div()
|
||||||
|
.id("netstat-list")
|
||||||
|
.test_support()
|
||||||
|
.relative()
|
||||||
|
.flex_1()
|
||||||
|
.min_h_0()
|
||||||
|
.border_t_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.map(|list| {
|
||||||
|
if rows.is_empty() {
|
||||||
|
list.child(
|
||||||
|
div()
|
||||||
|
.id("netstat-empty")
|
||||||
|
.test_support()
|
||||||
|
.aria_label(empty)
|
||||||
|
.py_8()
|
||||||
|
.text_sm()
|
||||||
|
.text_center()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(empty),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Thousands of sockets on a busy server: only those in
|
||||||
|
// sight are drawn.
|
||||||
|
let count = rows.len();
|
||||||
|
list.child(
|
||||||
|
uniform_list("netstat-sockets", count, move |range, _, cx| {
|
||||||
|
range
|
||||||
|
.map(|index| {
|
||||||
|
div()
|
||||||
|
.px_3()
|
||||||
|
.pt_2()
|
||||||
|
.child(render_socket(&table.sockets()[rows[index]], cx))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.size_full()
|
||||||
|
.pb_3()
|
||||||
|
.track_scroll(&self.scroll_handle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.vertical_scrollbar(&self.scroll_handle);
|
||||||
|
v_flex().size_full().child(header).child(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for NetstatPanel {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let host = self
|
||||||
|
.target
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|target| self.hosts.get(&target.terminal));
|
||||||
|
let table = host.and_then(|host| host.table.clone());
|
||||||
|
let problem = host.and_then(|host| host.problem.clone());
|
||||||
|
v_flex()
|
||||||
|
.id("netstat")
|
||||||
|
.test_support()
|
||||||
|
.size_full()
|
||||||
|
.map(|panel| match (table, problem) {
|
||||||
|
// A list from before a failed reading still says something;
|
||||||
|
// the failure goes above it.
|
||||||
|
(Some(table), problem @ (None | Some(Problem::Failed(_)))) => {
|
||||||
|
panel.child(self.render_table(table, problem, cx))
|
||||||
|
}
|
||||||
|
(_, Some(problem)) => panel.child(div().p_3().child(render_problem(&problem, cx))),
|
||||||
|
(None, None) => panel.child(
|
||||||
|
h_flex()
|
||||||
|
.id("netstat-loading")
|
||||||
|
.test_support()
|
||||||
|
.gap_2()
|
||||||
|
.justify_center()
|
||||||
|
.py_8()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(Spinner::new().small())
|
||||||
|
.child("正在读取…"),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
|
||||||
|
let (text, color) = match problem {
|
||||||
|
Problem::NotConnected => (
|
||||||
|
"终端没有连接。连接后这里显示主机的网络连接。".to_string(),
|
||||||
|
cx.theme().muted_foreground,
|
||||||
|
),
|
||||||
|
Problem::Unsupported(Some(system)) => (
|
||||||
|
format!("暂不支持查看 {system} 的网络连接,目前只支持 Linux 主机。"),
|
||||||
|
cx.theme().muted_foreground,
|
||||||
|
),
|
||||||
|
Problem::Unsupported(None) => (
|
||||||
|
"暂不支持查看这台主机的网络连接,目前只支持 Linux 主机。".to_string(),
|
||||||
|
cx.theme().muted_foreground,
|
||||||
|
),
|
||||||
|
Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger),
|
||||||
|
};
|
||||||
|
div()
|
||||||
|
.id("netstat-message")
|
||||||
|
.test_support()
|
||||||
|
.aria_label(text.clone())
|
||||||
|
.text_sm()
|
||||||
|
.text_color(color)
|
||||||
|
.child(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line under the summary: 「连接太多,只列出了前 3000 条。」.
|
||||||
|
fn note(id: &'static str, text: String, color: Hsla) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.id(id)
|
||||||
|
.test_support()
|
||||||
|
.aria_label(text.clone())
|
||||||
|
.text_xs()
|
||||||
|
.text_color(color)
|
||||||
|
.child(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a state looks like: green for what listens, blue for what is
|
||||||
|
/// connected, yellow for CLOSE_WAIT, which piles up when a program does not
|
||||||
|
/// close what its peer has; the rest quiet.
|
||||||
|
fn state_color(state: SocketState, cx: &App) -> Hsla {
|
||||||
|
match state {
|
||||||
|
SocketState::Listen => cx.theme().success,
|
||||||
|
SocketState::Established => cx.theme().info,
|
||||||
|
SocketState::CloseWait => cx.theme().warning,
|
||||||
|
_ => cx.theme().muted_foreground,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_icon(role: Role) -> CatalogIcon {
|
||||||
|
match role {
|
||||||
|
Role::Listening => CatalogIcon::RadioTower,
|
||||||
|
Role::Inbound => CatalogIcon::ArrowDownLeft,
|
||||||
|
Role::Outbound => CatalogIcon::ArrowUpRight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A socket's card. Every card has the same lines, so they are all as tall
|
||||||
|
/// as the list takes them to be.
|
||||||
|
fn render_socket(socket: &Socket, cx: &App) -> impl IntoElement {
|
||||||
|
let color = state_color(socket.state, cx);
|
||||||
|
let process = socket.process_label();
|
||||||
|
let label = format!(
|
||||||
|
"{} {} · 本地 {} · 远端 {} · {} · {}",
|
||||||
|
socket.protocol.label(),
|
||||||
|
socket.state.label(),
|
||||||
|
socket.local,
|
||||||
|
socket.peer,
|
||||||
|
process.as_deref().unwrap_or("—"),
|
||||||
|
socket.user.as_deref().unwrap_or("—"),
|
||||||
|
);
|
||||||
|
let address = |name: &'static str, value: &str| {
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_xs()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(name),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.truncate()
|
||||||
|
.font_family(cx.theme().mono_font_family.clone())
|
||||||
|
.child(value.to_owned()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
v_flex()
|
||||||
|
.id(SharedString::from(format!(
|
||||||
|
"netstat-socket:{} {} {}",
|
||||||
|
socket.protocol.label().to_lowercase(),
|
||||||
|
socket.local,
|
||||||
|
socket.peer
|
||||||
|
)))
|
||||||
|
.test_support()
|
||||||
|
.aria_label(label)
|
||||||
|
.gap_1()
|
||||||
|
.p_3()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().tokens.group_box)
|
||||||
|
.text_color(cx.theme().group_box_foreground)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(Icon::new(role_icon(socket.role)).small().text_color(color))
|
||||||
|
.child(Tag::secondary().small().child(socket.protocol.label()))
|
||||||
|
.child(
|
||||||
|
Tag::custom(color.opacity(0.12), color, color.opacity(0.3))
|
||||||
|
.small()
|
||||||
|
.child(socket.state.label()),
|
||||||
|
)
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(socket.role.label()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(address("本地", &socket.local))
|
||||||
|
.child(address("远端", &socket.peer))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.mt_1()
|
||||||
|
.pt_2()
|
||||||
|
.border_t_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.text_xs()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child("进程"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.truncate()
|
||||||
|
.when(process.is_none(), |text| {
|
||||||
|
text.text_color(cx.theme().muted_foreground)
|
||||||
|
})
|
||||||
|
.child(process.unwrap_or_else(|| "—".into())),
|
||||||
|
)
|
||||||
|
.when_some(socket.user.clone(), |row, user| {
|
||||||
|
row.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(user),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//! Commands the right sidebar's tools run beside an SSH terminal's shell.
|
||||||
|
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gpui_kit::*;
|
||||||
|
|
||||||
|
use super::{ExecResult, RemoteTerminalId, TerminalView};
|
||||||
|
|
||||||
|
/// How often an answer on its way is looked for. It comes from the
|
||||||
|
/// terminal's transport thread, which must not wake the UI itself.
|
||||||
|
const POLL: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
|
/// The SSH terminal whose host a tool reads, over that terminal's own
|
||||||
|
/// connection.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct ExecTarget {
|
||||||
|
pub terminal: RemoteTerminalId,
|
||||||
|
pub view: WeakEntity<TerminalView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look for a command's answer until it comes. `None` when the transport
|
||||||
|
/// drops the request instead: the terminal went away.
|
||||||
|
pub async fn exec_answer(
|
||||||
|
reply: mpsc::Receiver<ExecResult>,
|
||||||
|
cx: &mut AsyncApp,
|
||||||
|
) -> Option<ExecResult> {
|
||||||
|
loop {
|
||||||
|
match reply.try_recv() {
|
||||||
|
Ok(result) => return Some(result),
|
||||||
|
Err(mpsc::TryRecvError::Empty) => cx.background_executor().timer(POLL).await,
|
||||||
|
Err(mpsc::TryRecvError::Disconnected) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
//! 终端: shared terminal emulation, transports and Dock panels.
|
//! 终端: shared terminal emulation, transports and Dock panels.
|
||||||
|
|
||||||
mod engine;
|
mod engine;
|
||||||
|
mod exec;
|
||||||
mod font;
|
mod font;
|
||||||
mod local_pty;
|
mod local_pty;
|
||||||
mod local_terminal_panel;
|
mod local_terminal_panel;
|
||||||
@@ -11,6 +12,7 @@ mod terminal_view;
|
|||||||
mod transport;
|
mod transport;
|
||||||
|
|
||||||
pub use engine::{TerminalCell, TerminalEngine, TerminalEvent, TerminalSnapshot};
|
pub use engine::{TerminalCell, TerminalEngine, TerminalEvent, TerminalSnapshot};
|
||||||
|
pub use exec::{ExecTarget, exec_answer};
|
||||||
pub use font::{
|
pub use font::{
|
||||||
DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalFont,
|
DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalFont,
|
||||||
TerminalFontPreview, is_font_installed, monospace_font_families,
|
TerminalFontPreview, is_font_installed, monospace_font_families,
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ use gpui_kit::*;
|
|||||||
|
|
||||||
use crate::app::{CatalogIcon, ToggleTool, ToolKind};
|
use crate::app::{CatalogIcon, ToggleTool, ToolKind};
|
||||||
use crate::host::{HostId, HostOs, HostStore};
|
use crate::host::{HostId, HostOs, HostStore};
|
||||||
use crate::monitor::{MonitorDetail, MonitorPanel, MonitorTarget};
|
use crate::monitor::{MonitorDetail, MonitorPanel};
|
||||||
use crate::terminal::{RemoteTerminalId, TerminalView};
|
use crate::netstat::NetstatPanel;
|
||||||
|
use crate::terminal::{ExecTarget, RemoteTerminalId, TerminalView};
|
||||||
|
|
||||||
impl ToolKind {
|
impl ToolKind {
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
@@ -18,6 +19,7 @@ impl ToolKind {
|
|||||||
ToolKind::Snippets => "命令片段",
|
ToolKind::Snippets => "命令片段",
|
||||||
ToolKind::History => "历史命令",
|
ToolKind::History => "历史命令",
|
||||||
ToolKind::Docker => "Docker",
|
ToolKind::Docker => "Docker",
|
||||||
|
ToolKind::Connections => "网络连接",
|
||||||
ToolKind::Monitor => "系统监控",
|
ToolKind::Monitor => "系统监控",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,6 +29,7 @@ impl ToolKind {
|
|||||||
ToolKind::Snippets => CatalogIcon::CodeXml,
|
ToolKind::Snippets => CatalogIcon::CodeXml,
|
||||||
ToolKind::History => CatalogIcon::RotateCcwClock,
|
ToolKind::History => CatalogIcon::RotateCcwClock,
|
||||||
ToolKind::Docker => CatalogIcon::Container,
|
ToolKind::Docker => CatalogIcon::Container,
|
||||||
|
ToolKind::Connections => CatalogIcon::Network,
|
||||||
ToolKind::Monitor => CatalogIcon::Activity,
|
ToolKind::Monitor => CatalogIcon::Activity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +38,7 @@ impl ToolKind {
|
|||||||
/// yet gets every tool, and is told if one cannot work there.
|
/// yet gets every tool, and is told if one cannot work there.
|
||||||
pub fn works_on(self, os: Option<HostOs>) -> bool {
|
pub fn works_on(self, os: Option<HostOs>) -> bool {
|
||||||
match self {
|
match self {
|
||||||
ToolKind::Monitor => os.is_none_or(HostOs::is_linux),
|
ToolKind::Connections | ToolKind::Monitor => os.is_none_or(HostOs::is_linux),
|
||||||
ToolKind::Snippets | ToolKind::History | ToolKind::Docker => true,
|
ToolKind::Snippets | ToolKind::History | ToolKind::Docker => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,6 +49,7 @@ impl ToolKind {
|
|||||||
ToolKind::Snippets => "tool-snippets",
|
ToolKind::Snippets => "tool-snippets",
|
||||||
ToolKind::History => "tool-history",
|
ToolKind::History => "tool-history",
|
||||||
ToolKind::Docker => "tool-docker",
|
ToolKind::Docker => "tool-docker",
|
||||||
|
ToolKind::Connections => "tool-connections",
|
||||||
ToolKind::Monitor => "tool-monitor",
|
ToolKind::Monitor => "tool-monitor",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +61,7 @@ impl ToolKind {
|
|||||||
ToolKind::Snippets => "常用的命令存在这里,点一下就发送到当前终端。".into(),
|
ToolKind::Snippets => "常用的命令存在这里,点一下就发送到当前终端。".into(),
|
||||||
ToolKind::History => format!("{host} 上执行过的命令,可以搜索、再次执行。"),
|
ToolKind::History => format!("{host} 上执行过的命令,可以搜索、再次执行。"),
|
||||||
ToolKind::Docker => format!("{host} 上的容器:状态、日志,启动和停止。"),
|
ToolKind::Docker => format!("{host} 上的容器:状态、日志,启动和停止。"),
|
||||||
|
ToolKind::Connections => format!("{host} 上的 TCP、UDP 连接和监听端口。"),
|
||||||
ToolKind::Monitor => format!("{host} 的 CPU、内存、网络和磁盘。"),
|
ToolKind::Monitor => format!("{host} 的 CPU、内存、网络和磁盘。"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +96,7 @@ pub struct ToolSidebar {
|
|||||||
/// Whether the right dock is open.
|
/// Whether the right dock is open.
|
||||||
shown: bool,
|
shown: bool,
|
||||||
monitor: Entity<MonitorPanel>,
|
monitor: Entity<MonitorPanel>,
|
||||||
|
netstat: Entity<NetstatPanel>,
|
||||||
store: Entity<HostStore>,
|
store: Entity<HostStore>,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
@@ -99,14 +105,20 @@ pub struct ToolSidebar {
|
|||||||
impl ToolSidebar {
|
impl ToolSidebar {
|
||||||
/// `dispatch` is the workspace's focus handle, which the tools' buttons
|
/// `dispatch` is the workspace's focus handle, which the tools' buttons
|
||||||
/// dispatch on.
|
/// dispatch on.
|
||||||
pub fn new(store: Entity<HostStore>, dispatch: FocusHandle, cx: &mut Context<Self>) -> Self {
|
pub fn new(
|
||||||
|
store: Entity<HostStore>,
|
||||||
|
dispatch: FocusHandle,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
// A host renamed is named anew.
|
// A host renamed is named anew.
|
||||||
let subscription = cx.observe(&store, |_, _, cx| cx.notify());
|
let subscription = cx.observe(&store, |_, _, cx| cx.notify());
|
||||||
Self {
|
Self {
|
||||||
tool: ToolKind::default(),
|
tool: ToolKind::default(),
|
||||||
terminal: None,
|
terminal: None,
|
||||||
shown: false,
|
shown: false,
|
||||||
monitor: cx.new(|_| MonitorPanel::new(dispatch)),
|
monitor: cx.new(|_| MonitorPanel::new(dispatch.clone())),
|
||||||
|
netstat: cx.new(|cx| NetstatPanel::new(dispatch, window, cx)),
|
||||||
store,
|
store,
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
@@ -138,15 +150,24 @@ impl ToolSidebar {
|
|||||||
.update(cx, |monitor, cx| monitor.toggle(detail, cx));
|
.update(cx, |monitor, cx| monitor.toggle(detail, cx));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 网络连接's 刷新.
|
||||||
|
pub fn refresh_connections(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.netstat.update(cx, |netstat, cx| netstat.refresh(cx));
|
||||||
|
}
|
||||||
|
|
||||||
/// Point each tool at the terminal, running only the one on screen.
|
/// Point each tool at the terminal, running only the one on screen.
|
||||||
fn sync_tools(&mut self, cx: &mut Context<Self>) {
|
fn sync_tools(&mut self, cx: &mut Context<Self>) {
|
||||||
let target = self.terminal.as_ref().map(|terminal| MonitorTarget {
|
let target = self.terminal.as_ref().map(|terminal| ExecTarget {
|
||||||
terminal: terminal.id,
|
terminal: terminal.id,
|
||||||
view: terminal.view.clone(),
|
view: terminal.view.clone(),
|
||||||
});
|
});
|
||||||
let active = self.shown && self.tool == ToolKind::Monitor;
|
let showing = |tool| self.shown && self.tool == tool;
|
||||||
self.monitor
|
let (monitor, netstat) = (showing(ToolKind::Monitor), showing(ToolKind::Connections));
|
||||||
.update(cx, |monitor, cx| monitor.set_target(target, active, cx));
|
self.monitor.update(cx, |panel, cx| {
|
||||||
|
panel.set_target(target.clone(), monitor, cx)
|
||||||
|
});
|
||||||
|
self.netstat
|
||||||
|
.update(cx, |panel, cx| panel.set_target(target, netstat, cx));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show another tool. A focus inside the one going away moves to the
|
/// Show another tool. A focus inside the one going away moves to the
|
||||||
@@ -239,6 +260,9 @@ impl Render for ToolSidebar {
|
|||||||
ToolKind::Monitor => {
|
ToolKind::Monitor => {
|
||||||
sidebar.child(div().flex_1().min_h_0().child(self.monitor.clone()))
|
sidebar.child(div().flex_1().min_h_0().child(self.monitor.clone()))
|
||||||
}
|
}
|
||||||
|
ToolKind::Connections => {
|
||||||
|
sidebar.child(div().flex_1().min_h_0().child(self.netstat.clone()))
|
||||||
|
}
|
||||||
_ => sidebar.child(self.render_placeholder(terminal.host, cx)),
|
_ => sidebar.child(self.render_placeholder(terminal.host, cx)),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+15
-2
@@ -11,12 +11,14 @@
|
|||||||
use gpui_kit::component::dock::{DockArea, DockPlacement};
|
use gpui_kit::component::dock::{DockArea, DockPlacement};
|
||||||
use gpui_kit::*;
|
use gpui_kit::*;
|
||||||
|
|
||||||
use crate::app::{CenterTab, ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind};
|
use crate::app::{
|
||||||
|
CenterTab, RefreshConnections, ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{Workspace, tool_sidebar::ToolTerminal};
|
use super::{Workspace, tool_sidebar::ToolTerminal};
|
||||||
|
|
||||||
/// How wide the right sidebar opens, and the narrowest it can be dragged:
|
/// How wide the right sidebar opens, and the narrowest it can be dragged:
|
||||||
/// the monitor's cards are laid out for this width.
|
/// the monitor's and the connections' cards are laid out for this width.
|
||||||
pub(super) const TOOL_SIDEBAR_WIDTH: Pixels = px(320.);
|
pub(super) const TOOL_SIDEBAR_WIDTH: Pixels = px(320.);
|
||||||
|
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
@@ -121,6 +123,17 @@ impl Workspace {
|
|||||||
.update(cx, |tools, cx| tools.toggle_monitor_detail(detail, cx));
|
.update(cx, |tools, cx| tools.toggle_monitor_detail(detail, cx));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 网络连接's 刷新: read the host's sockets again.
|
||||||
|
pub(super) fn on_refresh_connections(
|
||||||
|
&mut self,
|
||||||
|
_: &RefreshConnections,
|
||||||
|
_: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.tools
|
||||||
|
.update(cx, |tools, cx| tools.refresh_connections(cx));
|
||||||
|
}
|
||||||
|
|
||||||
/// Hide the sidebar, or show the tool shown last; the first tool on
|
/// Hide the sidebar, or show the tool shown last; the first tool on
|
||||||
/// offer when that one does not work on this host. Without an SSH
|
/// offer when that one does not work on this host. Without an SSH
|
||||||
/// terminal in front there is nothing to show or hide.
|
/// terminal in front there is nothing to show or hide.
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ impl Workspace {
|
|||||||
let credential_panel =
|
let credential_panel =
|
||||||
cx.new(|cx| CredentialPanel::new(store.clone(), focus_handle.clone(), window, cx));
|
cx.new(|cx| CredentialPanel::new(store.clone(), focus_handle.clone(), window, cx));
|
||||||
let sidebar = cx.new(|_| Sidebar::new(host_panel.clone(), forward_panel, credential_panel));
|
let sidebar = cx.new(|_| Sidebar::new(host_panel.clone(), forward_panel, credential_panel));
|
||||||
let tools = cx.new(|cx| ToolSidebar::new(store.clone(), focus_handle.clone(), cx));
|
let tools = cx.new(|cx| ToolSidebar::new(store.clone(), focus_handle.clone(), window, cx));
|
||||||
// Start with focus in the host panel so window-level actions have a
|
// Start with focus in the host panel so window-level actions have a
|
||||||
// dispatch path. The workspace's own handle is never focused: the
|
// dispatch path. The workspace's own handle is never focused: the
|
||||||
// dialog layer is its child, and a focused ancestor would keep the
|
// dialog layer is its child, and a focused ancestor would keep the
|
||||||
@@ -2235,6 +2235,7 @@ impl Render for Workspace {
|
|||||||
.on_action(cx.listener(Self::on_toggle_tool_sidebar))
|
.on_action(cx.listener(Self::on_toggle_tool_sidebar))
|
||||||
.on_action(cx.listener(Self::on_toggle_tool))
|
.on_action(cx.listener(Self::on_toggle_tool))
|
||||||
.on_action(cx.listener(Self::on_toggle_monitor_detail))
|
.on_action(cx.listener(Self::on_toggle_monitor_detail))
|
||||||
|
.on_action(cx.listener(Self::on_refresh_connections))
|
||||||
.on_action(cx.listener(Self::on_show_hosts))
|
.on_action(cx.listener(Self::on_show_hosts))
|
||||||
.on_action(cx.listener(Self::on_show_forwards))
|
.on_action(cx.listener(Self::on_show_forwards))
|
||||||
.on_action(cx.listener(Self::on_new_forward))
|
.on_action(cx.listener(Self::on_new_forward))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod forward;
|
|||||||
mod host_dialog;
|
mod host_dialog;
|
||||||
mod host_tree;
|
mod host_tree;
|
||||||
mod monitor;
|
mod monitor;
|
||||||
|
mod netstat;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod sftp;
|
mod sftp;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ fn the_monitor_is_not_offered_on_a_host_known_not_to_run_linux(cx: &mut TestAppC
|
|||||||
});
|
});
|
||||||
in_frame(cx, handle, |window, _| {
|
in_frame(cx, handle, |window, _| {
|
||||||
assert!(window.try_find("tool-monitor").is_none());
|
assert!(window.try_find("tool-monitor").is_none());
|
||||||
|
assert!(window.try_find("tool-connections").is_none());
|
||||||
assert!(window.find("tool-history").visible());
|
assert!(window.find("tool-history").visible());
|
||||||
assert!(
|
assert!(
|
||||||
window
|
window
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
//! 网络连接: the right sidebar's tool that lists the sockets of the SSH
|
||||||
|
//! terminal's host, read over the terminal's own connection.
|
||||||
|
|
||||||
|
use crate::support::*;
|
||||||
|
|
||||||
|
/// What `ss` says about a small web server, read as root.
|
||||||
|
const FIRST: &str = "\
|
||||||
|
@@uid
|
||||||
|
0
|
||||||
|
@@users
|
||||||
|
0 root
|
||||||
|
33 www-data
|
||||||
|
@@ss
|
||||||
|
udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:((\"systemd-resolve\",pid=600,fd=13)) uid:101 ino:20123 sk:1 <->
|
||||||
|
tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=891,fd=3)) ino:21233 sk:5 <->
|
||||||
|
tcp LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:((\"nginx\",pid=913756,fd=6)) uid:33 ino:30001 sk:6 <->
|
||||||
|
tcp ESTAB 0 0 10.0.0.5:22 203.0.113.7:51234 users:((\"sshd\",pid=1234,fd=4)) ino:123456 sk:9 <->
|
||||||
|
tcp TIME-WAIT 0 0 10.0.0.5:80 198.51.100.2:6000 timer:(timewait,50sec,0) ino:0 sk:a
|
||||||
|
@@counts
|
||||||
|
1 udp UNCONN
|
||||||
|
2 tcp LISTEN
|
||||||
|
1 tcp ESTAB
|
||||||
|
1 tcp TIME-WAIT
|
||||||
|
";
|
||||||
|
|
||||||
|
/// The same server a moment later: the visit to port 80 has gone, another
|
||||||
|
/// login has come.
|
||||||
|
const SECOND: &str = "\
|
||||||
|
@@uid
|
||||||
|
0
|
||||||
|
@@users
|
||||||
|
0 root
|
||||||
|
@@ss
|
||||||
|
tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=891,fd=3)) ino:21233 sk:5 <->
|
||||||
|
tcp ESTAB 0 0 10.0.0.5:22 203.0.113.7:51234 users:((\"sshd\",pid=1234,fd=4)) ino:123456 sk:9 <->
|
||||||
|
tcp ESTAB 0 0 10.0.0.5:22 203.0.113.8:40000 users:((\"sshd\",pid=1300,fd=4)) ino:123457 sk:b <->
|
||||||
|
@@counts
|
||||||
|
1 tcp LISTEN
|
||||||
|
2 tcp ESTAB
|
||||||
|
";
|
||||||
|
|
||||||
|
fn open_connections(
|
||||||
|
cx: &mut TestAppContext,
|
||||||
|
factory: Arc<FakeTerminalFactory>,
|
||||||
|
) -> WindowHandle<Root> {
|
||||||
|
let (handle, _) = open_workspace_with_remote_factory(cx, HostStore::seed(), factory);
|
||||||
|
in_frame(cx, handle, |window, cx| {
|
||||||
|
window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx)
|
||||||
|
});
|
||||||
|
in_frame(cx, handle, |window, cx| {
|
||||||
|
window.click("tool-connections", cx)
|
||||||
|
});
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_label(
|
||||||
|
cx: &mut TestAppContext,
|
||||||
|
handle: WindowHandle<Root>,
|
||||||
|
id: &'static str,
|
||||||
|
label: &str,
|
||||||
|
) {
|
||||||
|
cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| {
|
||||||
|
window
|
||||||
|
.try_find(id)
|
||||||
|
.is_some_and(|element| element.label() == Some(label))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
async fn the_connections_list_the_hosts_sockets_with_their_processes(cx: &mut TestAppContext) {
|
||||||
|
let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST]));
|
||||||
|
let handle = open_connections(cx, factory.clone());
|
||||||
|
wait_for_label(
|
||||||
|
cx,
|
||||||
|
handle,
|
||||||
|
"netstat-summary",
|
||||||
|
"共 5 条 · 3 个监听端口 · 1 条已连接",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
in_frame(cx, handle, |window, _| {
|
||||||
|
assert_eq!(
|
||||||
|
window.find(("tool-sidebar", INITIAL_WEB_TERMINAL)).label(),
|
||||||
|
Some("网络连接")
|
||||||
|
);
|
||||||
|
assert_eq!(window.find("tool-connections").checked(), Some(true));
|
||||||
|
assert_eq!(
|
||||||
|
window
|
||||||
|
.find("netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*")
|
||||||
|
.label(),
|
||||||
|
Some("TCP 监听 · 本地 0.0.0.0:22 · 远端 0.0.0.0:* · sshd (PID 891) · root")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
window
|
||||||
|
.find("netstat-socket:tcp 0.0.0.0:80 0.0.0.0:*")
|
||||||
|
.label(),
|
||||||
|
Some("TCP 监听 · 本地 0.0.0.0:80 · 远端 0.0.0.0:* · nginx (PID 913756) · www-data")
|
||||||
|
);
|
||||||
|
// No process holds a socket in TIME_WAIT.
|
||||||
|
assert_eq!(
|
||||||
|
window
|
||||||
|
.find("netstat-socket:tcp 10.0.0.5:80 198.51.100.2:6000")
|
||||||
|
.label(),
|
||||||
|
Some("TCP TIME_WAIT · 本地 10.0.0.5:80 · 远端 198.51.100.2:6000 · — · —")
|
||||||
|
);
|
||||||
|
// Root sees everything, and the list is whole: nothing to say.
|
||||||
|
assert!(window.try_find("netstat-truncated").is_none());
|
||||||
|
assert!(window.try_find("netstat-processes").is_none());
|
||||||
|
assert!(window.try_find("netstat-matches").is_none());
|
||||||
|
});
|
||||||
|
let command = &factory.exec_commands()[0];
|
||||||
|
assert!(command.starts_with("sh -c '") && command.contains("ss -tuanpe"));
|
||||||
|
|
||||||
|
// A list holds still: nothing more runs until it is asked to.
|
||||||
|
cx.executor().advance_clock(Duration::from_secs(10));
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(factory.exec_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
async fn the_search_narrows_the_list(cx: &mut TestAppContext) {
|
||||||
|
let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST]));
|
||||||
|
let handle = open_connections(cx, factory);
|
||||||
|
wait_for_label(
|
||||||
|
cx,
|
||||||
|
handle,
|
||||||
|
"netstat-summary",
|
||||||
|
"共 5 条 · 3 个监听端口 · 1 条已连接",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
in_frame(cx, handle, |window, cx| {
|
||||||
|
window.click("netstat-search", cx);
|
||||||
|
window.input("sshd", cx);
|
||||||
|
});
|
||||||
|
in_frame(cx, handle, |window, _| {
|
||||||
|
assert_eq!(window.find("netstat-matches").label(), Some("筛选出 2 条"));
|
||||||
|
assert!(
|
||||||
|
window
|
||||||
|
.try_find("netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
window
|
||||||
|
.try_find("netstat-socket:tcp 10.0.0.5:22 203.0.113.7:51234")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
window
|
||||||
|
.try_find("netstat-socket:tcp 0.0.0.0:80 0.0.0.0:*")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
// The summary is still the host's.
|
||||||
|
assert_eq!(
|
||||||
|
window.find("netstat-summary").label(),
|
||||||
|
Some("共 5 条 · 3 个监听端口 · 1 条已连接")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A PID finds its process.
|
||||||
|
in_frame(cx, handle, |window, cx| {
|
||||||
|
window.press("cmd-a", cx);
|
||||||
|
window.input("913756", cx);
|
||||||
|
});
|
||||||
|
in_frame(cx, handle, |window, _| {
|
||||||
|
assert_eq!(window.find("netstat-matches").label(), Some("筛选出 1 条"));
|
||||||
|
assert!(
|
||||||
|
window
|
||||||
|
.try_find("netstat-socket:tcp 0.0.0.0:80 0.0.0.0:*")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
in_frame(cx, handle, |window, cx| {
|
||||||
|
window.press("cmd-a", cx);
|
||||||
|
window.input("mysql", cx);
|
||||||
|
});
|
||||||
|
in_frame(cx, handle, |window, _| {
|
||||||
|
assert_eq!(
|
||||||
|
window.find("netstat-empty").label(),
|
||||||
|
Some("没有符合条件的连接")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
async fn refresh_reads_the_host_again(cx: &mut TestAppContext) {
|
||||||
|
let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST, SECOND]));
|
||||||
|
let handle = open_connections(cx, factory.clone());
|
||||||
|
wait_for_label(
|
||||||
|
cx,
|
||||||
|
handle,
|
||||||
|
"netstat-summary",
|
||||||
|
"共 5 条 · 3 个监听端口 · 1 条已连接",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
in_frame(cx, handle, |window, cx| window.click("netstat-refresh", cx));
|
||||||
|
wait_for_label(
|
||||||
|
cx,
|
||||||
|
handle,
|
||||||
|
"netstat-summary",
|
||||||
|
"共 3 条 · 1 个监听端口 · 2 条已连接",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
in_frame(cx, handle, |window, _| {
|
||||||
|
assert!(
|
||||||
|
window
|
||||||
|
.try_find("netstat-socket:tcp 10.0.0.5:22 203.0.113.8:40000")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
window
|
||||||
|
.try_find("netstat-socket:tcp 0.0.0.0:80 0.0.0.0:*")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
assert_eq!(factory.exec_count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
async fn the_connections_are_read_each_time_they_come_on_screen_and_only_then(
|
||||||
|
cx: &mut TestAppContext,
|
||||||
|
) {
|
||||||
|
let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST, SECOND]));
|
||||||
|
let handle = open_connections(cx, factory.clone());
|
||||||
|
wait_for_label(
|
||||||
|
cx,
|
||||||
|
handle,
|
||||||
|
"netstat-summary",
|
||||||
|
"共 5 条 · 3 个监听端口 · 1 条已连接",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Another tool: nothing runs.
|
||||||
|
in_frame(cx, handle, |window, cx| window.click("tool-history", cx));
|
||||||
|
cx.executor().advance_clock(Duration::from_secs(10));
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(factory.exec_count(), 1);
|
||||||
|
|
||||||
|
// Back, the list it had shows at once while it is read again.
|
||||||
|
in_frame(cx, handle, |window, cx| {
|
||||||
|
window.click("tool-connections", cx)
|
||||||
|
});
|
||||||
|
in_frame(cx, handle, |window, _| {
|
||||||
|
assert!(
|
||||||
|
window
|
||||||
|
.try_find("netstat-socket:tcp 0.0.0.0:80 0.0.0.0:*")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wait_for_label(
|
||||||
|
cx,
|
||||||
|
handle,
|
||||||
|
"netstat-summary",
|
||||||
|
"共 3 条 · 1 个监听端口 · 2 条已连接",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(factory.exec_count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
async fn the_connections_say_what_the_login_cannot_see_and_when_there_are_too_many(
|
||||||
|
cx: &mut TestAppContext,
|
||||||
|
) {
|
||||||
|
let output = FIRST
|
||||||
|
.replacen("@@uid\n0\n", "@@uid\n1000\n", 1)
|
||||||
|
.replace("1 tcp TIME-WAIT", "50000 tcp TIME-WAIT");
|
||||||
|
let handle = open_connections(cx, Arc::new(FakeTerminalFactory::answering(&[&output])));
|
||||||
|
wait_for_label(
|
||||||
|
cx,
|
||||||
|
handle,
|
||||||
|
"netstat-summary",
|
||||||
|
"共 50004 条 · 3 个监听端口 · 1 条已连接",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
in_frame(cx, handle, |window, _| {
|
||||||
|
assert_eq!(
|
||||||
|
window.find("netstat-truncated").label(),
|
||||||
|
Some("连接太多,只列出了前 3000 条。")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
window.find("netstat-processes").label(),
|
||||||
|
Some("当前用户不是 root,只能看到自己的进程。")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
async fn the_connections_wait_for_the_terminal_to_connect(cx: &mut TestAppContext) {
|
||||||
|
// web-01's terminal is the first started, and exits at once.
|
||||||
|
let handle = open_connections(cx, Arc::new(FakeTerminalFactory::exit_first()));
|
||||||
|
wait_for_label(
|
||||||
|
cx,
|
||||||
|
handle,
|
||||||
|
"netstat-message",
|
||||||
|
"终端没有连接。连接后这里显示主机的网络连接。",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
async fn the_connections_say_they_read_linux_hosts_only(cx: &mut TestAppContext) {
|
||||||
|
let factory = Arc::new(FakeTerminalFactory::answering(&["@@unsupported\nDarwin\n"]));
|
||||||
|
let handle = open_connections(cx, factory);
|
||||||
|
wait_for_label(
|
||||||
|
cx,
|
||||||
|
handle,
|
||||||
|
"netstat-message",
|
||||||
|
"暂不支持查看 Darwin 的网络连接,目前只支持 Linux 主机。",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user