网络连接:右侧栏的新工具,列出 Linux 主机的 TCP、UDP 连接和监听端口

- 卡片显示协议、状态、地址、进程和用户,可搜索、按协议和状态筛选
- 打开或切换终端时读取一次,点刷新再读;系统监控和它共用终端里执行命令的部分

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-02 14:41:00 +08:00
1 parent 8a0436caf6
commit 0d455e02f5
19 files changed
+2368 -60

No files matched your search

+5 -1
View File
@@ -39,6 +39,8 @@ gpui_kit::actions!(
ToggleHostPanel,
/// Show or hide the right sidebar, whichever tool it is showing.
ToggleToolSidebar,
/// Read the network connections of the SSH terminal's host again.
RefreshConnections,
/// Show the host list in the left dock.
ShowHosts,
/// Show the port-forwarding list in the left dock.
@@ -231,14 +233,16 @@ pub enum ToolKind {
Snippets,
History,
Docker,
Connections,
Monitor,
}
impl ToolKind {
pub const ALL: [ToolKind; 4] = [
pub const ALL: [ToolKind; 5] = [
ToolKind::Snippets,
ToolKind::History,
ToolKind::Docker,
ToolKind::Connections,
ToolKind::Monitor,
];
}
+5 -1
View File
@@ -52,7 +52,11 @@ icon_assets!(
RotateCcwClock,
Container,
Activity,
Monitor
Monitor,
Network,
RadioTower,
ArrowDownLeft,
ArrowUpRight
]
);
+4 -3
View File
@@ -3,9 +3,9 @@
//! Modules are organized by capability so they can become crates later:
//! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP
//! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential`
//! (凭据), `monitor` (系统监控), `secrets` (系统钥匙串), `settings` (设置),
//! `cli` (外部 CLI), `update` (在线升级), `connection` 与 `shared` (跨模块共用),
//! `workspace` (窗口壳), `app` (动作、快捷键、资源).
//! (凭据), `monitor` (系统监控), `netstat` (网络连接), `secrets` (系统钥匙串),
//! `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `connection` 与
//! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源).
pub mod app;
pub mod cli;
@@ -15,6 +15,7 @@ pub mod explorer;
pub mod forward;
pub mod host;
pub mod monitor;
pub mod netstat;
pub mod secrets;
pub mod settings;
pub mod sftp;
+1 -1
View File
@@ -6,7 +6,7 @@ mod linux;
mod model;
mod monitor_panel;
pub use monitor_panel::{MonitorPanel, MonitorTarget};
pub use monitor_panel::MonitorPanel;
/// A part of the system monitor that is folded away until asked for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+9 -34
View File
@@ -1,7 +1,6 @@
use std::cell::Cell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use gpui_kit::component::{
@@ -25,7 +24,7 @@ use super::model::{
format_bytes, format_percent, format_rate, format_uptime,
};
use crate::app::{CatalogIcon, ToggleMonitorDetail};
use crate::terminal::{ExecResult, RemoteTerminalId, TerminalView};
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
/// Time between readings of the load. The second comes sooner: the CPU's
/// load and the network's rates need two, and the panel should not sit on
@@ -35,18 +34,6 @@ const SECOND_READING: Duration = Duration::from_secs(1);
/// Disks are read this seldom: they fill slowly, and `df` is the slowest
/// part of a reading.
const DISK_INTERVAL: Duration = Duration::from_secs(30);
/// How often a reading on its way is looked for. The answer comes from the
/// terminal's transport thread, which must not wake the UI itself.
const POLL: Duration = Duration::from_millis(50);
/// The terminal whose host the monitor reads, over that terminal's own
/// connection.
#[derive(Clone, PartialEq)]
pub struct MonitorTarget {
pub terminal: RemoteTerminalId,
pub view: WeakEntity<TerminalView>,
}
/// 系统监控: the CPU, memory, network and disks of the host of the SSH
/// terminal in front, read every two seconds while the panel shows.
///
@@ -58,7 +45,7 @@ pub struct MonitorTarget {
/// each terminal's host showed last is kept, so coming back to a terminal
/// shows its numbers at once.
pub struct MonitorPanel {
target: Option<MonitorTarget>,
target: Option<ExecTarget>,
active: bool,
hosts: HashMap<RemoteTerminalId, HostState>,
/// Every core's load, not only the first row of it.
@@ -144,12 +131,7 @@ impl MonitorPanel {
}
/// Read `target`'s host while `active`, the panel showing it.
pub fn set_target(
&mut self,
target: Option<MonitorTarget>,
active: bool,
cx: &mut Context<Self>,
) {
pub fn set_target(&mut self, target: Option<ExecTarget>, active: bool, cx: &mut Context<Self>) {
if self.target == target && self.active == active {
return;
}
@@ -188,7 +170,12 @@ impl MonitorPanel {
let Ok(request) = request else { break };
let (outcome, connection) = match request {
None => (Outcome::NotConnected, 0),
Some((reply, connection)) => (wait_for(reply, cx).await, connection),
Some((reply, connection)) => (
exec_answer(reply, cx)
.await
.map_or(Outcome::NotConnected, Outcome::Output),
connection,
),
};
if this
.update(cx, |this, cx| {
@@ -235,18 +222,6 @@ impl MonitorPanel {
}
}
/// Look for the answer until it comes, or until the transport drops the
/// request: the terminal went away.
async fn wait_for(reply: mpsc::Receiver<ExecResult>, cx: &mut AsyncApp) -> Outcome {
loop {
match reply.try_recv() {
Ok(result) => return Outcome::Output(result),
Err(mpsc::TryRecvError::Empty) => cx.background_executor().timer(POLL).await,
Err(mpsc::TryRecvError::Disconnected) => return Outcome::NotConnected,
}
}
}
impl Render for MonitorPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let host = self
+593
View File
@@ -0,0 +1,593 @@
//! Reading a Linux host's sockets: one command, and the parser for what it
//! prints.
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr};
use super::model::{Counts, Processes, Protocol, Socket, SocketProcess, SocketState, Table};
/// The most sockets a reading lists; the host still counts them all. A busy
/// server has tens of thousands, mostly in TIME_WAIT, more than a reading's
/// output may hold (`ss` writes some 200 bytes a socket). `ss` lists UDP
/// and what listens before the connections, so those always make it.
pub const LIMIT: usize = 3000;
/// `ss` lines through, the first `LIMIT` of them, and a count of every
/// protocol and state after them.
const SS_AWK: &str = "$1 == \"Netid\" { next } \
{ n[$1 \" \" $2]++; if (++r <= LIMIT) print } \
END { print \"@@counts\"; for (k in n) print n[k], k }";
/// A `/proc/net` file's address, peer, state, uid and inode, the first
/// `LIMIT` of them, and a count of every state after them.
const PROC_AWK: &str = "FNR > 1 { n[$4]++; if (++r <= LIMIT) print $2, $3, $4, $8, $10 } \
END { print \"@@counts\"; for (k in n) print n[k], p, k }";
/// What 网络连接 runs on the host for a reading: who the login is, the
/// host's user names, and `ss -tuanpe` (every TCP and UDP socket, numeric,
/// with its processes, its uid and inode). A host without `ss` (BusyBox)
/// has `/proc/net` read instead, which names no processes. Each part under
/// an `@@` line of its own.
///
/// One line, in single quotes for `sh -c`, so that whatever the login shell
/// is (bash, zsh, fish, csh) it hands the script to `sh` untouched: the
/// script has no single quote, and no `!` for csh to expand.
pub fn command() -> String {
format!(
"sh -c 'export LC_ALL=C PATH=$PATH:/usr/sbin:/sbin; \
if test -r /proc/net/tcp; then \
echo @@uid; id -u; \
echo @@users; while IFS=: read -r name x uid rest; do echo \"$uid $name\"; done < /etc/passwd; \
if command -v ss >/dev/null 2>&1; then \
echo @@ss; ss -tuanpe 2>/dev/null | awk {ss}; \
else for f in tcp tcp6 udp udp6; do if test -r /proc/net/$f; then \
echo @@$f; awk -v p=$f {proc} /proc/net/$f; fi; done; fi; \
else echo @@unsupported; uname -s; fi'",
ss = double_quoted(&SS_AWK.replace("LIMIT", &LIMIT.to_string())),
proc = double_quoted(&PROC_AWK.replace("LIMIT", &LIMIT.to_string())),
)
}
/// `text` in double quotes for `sh`, which expands nothing in it.
fn double_quoted(text: &str) -> String {
let mut quoted = String::from("\"");
for character in text.chars() {
if matches!(character, '"' | '$' | '`' | '\\') {
quoted.push('\\');
}
quoted.push(character);
}
quoted.push('"');
quoted
}
/// What the command's output said.
#[derive(Debug, PartialEq)]
pub enum Parsed {
Table(Table),
/// Not a Linux host. Holds `uname -s` when the host has one: 「Darwin」.
Unsupported(Option<String>),
}
pub fn parse(output: &str) -> Parsed {
let mut sections: HashMap<&str, Vec<&str>> = HashMap::new();
let mut current = None;
for line in output.lines() {
if let Some(name) = line.strip_prefix("@@") {
current = Some(name.trim());
sections.entry(name.trim()).or_default();
} else if let Some(name) = current {
sections.entry(name).or_default().push(line);
}
}
let section = |name: &str| sections.get(name).map(Vec::as_slice).unwrap_or_default();
let first = |name: &str| {
section(name)
.iter()
.map(|line| line.trim())
.find(|line| !line.is_empty())
};
if !sections.contains_key("uid") {
// A shell other than sh's (cmd.exe) prints nothing we know.
return Parsed::Unsupported(first("unsupported").map(str::to_owned));
}
let root = first("uid") == Some("0");
let users: HashMap<&str, &str> = section("users")
.iter()
.filter_map(|line| line.trim().split_once(' '))
.collect();
let user = |uid: &str| users.get(uid).unwrap_or(&uid).to_string();
let mut counted = Counts::default();
let mut sockets = Vec::new();
let processes = if !sections.contains_key("ss") {
Processes::None
} else if root {
Processes::All
} else {
Processes::Own
};
if sections.contains_key("ss") {
sockets.extend(
section("ss")
.iter()
.filter_map(|line| parse_ss(line, &user)),
);
for line in section("counts") {
let mut fields = line.split_whitespace();
let (Some(count), Some(netid), Some(state)) =
(fields.next(), fields.next(), fields.next())
else {
continue;
};
if let (Ok(count), Some(protocol)) = (count.parse(), protocol_of(netid))
&& let Some(state) = SocketState::from_ss(protocol, state)
{
counted.add(state, count);
}
}
} else {
for file in ["tcp", "tcp6", "udp", "udp6"] {
let Some(protocol) = protocol_of(file) else {
continue;
};
sockets.extend(
section(file)
.iter()
.filter_map(|line| parse_proc(protocol, line, &user)),
);
}
for line in section("counts") {
let mut fields = line.split_whitespace();
let (Some(count), Some(file), Some(state)) =
(fields.next(), fields.next(), fields.next())
else {
continue;
};
if let (Ok(count), Some(protocol)) = (count.parse(), protocol_of(file))
&& let Some(state) = SocketState::from_proc(protocol, state)
{
counted.add(state, count);
}
}
}
Parsed::Table(Table::new(sockets, Some(counted), processes))
}
/// 「tcp」「udp6」.
fn protocol_of(name: &str) -> Option<Protocol> {
match name {
"tcp" | "tcp6" => Some(Protocol::Tcp),
"udp" | "udp6" => Some(Protocol::Udp),
_ => None,
}
}
/// A line of `ss -tuanpe`:
///
/// ```text
/// tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=891,fd=3)) ino:21233 sk:5 <->
/// ```
///
/// `ss` leaves `uid:0` out, so a socket with an inode and no uid is
/// root's; one with inode 0 has no owner (TIME_WAIT).
fn parse_ss(line: &str, user: &impl Fn(&str) -> String) -> Option<Socket> {
let mut fields = line.split_whitespace();
let protocol = protocol_of(fields.next()?)?;
let state = SocketState::from_ss(protocol, fields.next()?)?;
let (_received, _sent) = (fields.next()?, fields.next()?);
let (local, peer) = (fields.next()?, fields.next()?);
let mut socket = Socket::new(protocol, state, local.to_owned(), peer.to_owned());
// The rest: the processes, whose names may hold spaces, then the
// details.
let rest = after_fields(line, 6);
socket.processes = parse_users(rest);
let detail = |name: &str| {
rest.split_whitespace()
.find_map(|field| field.strip_prefix(name))
};
socket.user = match detail("ino:") {
Some("0") | None => None,
Some(_) => Some(user(detail("uid:").unwrap_or("0"))),
};
Some(socket)
}
/// `line` past its first `count` fields.
fn after_fields(line: &str, count: usize) -> &str {
let mut rest = line.trim_start();
for _ in 0..count {
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
rest = rest[end..].trim_start();
}
rest
}
/// `users:(("nginx",pid=913756,fd=6),("nginx",pid=913757,fd=6))`; an old
/// `ss` writes `("nginx",913756,6)`.
fn parse_users(rest: &str) -> Vec<SocketProcess> {
let Some(start) = rest.find("users:((") else {
return Vec::new();
};
let mut processes: Vec<SocketProcess> = Vec::new();
let mut remaining = &rest[start + "users:(".len()..];
while let Some(open) = remaining.strip_prefix("(\"") {
let Some((name, after)) = open.split_once("\",") else {
break;
};
let Some((fields, after)) = after.split_once(')') else {
break;
};
let pid = fields
.split(',')
.next()
.map(|pid| pid.trim_start_matches("pid="))
.and_then(|pid| pid.parse().ok());
if let Some(pid) = pid
&& !processes.iter().any(|process| process.pid == pid)
{
processes.push(SocketProcess {
name: name.to_owned(),
pid,
});
}
remaining = after.strip_prefix(',').unwrap_or(after);
}
processes
}
/// A line of `/proc/net/tcp` cut to address, peer, state, uid and inode:
///
/// ```text
/// 0100007F:0CEA 00000000:0000 0A 0 12345
/// ```
fn parse_proc(protocol: Protocol, line: &str, user: &impl Fn(&str) -> String) -> Option<Socket> {
let fields: Vec<&str> = line.split_whitespace().collect();
let [local, peer, state, uid, inode] = fields.as_slice() else {
return None;
};
let state = SocketState::from_proc(protocol, state)?;
let mut socket = Socket::new(protocol, state, proc_address(local)?, proc_address(peer)?);
socket.user = (*inode != "0").then(|| user(uid));
Some(socket)
}
/// 「0100007F:0CEA」 to 「127.0.0.1:3306」, as `ss` writes it: the address
/// in the host's byte order, a 32-bit word at a time, the port in hex; port
/// 0 is 「*」, IPv6 in brackets.
fn proc_address(hex: &str) -> Option<String> {
let (address, port) = hex.split_once(':')?;
let port = match u16::from_str_radix(port, 16).ok()? {
0 => "*".to_string(),
port => port.to_string(),
};
let words = (0..address.len() / 8)
.map(|word| {
let word = u32::from_str_radix(address.get(word * 8..word * 8 + 8)?, 16).ok()?;
// `/proc` prints the in-memory word, which is little-endian on
// every architecture Linux servers run on.
Some(word.swap_bytes().to_be_bytes())
})
.collect::<Option<Vec<[u8; 4]>>>()?;
match words.as_slice() {
[ipv4] => Some(format!("{}:{port}", Ipv4Addr::from(*ipv4))),
[a, b, c, d] => {
let bytes: Vec<u8> = [a, b, c, d].into_iter().flatten().copied().collect();
let ipv6 = Ipv6Addr::from(<[u8; 16]>::try_from(bytes).ok()?);
Some(format!("[{ipv6}]:{port}"))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::netstat::model::Role;
const SS: &str = "\
@@uid
0
@@users
0 root
33 www-data
101 systemd-resolve
@@ss
udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:((\"systemd-resolve\",pid=600,fd=13)) uid:101 ino:20123 sk:1 cgroup:/system.slice/systemd-resolved.service <->
tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=891,fd=3)) ino:21233 sk:5 cgroup:/system.slice/ssh.service <->
tcp LISTEN 0 511 *:80 *:* users:((\"nginx\",pid=913757,fd=6),(\"nginx\",pid=913756,fd=6)) ino:30001 sk:6 v6only:0 <->
tcp LISTEN 0 511 *:80 *:* users:((\"nginx\",pid=913758,fd=6)) ino:30002 sk:7 v6only:0 <->
tcp ESTAB 0 0 10.0.0.5:22 203.0.113.7:51234 users:((\"sshd\",pid=1234,fd=4),(\"sshd\",pid=1200,fd=4)) timer:(keepalive,119min,0) ino:123456 sk:9 <->
tcp ESTAB 0 0 10.0.0.5:80 198.51.100.2:6001 users:((\"Web Content\",pid=4242,fd=9)) uid:33 ino:4444 sk:b <->
tcp TIME-WAIT 0 0 10.0.0.5:80 198.51.100.2:6000 timer:(timewait,50sec,0) ino:0 sk:a
tcp ESTAB 0 0 [::ffff:10.0.0.5]:3306 [::ffff:10.0.0.9]:39000 ino:777 sk:c uid:999 <->
@@counts
2 tcp ESTAB
1 udp UNCONN
3 tcp LISTEN
1 tcp TIME-WAIT
1 tcp ESTAB
";
fn table(output: &str) -> Table {
match parse(output) {
Parsed::Table(table) => table,
other => panic!("not a table: {other:?}"),
}
}
#[test]
fn ss_lines_give_the_sockets_their_processes_and_users() {
let table = table(SS);
assert_eq!(table.processes(), Processes::All);
assert!(!table.truncated());
let line = |local: &str, peer: &str| {
table
.sockets()
.iter()
.find(|socket| socket.local == local && socket.peer == peer)
.unwrap_or_else(|| panic!("no {local} {peer}"))
};
let sshd = line("0.0.0.0:22", "0.0.0.0:*");
assert_eq!(sshd.state, SocketState::Listen);
assert_eq!(sshd.process_label().as_deref(), Some("sshd (PID 891)"));
// No uid: root's.
assert_eq!(sshd.user.as_deref(), Some("root"));
// Two lines on one port make one, with every worker.
let nginx = line("*:80", "*:*");
assert_eq!(
nginx.process_label().as_deref(),
Some("nginx (PID 913757 等 3 个)")
);
let resolver = line("127.0.0.53%lo:53", "0.0.0.0:*");
assert_eq!(resolver.protocol, Protocol::Udp);
assert_eq!(resolver.state, SocketState::Listen);
assert_eq!(resolver.user.as_deref(), Some("systemd-resolve"));
let session = line("10.0.0.5:22", "203.0.113.7:51234");
assert_eq!(session.role, Role::Inbound);
assert_eq!(session.processes.len(), 2);
// A name with a space in it.
let browser = line("10.0.0.5:80", "198.51.100.2:6001");
assert_eq!(
browser.process_label().as_deref(),
Some("Web Content (PID 4242)")
);
assert_eq!(browser.user.as_deref(), Some("www-data"));
// No process holds a socket in TIME_WAIT, and no one owns it.
let closing = line("10.0.0.5:80", "198.51.100.2:6000");
assert_eq!(closing.state, SocketState::TimeWait);
assert!(closing.processes.is_empty());
assert_eq!(closing.user, None);
// Another user's socket, seen without root: no process; a uid the
// host has no name for stays a number.
let database = line("[::ffff:10.0.0.5]:3306", "[::ffff:10.0.0.9]:39000");
assert!(database.processes.is_empty());
assert_eq!(database.user.as_deref(), Some("999"));
assert_eq!(
table.counts().summary(),
"共 7 条 · 3 个监听端口 · 3 条已连接"
);
}
#[test]
fn a_host_with_more_sockets_than_a_reading_lists_says_how_many() {
let output = SS.replace("1 tcp TIME-WAIT", "40000 tcp TIME-WAIT");
let table = table(&output);
assert!(table.truncated());
assert_eq!(table.sockets().len(), 7);
assert_eq!(
table.counts().summary(),
"共 40007 条 · 4 个监听端口 · 3 条已连接"
);
}
#[test]
fn proc_lines_are_read_where_there_is_no_ss() {
let output = "\
@@uid
1000
@@users
0 root
1000 deploy
@@tcp
0100007F:0CEA 00000000:0000 0A 999 12345
0500000A:0016 0700A8C0:C350 01 1000 23456
0500000A:0050 0200A8C0:1770 06 0 0
@@counts
1 tcp 0A
1 tcp 01
1 tcp 06
@@tcp6
00000000000000000000000000000000:0016 00000000000000000000000000000000:0000 0A 0 777
@@counts
1 tcp6 0A
@@udp
00000000:0044 00000000:0000 07 0 888
@@counts
1 udp 07
";
let table = table(output);
assert_eq!(table.processes(), Processes::None);
assert!(!table.truncated());
let lines: Vec<(Protocol, &str, &str, Option<&str>)> = table
.sockets()
.iter()
.map(|socket| {
(
socket.protocol,
socket.local.as_str(),
socket.peer.as_str(),
socket.user.as_deref(),
)
})
.collect();
assert_eq!(
lines,
[
(Protocol::Tcp, "[::]:22", "[::]:*", Some("root")),
(Protocol::Tcp, "127.0.0.1:3306", "0.0.0.0:*", Some("999")),
(Protocol::Udp, "0.0.0.0:68", "0.0.0.0:*", Some("root")),
(
Protocol::Tcp,
"10.0.0.5:22",
"192.168.0.7:50000",
Some("deploy")
),
(Protocol::Tcp, "10.0.0.5:80", "192.168.0.2:6000", None),
]
);
}
#[test]
fn a_host_that_is_not_linux_says_what_it_is() {
assert_eq!(
parse("@@unsupported\nDarwin\n"),
Parsed::Unsupported(Some("Darwin".into()))
);
// cmd.exe runs none of it.
assert_eq!(parse(""), Parsed::Unsupported(None));
}
#[test]
fn the_command_survives_any_login_shell() {
let command = command();
let script = command
.strip_prefix("sh -c '")
.and_then(|rest| rest.strip_suffix('\''))
.expect("one sh -c in single quotes");
assert!(!script.contains('\''));
assert!(!script.contains('!'));
assert!(!script.contains('\n'));
// fish reads 「\\」 in single quotes as one backslash.
assert!(!script.contains("\\\\"));
}
#[cfg(unix)]
fn run(script: &str, path: &str) -> String {
let output = std::process::Command::new("sh")
.arg("-c")
.arg(script)
.env("PATH", path)
.output()
.expect("sh runs");
assert!(output.status.success(), "{output:?}");
String::from_utf8(output.stdout).expect("utf-8")
}
/// The script, run by this machine's `sh` with `/proc`, `/etc/passwd`
/// and `ss` stood in for, gives what the parser reads.
#[cfg(unix)]
#[test]
fn the_script_reads_ss_or_else_proc() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().expect("temp dir");
let at = |path: &str| root.path().join(path);
std::fs::create_dir_all(at("proc/net")).unwrap();
std::fs::create_dir_all(at("etc")).unwrap();
std::fs::create_dir_all(at("bin")).unwrap();
std::fs::write(at("etc/passwd"), "root:x:0:0:root:/root:/bin/bash\n").unwrap();
let header = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n";
std::fs::write(
at("proc/net/tcp"),
format!(
"{header} 0: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21233 1 0000000000000000 100 0 0 10 0\n"
),
)
.unwrap();
std::fs::write(
at("proc/net/udp"),
format!(
"{header} 1: 3500007F:0035 00000000:0000 07 00000000:00000000 00:00000000 00000000 101 0 20123 2 0000000000000000 0\n"
),
)
.unwrap();
let ss = at("bin/ss");
std::fs::write(
&ss,
"#!/bin/sh\necho 'Netid State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess'\n\
echo 'tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=891,fd=3)) ino:21233 sk:1'\n\
echo 'tcp TIME-WAIT 0 0 10.0.0.5:22 10.0.0.9:4000 timer:(timewait,1sec,0) ino:0 sk:2'\n",
)
.unwrap();
std::fs::set_permissions(&ss, std::fs::Permissions::from_mode(0o755)).unwrap();
let command = command().replace("/proc/", &format!("{}/", at("proc").display()));
let command = command.replace("/etc/passwd", &at("etc/passwd").display().to_string());
let script = command
.strip_prefix("sh -c '")
.and_then(|rest| rest.strip_suffix('\''))
.unwrap();
let system = std::env::var("PATH").unwrap_or_default();
let with_ss = table(&run(script, &format!("{}:{system}", at("bin").display())));
let lines: Vec<(&str, Option<String>, Option<&str>)> = with_ss
.sockets()
.iter()
.map(|socket| {
(
socket.local.as_str(),
socket.process_label(),
socket.user.as_deref(),
)
})
.collect();
assert_eq!(
lines,
[
("0.0.0.0:22", Some("sshd (PID 891)".into()), Some("root")),
("10.0.0.5:22", None, None),
]
);
assert!(!with_ss.truncated());
// Without `ss`, the files themselves; tcp6 and udp6 are missing,
// as on a host with IPv6 turned off.
let script = script.replace("command -v ss ", "command -v no-such-ss ");
let without = table(&run(&script, &system));
let lines: Vec<(Protocol, &str, Option<&str>)> = without
.sockets()
.iter()
.map(|socket| {
(
socket.protocol,
socket.local.as_str(),
socket.user.as_deref(),
)
})
.collect();
assert_eq!(
lines,
[
(Protocol::Tcp, "0.0.0.0:22", Some("root")),
(Protocol::Udp, "127.0.0.53:53", Some("101")),
]
);
assert_eq!(without.counts().total, 2);
}
#[cfg(unix)]
#[test]
fn the_command_is_valid_sh() {
let command = command();
let script = command
.strip_prefix("sh -c '")
.and_then(|rest| rest.strip_suffix('\''))
.unwrap();
let status = std::process::Command::new("sh")
.args(["-n", "-c", script])
.status()
.expect("sh runs");
assert!(status.success());
}
}
+9
View File
@@ -0,0 +1,9 @@
//! 网络连接: the right sidebar's tool that lists the TCP and UDP sockets of
//! the host of the SSH terminal in front, with the processes holding them,
//! read over that terminal's own connection. Linux hosts only, for now.
mod linux;
mod model;
mod netstat_panel;
pub use netstat_panel::NetstatPanel;
+711
View File
@@ -0,0 +1,711 @@
//! What 网络连接 lists, and how its search and filters pick from the list.
use std::collections::{HashMap, HashSet};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Protocol {
Tcp,
Udp,
}
impl Protocol {
pub fn label(self) -> &'static str {
match self {
Protocol::Tcp => "TCP",
Protocol::Udp => "UDP",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum SocketState {
/// A TCP socket taking connections, or a UDP socket bound to a port and
/// connected to no peer: either takes what comes to its port.
Listen,
Established,
SynSent,
SynReceived,
FinWait1,
FinWait2,
TimeWait,
Close,
CloseWait,
LastAck,
Closing,
}
impl SocketState {
/// The state as `ss` names it: 「LISTEN」「ESTAB」「TIME-WAIT」. A UDP
/// socket connected to no peer is 「UNCONN」; a TCP one in that state
/// is bound and neither listening nor connected.
pub fn from_ss(protocol: Protocol, name: &str) -> Option<Self> {
Some(match (protocol, name) {
(Protocol::Udp, "UNCONN") => SocketState::Listen,
(Protocol::Tcp, "UNCONN") => SocketState::Close,
(_, "LISTEN") => SocketState::Listen,
(_, "ESTAB") => SocketState::Established,
(_, "SYN-SENT") => SocketState::SynSent,
(_, "SYN-RECV") => SocketState::SynReceived,
(_, "FIN-WAIT-1") => SocketState::FinWait1,
(_, "FIN-WAIT-2") => SocketState::FinWait2,
(_, "TIME-WAIT") => SocketState::TimeWait,
(_, "CLOSE-WAIT") => SocketState::CloseWait,
(_, "LAST-ACK") => SocketState::LastAck,
(_, "CLOSING") => SocketState::Closing,
_ => return None,
})
}
/// The state as `/proc/net/tcp` and its kin write it, in hex. UDP uses
/// only two: connected (01) and not (07).
pub fn from_proc(protocol: Protocol, hex: &str) -> Option<Self> {
Some(match (protocol, u8::from_str_radix(hex, 16).ok()?) {
(Protocol::Udp, 0x07) => SocketState::Listen,
(Protocol::Udp, 0x01) => SocketState::Established,
(Protocol::Udp, _) => return None,
(Protocol::Tcp, 0x01) => SocketState::Established,
(Protocol::Tcp, 0x02) => SocketState::SynSent,
(Protocol::Tcp, 0x03 | 0x0C) => SocketState::SynReceived,
(Protocol::Tcp, 0x04) => SocketState::FinWait1,
(Protocol::Tcp, 0x05) => SocketState::FinWait2,
(Protocol::Tcp, 0x06) => SocketState::TimeWait,
(Protocol::Tcp, 0x07) => SocketState::Close,
(Protocol::Tcp, 0x08) => SocketState::CloseWait,
(Protocol::Tcp, 0x09) => SocketState::LastAck,
(Protocol::Tcp, 0x0A) => SocketState::Listen,
(Protocol::Tcp, 0x0B) => SocketState::Closing,
(Protocol::Tcp, _) => return None,
})
}
/// How the list says it: 「监听」「已连接」, and the TCP name for the
/// states only the TCP-minded look for: 「TIME_WAIT」.
pub fn label(self) -> &'static str {
match self {
SocketState::Listen => "监听",
SocketState::Established => "已连接",
SocketState::SynSent => "SYN_SENT",
SocketState::SynReceived => "SYN_RECV",
SocketState::FinWait1 => "FIN_WAIT1",
SocketState::FinWait2 => "FIN_WAIT2",
SocketState::TimeWait => "TIME_WAIT",
SocketState::Close => "CLOSE",
SocketState::CloseWait => "CLOSE_WAIT",
SocketState::LastAck => "LAST_ACK",
SocketState::Closing => "CLOSING",
}
}
/// The name netstat gives it, which a search finds as well.
fn netstat_name(self) -> &'static str {
match self {
SocketState::Listen => "LISTEN",
SocketState::Established => "ESTABLISHED",
other => other.label(),
}
}
/// Where the list puts it: what listens first, then the live
/// connections, the ones on their way out last.
fn rank(self) -> u8 {
match self {
SocketState::Listen => 0,
SocketState::Established => 1,
SocketState::CloseWait => 2,
SocketState::SynSent | SocketState::SynReceived => 3,
SocketState::FinWait1
| SocketState::FinWait2
| SocketState::Closing
| SocketState::LastAck
| SocketState::Close => 4,
SocketState::TimeWait => 5,
}
}
}
/// What a socket is for the host.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
/// Takes what comes to its port.
Listening,
/// Came in to a port something on the host listens on.
Inbound,
/// Went out from the host.
Outbound,
}
impl Role {
pub fn label(self) -> &'static str {
match self {
Role::Listening => "监听端口",
Role::Inbound => "入站连接",
Role::Outbound => "出站连接",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SocketProcess {
pub name: String,
pub pid: u32,
}
/// One line of the list: a socket, or the sockets sharing one address
/// (a server's workers listening on the same port).
#[derive(Clone, Debug, PartialEq)]
pub struct Socket {
pub protocol: Protocol,
pub state: SocketState,
/// 「0.0.0.0:22」「[::1]:631」「*:5678」, as `ss` writes it.
pub local: String,
/// 「203.0.113.7:51234」, or 「0.0.0.0:*」 with no peer.
pub peer: String,
/// Empty when the login cannot see it (another user's, without root),
/// and for a socket no process holds (in TIME_WAIT).
pub processes: Vec<SocketProcess>,
/// Whose it is: the name, or the uid when the host has no name for it.
/// `None` for a socket no process holds.
pub user: Option<String>,
pub role: Role,
}
impl Socket {
pub fn new(protocol: Protocol, state: SocketState, local: String, peer: String) -> Self {
Self {
protocol,
state,
local,
peer,
processes: Vec::new(),
user: None,
role: Role::Outbound,
}
}
/// The port it is bound to, `None` for 「*」.
pub fn port(&self) -> Option<u16> {
port_of(&self.local)
}
/// 「sshd (PID 891)」「nginx (PID 913756 等 4 个)」, `None` with none.
pub fn process_label(&self) -> Option<String> {
let first = self.processes.first()?;
Some(match self.processes.len() {
1 => format!("{} (PID {})", first.name, first.pid),
count => format!("{} (PID {} 等 {count} 个)", first.name, first.pid),
})
}
/// What the search looks in, lowercased.
fn haystack(&self) -> String {
let mut haystack = format!(
"{} {} {} {} {}",
self.protocol.label(),
self.state.label(),
self.state.netstat_name(),
self.local,
self.peer,
);
for process in &self.processes {
haystack.push_str(&format!(" {} {}", process.name, process.pid));
}
if let Some(user) = &self.user {
haystack.push(' ');
haystack.push_str(user);
}
haystack.to_lowercase()
}
}
fn port_of(address: &str) -> Option<u16> {
address.rsplit_once(':')?.1.parse().ok()
}
/// How many sockets there are, of all and of the two kinds the summary
/// names.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Counts {
pub total: usize,
pub listening: usize,
pub connected: usize,
}
impl Counts {
pub fn add(&mut self, state: SocketState, count: usize) {
self.total += count;
match state {
SocketState::Listen => self.listening += count,
SocketState::Established => self.connected += count,
_ => {}
}
}
/// 「共 96 条 · 15 个监听端口 · 21 条已连接」.
pub fn summary(&self) -> String {
format!(
"共 {} 条 · {} 个监听端口 · {} 条已连接",
self.total, self.listening, self.connected
)
}
}
/// Whose processes a reading names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Processes {
/// Every socket's: the login is root.
All,
/// Only the login's own sockets'.
Own,
/// None: the host has no `ss`, and `/proc/net` names no processes.
None,
}
/// One reading of a host's sockets, in list order.
#[derive(Debug, PartialEq)]
pub struct Table {
sockets: Vec<Socket>,
/// Lowercased, beside each socket.
haystacks: Vec<String>,
counts: Counts,
truncated: bool,
processes: Processes,
}
impl Table {
/// `sockets` as the host listed them. `counted`: how many the host
/// counted, when it listed only so many; then there are more than
/// `sockets`.
pub fn new(sockets: Vec<Socket>, counted: Option<Counts>, processes: Processes) -> Self {
let listed = sockets.len();
let mut merged: Vec<Socket> = Vec::with_capacity(listed);
let mut seen: HashMap<(Protocol, SocketState, String, String), usize> = HashMap::new();
for socket in sockets {
let key = (
socket.protocol,
socket.state,
socket.local.clone(),
socket.peer.clone(),
);
match seen.get(&key) {
Some(&index) => {
let into = &mut merged[index];
for process in socket.processes {
if !into.processes.contains(&process) {
into.processes.push(process);
}
}
if into.user.is_none() {
into.user = socket.user;
}
}
None => {
seen.insert(key, merged.len());
merged.push(socket);
}
}
}
let listening: HashSet<(Protocol, u16)> = merged
.iter()
.filter(|socket| socket.state == SocketState::Listen)
.filter_map(|socket| Some((socket.protocol, socket.port()?)))
.collect();
for socket in &mut merged {
socket.role = if socket.state == SocketState::Listen {
Role::Listening
} else if socket
.port()
.is_some_and(|port| listening.contains(&(socket.protocol, port)))
{
Role::Inbound
} else {
Role::Outbound
};
}
merged.sort_by(|a, b| {
(a.state.rank(), a.protocol, a.port(), &a.local, &a.peer).cmp(&(
b.state.rank(),
b.protocol,
b.port(),
&b.local,
&b.peer,
))
});
let truncated = counted.is_some_and(|counted| counted.total > listed);
let counts = match counted {
Some(counted) if truncated => counted,
_ => merged.iter().fold(Counts::default(), |mut counts, socket| {
counts.add(socket.state, 1);
counts
}),
};
Self {
haystacks: merged.iter().map(Socket::haystack).collect(),
sockets: merged,
counts,
truncated,
processes,
}
}
pub fn sockets(&self) -> &[Socket] {
&self.sockets
}
pub fn counts(&self) -> Counts {
self.counts
}
/// Whether the host had more sockets than it listed.
pub fn truncated(&self) -> bool {
self.truncated
}
pub fn processes(&self) -> Processes {
self.processes
}
/// The sockets `filter` lets through, by their place in `sockets()`.
pub fn filtered(&self, filter: &Filter) -> Vec<usize> {
let query = filter.query.trim().to_lowercase();
self.sockets
.iter()
.zip(&self.haystacks)
.enumerate()
.filter(|(_, (socket, haystack))| {
filter.protocol.lets_through(socket.protocol)
&& filter.state.lets_through(socket.state)
&& (query.is_empty() || haystack.contains(&query))
})
.map(|(index, _)| index)
.collect()
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ProtocolFilter {
#[default]
All,
Tcp,
Udp,
}
impl ProtocolFilter {
pub const ALL: [ProtocolFilter; 3] = [
ProtocolFilter::All,
ProtocolFilter::Tcp,
ProtocolFilter::Udp,
];
pub fn label(self) -> &'static str {
match self {
ProtocolFilter::All => "全部协议",
ProtocolFilter::Tcp => "TCP",
ProtocolFilter::Udp => "UDP",
}
}
fn lets_through(self, protocol: Protocol) -> bool {
match self {
ProtocolFilter::All => true,
ProtocolFilter::Tcp => protocol == Protocol::Tcp,
ProtocolFilter::Udp => protocol == Protocol::Udp,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StateFilter {
#[default]
All,
Listen,
Established,
TimeWait,
CloseWait,
/// The states not named above.
Other,
}
impl StateFilter {
pub const ALL: [StateFilter; 6] = [
StateFilter::All,
StateFilter::Listen,
StateFilter::Established,
StateFilter::TimeWait,
StateFilter::CloseWait,
StateFilter::Other,
];
pub fn label(self) -> &'static str {
match self {
StateFilter::All => "全部状态",
StateFilter::Listen => SocketState::Listen.label(),
StateFilter::Established => SocketState::Established.label(),
StateFilter::TimeWait => SocketState::TimeWait.label(),
StateFilter::CloseWait => SocketState::CloseWait.label(),
StateFilter::Other => "其他状态",
}
}
fn lets_through(self, state: SocketState) -> bool {
match self {
StateFilter::All => true,
StateFilter::Listen => state == SocketState::Listen,
StateFilter::Established => state == SocketState::Established,
StateFilter::TimeWait => state == SocketState::TimeWait,
StateFilter::CloseWait => state == SocketState::CloseWait,
StateFilter::Other => !matches!(
state,
SocketState::Listen
| SocketState::Established
| SocketState::TimeWait
| SocketState::CloseWait
),
}
}
}
/// What the list is narrowed to.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Filter {
pub protocol: ProtocolFilter,
pub state: StateFilter,
/// Looked for in the addresses, the state, the processes and their
/// PIDs and the user, ignoring case.
pub query: String,
}
impl Filter {
/// Whether it narrows the list at all.
pub fn is_active(&self) -> bool {
self.protocol != ProtocolFilter::All
|| self.state != StateFilter::All
|| !self.query.trim().is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn socket(protocol: Protocol, state: SocketState, local: &str, peer: &str) -> Socket {
Socket::new(protocol, state, local.into(), peer.into())
}
fn process(name: &str, pid: u32) -> SocketProcess {
SocketProcess {
name: name.into(),
pid,
}
}
fn sample() -> Table {
let mut sshd = socket(
Protocol::Tcp,
SocketState::Listen,
"0.0.0.0:22",
"0.0.0.0:*",
);
sshd.processes = vec![process("sshd", 891)];
sshd.user = Some("root".into());
let worker = |pid| {
let mut nginx = socket(
Protocol::Tcp,
SocketState::Listen,
"0.0.0.0:80",
"0.0.0.0:*",
);
nginx.processes = vec![process("nginx", pid)];
nginx.user = Some("nginx".into());
nginx
};
let (first, second) = (worker(100), worker(101));
Table::new(
vec![
socket(
Protocol::Tcp,
SocketState::TimeWait,
"10.0.0.5:80",
"198.51.100.2:6000",
),
socket(
Protocol::Tcp,
SocketState::Established,
"10.0.0.5:48210",
"140.82.112.4:443",
),
socket(
Protocol::Tcp,
SocketState::Established,
"10.0.0.5:22",
"203.0.113.7:51234",
),
first,
socket(
Protocol::Udp,
SocketState::Listen,
"127.0.0.53%lo:53",
"0.0.0.0:*",
),
sshd,
second,
socket(
Protocol::Tcp,
SocketState::CloseWait,
"10.0.0.5:39000",
"10.0.0.9:3306",
),
],
None,
Processes::All,
)
}
#[test]
fn what_listens_comes_first_and_shares_of_a_port_make_one_line() {
let table = sample();
let lines: Vec<(&str, &str, Role)> = table
.sockets()
.iter()
.map(|socket| (socket.state.label(), socket.local.as_str(), socket.role))
.collect();
assert_eq!(
lines,
[
("监听", "0.0.0.0:22", Role::Listening),
("监听", "0.0.0.0:80", Role::Listening),
("监听", "127.0.0.53%lo:53", Role::Listening),
("已连接", "10.0.0.5:22", Role::Inbound),
("已连接", "10.0.0.5:48210", Role::Outbound),
("CLOSE_WAIT", "10.0.0.5:39000", Role::Outbound),
("TIME_WAIT", "10.0.0.5:80", Role::Inbound),
]
);
let nginx = &table.sockets()[1];
assert_eq!(
nginx.process_label().as_deref(),
Some("nginx (PID 100 等 2 个)")
);
assert_eq!(
table.counts(),
Counts {
total: 7,
listening: 3,
connected: 2
}
);
assert!(!table.truncated());
assert_eq!(
table.counts().summary(),
"共 7 条 · 3 个监听端口 · 2 条已连接"
);
}
#[test]
fn a_host_that_counted_more_than_it_listed_is_said_to_have_more() {
let counted = Counts {
total: 50_000,
listening: 12,
connected: 9_000,
};
let table = Table::new(
vec![socket(
Protocol::Tcp,
SocketState::Listen,
"0.0.0.0:22",
"0.0.0.0:*",
)],
Some(counted),
Processes::All,
);
assert!(table.truncated());
assert_eq!(table.counts(), counted);
// A count that matches the list is no news.
let table = Table::new(
vec![socket(
Protocol::Tcp,
SocketState::Listen,
"0.0.0.0:22",
"0.0.0.0:*",
)],
Some(Counts {
total: 1,
listening: 1,
connected: 0,
}),
Processes::All,
);
assert!(!table.truncated());
}
#[test]
fn the_search_finds_addresses_states_processes_pids_and_users() {
let table = sample();
let local = |filter: Filter| -> Vec<String> {
table
.filtered(&filter)
.into_iter()
.map(|index| table.sockets()[index].local.clone())
.collect()
};
let query = |text: &str| Filter {
query: text.into(),
..Filter::default()
};
assert_eq!(local(query("NGINX")), ["0.0.0.0:80"]);
assert_eq!(local(query("101")), ["0.0.0.0:80"]);
assert_eq!(local(query(":22")), ["0.0.0.0:22", "10.0.0.5:22"]);
assert_eq!(
local(query("established")),
["10.0.0.5:22", "10.0.0.5:48210"]
);
assert_eq!(local(query("time_wait")), ["10.0.0.5:80"]);
assert_eq!(local(query("203.0.113")), ["10.0.0.5:22"]);
assert_eq!(local(query(" root ")), ["0.0.0.0:22"]);
assert_eq!(local(Filter::default()).len(), 7);
let udp = Filter {
protocol: ProtocolFilter::Udp,
..Filter::default()
};
assert_eq!(local(udp), ["127.0.0.53%lo:53"]);
let other = Filter {
state: StateFilter::Other,
..Filter::default()
};
assert!(local(other).is_empty());
let close_wait = Filter {
state: StateFilter::CloseWait,
..Filter::default()
};
assert!(close_wait.is_active());
assert_eq!(local(close_wait), ["10.0.0.5:39000"]);
assert!(!query(" ").is_active());
}
#[test]
fn states_read_from_ss_and_from_proc_agree() {
for (name, hex, state) in [
("LISTEN", "0A", SocketState::Listen),
("ESTAB", "01", SocketState::Established),
("TIME-WAIT", "06", SocketState::TimeWait),
("CLOSE-WAIT", "08", SocketState::CloseWait),
("SYN-RECV", "03", SocketState::SynReceived),
("FIN-WAIT-2", "05", SocketState::FinWait2),
] {
assert_eq!(SocketState::from_ss(Protocol::Tcp, name), Some(state));
assert_eq!(SocketState::from_proc(Protocol::Tcp, hex), Some(state));
}
assert_eq!(
SocketState::from_ss(Protocol::Udp, "UNCONN"),
Some(SocketState::Listen)
);
assert_eq!(
SocketState::from_proc(Protocol::Udp, "07"),
Some(SocketState::Listen)
);
assert_eq!(SocketState::from_ss(Protocol::Tcp, "Netid"), None);
}
}
+615
View File
@@ -0,0 +1,615 @@
use std::collections::HashMap;
use std::rc::Rc;
use std::time::Duration;
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, IndexPath, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex,
input::{Input, InputEvent, InputState},
scroll::ScrollableElement as _,
searchable_list::SearchableListItem,
select::{Select, SelectEvent, SelectState},
spinner::Spinner,
tag::Tag,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::linux::{self, Parsed};
use super::model::{
Filter, Processes, ProtocolFilter, Role, Socket, SocketState, StateFilter, Table,
};
use crate::app::{CatalogIcon, RefreshConnections};
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
/// How long a terminal not connected is left before it is asked again.
const RETRY: Duration = Duration::from_secs(2);
/// 网络连接: every TCP and UDP socket of the host of the SSH terminal in
/// front, with the processes holding them, read when the panel comes on
/// screen and again on 刷新.
///
/// A list, not a gauge, so it holds still while it is read: what changes
/// underneath shows on the next refresh, not under the pointer. Each
/// reading runs `linux::command` beside the shell, on the terminal's own
/// connection: no second login, nothing asked again. What each terminal's
/// host showed last is kept, so coming back to a terminal shows its list at
/// once while it is read again. The search and the filters stay as they
/// are from one terminal to the next.
pub struct NetstatPanel {
target: Option<ExecTarget>,
active: bool,
hosts: HashMap<RemoteTerminalId, HostState>,
/// The reading on its way; dropping it stops it.
reading: Option<Task<()>>,
/// Whether a reading is on its way.
loading: bool,
search: Entity<InputState>,
protocol: Entity<SelectState<Vec<ProtocolFilter>>>,
state: Entity<SelectState<Vec<StateFilter>>>,
filter: Filter,
scroll_handle: UniformListScrollHandle,
/// The workspace's focus handle: 刷新 dispatches on it, so it works
/// whatever is focused.
dispatch: FocusHandle,
_subscriptions: Vec<Subscription>,
}
#[derive(Default)]
struct HostState {
table: Option<Rc<Table>>,
problem: Option<Problem>,
}
#[derive(Clone, Debug, PartialEq)]
enum Problem {
/// The terminal is not connected, so there is no connection to read on.
NotConnected,
/// Not Linux; `uname -s` when the host said it.
Unsupported(Option<String>),
/// The command did not run, or did not finish.
Failed(String),
}
impl SearchableListItem for ProtocolFilter {
type Value = Self;
fn title(&self) -> SharedString {
self.label().into()
}
fn value(&self) -> &Self::Value {
self
}
}
impl SearchableListItem for StateFilter {
type Value = Self;
fn title(&self) -> SharedString {
self.label().into()
}
fn value(&self) -> &Self::Value {
self
}
}
impl NetstatPanel {
pub fn new(dispatch: FocusHandle, window: &mut Window, cx: &mut Context<Self>) -> Self {
let search = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("搜索地址、端口、状态、PID 或进程")
.clean_on_escape()
});
let protocol = cx.new(|cx| {
SelectState::new(
ProtocolFilter::ALL.to_vec(),
Some(IndexPath::new(0)),
window,
cx,
)
});
let state = cx.new(|cx| {
SelectState::new(
StateFilter::ALL.to_vec(),
Some(IndexPath::new(0)),
window,
cx,
)
});
let subscriptions = vec![
cx.subscribe(&search, |this, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
let query = input.read(cx).value().to_string();
this.set_filter(
Filter {
query,
..this.filter.clone()
},
cx,
);
}
}),
cx.subscribe(
&protocol,
|this, _, event: &SelectEvent<Vec<ProtocolFilter>>, cx| {
if let SelectEvent::Confirm(Some(protocol)) = event {
this.set_filter(
Filter {
protocol: *protocol,
..this.filter.clone()
},
cx,
);
}
},
),
cx.subscribe(
&state,
|this, _, event: &SelectEvent<Vec<StateFilter>>, cx| {
if let SelectEvent::Confirm(Some(state)) = event {
this.set_filter(
Filter {
state: *state,
..this.filter.clone()
},
cx,
);
}
},
),
];
Self {
target: None,
active: false,
hosts: HashMap::new(),
reading: None,
loading: false,
search,
protocol,
state,
filter: Filter::default(),
scroll_handle: UniformListScrollHandle::new(),
dispatch,
_subscriptions: subscriptions,
}
}
/// Read `target`'s host when `active`, the panel showing it, and was
/// not before.
pub fn set_target(&mut self, target: Option<ExecTarget>, active: bool, cx: &mut Context<Self>) {
if self.target == target && self.active == active {
return;
}
self.target = target;
self.active = active;
self.read(cx);
cx.notify();
}
/// Read the host again: 刷新.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
self.read(cx);
cx.notify();
}
fn set_filter(&mut self, filter: Filter, cx: &mut Context<Self>) {
if self.filter != filter {
self.filter = filter;
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
cx.notify();
}
}
/// Start reading the target's host over, or stop when there is nothing
/// to show. A terminal not connected is asked again every little while,
/// so its list comes once it connects.
fn read(&mut self, cx: &mut Context<Self>) {
self.reading = None;
self.loading = false;
let Some(target) = self.target.clone().filter(|_| self.active) else {
return;
};
self.loading = true;
self.reading = Some(cx.spawn(async move |this, cx| {
loop {
let reply = this.update(cx, |_, cx| {
let view = target.view.upgrade()?;
view.read(cx).exec(linux::command(), cx)
});
let Ok(reply) = reply else { return };
let answer = match reply {
Some(reply) => exec_answer(reply, cx).await,
None => None,
};
let connected = answer.is_some();
if this
.update(cx, |this, cx| this.record(target.terminal, answer, cx))
.is_err()
|| connected
{
return;
}
cx.background_executor().timer(RETRY).await;
}
}));
}
fn record(
&mut self,
terminal: RemoteTerminalId,
answer: Option<ExecResult>,
cx: &mut Context<Self>,
) {
let host = self.hosts.entry(terminal).or_default();
host.problem = match answer {
None => Some(Problem::NotConnected),
Some(Err(error)) => Some(Problem::Failed(error)),
Some(Ok(output)) => match linux::parse(&output) {
Parsed::Table(table) => {
host.table = Some(Rc::new(table));
None
}
Parsed::Unsupported(system) => Some(Problem::Unsupported(system)),
},
};
// Not connected, it keeps asking; the spinner stops all the same.
self.loading = false;
cx.notify();
}
fn render_table(
&self,
table: Rc<Table>,
problem: Option<Problem>,
cx: &Context<Self>,
) -> impl IntoElement {
let rows = Rc::new(table.filtered(&self.filter));
let summary = table.counts().summary();
let dispatch = self.dispatch.clone();
let header = v_flex()
.flex_shrink_0()
.px_3()
.pt_3()
.pb_2()
.gap_2()
.child(
h_flex()
.gap_2()
.child(
div()
.id("netstat-summary")
.test_support()
.aria_label(summary.clone())
.flex_1()
.min_w_0()
.truncate()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(summary),
)
.child(
Button::new("netstat-refresh")
.ghost()
.xsmall()
.icon(Icon::new(CatalogIcon::RefreshCw))
.loading(self.loading)
.tooltip("刷新")
.accessibility_label("刷新")
.on_click(move |_, window, cx| {
dispatch.dispatch_action(&RefreshConnections, window, cx)
}),
),
)
.when_some(problem, |header, problem| {
header.child(render_problem(&problem, cx))
})
.when(table.truncated(), |header| {
header.child(note(
"netstat-truncated",
format!("连接太多,只列出了前 {} 条。", linux::LIMIT),
cx.theme().warning,
))
})
.map(|header| {
let text = match table.processes() {
Processes::All => return header,
Processes::Own => "当前用户不是 root,只能看到自己的进程。",
Processes::None => "这台主机没有 ss 命令,看不到占用连接的进程。",
};
header.child(note(
"netstat-processes",
text.into(),
cx.theme().muted_foreground,
))
})
.child(
Input::new(&self.search)
.id("netstat-search")
.small()
.cleanable(true)
.prefix(Icon::new(IconName::Search).small()),
)
.child(
h_flex()
.gap_2()
// Each in a box of its own, or it covers the whole row.
.child(
div().flex_1().min_w_0().child(
Select::new(&self.protocol)
.id("netstat-protocol")
.small()
.accessibility_label("协议"),
),
)
.child(
div().flex_1().min_w_0().child(
Select::new(&self.state)
.id("netstat-state")
.small()
.accessibility_label("状态"),
),
),
)
.when(self.filter.is_active(), |header| {
let text = format!("筛选出 {} 条", rows.len());
header.child(
div()
.id("netstat-matches")
.test_support()
.aria_label(text.clone())
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(text),
)
});
let empty = if self.filter.is_active() {
"没有符合条件的连接"
} else {
"没有网络连接"
};
// The scrollbar goes on the list's box, which does not scroll: on
// the scrolled rows it would scroll away with them.
let list = div()
.id("netstat-list")
.test_support()
.relative()
.flex_1()
.min_h_0()
.border_t_1()
.border_color(cx.theme().border)
.map(|list| {
if rows.is_empty() {
list.child(
div()
.id("netstat-empty")
.test_support()
.aria_label(empty)
.py_8()
.text_sm()
.text_center()
.text_color(cx.theme().muted_foreground)
.child(empty),
)
} else {
// Thousands of sockets on a busy server: only those in
// sight are drawn.
let count = rows.len();
list.child(
uniform_list("netstat-sockets", count, move |range, _, cx| {
range
.map(|index| {
div()
.px_3()
.pt_2()
.child(render_socket(&table.sockets()[rows[index]], cx))
})
.collect::<Vec<_>>()
})
.size_full()
.pb_3()
.track_scroll(&self.scroll_handle),
)
}
})
.vertical_scrollbar(&self.scroll_handle);
v_flex().size_full().child(header).child(list)
}
}
impl Render for NetstatPanel {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let host = self
.target
.as_ref()
.and_then(|target| self.hosts.get(&target.terminal));
let table = host.and_then(|host| host.table.clone());
let problem = host.and_then(|host| host.problem.clone());
v_flex()
.id("netstat")
.test_support()
.size_full()
.map(|panel| match (table, problem) {
// A list from before a failed reading still says something;
// the failure goes above it.
(Some(table), problem @ (None | Some(Problem::Failed(_)))) => {
panel.child(self.render_table(table, problem, cx))
}
(_, Some(problem)) => panel.child(div().p_3().child(render_problem(&problem, cx))),
(None, None) => panel.child(
h_flex()
.id("netstat-loading")
.test_support()
.gap_2()
.justify_center()
.py_8()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small())
.child("正在读取…"),
),
})
}
}
fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
let (text, color) = match problem {
Problem::NotConnected => (
"终端没有连接。连接后这里显示主机的网络连接。".to_string(),
cx.theme().muted_foreground,
),
Problem::Unsupported(Some(system)) => (
format!("暂不支持查看 {system} 的网络连接,目前只支持 Linux 主机。"),
cx.theme().muted_foreground,
),
Problem::Unsupported(None) => (
"暂不支持查看这台主机的网络连接,目前只支持 Linux 主机。".to_string(),
cx.theme().muted_foreground,
),
Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger),
};
div()
.id("netstat-message")
.test_support()
.aria_label(text.clone())
.text_sm()
.text_color(color)
.child(text)
}
/// A line under the summary: 「连接太多,只列出了前 3000 条。」.
fn note(id: &'static str, text: String, color: Hsla) -> impl IntoElement {
div()
.id(id)
.test_support()
.aria_label(text.clone())
.text_xs()
.text_color(color)
.child(text)
}
/// What a state looks like: green for what listens, blue for what is
/// connected, yellow for CLOSE_WAIT, which piles up when a program does not
/// close what its peer has; the rest quiet.
fn state_color(state: SocketState, cx: &App) -> Hsla {
match state {
SocketState::Listen => cx.theme().success,
SocketState::Established => cx.theme().info,
SocketState::CloseWait => cx.theme().warning,
_ => cx.theme().muted_foreground,
}
}
fn role_icon(role: Role) -> CatalogIcon {
match role {
Role::Listening => CatalogIcon::RadioTower,
Role::Inbound => CatalogIcon::ArrowDownLeft,
Role::Outbound => CatalogIcon::ArrowUpRight,
}
}
/// A socket's card. Every card has the same lines, so they are all as tall
/// as the list takes them to be.
fn render_socket(socket: &Socket, cx: &App) -> impl IntoElement {
let color = state_color(socket.state, cx);
let process = socket.process_label();
let label = format!(
"{} {} · 本地 {} · 远端 {} · {} · {}",
socket.protocol.label(),
socket.state.label(),
socket.local,
socket.peer,
process.as_deref().unwrap_or("—"),
socket.user.as_deref().unwrap_or("—"),
);
let address = |name: &'static str, value: &str| {
h_flex()
.gap_2()
.text_xs()
.child(
div()
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.child(name),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.font_family(cx.theme().mono_font_family.clone())
.child(value.to_owned()),
)
};
v_flex()
.id(SharedString::from(format!(
"netstat-socket:{} {} {}",
socket.protocol.label().to_lowercase(),
socket.local,
socket.peer
)))
.test_support()
.aria_label(label)
.gap_1()
.p_3()
.rounded(cx.theme().radius)
.bg(cx.theme().tokens.group_box)
.text_color(cx.theme().group_box_foreground)
.child(
h_flex()
.gap_1p5()
.child(Icon::new(role_icon(socket.role)).small().text_color(color))
.child(Tag::secondary().small().child(socket.protocol.label()))
.child(
Tag::custom(color.opacity(0.12), color, color.opacity(0.3))
.small()
.child(socket.state.label()),
)
.child(div().flex_1())
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(socket.role.label()),
),
)
.child(address("本地", &socket.local))
.child(address("远端", &socket.peer))
.child(
h_flex()
.gap_2()
.mt_1()
.pt_2()
.border_t_1()
.border_color(cx.theme().border)
.text_xs()
.child(
div()
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.child("进程"),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.when(process.is_none(), |text| {
text.text_color(cx.theme().muted_foreground)
})
.child(process.unwrap_or_else(|| "—".into())),
)
.when_some(socket.user.clone(), |row, user| {
row.child(
div()
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.child(user),
)
}),
)
}
+35
View File
@@ -0,0 +1,35 @@
//! Commands the right sidebar's tools run beside an SSH terminal's shell.
use std::sync::mpsc;
use std::time::Duration;
use gpui_kit::*;
use super::{ExecResult, RemoteTerminalId, TerminalView};
/// How often an answer on its way is looked for. It comes from the
/// terminal's transport thread, which must not wake the UI itself.
const POLL: Duration = Duration::from_millis(50);
/// The SSH terminal whose host a tool reads, over that terminal's own
/// connection.
#[derive(Clone, PartialEq)]
pub struct ExecTarget {
pub terminal: RemoteTerminalId,
pub view: WeakEntity<TerminalView>,
}
/// Look for a command's answer until it comes. `None` when the transport
/// drops the request instead: the terminal went away.
pub async fn exec_answer(
reply: mpsc::Receiver<ExecResult>,
cx: &mut AsyncApp,
) -> Option<ExecResult> {
loop {
match reply.try_recv() {
Ok(result) => return Some(result),
Err(mpsc::TryRecvError::Empty) => cx.background_executor().timer(POLL).await,
Err(mpsc::TryRecvError::Disconnected) => return None,
}
}
}
+2
View File
@@ -1,6 +1,7 @@
//! 终端: shared terminal emulation, transports and Dock panels.
mod engine;
mod exec;
mod font;
mod local_pty;
mod local_terminal_panel;
@@ -11,6 +12,7 @@ mod terminal_view;
mod transport;
pub use engine::{TerminalCell, TerminalEngine, TerminalEvent, TerminalSnapshot};
pub use exec::{ExecTarget, exec_answer};
pub use font::{
DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalFont,
TerminalFontPreview, is_font_installed, monospace_font_families,
+33 -9
View File
@@ -9,8 +9,9 @@ use gpui_kit::*;
use crate::app::{CatalogIcon, ToggleTool, ToolKind};
use crate::host::{HostId, HostOs, HostStore};
use crate::monitor::{MonitorDetail, MonitorPanel, MonitorTarget};
use crate::terminal::{RemoteTerminalId, TerminalView};
use crate::monitor::{MonitorDetail, MonitorPanel};
use crate::netstat::NetstatPanel;
use crate::terminal::{ExecTarget, RemoteTerminalId, TerminalView};
impl ToolKind {
pub fn label(self) -> &'static str {
@@ -18,6 +19,7 @@ impl ToolKind {
ToolKind::Snippets => "命令片段",
ToolKind::History => "历史命令",
ToolKind::Docker => "Docker",
ToolKind::Connections => "网络连接",
ToolKind::Monitor => "系统监控",
}
}
@@ -27,6 +29,7 @@ impl ToolKind {
ToolKind::Snippets => CatalogIcon::CodeXml,
ToolKind::History => CatalogIcon::RotateCcwClock,
ToolKind::Docker => CatalogIcon::Container,
ToolKind::Connections => CatalogIcon::Network,
ToolKind::Monitor => CatalogIcon::Activity,
}
}
@@ -35,7 +38,7 @@ 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::Monitor => os.is_none_or(HostOs::is_linux),
ToolKind::Connections | ToolKind::Monitor => os.is_none_or(HostOs::is_linux),
ToolKind::Snippets | ToolKind::History | ToolKind::Docker => true,
}
}
@@ -46,6 +49,7 @@ impl ToolKind {
ToolKind::Snippets => "tool-snippets",
ToolKind::History => "tool-history",
ToolKind::Docker => "tool-docker",
ToolKind::Connections => "tool-connections",
ToolKind::Monitor => "tool-monitor",
}
}
@@ -57,6 +61,7 @@ impl ToolKind {
ToolKind::Snippets => "常用的命令存在这里,点一下就发送到当前终端。".into(),
ToolKind::History => format!("{host} 上执行过的命令,可以搜索、再次执行。"),
ToolKind::Docker => format!("{host} 上的容器:状态、日志,启动和停止。"),
ToolKind::Connections => format!("{host} 上的 TCP、UDP 连接和监听端口。"),
ToolKind::Monitor => format!("{host} 的 CPU、内存、网络和磁盘。"),
}
}
@@ -91,6 +96,7 @@ pub struct ToolSidebar {
/// Whether the right dock is open.
shown: bool,
monitor: Entity<MonitorPanel>,
netstat: Entity<NetstatPanel>,
store: Entity<HostStore>,
focus_handle: FocusHandle,
_subscription: Subscription,
@@ -99,14 +105,20 @@ pub struct ToolSidebar {
impl ToolSidebar {
/// `dispatch` is the workspace's focus handle, which the tools' buttons
/// dispatch on.
pub fn new(store: Entity<HostStore>, dispatch: FocusHandle, cx: &mut Context<Self>) -> Self {
pub fn new(
store: Entity<HostStore>,
dispatch: FocusHandle,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
// A host renamed is named anew.
let subscription = cx.observe(&store, |_, _, cx| cx.notify());
Self {
tool: ToolKind::default(),
terminal: None,
shown: false,
monitor: cx.new(|_| MonitorPanel::new(dispatch)),
monitor: cx.new(|_| MonitorPanel::new(dispatch.clone())),
netstat: cx.new(|cx| NetstatPanel::new(dispatch, window, cx)),
store,
focus_handle: cx.focus_handle(),
_subscription: subscription,
@@ -138,15 +150,24 @@ impl ToolSidebar {
.update(cx, |monitor, cx| monitor.toggle(detail, cx));
}
/// 网络连接's 刷新.
pub fn refresh_connections(&mut self, cx: &mut Context<Self>) {
self.netstat.update(cx, |netstat, cx| netstat.refresh(cx));
}
/// Point each tool at the terminal, running only the one on screen.
fn sync_tools(&mut self, cx: &mut Context<Self>) {
let target = self.terminal.as_ref().map(|terminal| MonitorTarget {
let target = self.terminal.as_ref().map(|terminal| ExecTarget {
terminal: terminal.id,
view: terminal.view.clone(),
});
let active = self.shown && self.tool == ToolKind::Monitor;
self.monitor
.update(cx, |monitor, cx| monitor.set_target(target, active, cx));
let showing = |tool| self.shown && self.tool == tool;
let (monitor, netstat) = (showing(ToolKind::Monitor), showing(ToolKind::Connections));
self.monitor.update(cx, |panel, cx| {
panel.set_target(target.clone(), monitor, cx)
});
self.netstat
.update(cx, |panel, cx| panel.set_target(target, netstat, cx));
}
/// Show another tool. A focus inside the one going away moves to the
@@ -239,6 +260,9 @@ impl Render for ToolSidebar {
ToolKind::Monitor => {
sidebar.child(div().flex_1().min_h_0().child(self.monitor.clone()))
}
ToolKind::Connections => {
sidebar.child(div().flex_1().min_h_0().child(self.netstat.clone()))
}
_ => sidebar.child(self.render_placeholder(terminal.host, cx)),
}
})
+15 -2
View File
@@ -11,12 +11,14 @@
use gpui_kit::component::dock::{DockArea, DockPlacement};
use gpui_kit::*;
use crate::app::{CenterTab, ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind};
use crate::app::{
CenterTab, RefreshConnections, ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind,
};
use super::{Workspace, tool_sidebar::ToolTerminal};
/// How wide the right sidebar opens, and the narrowest it can be dragged:
/// the monitor's cards are laid out for this width.
/// the monitor's and the connections' cards are laid out for this width.
pub(super) const TOOL_SIDEBAR_WIDTH: Pixels = px(320.);
impl Workspace {
@@ -121,6 +123,17 @@ impl Workspace {
.update(cx, |tools, cx| tools.toggle_monitor_detail(detail, cx));
}
/// 网络连接's 刷新: read the host's sockets again.
pub(super) fn on_refresh_connections(
&mut self,
_: &RefreshConnections,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.tools
.update(cx, |tools, cx| tools.refresh_connections(cx));
}
/// Hide the sidebar, or show the tool shown last; the first tool on
/// offer when that one does not work on this host. Without an SSH
/// terminal in front there is nothing to show or hide.
+2 -1
View File
@@ -330,7 +330,7 @@ impl Workspace {
let credential_panel =
cx.new(|cx| CredentialPanel::new(store.clone(), focus_handle.clone(), window, cx));
let sidebar = cx.new(|_| Sidebar::new(host_panel.clone(), forward_panel, credential_panel));
let tools = cx.new(|cx| ToolSidebar::new(store.clone(), focus_handle.clone(), cx));
let tools = cx.new(|cx| ToolSidebar::new(store.clone(), focus_handle.clone(), window, cx));
// Start with focus in the host panel so window-level actions have a
// dispatch path. The workspace's own handle is never focused: the
// dialog layer is its child, and a focused ancestor would keep the
@@ -2235,6 +2235,7 @@ impl Render for Workspace {
.on_action(cx.listener(Self::on_toggle_tool_sidebar))
.on_action(cx.listener(Self::on_toggle_tool))
.on_action(cx.listener(Self::on_toggle_monitor_detail))
.on_action(cx.listener(Self::on_refresh_connections))
.on_action(cx.listener(Self::on_show_hosts))
.on_action(cx.listener(Self::on_show_forwards))
.on_action(cx.listener(Self::on_new_forward))