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