系统服务:右侧栏的新工具,管理 Linux 主机的 systemd 服务
- 按自定义 / 系统分组,按状态分页、搜索;启动、停止、重启,启用或禁用开机启动 - 点服务打开详情,含状态和最近的日志 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3e8f635fa7
commit
610cb12fbf
16 files changed
+2684
-14
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/`)。
|
||||
|
||||
已确认的产品决定: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` 为准。
|
||||
|
||||
@@ -58,6 +58,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- `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`;切换来源会清空口令栏,因为口令属于一把钥匙)。
|
||||
- `monitor/` — 系统监控,右侧栏的第一个真工具,只读远程 Linux 主机:`linux.rs`(在终端自己的连接上执行的命令 `COMMAND` 和解析 `parse`)、`model.rs`(读数、`Tracker` 算比率、格式化)、`monitor_panel.rs`(`MonitorPanel`:轮询读数、五张卡片),见下面「系统监控(`monitor/`)是纯解析加一个面板」。依赖 `terminal`(`TerminalView::exec`、`ExecTarget`),不依赖 `workspace`。
|
||||
- `services/` — 系统服务,右侧栏的第四个真工具,只管 systemd 的 Linux 主机:`linux.rs`(列表的命令 `command()` 和解析 `parse`,`valid_name`,命令 `control_command` / `controlled`,详情的 `status_command` / `parse_status`,日志的 `log_command`)、`model.rs`(`Service` / `ActiveState` / `ServiceCommand`,`ServiceTable::rows` 分组、按标签和搜索过滤,`counts`,状态的中文说法,都是纯函数)、`service_panel.rs`(`ServicePanel`:读取、搜索框、`TabBar`、gpui 的 `list` 画的分组标题和卡片、卡片上的按钮和挂在列表上的右键菜单)、`service_details.rs`(`open_service_dialog`:详情对话框,正文 `ServiceDetailsView` 打开时读状态、切到日志页时读日志)。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区(`on_control_service`)。
|
||||
- `processes/` — 进程管理,右侧栏的第三个真工具,只读和结束远程 Linux 主机的进程:`linux.rs`(读数的命令 `command()` 和解析 `parse`,结束进程的 `end_command` 和读结果的 `ended`)、`model.rs`(`Reading` / `ProcessTimes`,`Tracker` 用两次读数的 CPU 节拍算出 `Snapshot`,`Snapshot::listed` 按 `Order` 排序、按搜索词过滤,都是纯函数)、`process_panel.rs`(`ProcessPanel`:轮询读数、搜索框、排序按钮、`uniform_list` 画的卡片和挂在列表上的右键菜单)、`process_details.rs`(`open_process_dialog`:详情对话框,正文 `ProcessDetailsView` 打开时读命令行)。依赖 `terminal` 和 `shared`,不依赖 `workspace`、`monitor` 和 `netstat`;结束进程的确认和通知在工作区(`workspace/tools.rs` 的 `on_end_process`)。
|
||||
- `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` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
|
||||
@@ -111,9 +112,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`。
|
||||
- **首次登录可以问,重连不问。** 第一次用交互式 `SshPrompts`;断线后的重连用 `.non_interactive()` 而且陌生主机直接答否,后台的转发因此不会自己弹框。重连只对网络类错误重试(`ssh::is_network_error`,要显式认 `russh::Error::IO`,它是 transparent 的、链上没有单独的 `io::Error`),缺密码、主机密钥问题立即以「连接中断,自动重连失败:…」停止。远程转发在重连时 `tcpip_forward` 被拒也算可重试:服务器可能还替刚死的那条连接占着端口。
|
||||
- **停止中的运行占着自己的位置。** 本地监听端口要等旧的工作线程真的结束才空出来,所以 `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`、`NetstatPanel`、`ProcessPanel`),`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`、`ProcessPanel`、`ServicePanel`),`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,`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()`(见下面「滚动条挂在不滚动的外层上」)。
|
||||
- **系统服务(`services/`)一次 `systemctl show` 读全部服务。** 列表命令(`linux::command()`)先判 `/run/systemd/system` 存不存在(sd_booted 的判法,不在就 `@@unsupported` 加 `uname -s`,Linux 没 systemd 时界面写「这台主机没有使用 systemd」),`@@version`、`@@state` 是 `systemctl --version` 和 `is-system-running`;然后 `list-unit-files` 和 `list-units --all --plain` 的第一列合起来,去掉模板(`*@.service`,只能以实例运行),交给**一次** `systemctl show -p Id -p Description … -- $names`(属性一个 `-p` 一个,systemd 230 以前不认逗号)。别名(`sshd.service`)在 `show` 里的 `Id` 是本名,`ServiceTable::new` 按名字去重;`LoadState=not-found`(被引用但没有 unit 文件)的只在失败时列出;自定义与否看 `FragmentPath` 在不在 `/etc/systemd/system/`。`$(…)` 里的 `case` 分支写成 `(pattern)`:bash 3.2 会把分支裸的 `)` 当成命令替换的结尾(macOS 的 `sh` 就是它,`the_commands_are_valid_sh` 在本机这样失败过);服务名用 `printf "%s\n"` 印,dash 的 `echo` 会解释反斜杠。**拼进命令的服务名先过 `valid_name`**(只许字母、数字和 `:_.@-\`,systemd 自己的规则,且不许连续两个反斜杠),放在双引号里(`\x2d` 这种转义原样留下),不合格就不执行。命令(`control_command`)是 `if test "$(id -u)" = 0; then systemctl <动词> -- "名字"; else sudo -n systemctl …; fi 2>&1; echo @@status $?`,`controlled` 把要密码、要交互认证、不在 sudoers 都翻成「需要 root 权限…」,别的取 systemctl 的第一行(「Job for … failed because …」)。日志是 `journalctl -u … -n 200 --no-pager`,非 root 先试 `sudo -n`、不行就看自己看得到的。`the_script_shows_every_service_but_templates` 用一个假 `systemctl` 脚本(按第一个参数回答)真跑列表命令。列表用 gpui 的 `list`(`ListState`,量每行的高度)而不是 `uniform_list`,因为分组标题和卡片不一样高;`ServicePanel::sync_list` 在行变了时 `splice`、换标签或搜索时 `reset`(回到顶上)。卡片上的按钮 `stop_propagation`,免得连卡片的单击(打开详情)一起触发。`ControlService { name, command }` 由工作区处理:停止、重启走 `confirm_danger`,其余直接执行;详情对话框的按钮同进程详情,先 `close_dialog` 再在工作区的 focus handle 上派发。
|
||||
- **进程管理(`processes/`)只用 shell 内建命令读 `/proc`。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:`@@uptime`、`@@memory`(`MemTotal`)、`@@clock` / `@@page` 是 `getconf CLK_TCK` / `getconf PAGESIZE`(没有 `getconf` 时按 100 和 4096),`@@users` 读 `/etc/passwd`,然后 `for d in /proc/[0-9]*` 每个进程读一行 `stat`、再读 `status` 读到 `Uid:` 为止,印出「uid stat 行」;进程在列出和读取之间退出时 `read … || continue` 跳过(dash、bash 遇到重定向失败都不退出),整个循环的错误输出丢掉;数到 `LIMIT`(2500,一行约 300 字节,exec 的输出最多 1 MB)就印 `@@more` 停下。`stat` 的名字在括号里、可能带空格和括号,所以 `parse_process` 从最后一个 `)` 往后数字段(第 9 个是 flags,14、15 是 utime、stime,22 是启动时刻,24 是 rss 页数);flags 带 `PF_KTHREAD`(0x200000)的是内核线程,不列。`Tracker` 以「PID + 启动时刻」认进程(PID 会被新进程重用),用主机自己的 uptime 差做分母,uptime 变小(重启、另一台机器)不出比率;终端重连(`TerminalView::generation` 变了)就换一个新的 `Tracker`。启动时间是「本机现在 − uptime + 启动节拍 / 每秒节拍」,按本机时区显示,和主机的时钟差多少就偏多少。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`(含一个内核线程和一个读不到 `stat` 的进程)用本机的 `sh` 真跑。结束进程:右键菜单挂在 `processes-list` 上(同主机树,卡片是 `uniform_list` 在布局之后画的),卡片的右键按下记下 PID(`menu_hit`)并把焦点交给面板,菜单项才有派发路径;菜单派发 `EndProcess { pid, force }`,工作区的 `on_end_process` 从 `ToolSidebar::process` 取名称和用户,用 `shared::confirm_danger` 确认(`confirm_delete` 也是它),确认后在同一个终端上执行 `end_command`(`kill -TERM|-KILL <pid> 2>&1; echo @@status $?`,PID 是数字,命令里不拼进程名之类主机给的文字),`ended` 把「Operation not permitted」「No such process」翻成中文,通知结果,再 `refresh_processes_soon`(1 秒后重读)。排序按钮派发 `SortProcesses(ProcessSort)`、刷新派发 `RefreshProcesses`,都经工作区转给面板;搜索框是面板自己的状态。详情:卡片的单击派发 `ShowProcess(pid)`,工作区的 `on_show_process` 从 `ToolSidebar::process_details` 取 `ProcessDetails`(`Snapshot::details`:进程、父进程名、按 ppid 数出的直接子进程和后代,列表里没有父进程时写「PID n」,ppid 0 是「无」),交给 `processes::open_process_dialog`;详情要的 `stat` 字段(父进程、进程组、会话、终端、前台进程组、优先级、nice、线程、虚拟内存)和读数一起解析进 `ProcessInfo`,`ps` 的状态字母(`<` / `N`、`s`、`l`、`+`)和终端名(136–143 是 `pts/n`,4 是 `ttyN` / `ttySN`)在 `parse_process` 里算好;命令行由 `ProcessDetailsView` 打开时在同一个终端上执行 `cat /proc/<pid>/cmdline`(`command_line_command`),NUL 分隔的参数用空格连起来(`command_line`),空的是僵尸或已退出。对话框的标题和页脚在 `open_dialog` 的构造闭包里,每帧读正文实体(复制按钮按一下变成「已复制」两秒,状态在正文实体里);「结束进程」「强制结束」先 `close_dialog` 再在工作区的 focus handle 上派发 `EndProcess`(关掉对话框后焦点是空的,`window.dispatch_action` 没有落点)。卡片的行数必须一样(同网络连接,`uniform_list` 按第一项的高度排)。
|
||||
- **网络连接(`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` 上。
|
||||
|
||||
@@ -187,4 +189,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`(系统监控)、`netstat`(网络连接)、`processes`(进程管理)、`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 标签、开始页和设置页没有这一格)、标Line 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`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`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 标签、开始页和设Line truncated
|
||||
+6
-2
@@ -135,9 +135,13 @@ shellrs download <ID> <远程路径> <本地路径>
|
||||
- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。
|
||||
- **清屏**(⌘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)不显示这几个工具的图标。
|
||||
|
||||
**系统服务**列出远程主机上的 systemd 服务(不含模板),分成「自定义服务」(unit 文件在 `/etc/systemd/system`,也就是管理员自己加的)和「系统服务」两组。顶部是 systemd 版本和整机状态(有服务失败时是「降级运行」);「全部」「运行中」「已停止」「失败」四个标签页带数量,也可以按服务名或描述搜索。每个服务一张卡片:状态圆点、名称、描述、运行状态和开机启动状态;运行中的服务右边有「停止」「重启」,没运行的有「启动」,点「…」或卡片打开详情。详情的「状态」页是加载状态、运行状态、开机启动、主进程 PID、内存、任务数、重启次数、退出状态、启动和停止时间、unit 文件;「日志」页是这个服务在 journal 里最近 200 行。详情底部和右键菜单里同样可以启动、停止、重启,以及启用或禁用开机启动。
|
||||
|
||||
停止和重启会先确认(停止 SSH 服务时会特别说明),启动和开机启动的设置直接执行,结果以通知显示,之后列表重新读取。这些操作需要 root 权限:不是以 root 登录时通过免密码的 sudo 执行,sudo 需要密码时会提示需要 root 权限。列表在打开系统服务或切换终端时读取,之后点刷新按钮才重新读取。没有使用 systemd 的主机会写明暂不支持。
|
||||
|
||||
**进程管理**列出远程 Linux 主机上的进程(不含内核线程),每个一张卡片:状态圆点(绿色运行中、灰色休眠、黄色等待 I/O 或已暂停、红色僵尸进程,鼠标停上去显示状态)、进程名、PID、用户、启动时间(按本机时区),以及内存用量和占物理内存的比例、CPU 占用。CPU 占用和 `top` 的算法一样,按一个核计:占满两个核是 200%;它要两次读数才算得出,所以刚打开时先显示「—」,2 秒后出现。默认按内存从多到少排列,点「CPU」改按 CPU 排列,再点一次同一个按钮反过来排;可以按进程名、PID 或用户搜索,右边的数字是当前列出的个数。每 15 秒刷新一次,只在进程管理显示时读取,点右上角的刷新按钮立即读取。
|
||||
|
||||
|
||||
+19
-1
@@ -9,6 +9,7 @@ use crate::{
|
||||
host::{CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop},
|
||||
monitor::MonitorDetail,
|
||||
processes::ProcessSort,
|
||||
services::ServiceCommand,
|
||||
terminal::{LocalTerminalId, RemoteTerminalId},
|
||||
};
|
||||
|
||||
@@ -44,6 +45,8 @@ gpui_kit::actions!(
|
||||
RefreshConnections,
|
||||
/// Read the processes of the SSH terminal's host again.
|
||||
RefreshProcesses,
|
||||
/// Read the services of the SSH terminal's host again.
|
||||
RefreshServices,
|
||||
/// Show the host list in the left dock.
|
||||
ShowHosts,
|
||||
/// Show the port-forwarding list in the left dock.
|
||||
@@ -197,6 +200,9 @@ id_actions! {
|
||||
/// Open the details of a process of the host of the SSH terminal in
|
||||
/// front, by its PID.
|
||||
ShowProcess(u32);
|
||||
/// Open the details of a service of the host of the SSH terminal in
|
||||
/// front, by its name.
|
||||
ShowService(String);
|
||||
|
||||
/// Start a port forward over a connection of its own.
|
||||
StartForward(ForwardId);
|
||||
@@ -234,6 +240,16 @@ pub struct EndProcess {
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
/// Start, stop or restart a service of the host of the SSH terminal in
|
||||
/// front, or enable or disable it at boot; asks first before stopping or
|
||||
/// restarting it.
|
||||
#[derive(Action, Clone, PartialEq, Eq)]
|
||||
#[action(namespace = shellrs, no_json)]
|
||||
pub struct ControlService {
|
||||
pub name: String,
|
||||
pub command: ServiceCommand,
|
||||
}
|
||||
|
||||
/// One tab of the center area, by the identity of what it shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CenterTab {
|
||||
@@ -251,16 +267,18 @@ pub enum ToolKind {
|
||||
Snippets,
|
||||
History,
|
||||
Docker,
|
||||
Services,
|
||||
Processes,
|
||||
Connections,
|
||||
Monitor,
|
||||
}
|
||||
|
||||
impl ToolKind {
|
||||
pub const ALL: [ToolKind; 6] = [
|
||||
pub const ALL: [ToolKind; 7] = [
|
||||
ToolKind::Snippets,
|
||||
ToolKind::History,
|
||||
ToolKind::Docker,
|
||||
ToolKind::Services,
|
||||
ToolKind::Processes,
|
||||
ToolKind::Connections,
|
||||
ToolKind::Monitor,
|
||||
|
||||
+7
-1
@@ -59,7 +59,13 @@ icon_assets!(
|
||||
ArrowUpRight,
|
||||
ListFilter,
|
||||
CircleStop,
|
||||
OctagonX
|
||||
OctagonX,
|
||||
ServerCog,
|
||||
RotateCw,
|
||||
CircleCheck,
|
||||
Ban,
|
||||
CircleDot,
|
||||
FileText
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
+3
-1
@@ -3,7 +3,8 @@
|
||||
//! Modules are organized by capability so they can become crates later:
|
||||
//! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP
|
||||
//! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential`
|
||||
//! (凭据), `monitor` (系统监控), `processes` (进程管理), `netstat` (网络连接),
|
||||
//! (凭据), `monitor` (系统监控), `services` (系统服务), `processes` (进程管理),
|
||||
//! `netstat` (网络连接),
|
||||
//! `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `connection` 与
|
||||
//! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源).
|
||||
|
||||
@@ -18,6 +19,7 @@ pub mod monitor;
|
||||
pub mod netstat;
|
||||
pub mod processes;
|
||||
pub mod secrets;
|
||||
pub mod services;
|
||||
pub mod settings;
|
||||
pub mod sftp;
|
||||
pub mod shared;
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
//! Reading and running a Linux host's systemd services: the commands, and
|
||||
//! the parsers for what they print.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::model::{Service, ServiceCommand, ServiceStatus, ServiceTable};
|
||||
|
||||
/// What the list asks `systemctl show` for each service.
|
||||
const LIST_PROPERTIES: [&str; 6] = [
|
||||
"Id",
|
||||
"Description",
|
||||
"ActiveState",
|
||||
"UnitFileState",
|
||||
"FragmentPath",
|
||||
"LoadState",
|
||||
];
|
||||
|
||||
/// What a service's details ask for.
|
||||
const STATUS_PROPERTIES: [&str; 14] = [
|
||||
"Id",
|
||||
"Description",
|
||||
"LoadState",
|
||||
"ActiveState",
|
||||
"SubState",
|
||||
"UnitFileState",
|
||||
"FragmentPath",
|
||||
"MainPID",
|
||||
"MemoryCurrent",
|
||||
"TasksCurrent",
|
||||
"NRestarts",
|
||||
"ExecMainStatus",
|
||||
"ActiveEnterTimestamp",
|
||||
"InactiveEnterTimestamp",
|
||||
];
|
||||
|
||||
/// `-p Id -p Description …`: one property a flag, which systemd before 230
|
||||
/// needs (later ones take them comma-separated too).
|
||||
fn properties(names: &[&str]) -> String {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| format!("-p {name}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// What 系统服务 runs on the host for the list: systemd's version and how
|
||||
/// the machine is doing, then every service's state in one `systemctl
|
||||
/// show`: those with a unit file (but templates, which run only as
|
||||
/// instances) and those loaded without one (instances, and some LSB
|
||||
/// scripts). 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, no `!` for csh to expand, and no two
|
||||
/// backslashes in a row for fish to make one. The `case` patterns open
|
||||
/// with `(`: inside `$(…)`, bash 3.2 takes a pattern's bare `)` for the
|
||||
/// end of the substitution.
|
||||
pub fn command() -> String {
|
||||
format!(
|
||||
"sh -c 'export LC_ALL=C PATH=$PATH:/usr/sbin:/sbin; \
|
||||
if test -d /run/systemd/system; then \
|
||||
echo @@version; systemctl --version 2>/dev/null; \
|
||||
echo @@state; systemctl is-system-running 2>/dev/null; \
|
||||
names=$({{ systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null; \
|
||||
systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null; }} \
|
||||
| while read -r name rest; do case $name in (*@.service) ;; (*.service) printf \"%s\\n\" \"$name\";; esac; done); \
|
||||
echo @@units; systemctl show {properties} -- $names 2>/dev/null; \
|
||||
else echo @@unsupported; uname -s; fi'",
|
||||
properties = properties(&LIST_PROPERTIES),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `name` is a service's name and nothing else, so that it can go
|
||||
/// into a command: systemd allows letters, digits and `:_.@-`, and `\` for
|
||||
/// its escapes (「\x2d」).
|
||||
pub fn valid_name(name: &str) -> bool {
|
||||
name.ends_with(".service")
|
||||
&& name
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || ":_.@-\\".contains(character))
|
||||
&& !name.contains("\\\\")
|
||||
}
|
||||
|
||||
/// `script` as root: as it is when the login is root, else through `sudo`
|
||||
/// without a password, which fails at once where one is needed.
|
||||
fn as_root(script: &str) -> String {
|
||||
format!("if test \"$(id -u)\" = 0; then {script}; else sudo -n {script}; fi")
|
||||
}
|
||||
|
||||
/// What runs `command` on the service `name`, saying how it went in an
|
||||
/// `@@status` line with `systemctl`'s complaint before it. `None` for a
|
||||
/// name that is not one.
|
||||
pub fn control_command(name: &str, command: ServiceCommand) -> Option<String> {
|
||||
valid_name(name).then(|| {
|
||||
let script = format!("systemctl {} -- \"{name}\"", command.verb());
|
||||
format!("sh -c '{} 2>&1; echo @@status $?'", as_root(&script))
|
||||
})
|
||||
}
|
||||
|
||||
/// How a command went, from what `control_command` printed: why not, when
|
||||
/// it did not.
|
||||
pub fn controlled(output: &str) -> Result<(), String> {
|
||||
let (said, status) = output
|
||||
.rsplit_once("@@status")
|
||||
.ok_or_else(|| "主机没有回答".to_string())?;
|
||||
if status.trim() == "0" {
|
||||
return Ok(());
|
||||
}
|
||||
let said = said.trim();
|
||||
Err(
|
||||
if said.contains("password is required")
|
||||
|| said.contains("Interactive authentication required")
|
||||
|| said.contains("Access denied")
|
||||
|| said.contains("not in the sudoers")
|
||||
{
|
||||
"需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo".into()
|
||||
} else if said.contains("command not found") && said.contains("sudo") {
|
||||
"需要 root 权限:这台主机没有 sudo,请以 root 登录".into()
|
||||
} else if said.is_empty() {
|
||||
format!("systemctl 返回 {}", status.trim())
|
||||
} else {
|
||||
said.lines().next().unwrap_or(said).trim().to_owned()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// What reads a service's state in full, for its details.
|
||||
pub fn status_command(name: &str) -> Option<String> {
|
||||
valid_name(name).then(|| {
|
||||
format!(
|
||||
"sh -c 'export LC_ALL=C; systemctl show {} -- \"{name}\"'",
|
||||
properties(&STATUS_PROPERTIES)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The service's last 200 lines of journal, the newest last. The journal
|
||||
/// is root's to read, besides some groups', so a login that is not root
|
||||
/// asks `sudo` first and settles for what it may see.
|
||||
pub fn log_command(name: &str) -> Option<String> {
|
||||
valid_name(name).then(|| {
|
||||
let journal = format!("journalctl -u \"{name}\" -n 200 --no-pager");
|
||||
format!(
|
||||
"sh -c 'export LC_ALL=C; if test \"$(id -u)\" = 0; then {journal}; \
|
||||
else sudo -n {journal} 2>/dev/null || {journal}; fi 2>&1'"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// What the list's command printed.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Parsed {
|
||||
Table(ServiceTable),
|
||||
/// Not a host whose services systemd runs. Holds `uname -s` when the
|
||||
/// host has one: 「Linux」 without systemd, 「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("units") {
|
||||
// A shell other than sh's (cmd.exe) prints nothing we know.
|
||||
return Parsed::Unsupported(first("unsupported").map(str::to_owned));
|
||||
}
|
||||
// 「systemd 249 (249.11-0ubuntu3.12)」.
|
||||
let version = first("version").map(|line| {
|
||||
line.split_whitespace()
|
||||
.take(2)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
});
|
||||
let services = blocks(section("units"))
|
||||
.into_iter()
|
||||
.filter_map(|properties| {
|
||||
let property = |name: &str| properties.get(name).copied().unwrap_or_default();
|
||||
let name = property("Id");
|
||||
// A unit no file is found for is only worth a line when it
|
||||
// failed.
|
||||
let missing = property("LoadState") == "not-found";
|
||||
(name.ends_with(".service") && (!missing || property("ActiveState") == "failed")).then(
|
||||
|| {
|
||||
Service::new(
|
||||
name,
|
||||
property("Description"),
|
||||
property("ActiveState"),
|
||||
property("UnitFileState"),
|
||||
property("FragmentPath"),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Parsed::Table(ServiceTable::new(
|
||||
services,
|
||||
version,
|
||||
first("state").map(str::to_owned),
|
||||
))
|
||||
}
|
||||
|
||||
/// `systemctl show`'s `Key=value` lines, a blank line between units.
|
||||
fn blocks<'a>(lines: &[&'a str]) -> Vec<HashMap<&'a str, &'a str>> {
|
||||
let mut blocks = vec![HashMap::new()];
|
||||
for line in lines {
|
||||
match line.split_once('=') {
|
||||
Some((key, value)) => {
|
||||
blocks
|
||||
.last_mut()
|
||||
.expect("one at least")
|
||||
.insert(key.trim(), value.trim());
|
||||
}
|
||||
None if line.trim().is_empty() => blocks.push(HashMap::new()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
blocks.retain(|block| !block.is_empty());
|
||||
blocks
|
||||
}
|
||||
|
||||
/// A service's details from what `status_command` printed.
|
||||
pub fn parse_status(output: &str) -> Option<ServiceStatus> {
|
||||
let lines: Vec<&str> = output.lines().collect();
|
||||
let properties = blocks(&lines).into_iter().next()?;
|
||||
let property = |name: &str| {
|
||||
properties
|
||||
.get(name)
|
||||
.copied()
|
||||
.filter(|value| !value.is_empty() && *value != "[not set]")
|
||||
};
|
||||
// systemd writes an unset number as the largest one it has.
|
||||
let number = |name: &str| {
|
||||
property(name)
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value != u64::MAX)
|
||||
};
|
||||
Some(ServiceStatus {
|
||||
description: property("Description").unwrap_or_default().to_owned(),
|
||||
load: property("LoadState").unwrap_or_default().to_owned(),
|
||||
active: property("ActiveState").unwrap_or_default().to_owned(),
|
||||
sub: property("SubState").unwrap_or_default().to_owned(),
|
||||
file_state: property("UnitFileState").unwrap_or_default().to_owned(),
|
||||
main_pid: number("MainPID")
|
||||
.filter(|pid| *pid != 0)
|
||||
.map(|pid| pid as u32),
|
||||
memory: number("MemoryCurrent"),
|
||||
tasks: number("TasksCurrent"),
|
||||
restarts: number("NRestarts"),
|
||||
exit_status: property("ExecMainStatus").and_then(|value| value.parse().ok()),
|
||||
started: property("ActiveEnterTimestamp").map(str::to_owned),
|
||||
stopped: property("InactiveEnterTimestamp").map(str::to_owned),
|
||||
path: property("FragmentPath").map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::services::model::{ActiveState, ServiceFilter};
|
||||
|
||||
const LIST: &str = "\
|
||||
@@version
|
||||
systemd 249 (249.11-0ubuntu3.12)
|
||||
+PAM +AUDIT +SELINUX
|
||||
@@state
|
||||
degraded
|
||||
@@units
|
||||
Id=socialc.service
|
||||
Description=Social Callback Proxy Service
|
||||
ActiveState=active
|
||||
UnitFileState=enabled
|
||||
FragmentPath=/etc/systemd/system/socialc.service
|
||||
LoadState=loaded
|
||||
|
||||
Id=apport.service
|
||||
Description=LSB: automatic crash report generation
|
||||
ActiveState=active
|
||||
UnitFileState=generated
|
||||
FragmentPath=/run/systemd/generator.late/apport.service
|
||||
LoadState=loaded
|
||||
|
||||
Id=auditd.service
|
||||
Description=auditd.service
|
||||
ActiveState=inactive
|
||||
UnitFileState=
|
||||
FragmentPath=
|
||||
LoadState=not-found
|
||||
|
||||
Id=certbot.service
|
||||
Description=Certbot
|
||||
ActiveState=failed
|
||||
UnitFileState=static
|
||||
FragmentPath=/lib/systemd/system/certbot.service
|
||||
LoadState=loaded
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn systemctl_show_gives_the_services_but_those_without_a_file() {
|
||||
let Parsed::Table(table) = parse(LIST) else {
|
||||
panic!("not a table");
|
||||
};
|
||||
assert_eq!(table.summary(), "systemd 249 · 降级运行");
|
||||
let services: Vec<(&str, ActiveState, &str, bool)> = table
|
||||
.services()
|
||||
.iter()
|
||||
.map(|service| {
|
||||
(
|
||||
service.name.as_str(),
|
||||
service.active,
|
||||
service.file_state.as_str(),
|
||||
service.custom,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
services,
|
||||
[
|
||||
("apport.service", ActiveState::Active, "generated", false),
|
||||
("certbot.service", ActiveState::Failed, "static", false),
|
||||
("socialc.service", ActiveState::Active, "enabled", true),
|
||||
]
|
||||
);
|
||||
assert_eq!(table.counts(""), [3, 2, 0, 1]);
|
||||
assert_eq!(table.rows(ServiceFilter::All, "").len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_without_systemd_says_what_it_is() {
|
||||
assert_eq!(
|
||||
parse("@@unsupported\nLinux\n"),
|
||||
Parsed::Unsupported(Some("Linux".into()))
|
||||
);
|
||||
assert_eq!(parse(""), Parsed::Unsupported(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn details_leave_out_what_systemd_has_not_set() {
|
||||
let status = parse_status(
|
||||
"Id=socialc.service\nDescription=Social Callback Proxy Service\nLoadState=loaded\n\
|
||||
ActiveState=active\nSubState=running\nUnitFileState=enabled\n\
|
||||
FragmentPath=/etc/systemd/system/socialc.service\nMainPID=781\n\
|
||||
MemoryCurrent=1990656\nTasksCurrent=5\nNRestarts=0\nExecMainStatus=0\n\
|
||||
ActiveEnterTimestamp=Tue 2026-09-15 17:49:07 CST\nInactiveEnterTimestamp=\n",
|
||||
)
|
||||
.expect("details");
|
||||
assert_eq!(status.main_pid, Some(781));
|
||||
assert_eq!(status.memory, Some(1_990_656));
|
||||
assert_eq!(status.tasks, Some(5));
|
||||
assert_eq!(
|
||||
status.started.as_deref(),
|
||||
Some("Tue 2026-09-15 17:49:07 CST")
|
||||
);
|
||||
assert_eq!(status.stopped, None);
|
||||
|
||||
let stopped = parse_status(
|
||||
"Id=a.service\nMainPID=0\nMemoryCurrent=[not set]\nTasksCurrent=18446744073709551615\n",
|
||||
)
|
||||
.expect("details");
|
||||
assert_eq!(
|
||||
(stopped.main_pid, stopped.memory, stopped.tasks),
|
||||
(None, None, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_services_name_goes_into_a_command() {
|
||||
assert!(valid_name("nginx.service"));
|
||||
assert!(valid_name("getty@tty1.service"));
|
||||
assert!(valid_name("systemd-fsck@dev-disk-by\\x2duuid-1234.service"));
|
||||
assert!(!valid_name("nginx"));
|
||||
assert!(!valid_name("a;reboot.service"));
|
||||
assert!(!valid_name("a b.service"));
|
||||
assert!(!valid_name("$(reboot).service"));
|
||||
assert_eq!(control_command("a\"b.service", ServiceCommand::Stop), None);
|
||||
assert_eq!(
|
||||
control_command("nginx.service", ServiceCommand::Restart).as_deref(),
|
||||
Some(
|
||||
"sh -c 'if test \"$(id -u)\" = 0; then systemctl restart -- \"nginx.service\"; \
|
||||
else sudo -n systemctl restart -- \"nginx.service\"; fi 2>&1; echo @@status $?'"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_says_why_it_did_not_run() {
|
||||
assert_eq!(controlled("@@status 0\n"), Ok(()));
|
||||
assert_eq!(
|
||||
controlled("sudo: a password is required\n@@status 1\n"),
|
||||
Err("需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo".into())
|
||||
);
|
||||
assert_eq!(
|
||||
controlled(
|
||||
"Job for nginx.service failed because the control process exited with error code.\n\
|
||||
See \"systemctl status nginx.service\" and \"journalctl -xeu nginx.service\" for details.\n\
|
||||
@@status 1\n"
|
||||
),
|
||||
Err(
|
||||
"Job for nginx.service failed because the control process exited with error code."
|
||||
.into()
|
||||
)
|
||||
);
|
||||
assert_eq!(controlled(""), Err("主机没有回答".into()));
|
||||
}
|
||||
|
||||
fn scripts() -> Vec<String> {
|
||||
vec![
|
||||
command(),
|
||||
control_command("getty@tty1.service", ServiceCommand::Stop).unwrap(),
|
||||
status_command("nginx.service").unwrap(),
|
||||
log_command("nginx.service").unwrap(),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commands_survive_any_login_shell() {
|
||||
for command in scripts() {
|
||||
let script = command
|
||||
.strip_prefix("sh -c '")
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
.expect("one sh -c in single quotes");
|
||||
assert!(!script.contains('\''), "{script}");
|
||||
assert!(!script.contains('!'), "{script}");
|
||||
assert!(!script.contains('\n'), "{script}");
|
||||
assert!(!script.contains("\\\\"), "{script}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_commands_are_valid_sh() {
|
||||
for command in scripts() {
|
||||
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(), "{script}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The list's script, run by this machine's `sh` with a `systemctl`
|
||||
/// stood in for, asks `systemctl show` for the right services.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_script_shows_every_service_but_templates() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
let bin = root.path().join("bin");
|
||||
let booted = root.path().join("systemd");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::create_dir_all(&booted).unwrap();
|
||||
let systemctl = bin.join("systemctl");
|
||||
std::fs::write(
|
||||
&systemctl,
|
||||
"#!/bin/sh\n\
|
||||
case \"$1\" in\n\
|
||||
--version) echo 'systemd 249 (249.11)';;\n\
|
||||
is-system-running) echo running;;\n\
|
||||
list-unit-files) printf '%s\\n' 'ssh.service enabled enabled' 'getty@.service enabled enabled' 'sshd.service alias -';;\n\
|
||||
list-units) printf '%s\\n' 'getty@tty1.service loaded active running Getty on tty1' 'ssh.service loaded active running OpenBSD Secure Shell server';;\n\
|
||||
show) shift; while test \"$1\" = -p; do shift 2; done; shift; for unit in \"$@\"; do \
|
||||
id=$unit; test $unit = sshd.service && id=ssh.service; \
|
||||
printf 'Id=%s\\nDescription=%s\\nActiveState=active\\nUnitFileState=enabled\\nFragmentPath=/lib/systemd/system/%s\\nLoadState=loaded\\n\\n' $id $id $id; done;;\n\
|
||||
esac\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&systemctl, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let command = command().replace("/run/systemd/system", &booted.display().to_string());
|
||||
let script = command
|
||||
.strip_prefix("sh -c '")
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
.unwrap();
|
||||
let path = format!(
|
||||
"{}:{}",
|
||||
bin.display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
let output = std::process::Command::new("sh")
|
||||
.args(["-c", script])
|
||||
.env("PATH", path)
|
||||
.output()
|
||||
.expect("sh runs");
|
||||
let Parsed::Table(table) = parse(&String::from_utf8(output.stdout).unwrap()) else {
|
||||
panic!("not a table");
|
||||
};
|
||||
let names: Vec<&str> = table
|
||||
.services()
|
||||
.iter()
|
||||
.map(|service| service.name.as_str())
|
||||
.collect();
|
||||
// The template is left out, and the alias is ssh itself.
|
||||
assert_eq!(names, ["getty@tty1.service", "ssh.service"]);
|
||||
assert_eq!(table.summary(), "systemd 249 · 运行正常");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! 系统服务: the right sidebar's tool that lists the systemd services of the
|
||||
//! host of the SSH terminal in front, starts, stops and restarts them, and
|
||||
//! shows their state and journal, over that terminal's own connection.
|
||||
//! Linux hosts run by systemd only, for now.
|
||||
|
||||
mod linux;
|
||||
mod model;
|
||||
mod service_details;
|
||||
mod service_panel;
|
||||
|
||||
pub use linux::{control_command, controlled};
|
||||
pub use model::{Service, ServiceCommand};
|
||||
pub use service_details::open_service_dialog;
|
||||
pub use service_panel::ServicePanel;
|
||||
@@ -0,0 +1,533 @@
|
||||
//! What 系统服务 reads off a host, and how its tabs, groups and search
|
||||
//! pick from it.
|
||||
|
||||
/// Where a service's unit file lives, which the list groups by.
|
||||
const CUSTOM_UNITS: &str = "/etc/systemd/system/";
|
||||
|
||||
/// What a service is doing, as systemd's `ActiveState` says.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ActiveState {
|
||||
Active,
|
||||
Reloading,
|
||||
Inactive,
|
||||
Failed,
|
||||
Activating,
|
||||
Deactivating,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ActiveState {
|
||||
pub fn parse(state: &str) -> Self {
|
||||
match state {
|
||||
"active" => ActiveState::Active,
|
||||
"reloading" => ActiveState::Reloading,
|
||||
"inactive" => ActiveState::Inactive,
|
||||
"failed" => ActiveState::Failed,
|
||||
"activating" => ActiveState::Activating,
|
||||
"deactivating" => ActiveState::Deactivating,
|
||||
_ => ActiveState::Other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ActiveState::Active => "运行中",
|
||||
ActiveState::Reloading => "正在重载",
|
||||
ActiveState::Inactive | ActiveState::Other => "已停止",
|
||||
ActiveState::Failed => "失败",
|
||||
ActiveState::Activating => "正在启动",
|
||||
ActiveState::Deactivating => "正在停止",
|
||||
}
|
||||
}
|
||||
|
||||
/// Which tab it is under.
|
||||
pub fn kind(self) -> ServiceKind {
|
||||
match self {
|
||||
ActiveState::Active
|
||||
| ActiveState::Reloading
|
||||
| ActiveState::Activating
|
||||
| ActiveState::Deactivating => ServiceKind::Running,
|
||||
ActiveState::Failed => ServiceKind::Failed,
|
||||
ActiveState::Inactive | ActiveState::Other => ServiceKind::Stopped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ServiceKind {
|
||||
Running,
|
||||
Stopped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// The finer state under `ActiveState`, as systemd's `SubState` says: 「已退出」
|
||||
/// for a one-shot service that did its work.
|
||||
pub fn sub_state_label(state: &str) -> String {
|
||||
match state {
|
||||
"running" => "运行中",
|
||||
"exited" => "已退出",
|
||||
"dead" => "未运行",
|
||||
"failed" => "失败",
|
||||
"waiting" => "等待中",
|
||||
"listening" => "监听中",
|
||||
"reload" => "正在重载",
|
||||
"auto-restart" => "等待自动重启",
|
||||
"condition" => "条件不满足",
|
||||
state if state.starts_with("start") => "正在启动",
|
||||
state if state.starts_with("stop") || state.starts_with("final") => "正在停止",
|
||||
state => return state.to_owned(),
|
||||
}
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// Whether it starts at boot, as systemd's `UnitFileState` says; `None`
|
||||
/// with nothing to say.
|
||||
pub fn file_state_label(state: &str) -> Option<&'static str> {
|
||||
Some(match state {
|
||||
"enabled" => "已启用",
|
||||
"enabled-runtime" => "已启用(本次运行)",
|
||||
"disabled" => "已禁用",
|
||||
"static" => "静态",
|
||||
"masked" | "masked-runtime" => "已屏蔽",
|
||||
"generated" => "自动生成",
|
||||
"indirect" => "间接启用",
|
||||
"linked" | "linked-runtime" => "已链接",
|
||||
"transient" => "临时",
|
||||
"alias" => "别名",
|
||||
"bad" => "出错",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the unit file was found and read, as systemd's `LoadState` says.
|
||||
pub fn load_state_label(state: &str) -> String {
|
||||
match state {
|
||||
"loaded" => "已加载",
|
||||
"not-found" => "未找到",
|
||||
"masked" => "已屏蔽",
|
||||
"error" => "出错",
|
||||
"bad-setting" => "配置有误",
|
||||
state => return state.to_owned(),
|
||||
}
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// How the machine as a whole is doing, as `systemctl is-system-running`
|
||||
/// says: 「降级运行」 when some unit failed.
|
||||
pub fn system_state_label(state: &str) -> String {
|
||||
match state {
|
||||
"running" => "运行正常",
|
||||
"degraded" => "降级运行",
|
||||
"starting" => "正在启动",
|
||||
"initializing" => "正在初始化",
|
||||
"maintenance" => "维护模式",
|
||||
"stopping" => "正在关机",
|
||||
"offline" => "离线",
|
||||
state => return state.to_owned(),
|
||||
}
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// What can be done to a service.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ServiceCommand {
|
||||
Start,
|
||||
Stop,
|
||||
Restart,
|
||||
/// Start it at boot.
|
||||
Enable,
|
||||
/// No longer start it at boot.
|
||||
Disable,
|
||||
}
|
||||
|
||||
impl ServiceCommand {
|
||||
/// The `systemctl` verb.
|
||||
pub fn verb(self) -> &'static str {
|
||||
match self {
|
||||
ServiceCommand::Start => "start",
|
||||
ServiceCommand::Stop => "stop",
|
||||
ServiceCommand::Restart => "restart",
|
||||
ServiceCommand::Enable => "enable",
|
||||
ServiceCommand::Disable => "disable",
|
||||
}
|
||||
}
|
||||
|
||||
/// The button: 「停止」「禁用开机启动」.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ServiceCommand::Start => "启动",
|
||||
ServiceCommand::Stop => "停止",
|
||||
ServiceCommand::Restart => "重启",
|
||||
ServiceCommand::Enable => "启用开机启动",
|
||||
ServiceCommand::Disable => "禁用开机启动",
|
||||
}
|
||||
}
|
||||
|
||||
/// What it did, after the service's name: 「已停止」.
|
||||
pub fn done(self) -> &'static str {
|
||||
match self {
|
||||
ServiceCommand::Start => "已启动",
|
||||
ServiceCommand::Stop => "已停止",
|
||||
ServiceCommand::Restart => "已重启",
|
||||
ServiceCommand::Enable => "已设为开机启动",
|
||||
ServiceCommand::Disable => "已取消开机启动",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A service as the list shows it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Service {
|
||||
/// 「nginx.service」.
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub active: ActiveState,
|
||||
/// systemd's `UnitFileState`: 「enabled」「static」, empty without a
|
||||
/// unit file.
|
||||
pub file_state: String,
|
||||
/// Its unit file is the administrator's, in `/etc/systemd/system`.
|
||||
pub custom: bool,
|
||||
}
|
||||
|
||||
impl Service {
|
||||
pub fn new(name: &str, description: &str, active: &str, file_state: &str, path: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_owned(),
|
||||
description: description.to_owned(),
|
||||
active: ActiveState::parse(active),
|
||||
file_state: file_state.to_owned(),
|
||||
custom: path.starts_with(CUSTOM_UNITS),
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands its buttons offer, the one that matters first: stop
|
||||
/// and restart while it runs, start otherwise.
|
||||
pub fn commands(&self) -> &'static [ServiceCommand] {
|
||||
match self.active.kind() {
|
||||
ServiceKind::Running => &[ServiceCommand::Stop, ServiceCommand::Restart],
|
||||
ServiceKind::Stopped | ServiceKind::Failed => &[ServiceCommand::Start],
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable, when its unit file can be either.
|
||||
pub fn boot_command(&self) -> Option<ServiceCommand> {
|
||||
match self.file_state.as_str() {
|
||||
"enabled" | "enabled-runtime" => Some(ServiceCommand::Disable),
|
||||
"disabled" => Some(ServiceCommand::Enable),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, query: &str) -> bool {
|
||||
query.is_empty()
|
||||
|| self.name.to_lowercase().contains(query)
|
||||
|| self.description.to_lowercase().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
/// The tabs over the list.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ServiceFilter {
|
||||
#[default]
|
||||
All,
|
||||
Running,
|
||||
Stopped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl ServiceFilter {
|
||||
pub const ALL: [ServiceFilter; 4] = [
|
||||
ServiceFilter::All,
|
||||
ServiceFilter::Running,
|
||||
ServiceFilter::Stopped,
|
||||
ServiceFilter::Failed,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ServiceFilter::All => "全部",
|
||||
ServiceFilter::Running => "运行中",
|
||||
ServiceFilter::Stopped => "已停止",
|
||||
ServiceFilter::Failed => "失败",
|
||||
}
|
||||
}
|
||||
|
||||
fn lets_through(self, service: &Service) -> bool {
|
||||
match self {
|
||||
ServiceFilter::All => true,
|
||||
ServiceFilter::Running => service.active.kind() == ServiceKind::Running,
|
||||
ServiceFilter::Stopped => service.active.kind() == ServiceKind::Stopped,
|
||||
ServiceFilter::Failed => service.active.kind() == ServiceKind::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line of the list: a group's heading, or a service by its place in
|
||||
/// `ServiceTable::services`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ServiceRow {
|
||||
Group { custom: bool, count: usize },
|
||||
Service(usize),
|
||||
}
|
||||
|
||||
/// One reading of a host's services, by name.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ServiceTable {
|
||||
services: Vec<Service>,
|
||||
/// 「systemd 249」.
|
||||
version: Option<String>,
|
||||
/// `systemctl is-system-running`: 「degraded」.
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
impl ServiceTable {
|
||||
pub fn new(mut services: Vec<Service>, version: Option<String>, state: Option<String>) -> Self {
|
||||
services.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
services.dedup_by(|a, b| a.name == b.name);
|
||||
Self {
|
||||
services,
|
||||
version,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn services(&self) -> &[Service] {
|
||||
&self.services
|
||||
}
|
||||
|
||||
pub fn service(&self, name: &str) -> Option<&Service> {
|
||||
self.services.iter().find(|service| service.name == name)
|
||||
}
|
||||
|
||||
/// 「systemd 249 · 降级运行」.
|
||||
pub fn summary(&self) -> String {
|
||||
[
|
||||
self.version.clone(),
|
||||
self.state.as_deref().map(system_state_label),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ")
|
||||
}
|
||||
|
||||
/// Whether some unit failed, so the machine is not quite well.
|
||||
pub fn degraded(&self) -> bool {
|
||||
self.state.as_deref() == Some("degraded")
|
||||
}
|
||||
|
||||
/// How many services `query` finds under each tab, in the tabs' order.
|
||||
pub fn counts(&self, query: &str) -> [usize; 4] {
|
||||
let query = query.trim().to_lowercase();
|
||||
ServiceFilter::ALL.map(|filter| {
|
||||
self.services
|
||||
.iter()
|
||||
.filter(|service| filter.lets_through(service) && service.matches(&query))
|
||||
.count()
|
||||
})
|
||||
}
|
||||
|
||||
/// The list under `filter` for `query` (in the name or description,
|
||||
/// ignoring case): the administrator's own services, then the rest,
|
||||
/// each group under its heading; a group with none is left out.
|
||||
pub fn rows(&self, filter: ServiceFilter, query: &str) -> Vec<ServiceRow> {
|
||||
let query = query.trim().to_lowercase();
|
||||
let mut rows = Vec::new();
|
||||
for custom in [true, false] {
|
||||
let services: Vec<usize> = self
|
||||
.services
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, service)| {
|
||||
service.custom == custom
|
||||
&& filter.lets_through(service)
|
||||
&& service.matches(&query)
|
||||
})
|
||||
.map(|(index, _)| index)
|
||||
.collect();
|
||||
if !services.is_empty() {
|
||||
rows.push(ServiceRow::Group {
|
||||
custom,
|
||||
count: services.len(),
|
||||
});
|
||||
rows.extend(services.into_iter().map(ServiceRow::Service));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
/// A service's state in full, for its details, as `systemctl show` says.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ServiceStatus {
|
||||
pub description: String,
|
||||
pub load: String,
|
||||
pub active: String,
|
||||
pub sub: String,
|
||||
pub file_state: String,
|
||||
pub main_pid: Option<u32>,
|
||||
/// Bytes; `None` without memory accounting.
|
||||
pub memory: Option<u64>,
|
||||
pub tasks: Option<u64>,
|
||||
pub restarts: Option<u64>,
|
||||
pub exit_status: Option<i64>,
|
||||
/// 「Tue 2026-09-15 17:49:07 CST」, as the host writes it.
|
||||
pub started: Option<String>,
|
||||
pub stopped: Option<String>,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn table() -> ServiceTable {
|
||||
ServiceTable::new(
|
||||
vec![
|
||||
Service::new(
|
||||
"socialc.service",
|
||||
"Social Callback Proxy Service",
|
||||
"active",
|
||||
"enabled",
|
||||
"/etc/systemd/system/socialc.service",
|
||||
),
|
||||
Service::new(
|
||||
"ssh.service",
|
||||
"OpenBSD Secure Shell server",
|
||||
"active",
|
||||
"enabled",
|
||||
"/lib/systemd/system/ssh.service",
|
||||
),
|
||||
Service::new(
|
||||
"apport.service",
|
||||
"LSB: automatic crash report generation",
|
||||
"active",
|
||||
"generated",
|
||||
"/run/systemd/generator.late/apport.service",
|
||||
),
|
||||
Service::new(
|
||||
"ModemManager.service",
|
||||
"Modem Manager",
|
||||
"inactive",
|
||||
"disabled",
|
||||
"/lib/systemd/system/ModemManager.service",
|
||||
),
|
||||
Service::new(
|
||||
"certbot.service",
|
||||
"Certbot",
|
||||
"failed",
|
||||
"static",
|
||||
"/etc/systemd/system/certbot.service",
|
||||
),
|
||||
// Read twice under two names, once as itself.
|
||||
Service::new(
|
||||
"ssh.service",
|
||||
"OpenBSD Secure Shell server",
|
||||
"active",
|
||||
"enabled",
|
||||
"/lib/systemd/system/ssh.service",
|
||||
),
|
||||
],
|
||||
Some("systemd 249".into()),
|
||||
Some("degraded".into()),
|
||||
)
|
||||
}
|
||||
|
||||
fn names(table: &ServiceTable, rows: &[ServiceRow]) -> Vec<String> {
|
||||
rows.iter()
|
||||
.map(|row| match row {
|
||||
ServiceRow::Group {
|
||||
custom: true,
|
||||
count,
|
||||
} => format!("自定义服务 {count}"),
|
||||
ServiceRow::Group {
|
||||
custom: false,
|
||||
count,
|
||||
} => format!("系统服务 {count}"),
|
||||
ServiceRow::Service(index) => table.services()[*index].name.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_administrators_services_come_first_under_each_tab() {
|
||||
let table = table();
|
||||
assert_eq!(table.summary(), "systemd 249 · 降级运行");
|
||||
assert!(table.degraded());
|
||||
assert_eq!(table.counts(""), [5, 3, 1, 1]);
|
||||
assert_eq!(
|
||||
names(&table, &table.rows(ServiceFilter::All, "")),
|
||||
[
|
||||
"自定义服务 2",
|
||||
"certbot.service",
|
||||
"socialc.service",
|
||||
"系统服务 3",
|
||||
"ModemManager.service",
|
||||
"apport.service",
|
||||
"ssh.service",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
names(&table, &table.rows(ServiceFilter::Running, "")),
|
||||
[
|
||||
"自定义服务 1",
|
||||
"socialc.service",
|
||||
"系统服务 2",
|
||||
"apport.service",
|
||||
"ssh.service",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
names(&table, &table.rows(ServiceFilter::Failed, "")),
|
||||
["自定义服务 1", "certbot.service"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_finds_names_and_descriptions() {
|
||||
let table = table();
|
||||
assert_eq!(
|
||||
names(&table, &table.rows(ServiceFilter::All, " SECURE ")),
|
||||
["系统服务 1", "ssh.service"]
|
||||
);
|
||||
assert_eq!(
|
||||
names(&table, &table.rows(ServiceFilter::All, "modem")),
|
||||
["系统服务 1", "ModemManager.service"]
|
||||
);
|
||||
assert_eq!(table.counts("cert"), [1, 0, 0, 1]);
|
||||
assert!(table.rows(ServiceFilter::Running, "cert").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_service_offers_what_its_state_allows() {
|
||||
let table = table();
|
||||
let service = |name: &str| table.service(name).expect(name);
|
||||
let ssh = service("ssh.service");
|
||||
assert_eq!(
|
||||
ssh.commands(),
|
||||
[ServiceCommand::Stop, ServiceCommand::Restart]
|
||||
);
|
||||
assert_eq!(ssh.boot_command(), Some(ServiceCommand::Disable));
|
||||
let modem = service("ModemManager.service");
|
||||
assert_eq!(modem.commands(), [ServiceCommand::Start]);
|
||||
assert_eq!(modem.boot_command(), Some(ServiceCommand::Enable));
|
||||
// Neither a static unit nor a generated one can be enabled.
|
||||
assert_eq!(service("certbot.service").boot_command(), None);
|
||||
assert_eq!(service("apport.service").boot_command(), None);
|
||||
assert_eq!(
|
||||
service("certbot.service").commands(),
|
||||
[ServiceCommand::Start]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_read_in_chinese() {
|
||||
assert_eq!(sub_state_label("exited"), "已退出");
|
||||
assert_eq!(sub_state_label("stop-sigterm"), "正在停止");
|
||||
assert_eq!(sub_state_label("mounted"), "mounted");
|
||||
assert_eq!(file_state_label("generated"), Some("自动生成"));
|
||||
assert_eq!(file_state_label(""), None);
|
||||
assert_eq!(load_state_label("loaded"), "已加载");
|
||||
assert_eq!(system_state_label("running"), "运行正常");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! A service's details: the dialog a click on it in 系统服务 opens.
|
||||
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, Icon, Sizable as _, WindowExt as _,
|
||||
button::{Button, ButtonVariants as _},
|
||||
dialog::DialogFooter,
|
||||
h_flex,
|
||||
spinner::Spinner,
|
||||
tab::{Tab, TabBar},
|
||||
v_flex,
|
||||
};
|
||||
use gpui_kit::*;
|
||||
|
||||
use super::linux::{log_command, parse_status, status_command};
|
||||
use super::model::{
|
||||
ActiveState, Service, ServiceCommand, ServiceKind, ServiceStatus, file_state_label,
|
||||
load_state_label, sub_state_label,
|
||||
};
|
||||
use super::service_panel::{command_icon, soft_tag, state_color};
|
||||
use crate::app::{CatalogIcon, ControlService};
|
||||
use crate::shared::format_bytes;
|
||||
use crate::terminal::{ExecResult, TerminalView, exec_answer};
|
||||
|
||||
/// How wide the dialog is: two columns of a label and a value, and the
|
||||
/// journal's lines.
|
||||
const DIALOG_WIDTH: f32 = 44.;
|
||||
|
||||
/// Open the details of a service of the host of `terminal`, whose
|
||||
/// connection reads its state and journal. `dispatch` is the workspace's
|
||||
/// focus handle: the dialog's buttons close it and dispatch there.
|
||||
pub fn open_service_dialog(
|
||||
service: Service,
|
||||
terminal: WeakEntity<TerminalView>,
|
||||
dispatch: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let view = cx.new(|cx| ServiceDetailsView::new(service, terminal, cx));
|
||||
window.open_dialog(cx, move |dialog, window, cx| {
|
||||
let this = view.read(cx);
|
||||
let service = this.current();
|
||||
let color = state_color(service.active, cx);
|
||||
let title = v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(div().truncate().child(service.name.clone()))
|
||||
.child(soft_tag(service.active.label(), color)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::NORMAL)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(service.description.clone()),
|
||||
);
|
||||
let button = |command: ServiceCommand| {
|
||||
let (dispatch, name) = (dispatch.clone(), service.name.clone());
|
||||
let button = Button::new(SharedString::from(format!(
|
||||
"service-dialog-{}",
|
||||
command.verb()
|
||||
)))
|
||||
.icon(command_icon(command))
|
||||
.label(command.label())
|
||||
.on_click(move |_, window, cx| {
|
||||
// One dialog at a time: a confirmation takes its place.
|
||||
window.close_dialog(cx);
|
||||
dispatch.dispatch_action(
|
||||
&ControlService {
|
||||
name: name.clone(),
|
||||
command,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
match command {
|
||||
ServiceCommand::Stop => button.danger(),
|
||||
ServiceCommand::Start => button.primary(),
|
||||
_ => button.outline(),
|
||||
}
|
||||
};
|
||||
let footer = DialogFooter::new()
|
||||
.justify_start()
|
||||
.children(service.commands().iter().map(|&command| button(command)))
|
||||
.children(service.boot_command().map(button));
|
||||
dialog
|
||||
.title(title)
|
||||
// Dialog geometry is an API boundary that takes `Pixels`; the
|
||||
// width follows the interface zoom through the rem.
|
||||
.w(rems(DIALOG_WIDTH).to_pixels(window.rem_size()))
|
||||
.max_h(window.viewport_size().height * 0.9)
|
||||
// Closed by its buttons or Escape, like every dialog.
|
||||
.overlay_closable(false)
|
||||
.child(view.clone())
|
||||
.footer(footer)
|
||||
});
|
||||
}
|
||||
|
||||
/// Something read off the host for the dialog.
|
||||
enum Read<T> {
|
||||
/// Not asked for yet: the journal until its tab is opened.
|
||||
NotYet,
|
||||
Reading,
|
||||
Known(T),
|
||||
/// Why not: 「读取失败:命令超时」.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
/// The dialog's body: the service's state in full, and its journal.
|
||||
pub struct ServiceDetailsView {
|
||||
service: Service,
|
||||
terminal: WeakEntity<TerminalView>,
|
||||
status: Read<ServiceStatus>,
|
||||
journal: Read<String>,
|
||||
/// 0 the state, 1 the journal.
|
||||
tab: usize,
|
||||
journal_scroll: ScrollHandle,
|
||||
_status_task: Task<()>,
|
||||
_journal_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl ServiceDetailsView {
|
||||
fn new(service: Service, terminal: WeakEntity<TerminalView>, cx: &mut Context<Self>) -> Self {
|
||||
let status = run(
|
||||
&terminal,
|
||||
status_command(&service.name),
|
||||
cx,
|
||||
|this, answer, _| {
|
||||
this.status = match answer {
|
||||
Ok(output) => match parse_status(&output) {
|
||||
Some(status) => Read::Known(status),
|
||||
None => Read::Unknown("读不到这个服务的状态".into()),
|
||||
},
|
||||
Err(why) => Read::Unknown(why),
|
||||
};
|
||||
},
|
||||
);
|
||||
Self {
|
||||
service,
|
||||
terminal,
|
||||
status: Read::Reading,
|
||||
journal: Read::NotYet,
|
||||
tab: 0,
|
||||
journal_scroll: ScrollHandle::new(),
|
||||
_status_task: status,
|
||||
_journal_task: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The service as its state now says, which may have moved on since
|
||||
/// the list read it.
|
||||
fn current(&self) -> Service {
|
||||
let mut service = self.service.clone();
|
||||
if let Read::Known(status) = &self.status {
|
||||
service.active = ActiveState::parse(&status.active);
|
||||
service.file_state = status.file_state.clone();
|
||||
if !status.description.is_empty() {
|
||||
service.description = status.description.clone();
|
||||
}
|
||||
}
|
||||
service
|
||||
}
|
||||
|
||||
fn select(&mut self, tab: usize, cx: &mut Context<Self>) {
|
||||
self.tab = tab;
|
||||
if tab == 1 && matches!(self.journal, Read::NotYet) {
|
||||
self.journal = Read::Reading;
|
||||
self._journal_task = Some(run(
|
||||
&self.terminal,
|
||||
log_command(&self.service.name),
|
||||
cx,
|
||||
|this, answer, _| {
|
||||
this.journal = match answer {
|
||||
Ok(output) if output.trim().is_empty() => Read::Unknown("没有日志".into()),
|
||||
Ok(output) => Read::Known(output.trim_end().to_owned()),
|
||||
Err(why) => Read::Unknown(why),
|
||||
};
|
||||
// The newest lines are the last.
|
||||
this.journal_scroll.scroll_to_bottom();
|
||||
},
|
||||
));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_status(&self, cx: &App) -> AnyElement {
|
||||
let status = match &self.status {
|
||||
Read::Known(status) => status,
|
||||
Read::Unknown(why) => return message("service-status-unknown", why.clone(), cx),
|
||||
Read::Reading | Read::NotYet => return reading("service-status-reading", cx),
|
||||
};
|
||||
let active = ActiveState::parse(&status.active);
|
||||
let number = |value: Option<u64>| value.map_or("—".to_owned(), |value| value.to_string());
|
||||
let fields: [(&str, String); 10] = [
|
||||
("加载状态", load_state_label(&status.load)),
|
||||
(
|
||||
"运行状态",
|
||||
format!("{} / {}", active.label(), sub_state_label(&status.sub)),
|
||||
),
|
||||
(
|
||||
"开机启动",
|
||||
file_state_label(&status.file_state)
|
||||
.unwrap_or("—")
|
||||
.to_owned(),
|
||||
),
|
||||
(
|
||||
"主进程 PID",
|
||||
status.main_pid.map_or("—".into(), |pid| pid.to_string()),
|
||||
),
|
||||
("内存", status.memory.map_or("—".into(), format_bytes)),
|
||||
("任务数", number(status.tasks)),
|
||||
("重启次数", number(status.restarts)),
|
||||
(
|
||||
"退出状态",
|
||||
status
|
||||
.exit_status
|
||||
.map_or("—".into(), |status| status.to_string()),
|
||||
),
|
||||
("启动时间", status.started.clone().unwrap_or("—".into())),
|
||||
(
|
||||
"停止时间",
|
||||
// When it last went down; while it runs, that is history.
|
||||
status
|
||||
.stopped
|
||||
.clone()
|
||||
.filter(|_| active.kind() != ServiceKind::Running)
|
||||
.unwrap_or("—".into()),
|
||||
),
|
||||
];
|
||||
v_flex()
|
||||
.child(
|
||||
div().grid().grid_cols(2).gap_x_8().children(
|
||||
fields
|
||||
.into_iter()
|
||||
.map(|(label, value)| field(label, value, cx)),
|
||||
),
|
||||
)
|
||||
.child(field(
|
||||
"Unit 文件",
|
||||
status.path.clone().unwrap_or("—".into()),
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_journal(&self, cx: &App) -> AnyElement {
|
||||
match &self.journal {
|
||||
Read::Known(journal) => div()
|
||||
.id("service-journal")
|
||||
.test_support()
|
||||
.aria_label(journal.clone())
|
||||
.h(rems(20.))
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.journal_scroll)
|
||||
.p_3()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().muted)
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.child(journal.clone())
|
||||
.into_any_element(),
|
||||
Read::Unknown(why) => message("service-journal-unknown", why.clone(), cx),
|
||||
Read::Reading | Read::NotYet => reading("service-journal-reading", cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `command` on `terminal`'s connection, and hand the answer to
|
||||
/// `record`: what it printed, or why it did not run.
|
||||
fn run(
|
||||
terminal: &WeakEntity<TerminalView>,
|
||||
command: Option<String>,
|
||||
cx: &mut Context<ServiceDetailsView>,
|
||||
record: impl FnOnce(
|
||||
&mut ServiceDetailsView,
|
||||
Result<String, String>,
|
||||
&mut Context<ServiceDetailsView>,
|
||||
) + 'static,
|
||||
) -> Task<()> {
|
||||
let reply = command.and_then(|command| terminal.upgrade()?.read(cx).exec(command, cx));
|
||||
cx.spawn(async move |this, cx| {
|
||||
let answer: ExecResult = match reply {
|
||||
None => Err("终端没有连接".into()),
|
||||
Some(reply) => match exec_answer(reply, cx).await {
|
||||
None => Err("终端没有连接".into()),
|
||||
Some(Err(error)) => Err(format!("读取失败:{error}")),
|
||||
Some(Ok(output)) => Ok(output),
|
||||
},
|
||||
};
|
||||
this.update(cx, |this, cx| {
|
||||
record(this, answer, cx);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
/// A label and its value, over a line.
|
||||
fn field(label: &'static str, value: String, cx: &App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.id(SharedString::from(format!("service-field:{label}")))
|
||||
.test_support()
|
||||
.aria_label(value.clone())
|
||||
.min_w_0()
|
||||
.gap_3()
|
||||
.py_2()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.w(rems(6.5))
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(label),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().truncate().child(value))
|
||||
}
|
||||
|
||||
fn reading(id: &'static str, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.id(id)
|
||||
.test_support()
|
||||
.gap_2()
|
||||
.py_8()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Spinner::new().small())
|
||||
.child("正在读取…")
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn message(id: &'static str, text: String, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.id(id)
|
||||
.test_support()
|
||||
.aria_label(text.clone())
|
||||
.py_8()
|
||||
.text_sm()
|
||||
.text_center()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl Render for ServiceDetailsView {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.id("service-details")
|
||||
.test_support()
|
||||
.gap_3()
|
||||
.child(
|
||||
div().id("service-tabs").test_support().child(
|
||||
TabBar::new("service-tab-bar")
|
||||
.underline()
|
||||
.small()
|
||||
.selected_index(self.tab)
|
||||
.on_click(cx.listener(|this, index: &usize, _, cx| this.select(*index, cx)))
|
||||
.child(
|
||||
Tab::new()
|
||||
.icon(Icon::new(CatalogIcon::CircleDot))
|
||||
.label("状态"),
|
||||
)
|
||||
.child(
|
||||
Tab::new()
|
||||
.icon(Icon::new(CatalogIcon::FileText))
|
||||
.label("日志"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(if self.tab == 0 {
|
||||
self.render_status(cx)
|
||||
} else {
|
||||
self.render_journal(cx)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, Icon, IconName, Sizable as _,
|
||||
button::{Button, ButtonCustomVariant, ButtonVariants as _},
|
||||
h_flex,
|
||||
input::{Input, InputEvent, InputState},
|
||||
menu::{ContextMenuExt as _, PopupMenu},
|
||||
scroll::ScrollableElement as _,
|
||||
spinner::Spinner,
|
||||
tab::{Tab, TabBar},
|
||||
tag::Tag,
|
||||
v_flex,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use super::linux::{self, Parsed};
|
||||
use super::model::{
|
||||
ActiveState, Service, ServiceCommand, ServiceFilter, ServiceRow, ServiceTable, file_state_label,
|
||||
};
|
||||
use crate::app::{CatalogIcon, ControlService, RefreshServices, ShowService};
|
||||
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);
|
||||
|
||||
/// 系统服务: the systemd services of the host of the SSH terminal in front,
|
||||
/// the administrator's own first, read when the panel comes on screen, on
|
||||
/// 刷新 and after a command; each can be started, stopped, restarted and
|
||||
/// opened for its details and journal.
|
||||
///
|
||||
/// Each reading runs `linux::command` beside the shell, on the terminal's
|
||||
/// own connection. Services change seldom and a reading starts a handful
|
||||
/// of `systemctl`s, so the list is not read on a timer. 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 tab and the search stay as
|
||||
/// they are from one terminal to the next.
|
||||
pub struct ServicePanel {
|
||||
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>,
|
||||
query: String,
|
||||
filter: ServiceFilter,
|
||||
/// The list's lines differ in height (headings and cards), so it is a
|
||||
/// list that measures them rather than a uniform one.
|
||||
list: ListState,
|
||||
/// The lines the list was last told about.
|
||||
rows: Vec<ServiceRow>,
|
||||
/// The tab or the search changed: the list goes back to its top.
|
||||
back_to_top: bool,
|
||||
/// The service a right click landed on, for the list's menu; `None`
|
||||
/// off every service.
|
||||
menu_hit: Rc<RefCell<Option<Service>>>,
|
||||
/// The workspace's focus handle: the panel's buttons dispatch on it, so
|
||||
/// they work whatever is focused.
|
||||
dispatch: FocusHandle,
|
||||
focus_handle: FocusHandle,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HostState {
|
||||
table: Option<Rc<ServiceTable>>,
|
||||
problem: Option<Problem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Problem {
|
||||
/// The terminal is not connected, so there is no connection to read on.
|
||||
NotConnected,
|
||||
/// Not a host systemd runs; `uname -s` when the host said it.
|
||||
Unsupported(Option<String>),
|
||||
/// The command did not run, or did not finish.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl ServicePanel {
|
||||
pub fn new(dispatch: FocusHandle, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let search = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("搜索服务名称或描述")
|
||||
.clean_on_escape()
|
||||
});
|
||||
let subscriptions = vec![
|
||||
cx.subscribe(&search, |this, input, event: &InputEvent, cx| {
|
||||
if matches!(event, InputEvent::Change) {
|
||||
this.query = input.read(cx).value().to_string();
|
||||
this.back_to_top = true;
|
||||
cx.notify();
|
||||
}
|
||||
}),
|
||||
];
|
||||
Self {
|
||||
target: None,
|
||||
active: false,
|
||||
hosts: HashMap::new(),
|
||||
reading: None,
|
||||
loading: false,
|
||||
search,
|
||||
query: String::new(),
|
||||
filter: ServiceFilter::default(),
|
||||
list: ListState::new(0, ListAlignment::Top, px(400.)),
|
||||
rows: Vec::new(),
|
||||
back_to_top: false,
|
||||
menu_hit: Rc::new(RefCell::new(None)),
|
||||
dispatch,
|
||||
focus_handle: cx.focus_handle(),
|
||||
_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;
|
||||
}
|
||||
if self.target != target {
|
||||
self.back_to_top = true;
|
||||
}
|
||||
self.target = target;
|
||||
self.active = active;
|
||||
self.read(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Read the host again: 刷新, and after a command.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.read(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A service of the host on screen, as the list last showed it.
|
||||
pub fn service(&self, name: &str) -> Option<Service> {
|
||||
let target = self.target.as_ref()?;
|
||||
self.hosts
|
||||
.get(&target.terminal)?
|
||||
.table
|
||||
.as_ref()?
|
||||
.service(name)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn show(&mut self, filter: ServiceFilter, cx: &mut Context<Self>) {
|
||||
if self.filter != filter {
|
||||
self.filter = filter;
|
||||
self.back_to_top = true;
|
||||
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)),
|
||||
},
|
||||
};
|
||||
self.loading = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Tell the list about `rows` when they are not the ones it has; back
|
||||
/// at the top when the tab or the search changed.
|
||||
fn sync_list(&mut self, rows: &[ServiceRow]) {
|
||||
if std::mem::take(&mut self.back_to_top) {
|
||||
self.list.reset(rows.len());
|
||||
} else if self.rows != rows {
|
||||
self.list.splice(0..self.rows.len(), rows.len());
|
||||
}
|
||||
self.rows = rows.to_vec();
|
||||
}
|
||||
|
||||
fn render_list(
|
||||
&mut self,
|
||||
table: Rc<ServiceTable>,
|
||||
problem: Option<Problem>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let rows = table.rows(self.filter, &self.query);
|
||||
self.sync_list(&rows);
|
||||
let counts = table.counts(&self.query);
|
||||
let summary = table.summary();
|
||||
let refresh = self.dispatch.clone();
|
||||
let header =
|
||||
v_flex()
|
||||
.flex_shrink_0()
|
||||
.px_3()
|
||||
.pt_3()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.id("services-summary")
|
||||
.test_support()
|
||||
.aria_label(summary.clone())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(if table.degraded() {
|
||||
cx.theme().warning
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
})
|
||||
.child(summary),
|
||||
)
|
||||
.child(
|
||||
Button::new("services-refresh")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(Icon::new(CatalogIcon::RefreshCw))
|
||||
.loading(self.loading)
|
||||
.tooltip("刷新")
|
||||
.accessibility_label("刷新")
|
||||
.on_click(move |_, window, cx| {
|
||||
refresh.dispatch_action(&RefreshServices, window, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when_some(problem, |header, problem| {
|
||||
header.child(render_problem(&problem, cx))
|
||||
})
|
||||
.child(
|
||||
Input::new(&self.search)
|
||||
.id("services-search")
|
||||
.small()
|
||||
.cleanable(true)
|
||||
.prefix(Icon::new(IconName::Search).small()),
|
||||
)
|
||||
.child(
|
||||
div().id("services-tabs").test_support().child(
|
||||
TabBar::new("services-tab-bar")
|
||||
.underline()
|
||||
.small()
|
||||
.selected_index(
|
||||
ServiceFilter::ALL
|
||||
.iter()
|
||||
.position(|filter| *filter == self.filter)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
.on_click(cx.listener(|this, index: &usize, _, cx| {
|
||||
this.show(ServiceFilter::ALL[*index], cx)
|
||||
}))
|
||||
.children(ServiceFilter::ALL.iter().zip(counts).map(
|
||||
|(filter, count)| {
|
||||
Tab::new()
|
||||
.label(filter.label())
|
||||
.aria_label(format!("{} {count}", filter.label()))
|
||||
.suffix(
|
||||
div()
|
||||
.ml_1()
|
||||
.px_1p5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().muted)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(count.to_string()),
|
||||
)
|
||||
},
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
let clear_hit = self.menu_hit.clone();
|
||||
let menu_hit = self.menu_hit.clone();
|
||||
let empty = if self.query.trim().is_empty() {
|
||||
"没有服务"
|
||||
} else {
|
||||
"没有符合条件的服务"
|
||||
};
|
||||
// The scrollbar goes on the list's box, which does not scroll: on
|
||||
// the scrolled lines it would scroll away with them. The menu hangs
|
||||
// off the box too, not off the cards, which are drawn after layout
|
||||
// (see the host tree).
|
||||
let list = div()
|
||||
.id("services-list")
|
||||
.test_support()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.map(|list| {
|
||||
if rows.is_empty() {
|
||||
list.child(
|
||||
div()
|
||||
.id("services-empty")
|
||||
.test_support()
|
||||
.aria_label(empty)
|
||||
.py_8()
|
||||
.text_sm()
|
||||
.text_center()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(empty),
|
||||
)
|
||||
} else {
|
||||
let rows = Rc::new(rows);
|
||||
let (hit, focus, dispatch) = (
|
||||
self.menu_hit.clone(),
|
||||
self.focus_handle.clone(),
|
||||
self.dispatch.clone(),
|
||||
);
|
||||
list.child(
|
||||
gpui_kit::list(self.list.clone(), move |index, _, cx| {
|
||||
match rows[index] {
|
||||
ServiceRow::Group { custom, count } => {
|
||||
render_group(custom, count, cx).into_any_element()
|
||||
}
|
||||
ServiceRow::Service(service) => div()
|
||||
.px_3()
|
||||
.pt_2()
|
||||
// The last one clears the bottom.
|
||||
.when(index + 1 == rows.len(), |row| row.pb_3())
|
||||
.child(render_service(
|
||||
&table.services()[service],
|
||||
hit.clone(),
|
||||
focus.clone(),
|
||||
dispatch.clone(),
|
||||
cx,
|
||||
))
|
||||
.into_any_element(),
|
||||
}
|
||||
})
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.vertical_scrollbar(&self.list)
|
||||
.capture_any_mouse_down(move |event, _, _| {
|
||||
if matches!(event.button, MouseButton::Left | MouseButton::Right) {
|
||||
clear_hit.replace(None);
|
||||
}
|
||||
})
|
||||
.context_menu(move |menu, _, _| build_context_menu(menu_hit.borrow().as_ref(), menu));
|
||||
v_flex().size_full().child(header).child(list)
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for ServicePanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ServicePanel {
|
||||
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());
|
||||
let body = 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(_)))) => {
|
||||
self.render_list(table, problem, cx).into_any_element()
|
||||
}
|
||||
(_, Some(problem)) => div()
|
||||
.p_3()
|
||||
.child(render_problem(&problem, cx))
|
||||
.into_any_element(),
|
||||
(None, None) => h_flex()
|
||||
.id("services-loading")
|
||||
.test_support()
|
||||
.gap_2()
|
||||
.justify_center()
|
||||
.py_8()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Spinner::new().small())
|
||||
.child("正在读取…")
|
||||
.into_any_element(),
|
||||
};
|
||||
v_flex()
|
||||
.id("services")
|
||||
.test_support()
|
||||
.track_focus(&self.focus_handle)
|
||||
.size_full()
|
||||
.child(body)
|
||||
}
|
||||
}
|
||||
|
||||
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)) if system == "Linux" => (
|
||||
"这台主机没有使用 systemd,暂不支持管理它的服务。".to_string(),
|
||||
cx.theme().muted_foreground,
|
||||
),
|
||||
Problem::Unsupported(Some(system)) => (
|
||||
format!("暂不支持管理 {system} 的服务,目前只支持使用 systemd 的 Linux 主机。"),
|
||||
cx.theme().muted_foreground,
|
||||
),
|
||||
Problem::Unsupported(None) => (
|
||||
"暂不支持管理这台主机的服务,目前只支持使用 systemd 的 Linux 主机。".to_string(),
|
||||
cx.theme().muted_foreground,
|
||||
),
|
||||
Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger),
|
||||
};
|
||||
div()
|
||||
.id("services-message")
|
||||
.test_support()
|
||||
.aria_label(text.clone())
|
||||
.text_sm()
|
||||
.text_color(color)
|
||||
.child(text)
|
||||
}
|
||||
|
||||
fn build_context_menu(hit: Option<&Service>, menu: PopupMenu) -> PopupMenu {
|
||||
let Some(service) = hit else {
|
||||
return menu;
|
||||
};
|
||||
let action = |command: ServiceCommand| -> Box<dyn Action> {
|
||||
Box::new(ControlService {
|
||||
name: service.name.clone(),
|
||||
command,
|
||||
})
|
||||
};
|
||||
let menu = menu
|
||||
.menu_with_icon(
|
||||
"查看详情",
|
||||
Icon::new(IconName::Info),
|
||||
Box::new(ShowService(service.name.clone())),
|
||||
)
|
||||
.separator();
|
||||
let menu = service.commands().iter().fold(menu, |menu, &command| {
|
||||
menu.menu_with_icon(command.label(), command_icon(command), action(command))
|
||||
});
|
||||
match service.boot_command() {
|
||||
Some(command) => {
|
||||
menu.separator()
|
||||
.menu_with_icon(command.label(), command_icon(command), action(command))
|
||||
}
|
||||
None => menu,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn command_icon(command: ServiceCommand) -> Icon {
|
||||
Icon::new(match command {
|
||||
ServiceCommand::Start => CatalogIcon::Play,
|
||||
ServiceCommand::Stop => CatalogIcon::Square,
|
||||
ServiceCommand::Restart => CatalogIcon::RotateCw,
|
||||
ServiceCommand::Enable => CatalogIcon::CircleCheck,
|
||||
ServiceCommand::Disable => CatalogIcon::Ban,
|
||||
})
|
||||
}
|
||||
|
||||
/// What a state looks like: green running, red failed, yellow on its way
|
||||
/// up or down, the rest quiet.
|
||||
pub(super) fn state_color(state: ActiveState, cx: &App) -> Hsla {
|
||||
match state {
|
||||
ActiveState::Active | ActiveState::Reloading => cx.theme().success,
|
||||
ActiveState::Failed => cx.theme().danger,
|
||||
ActiveState::Activating | ActiveState::Deactivating => cx.theme().warning,
|
||||
ActiveState::Inactive | ActiveState::Other => cx.theme().muted_foreground,
|
||||
}
|
||||
}
|
||||
|
||||
/// A tag in `color`'s tint: 「运行中」.
|
||||
pub(super) fn soft_tag(text: &'static str, color: Hsla) -> Tag {
|
||||
Tag::custom(color.opacity(0.12), color, color.opacity(0.3))
|
||||
.small()
|
||||
.rounded_full()
|
||||
.child(text)
|
||||
}
|
||||
|
||||
/// 「自定义服务 3」.
|
||||
fn render_group(custom: bool, count: usize, cx: &App) -> impl IntoElement {
|
||||
let label = if custom {
|
||||
"自定义服务"
|
||||
} else {
|
||||
"系统服务"
|
||||
};
|
||||
h_flex()
|
||||
.id(if custom {
|
||||
"services-group:custom"
|
||||
} else {
|
||||
"services-group:system"
|
||||
})
|
||||
.test_support()
|
||||
.aria_label(format!("{label} {count}"))
|
||||
.px_4()
|
||||
.pt_4()
|
||||
.pb_1()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(label),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(count.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// A button on a card: the command in its own tint for starting and
|
||||
/// stopping, quiet for the rest.
|
||||
fn command_button(
|
||||
service: &Service,
|
||||
command: ServiceCommand,
|
||||
dispatch: &FocusHandle,
|
||||
cx: &App,
|
||||
) -> Button {
|
||||
let tint = |color: Hsla| {
|
||||
ButtonCustomVariant::new(cx)
|
||||
.color(color.opacity(0.1))
|
||||
.foreground(color)
|
||||
.hover(color.opacity(0.2))
|
||||
.active(color.opacity(0.25))
|
||||
};
|
||||
let button = Button::new(SharedString::from(format!(
|
||||
"service-{}:{}",
|
||||
command.verb(),
|
||||
service.name
|
||||
)))
|
||||
.small()
|
||||
.icon(command_icon(command))
|
||||
.tooltip(command.label())
|
||||
.accessibility_label(command.label());
|
||||
let button = match command {
|
||||
ServiceCommand::Stop => button.custom(tint(cx.theme().danger)),
|
||||
ServiceCommand::Start => button.custom(tint(cx.theme().success)),
|
||||
_ => button.ghost(),
|
||||
};
|
||||
let (dispatch, name) = (dispatch.clone(), service.name.clone());
|
||||
button.on_click(move |_, window, cx| {
|
||||
// Not the card's click too, which opens the details.
|
||||
cx.stop_propagation();
|
||||
dispatch.dispatch_action(
|
||||
&ControlService {
|
||||
name: name.clone(),
|
||||
command,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// A service's card. A click opens its details.
|
||||
fn render_service(
|
||||
service: &Service,
|
||||
menu_hit: Rc<RefCell<Option<Service>>>,
|
||||
focus: FocusHandle,
|
||||
dispatch: FocusHandle,
|
||||
cx: &App,
|
||||
) -> impl IntoElement {
|
||||
let color = state_color(service.active, cx);
|
||||
let boot = file_state_label(&service.file_state);
|
||||
let label = [
|
||||
Some(service.name.as_str()),
|
||||
Some(service.description.as_str()).filter(|text| !text.is_empty()),
|
||||
Some(service.active.label()),
|
||||
boot,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
let name = service.name.clone();
|
||||
let open = dispatch.clone();
|
||||
let hit = service.clone();
|
||||
v_flex()
|
||||
.id(SharedString::from(format!("service:{}", service.name)))
|
||||
.test_support()
|
||||
.aria_label(label)
|
||||
.p_3()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().tokens.group_box)
|
||||
.text_color(cx.theme().group_box_foreground)
|
||||
.border_1()
|
||||
.border_color(cx.theme().tokens.group_box)
|
||||
.hover({
|
||||
let border = cx.theme().border;
|
||||
move |style| style.border_color(border)
|
||||
})
|
||||
.cursor_pointer()
|
||||
.on_click(move |_, window, cx| open.dispatch_action(&ShowService(name.clone()), window, cx))
|
||||
// A right click puts the menu on this service. The menu's commands
|
||||
// are dispatched from what is focused, which may be nothing.
|
||||
.on_mouse_down(MouseButton::Right, move |_, window, cx| {
|
||||
menu_hit.replace(Some(hit.clone()));
|
||||
focus.focus(window, cx);
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(div().flex_shrink_0().size_2().rounded_full().bg(color))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(service.name.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
// Under the name, past the dot.
|
||||
div()
|
||||
.pl(rems(1.))
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(if service.description.is_empty() {
|
||||
"无描述".to_owned()
|
||||
} else {
|
||||
service.description.clone()
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.pl(rems(1.))
|
||||
.pt_0p5()
|
||||
.gap_1()
|
||||
.child(soft_tag(service.active.label(), color))
|
||||
.when_some(boot, |tags, boot| {
|
||||
let tint = if service.file_state.starts_with("enabled") {
|
||||
cx.theme().success
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
};
|
||||
tags.child(soft_tag(boot, tint))
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.gap_1()
|
||||
.children(
|
||||
service
|
||||
.commands()
|
||||
.iter()
|
||||
.map(|&command| command_button(service, command, &dispatch, cx)),
|
||||
)
|
||||
.child({
|
||||
let (dispatch, name) = (dispatch.clone(), service.name.clone());
|
||||
Button::new(SharedString::from(format!(
|
||||
"service-more:{}",
|
||||
service.name
|
||||
)))
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(IconName::Ellipsis)
|
||||
.tooltip("详情和日志")
|
||||
.accessibility_label("详情和日志")
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
dispatch.dispatch_action(&ShowService(name.clone()), window, cx)
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use crate::host::{HostId, HostOs, HostStore};
|
||||
use crate::monitor::{MonitorDetail, MonitorPanel};
|
||||
use crate::netstat::NetstatPanel;
|
||||
use crate::processes::{Process, ProcessDetails, ProcessPanel, ProcessSort};
|
||||
use crate::services::{Service, ServicePanel};
|
||||
use crate::terminal::{ExecTarget, RemoteTerminalId, TerminalView};
|
||||
|
||||
impl ToolKind {
|
||||
@@ -20,6 +21,7 @@ impl ToolKind {
|
||||
ToolKind::Snippets => "命令片段",
|
||||
ToolKind::History => "历史命令",
|
||||
ToolKind::Docker => "Docker",
|
||||
ToolKind::Services => "系统服务",
|
||||
ToolKind::Processes => "进程管理",
|
||||
ToolKind::Connections => "网络连接",
|
||||
ToolKind::Monitor => "系统监控",
|
||||
@@ -31,6 +33,7 @@ impl ToolKind {
|
||||
ToolKind::Snippets => CatalogIcon::CodeXml,
|
||||
ToolKind::History => CatalogIcon::RotateCcwClock,
|
||||
ToolKind::Docker => CatalogIcon::Container,
|
||||
ToolKind::Services => CatalogIcon::ServerCog,
|
||||
ToolKind::Processes => CatalogIcon::ListFilter,
|
||||
ToolKind::Connections => CatalogIcon::Network,
|
||||
ToolKind::Monitor => CatalogIcon::Activity,
|
||||
@@ -41,9 +44,10 @@ impl ToolKind {
|
||||
/// yet gets every tool, and is told if one cannot work there.
|
||||
pub fn works_on(self, os: Option<HostOs>) -> bool {
|
||||
match self {
|
||||
ToolKind::Processes | ToolKind::Connections | ToolKind::Monitor => {
|
||||
os.is_none_or(HostOs::is_linux)
|
||||
}
|
||||
ToolKind::Services
|
||||
| ToolKind::Processes
|
||||
| ToolKind::Connections
|
||||
| ToolKind::Monitor => os.is_none_or(HostOs::is_linux),
|
||||
ToolKind::Snippets | ToolKind::History | ToolKind::Docker => true,
|
||||
}
|
||||
}
|
||||
@@ -54,6 +58,7 @@ impl ToolKind {
|
||||
ToolKind::Snippets => "tool-snippets",
|
||||
ToolKind::History => "tool-history",
|
||||
ToolKind::Docker => "tool-docker",
|
||||
ToolKind::Services => "tool-services",
|
||||
ToolKind::Processes => "tool-processes",
|
||||
ToolKind::Connections => "tool-connections",
|
||||
ToolKind::Monitor => "tool-monitor",
|
||||
@@ -67,6 +72,7 @@ impl ToolKind {
|
||||
ToolKind::Snippets => "常用的命令存在这里,点一下就发送到当前终端。".into(),
|
||||
ToolKind::History => format!("{host} 上执行过的命令,可以搜索、再次执行。"),
|
||||
ToolKind::Docker => format!("{host} 上的容器:状态、日志,启动和停止。"),
|
||||
ToolKind::Services => format!("{host} 上的 systemd 服务:启动、停止、日志。"),
|
||||
ToolKind::Processes => format!("{host} 上的进程:内存、CPU,结束进程。"),
|
||||
ToolKind::Connections => format!("{host} 上的 TCP、UDP 连接和监听端口。"),
|
||||
ToolKind::Monitor => format!("{host} 的 CPU、内存、网络和磁盘。"),
|
||||
@@ -103,6 +109,7 @@ pub struct ToolSidebar {
|
||||
/// Whether the right dock is open.
|
||||
shown: bool,
|
||||
monitor: Entity<MonitorPanel>,
|
||||
services: Entity<ServicePanel>,
|
||||
processes: Entity<ProcessPanel>,
|
||||
netstat: Entity<NetstatPanel>,
|
||||
store: Entity<HostStore>,
|
||||
@@ -126,6 +133,7 @@ impl ToolSidebar {
|
||||
terminal: None,
|
||||
shown: false,
|
||||
monitor: cx.new(|_| MonitorPanel::new(dispatch.clone())),
|
||||
services: cx.new(|cx| ServicePanel::new(dispatch.clone(), window, cx)),
|
||||
processes: cx.new(|cx| ProcessPanel::new(dispatch.clone(), window, cx)),
|
||||
netstat: cx.new(|cx| NetstatPanel::new(dispatch, window, cx)),
|
||||
store,
|
||||
@@ -164,6 +172,17 @@ impl ToolSidebar {
|
||||
self.netstat.update(cx, |netstat, cx| netstat.refresh(cx));
|
||||
}
|
||||
|
||||
/// 系统服务's 刷新, and after a command.
|
||||
pub fn refresh_services(&mut self, cx: &mut Context<Self>) {
|
||||
self.services
|
||||
.update(cx, |services, cx| services.refresh(cx));
|
||||
}
|
||||
|
||||
/// A service of the host in front, as 系统服务 last showed it.
|
||||
pub fn service(&self, name: &str, cx: &App) -> Option<Service> {
|
||||
self.services.read(cx).service(name)
|
||||
}
|
||||
|
||||
/// 进程管理's 刷新.
|
||||
pub fn refresh_processes(&mut self, cx: &mut Context<Self>) {
|
||||
self.processes
|
||||
@@ -199,11 +218,15 @@ impl ToolSidebar {
|
||||
view: terminal.view.clone(),
|
||||
});
|
||||
let showing = |tool| self.shown && self.tool == tool;
|
||||
let (monitor, netstat, processes) = (
|
||||
let (monitor, netstat, processes, services) = (
|
||||
showing(ToolKind::Monitor),
|
||||
showing(ToolKind::Connections),
|
||||
showing(ToolKind::Processes),
|
||||
showing(ToolKind::Services),
|
||||
);
|
||||
self.services.update(cx, |panel, cx| {
|
||||
panel.set_target(target.clone(), services, cx)
|
||||
});
|
||||
self.monitor.update(cx, |panel, cx| {
|
||||
panel.set_target(target.clone(), monitor, cx)
|
||||
});
|
||||
@@ -310,6 +333,9 @@ impl Render for ToolSidebar {
|
||||
ToolKind::Processes => {
|
||||
sidebar.child(div().flex_1().min_h_0().child(self.processes.clone()))
|
||||
}
|
||||
ToolKind::Services => {
|
||||
sidebar.child(div().flex_1().min_h_0().child(self.services.clone()))
|
||||
}
|
||||
_ => sidebar.child(self.render_placeholder(terminal.host, cx)),
|
||||
}
|
||||
})
|
||||
|
||||
+125
-2
@@ -18,10 +18,12 @@ use gpui_kit::component::{
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{
|
||||
CenterTab, EndProcess, RefreshConnections, RefreshProcesses, ShowProcess, SortProcesses,
|
||||
ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind,
|
||||
CenterTab, ControlService, EndProcess, RefreshConnections, RefreshProcesses, RefreshServices,
|
||||
ShowProcess, ShowService, SortProcesses, ToggleMonitorDetail, ToggleTool, ToggleToolSidebar,
|
||||
ToolKind,
|
||||
};
|
||||
use crate::processes::{end_command, ended, open_process_dialog};
|
||||
use crate::services::{ServiceCommand, control_command, controlled, open_service_dialog};
|
||||
use crate::shared::confirm_danger;
|
||||
use crate::terminal::exec_answer;
|
||||
|
||||
@@ -167,6 +169,127 @@ impl Workspace {
|
||||
.update(cx, |tools, cx| tools.sort_processes(by, cx));
|
||||
}
|
||||
|
||||
/// 系统服务's 刷新: read the host's services again.
|
||||
pub(super) fn on_refresh_services(
|
||||
&mut self,
|
||||
_: &RefreshServices,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.tools
|
||||
.update(cx, |tools, cx| tools.refresh_services(cx));
|
||||
}
|
||||
|
||||
/// A service's details: its state in full and its journal.
|
||||
pub(super) fn on_show_service(
|
||||
&mut self,
|
||||
action: &ShowService,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(terminal) = self.tool_terminal(cx) else {
|
||||
return;
|
||||
};
|
||||
let Some(service) = self.tools.read(cx).service(&action.0, cx) else {
|
||||
return;
|
||||
};
|
||||
open_service_dialog(
|
||||
service,
|
||||
terminal.view,
|
||||
self.focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Run a command on a service over the SSH terminal's own connection,
|
||||
/// asking first before it stops or restarts one; how it went is a
|
||||
/// notification, and the list is read again.
|
||||
pub(super) fn on_control_service(
|
||||
&mut self,
|
||||
action: &ControlService,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(terminal) = self.tool_terminal(cx) else {
|
||||
return;
|
||||
};
|
||||
let ControlService { name, command } = action.clone();
|
||||
let workspace = cx.entity().downgrade();
|
||||
let run = Rc::new({
|
||||
let name = name.clone();
|
||||
move |window: &mut Window, cx: &mut App| {
|
||||
let reply = control_command(&name, command)
|
||||
.and_then(|script| terminal.view.upgrade()?.read(cx).exec(script, cx));
|
||||
let Some(reply) = reply else {
|
||||
window.push_notification(
|
||||
Notification::error(format!(
|
||||
"无法{} {name}:终端没有连接",
|
||||
command.label()
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let (workspace, name) = (workspace.clone(), name.clone());
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
let outcome = match exec_answer(reply, cx).await {
|
||||
None => Err("终端没有连接".to_string()),
|
||||
Some(Err(error)) => Err(error),
|
||||
Some(Ok(output)) => controlled(&output),
|
||||
};
|
||||
cx.update(|window, cx| {
|
||||
let notification = match outcome {
|
||||
Ok(()) => {
|
||||
Notification::success(format!("{name} {}", command.done()))
|
||||
}
|
||||
Err(why) => Notification::error(format!(
|
||||
"无法{} {name}:{why}",
|
||||
command.label()
|
||||
)),
|
||||
};
|
||||
window.push_notification(notification, cx);
|
||||
workspace
|
||||
.update(cx, |this, cx| {
|
||||
this.tools
|
||||
.update(cx, |tools, cx| tools.refresh_services(cx))
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
});
|
||||
// Stopping and restarting take something away, if only for a
|
||||
// moment; starting and the boot settings do not.
|
||||
let ssh = matches!(name.as_str(), "ssh.service" | "sshd.service");
|
||||
let (title, description) = match command {
|
||||
ServiceCommand::Stop => (
|
||||
format!("停止服务“{name}”?"),
|
||||
if ssh {
|
||||
"这是 SSH 服务:停止后新的连接都连不上这台主机,直到它再次启动。"
|
||||
} else {
|
||||
"停止后它提供的功能就不可用了,直到再次启动。"
|
||||
},
|
||||
),
|
||||
ServiceCommand::Restart => (
|
||||
format!("重启服务“{name}”?"),
|
||||
"服务会先停止再启动,中间短暂不可用。",
|
||||
),
|
||||
_ => return run(window, cx),
|
||||
};
|
||||
confirm_danger(
|
||||
title.into(),
|
||||
Some(description.into()),
|
||||
command.label(),
|
||||
run,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// A process's details, from 进程管理's list and its command line.
|
||||
pub(super) fn on_show_process(
|
||||
&mut self,
|
||||
|
||||
@@ -2240,6 +2240,9 @@ impl Render for Workspace {
|
||||
.on_action(cx.listener(Self::on_sort_processes))
|
||||
.on_action(cx.listener(Self::on_end_process))
|
||||
.on_action(cx.listener(Self::on_show_process))
|
||||
.on_action(cx.listener(Self::on_refresh_services))
|
||||
.on_action(cx.listener(Self::on_show_service))
|
||||
.on_action(cx.listener(Self::on_control_service))
|
||||
.on_action(cx.listener(Self::on_show_hosts))
|
||||
.on_action(cx.listener(Self::on_show_forwards))
|
||||
.on_action(cx.listener(Self::on_new_forward))
|
||||
|
||||
@@ -15,6 +15,7 @@ mod host_tree;
|
||||
mod monitor;
|
||||
mod netstat;
|
||||
mod processes;
|
||||
mod services;
|
||||
mod settings;
|
||||
mod sftp;
|
||||
mod terminal;
|
||||
|
||||
@@ -258,6 +258,7 @@ fn the_monitor_is_not_offered_on_a_host_known_not_to_run_linux(cx: &mut TestAppC
|
||||
assert!(window.try_find("tool-monitor").is_none());
|
||||
assert!(window.try_find("tool-connections").is_none());
|
||||
assert!(window.try_find("tool-processes").is_none());
|
||||
assert!(window.try_find("tool-services").is_none());
|
||||
assert!(window.find("tool-history").visible());
|
||||
assert!(
|
||||
window
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! 系统服务: the right sidebar's tool that lists the systemd services of the
|
||||
//! SSH terminal's host, runs commands on them and shows their details,
|
||||
//! over the terminal's own connection.
|
||||
|
||||
use gpui_kit::{SharedString, Window};
|
||||
|
||||
use crate::support::*;
|
||||
|
||||
/// A host with one service of the administrator's, two of the
|
||||
/// distribution's and one that failed.
|
||||
const LIST: &str = "\
|
||||
@@version
|
||||
systemd 249 (249.11-0ubuntu3.12)
|
||||
@@state
|
||||
degraded
|
||||
@@units
|
||||
Id=socialc.service
|
||||
Description=Social Callback Proxy Service
|
||||
ActiveState=active
|
||||
UnitFileState=enabled
|
||||
FragmentPath=/etc/systemd/system/socialc.service
|
||||
LoadState=loaded
|
||||
|
||||
Id=nginx.service
|
||||
Description=A high performance web server
|
||||
ActiveState=active
|
||||
UnitFileState=enabled
|
||||
FragmentPath=/lib/systemd/system/nginx.service
|
||||
LoadState=loaded
|
||||
|
||||
Id=ModemManager.service
|
||||
Description=Modem Manager
|
||||
ActiveState=inactive
|
||||
UnitFileState=disabled
|
||||
FragmentPath=/lib/systemd/system/ModemManager.service
|
||||
LoadState=loaded
|
||||
|
||||
Id=certbot.service
|
||||
Description=Certbot
|
||||
ActiveState=failed
|
||||
UnitFileState=static
|
||||
FragmentPath=/lib/systemd/system/certbot.service
|
||||
LoadState=loaded
|
||||
";
|
||||
|
||||
/// nginx in full, as `systemctl show` gives it.
|
||||
const NGINX: &str = "\
|
||||
Id=nginx.service
|
||||
Description=A high performance web server
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
SubState=running
|
||||
UnitFileState=enabled
|
||||
FragmentPath=/lib/systemd/system/nginx.service
|
||||
MainPID=781
|
||||
MemoryCurrent=1990656
|
||||
TasksCurrent=5
|
||||
NRestarts=0
|
||||
ExecMainStatus=0
|
||||
ActiveEnterTimestamp=Tue 2026-09-15 17:49:07 CST
|
||||
InactiveEnterTimestamp=
|
||||
";
|
||||
|
||||
const JOURNAL: &str = "\
|
||||
Sep 15 17:49:07 web-01 systemd[1]: Starting A high performance web server...
|
||||
Sep 15 17:49:07 web-01 systemd[1]: Started A high performance web server.
|
||||
";
|
||||
|
||||
fn open_services(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-services", cx));
|
||||
handle
|
||||
}
|
||||
|
||||
async fn wait_for_label(
|
||||
cx: &mut TestAppContext,
|
||||
handle: WindowHandle<Root>,
|
||||
id: &str,
|
||||
label: &str,
|
||||
) {
|
||||
let id = SharedString::from(id.to_owned());
|
||||
cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| {
|
||||
window
|
||||
.try_find(id.clone())
|
||||
.is_some_and(|element| element.label() == Some(label))
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn wait_for_list(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
|
||||
wait_for_label(cx, handle, "services-summary", "systemd 249 · 降级运行").await;
|
||||
}
|
||||
|
||||
/// The lines from the top of the list down, of those named.
|
||||
fn listed(window: &mut Window, ids: &[&str]) -> Vec<String> {
|
||||
let mut lines: Vec<(f32, String)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let element = window.try_find(SharedString::from(id.to_string()))?;
|
||||
Some((f32::from(element.bounds().origin.y), id.to_string()))
|
||||
})
|
||||
.collect();
|
||||
lines.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
lines.into_iter().map(|(_, id)| id).collect()
|
||||
}
|
||||
|
||||
const LINES: [&str; 6] = [
|
||||
"services-group:custom",
|
||||
"service:socialc.service",
|
||||
"services-group:system",
|
||||
"service:ModemManager.service",
|
||||
"service:certbot.service",
|
||||
"service:nginx.service",
|
||||
];
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn the_administrators_services_come_first(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&[LIST]));
|
||||
let handle = open_services(cx, factory.clone());
|
||||
wait_for_list(cx, handle).await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
window.find(("tool-sidebar", INITIAL_WEB_TERMINAL)).label(),
|
||||
Some("系统服务")
|
||||
);
|
||||
assert_eq!(listed(window, &LINES), LINES);
|
||||
assert_eq!(
|
||||
window.find("services-group:custom").label(),
|
||||
Some("自定义服务 1")
|
||||
);
|
||||
assert_eq!(
|
||||
window.find("services-group:system").label(),
|
||||
Some("系统服务 3")
|
||||
);
|
||||
assert_eq!(
|
||||
window.find("service:socialc.service").label(),
|
||||
Some("socialc.service · Social Callback Proxy Service · 运行中 · 已启用")
|
||||
);
|
||||
// A running service can be stopped and restarted, a stopped one
|
||||
// started.
|
||||
assert!(window.try_find("service-stop:nginx.service").is_some());
|
||||
assert!(window.try_find("service-restart:nginx.service").is_some());
|
||||
assert!(window.try_find("service-start:nginx.service").is_none());
|
||||
assert!(
|
||||
window
|
||||
.try_find("service-start:ModemManager.service")
|
||||
.is_some()
|
||||
);
|
||||
});
|
||||
|
||||
// Read once: services change seldom.
|
||||
cx.executor().advance_clock(Duration::from_secs(60));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(factory.exec_count(), 1);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn the_tabs_and_the_search_narrow_the_list(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&[LIST]));
|
||||
let handle = open_services(cx, factory);
|
||||
wait_for_list(cx, handle).await;
|
||||
|
||||
// 失败.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.within("services-tabs").click(3usize, cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
listed(window, &LINES),
|
||||
["services-group:system", "service:certbot.service"]
|
||||
);
|
||||
});
|
||||
|
||||
// 全部, then a search.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.within("services-tabs").click(0usize, cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("services-search", cx);
|
||||
window.input("modem", cx);
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
listed(window, &LINES),
|
||||
["services-group:system", "service:ModemManager.service"]
|
||||
);
|
||||
});
|
||||
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.press("cmd-a", cx);
|
||||
window.input("nothing-like-it", cx);
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
window.find("services-empty").label(),
|
||||
Some("没有符合条件的服务")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn stopping_a_service_asks_first_and_starting_one_does_not(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&[
|
||||
LIST,
|
||||
"@@status 0\n",
|
||||
LIST,
|
||||
"@@status 0\n",
|
||||
LIST,
|
||||
]));
|
||||
let handle = open_services(cx, factory.clone());
|
||||
wait_for_list(cx, handle).await;
|
||||
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("service-stop:nginx.service", cx)
|
||||
});
|
||||
// Nothing runs before the answer, and the card's own click does not
|
||||
// open the details.
|
||||
assert_eq!(factory.exec_count(), 1);
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
assert!(window.try_find("service-details").is_none());
|
||||
window.click("ok", cx)
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(5), |_, _| {
|
||||
factory.exec_count() == 3
|
||||
})
|
||||
.await;
|
||||
let commands = factory.exec_commands();
|
||||
assert!(
|
||||
commands[1].contains("systemctl stop -- \"nginx.service\""),
|
||||
"{}",
|
||||
commands[1]
|
||||
);
|
||||
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("service-start:ModemManager.service", cx)
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(5), |_, _| {
|
||||
factory.exec_count() == 5
|
||||
})
|
||||
.await;
|
||||
assert!(factory.exec_commands()[3].contains("systemctl start -- \"ModemManager.service\""));
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_click_on_a_service_shows_its_state_and_journal(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&[LIST, NGINX, JOURNAL]));
|
||||
let handle = open_services(cx, factory.clone());
|
||||
wait_for_list(cx, handle).await;
|
||||
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("service:nginx.service", cx)
|
||||
});
|
||||
wait_for_label(cx, handle, "service-field:主进程 PID", "781").await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
let field = |label: &str| {
|
||||
window
|
||||
.find(SharedString::from(format!("service-field:{label}")))
|
||||
.label()
|
||||
.map(str::to_owned)
|
||||
};
|
||||
for (label, value) in [
|
||||
("加载状态", "已加载"),
|
||||
("运行状态", "运行中 / 运行中"),
|
||||
("开机启动", "已启用"),
|
||||
("内存", "1.90 MB"),
|
||||
("任务数", "5"),
|
||||
("重启次数", "0"),
|
||||
("退出状态", "0"),
|
||||
("启动时间", "Tue 2026-09-15 17:49:07 CST"),
|
||||
("停止时间", "—"),
|
||||
("Unit 文件", "/lib/systemd/system/nginx.service"),
|
||||
] {
|
||||
assert_eq!(field(label).as_deref(), Some(value), "{label}");
|
||||
}
|
||||
assert!(window.try_find("service-dialog-stop").is_some());
|
||||
assert!(window.try_find("service-dialog-disable").is_some());
|
||||
});
|
||||
assert!(factory.exec_commands()[1].contains("systemctl show"));
|
||||
|
||||
// The journal is read when its tab opens.
|
||||
assert_eq!(factory.exec_count(), 2);
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.within("service-tabs").click(1usize, cx)
|
||||
});
|
||||
wait_for_label(cx, handle, "service-journal", JOURNAL.trim_end()).await;
|
||||
assert!(factory.exec_commands()[2].contains("journalctl -u \"nginx.service\""));
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn the_services_say_when_systemd_does_not_run_the_host(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&["@@unsupported\nLinux\n"]));
|
||||
let handle = open_services(cx, factory);
|
||||
wait_for_label(
|
||||
cx,
|
||||
handle,
|
||||
"services-message",
|
||||
"这台主机没有使用 systemd,暂不支持管理它的服务。",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn the_services_wait_for_the_terminal_to_connect(cx: &mut TestAppContext) {
|
||||
// web-01's terminal is the first started, and exits at once.
|
||||
let handle = open_services(cx, Arc::new(FakeTerminalFactory::exit_first()));
|
||||
wait_for_label(
|
||||
cx,
|
||||
handle,
|
||||
"services-message",
|
||||
"终端没有连接。连接后这里显示主机的系统服务。",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Reference in new issue
Block a user