2025-08-08 12:41:32 -07:00
|
|
|
//! App state and main loop: input handling, fetching metrics, updating history, and drawing.
|
|
|
|
|
|
2025-08-08 17:25:15 -07:00
|
|
|
use std::{
|
|
|
|
|
collections::VecDeque,
|
|
|
|
|
io,
|
|
|
|
|
time::{Duration, Instant},
|
|
|
|
|
};
|
2025-08-08 12:41:32 -07:00
|
|
|
|
|
|
|
|
use crossterm::{
|
2025-08-11 22:56:35 -07:00
|
|
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
|
2025-08-08 12:41:32 -07:00
|
|
|
execute,
|
2025-09-04 05:30:25 -07:00
|
|
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
2025-08-08 12:41:32 -07:00
|
|
|
};
|
|
|
|
|
use ratatui::{
|
2025-08-11 12:04:55 -07:00
|
|
|
//style::Color, // + add Color
|
2025-08-08 12:41:32 -07:00
|
|
|
Terminal,
|
2025-09-04 05:30:25 -07:00
|
|
|
backend::CrosstermBackend,
|
2026-08-18 22:29:54 -07:00
|
|
|
layout::Rect,
|
2025-08-08 12:41:32 -07:00
|
|
|
};
|
2025-10-02 16:54:27 -07:00
|
|
|
use tokio::time::{sleep, timeout};
|
2025-08-08 12:41:32 -07:00
|
|
|
|
2025-09-04 05:30:25 -07:00
|
|
|
use crate::history::{PerCoreHistory, push_capped};
|
2026-08-23 08:11:17 -07:00
|
|
|
use crate::proc_kill::{KillSignal, kill_local_process};
|
2025-09-15 10:16:47 -07:00
|
|
|
use crate::retry::{RetryTiming, compute_retry_timing};
|
2025-08-08 12:41:32 -07:00
|
|
|
use crate::types::Metrics;
|
2025-08-10 23:32:44 -07:00
|
|
|
use crate::ui::cpu::{
|
2025-09-04 05:30:25 -07:00
|
|
|
PerCoreScrollDrag, draw_cpu_avg_graph, draw_per_core_bars, per_core_clamp,
|
|
|
|
|
per_core_content_area, per_core_handle_key, per_core_handle_mouse,
|
|
|
|
|
per_core_handle_scrollbar_mouse,
|
2025-08-10 23:32:44 -07:00
|
|
|
};
|
2026-08-18 22:29:54 -07:00
|
|
|
use crate::ui::layout::{AppLayout, compute as compute_layout};
|
2025-09-15 10:16:47 -07:00
|
|
|
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
|
2025-10-02 16:54:27 -07:00
|
|
|
use crate::ui::processes::{
|
2026-06-02 13:02:25 -07:00
|
|
|
ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection,
|
2025-11-17 00:05:02 -08:00
|
|
|
processes_handle_mouse_with_selection,
|
2025-10-02 16:54:27 -07:00
|
|
|
};
|
2025-08-08 17:25:15 -07:00
|
|
|
use crate::ui::{
|
2026-06-02 13:02:25 -07:00
|
|
|
disks::draw_disks,
|
2026-08-18 22:29:54 -07:00
|
|
|
gpu::{draw_gpu, draw_gpu_compact},
|
2026-08-18 22:47:03 -07:00
|
|
|
header::{HeaderState, build_header, draw_header},
|
2026-06-02 13:02:25 -07:00
|
|
|
mem::draw_mem,
|
|
|
|
|
net::draw_net_spark,
|
2025-08-12 15:52:46 -07:00
|
|
|
swap::draw_swap,
|
2025-08-08 17:25:15 -07:00
|
|
|
};
|
2025-11-17 11:24:32 -08:00
|
|
|
|
2025-09-04 05:30:25 -07:00
|
|
|
use socktop_connector::{
|
|
|
|
|
AgentRequest, AgentResponse, SocktopConnector, connect_to_socktop_agent,
|
|
|
|
|
connect_to_socktop_agent_with_tls,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Constants for minimum intervals to ensure reasonable performance
|
|
|
|
|
const MIN_METRICS_INTERVAL_MS: u64 = 100;
|
|
|
|
|
const MIN_PROCESSES_INTERVAL_MS: u64 = 200;
|
2025-08-08 12:41:32 -07:00
|
|
|
|
2026-08-23 08:11:17 -07:00
|
|
|
/// Floor for the post-kill forced refresh delay: just past the agent's
|
|
|
|
|
/// DEFAULT `Processes` cache TTL of 1500ms, so the answer reflects the kill
|
|
|
|
|
/// instead of the cached snapshot taken before it. The effective delay scales
|
|
|
|
|
/// with the user's processes interval — see [`App::proc_refresh_settle`].
|
|
|
|
|
const PROC_CACHE_SETTLE_FLOOR: Duration = Duration::from_millis(1_600);
|
|
|
|
|
|
|
|
|
|
/// Margin a tombstone outlives the settle window by. With default intervals
|
|
|
|
|
/// this reproduces the original fixed 5s tombstone (1.6s + 3.4s).
|
|
|
|
|
const TOMBSTONE_MARGIN: Duration = Duration::from_millis(3_400);
|
|
|
|
|
|
|
|
|
|
/// How long to keep re-checking a signalled process for its exit. Long enough
|
|
|
|
|
/// to cover a slow shutdown, short enough that a process which plainly ignored
|
|
|
|
|
/// the signal keeps its row.
|
|
|
|
|
const KILL_WATCH_FOR: Duration = Duration::from_secs(5);
|
|
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
/// Budget for one request/response round trip. Replies are matched to
|
|
|
|
|
/// requests by order, so a request that never answers would otherwise hang
|
|
|
|
|
/// `ws.next()` forever and freeze the TUI (raw mode even eats Ctrl+C).
|
|
|
|
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
|
|
2026-06-02 13:02:25 -07:00
|
|
|
/// Drop duplicate-name entries from a disks payload (the agent occasionally
|
|
|
|
|
/// reports a partition twice). Done once when fresh disk data arrives so the
|
|
|
|
|
/// per-frame draw path doesn't have to rebuild a HashSet.
|
|
|
|
|
fn dedup_disks(disks: &mut Vec<socktop_connector::DiskInfo>) {
|
|
|
|
|
let mut seen: std::collections::HashSet<String> =
|
|
|
|
|
std::collections::HashSet::with_capacity(disks.len());
|
|
|
|
|
disks.retain(|d| seen.insert(d.name.clone()));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
/// Outcome of draining input: keep going, or restart the event loop because a
|
|
|
|
|
/// reconnect installed a replacement connection.
|
|
|
|
|
enum InputFlow {
|
|
|
|
|
Continue,
|
|
|
|
|
RestartConnection,
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-15 10:16:47 -07:00
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
|
|
|
pub enum ConnectionState {
|
|
|
|
|
Connected,
|
|
|
|
|
Disconnected,
|
|
|
|
|
Reconnecting,
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-08 12:41:32 -07:00
|
|
|
pub struct App {
|
|
|
|
|
// Latest metrics + histories
|
|
|
|
|
last_metrics: Option<Metrics>,
|
|
|
|
|
|
2026-06-02 13:02:25 -07:00
|
|
|
// CPU avg history (0..100) with a running sum so draw avoids a 600-element fold per frame
|
2025-08-08 12:41:32 -07:00
|
|
|
cpu_hist: VecDeque<u64>,
|
2026-06-02 13:02:25 -07:00
|
|
|
cpu_hist_sum: u64,
|
2025-08-08 12:41:32 -07:00
|
|
|
|
|
|
|
|
// Per-core history (0..100)
|
|
|
|
|
per_core_hist: PerCoreHistory,
|
|
|
|
|
|
|
|
|
|
// Network totals snapshot + histories of KB/s
|
|
|
|
|
last_net_totals: Option<(u64, u64, Instant)>,
|
2026-08-21 12:45:10 -07:00
|
|
|
// Agent-side sample timestamp of the previous snapshot (1.60+ agents).
|
|
|
|
|
last_net_sampled_at_ms: Option<u64>,
|
|
|
|
|
|
|
|
|
|
// Consecutive metrics-request timeouts. One timeout gets a silent stream
|
|
|
|
|
// refresh; a second in a row means the agent accepts connections but
|
|
|
|
|
// never answers, and deserves a persistent error instead of an invisible
|
|
|
|
|
// reconnect loop that starves the UI.
|
|
|
|
|
consecutive_request_timeouts: u32,
|
2025-08-08 12:41:32 -07:00
|
|
|
rx_hist: VecDeque<u64>,
|
|
|
|
|
tx_hist: VecDeque<u64>,
|
|
|
|
|
rx_peak: u64,
|
|
|
|
|
tx_peak: u64,
|
|
|
|
|
|
|
|
|
|
// Quit flag
|
|
|
|
|
should_quit: bool,
|
2025-08-10 23:32:44 -07:00
|
|
|
|
|
|
|
|
pub per_core_scroll: usize,
|
|
|
|
|
pub per_core_drag: Option<PerCoreScrollDrag>, // new: drag state
|
2025-08-12 15:52:46 -07:00
|
|
|
pub procs_scroll_offset: usize,
|
|
|
|
|
pub procs_drag: Option<PerCoreScrollDrag>,
|
|
|
|
|
pub procs_sort_by: ProcSortBy,
|
|
|
|
|
last_procs_area: Option<ratatui::layout::Rect>,
|
|
|
|
|
|
2025-10-02 16:54:27 -07:00
|
|
|
// Process selection state
|
|
|
|
|
pub selected_process_pid: Option<u32>,
|
|
|
|
|
pub selected_process_index: Option<usize>, // Index in the visible/sorted list
|
|
|
|
|
prev_selected_process_pid: Option<u32>, // Track previous selection to detect changes
|
|
|
|
|
|
2025-11-17 11:24:32 -08:00
|
|
|
// Process search state
|
|
|
|
|
pub process_search_active: bool,
|
|
|
|
|
pub process_search_query: String,
|
|
|
|
|
|
2026-06-02 13:02:25 -07:00
|
|
|
// Cached filtered + sorted process indices. Refreshed lazily when any of
|
|
|
|
|
// (metrics, sort order, search query) changes — input handlers, the draw
|
|
|
|
|
// path, and auto-scroll all read from this slice so we avoid rebuilding
|
|
|
|
|
// an indices Vec on every event.
|
|
|
|
|
procs_filtered: Vec<usize>,
|
|
|
|
|
procs_filter_dirty: bool,
|
|
|
|
|
// Pre-formatted process-row strings, rebuilt once per procs poll. Indexed
|
|
|
|
|
// parallel to `last_metrics.top_processes`.
|
|
|
|
|
procs_row_cache: Vec<crate::ui::processes::CachedRow>,
|
|
|
|
|
procs_row_peak_cpu: f32,
|
|
|
|
|
|
2025-08-12 15:52:46 -07:00
|
|
|
last_procs_poll: Instant,
|
2026-08-23 08:11:17 -07:00
|
|
|
/// When set, the next metrics tick polls processes regardless of the
|
|
|
|
|
/// regular cadence. Used after a kill — see refresh_after_kill.
|
|
|
|
|
procs_refresh_due_at: Option<Instant>,
|
|
|
|
|
/// PIDs we have signalled, with the instant we stop watching for their
|
|
|
|
|
/// exit. Re-checked each metrics tick — see poll_kill_watch.
|
|
|
|
|
kill_watch: Vec<(u32, Instant)>,
|
|
|
|
|
/// PIDs confirmed gone after a signal, kept briefly so the agent's cached
|
|
|
|
|
/// process list cannot put them back on screen.
|
|
|
|
|
killed_gone: Vec<(u32, Instant)>,
|
2025-08-12 15:52:46 -07:00
|
|
|
last_disks_poll: Instant,
|
|
|
|
|
procs_interval: Duration,
|
|
|
|
|
disks_interval: Duration,
|
2025-08-21 16:18:41 -07:00
|
|
|
metrics_interval: Duration,
|
2025-08-15 19:21:34 -07:00
|
|
|
|
2025-10-02 16:54:27 -07:00
|
|
|
// Process details polling
|
|
|
|
|
pub process_details: Option<socktop_connector::ProcessMetricsResponse>,
|
|
|
|
|
pub journal_entries: Option<socktop_connector::JournalResponse>,
|
|
|
|
|
pub process_cpu_history: VecDeque<f32>, // CPU history for sparkline (last 60 samples)
|
2026-06-02 13:02:25 -07:00
|
|
|
pub process_cpu_history_sum: f32, // running sum of process_cpu_history
|
2025-10-02 16:54:27 -07:00
|
|
|
pub process_mem_history: VecDeque<u64>, // Memory usage history in bytes (last 60 samples)
|
|
|
|
|
pub process_io_read_history: VecDeque<u64>, // Disk read DELTA history in bytes (last 60 samples)
|
|
|
|
|
pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples)
|
|
|
|
|
last_io_read_bytes: Option<u64>, // Previous read bytes for delta calculation
|
|
|
|
|
last_io_write_bytes: Option<u64>, // Previous write bytes for delta calculation
|
2025-11-17 11:24:32 -08:00
|
|
|
pub max_process_mem_bytes: u64, // Maximum memory usage observed for current process
|
|
|
|
|
pub process_details_unsupported: bool, // Track if agent doesn't support process details
|
2026-08-23 08:11:17 -07:00
|
|
|
/// The agent has successfully answered at least one details request this
|
|
|
|
|
/// session. Distinguishes "this agent is too old" from "that process is
|
|
|
|
|
/// gone", which arrive over the wire as the same error.
|
|
|
|
|
process_details_answered: bool,
|
2025-10-02 16:54:27 -07:00
|
|
|
last_process_details_poll: Instant,
|
|
|
|
|
last_journal_poll: Instant,
|
|
|
|
|
process_details_interval: Duration,
|
|
|
|
|
journal_interval: Duration,
|
|
|
|
|
|
2025-08-15 19:21:34 -07:00
|
|
|
// For reconnects
|
|
|
|
|
ws_url: String,
|
2025-09-15 10:16:47 -07:00
|
|
|
tls_ca: Option<String>,
|
|
|
|
|
verify_hostname: bool,
|
2025-08-21 17:24:41 -07:00
|
|
|
// Security / status flags
|
|
|
|
|
pub is_tls: bool,
|
|
|
|
|
pub has_token: bool,
|
2026-08-24 07:19:37 -07:00
|
|
|
// Whether the local process-kill feature (t = SIGTERM, k = SIGKILL) is
|
|
|
|
|
// available: the connected agent is on this machine AND no policy override
|
|
|
|
|
// (--no-kill / SOCKTOP_NO_KILL) has disabled it.
|
|
|
|
|
pub kill_enabled: bool,
|
2026-08-23 08:11:17 -07:00
|
|
|
// Pending kill awaiting confirmation: (pid, process name). Which signal is
|
|
|
|
|
// sent depends on the button chosen in the confirmation modal, so it isn't
|
|
|
|
|
// decided until then.
|
|
|
|
|
pending_kill: Option<(u32, String)>,
|
2025-09-15 10:16:47 -07:00
|
|
|
|
2026-08-18 22:29:54 -07:00
|
|
|
// --compact: pin the compact layout regardless of window size. Without it the
|
|
|
|
|
// layout switches on its own once the window is too short for the Disks pane.
|
|
|
|
|
force_compact: bool,
|
|
|
|
|
|
2026-06-02 13:02:25 -07:00
|
|
|
// Cached title strings — only rebuilt when source values change so the
|
|
|
|
|
// diff renderer can suppress redraws on idle frames.
|
|
|
|
|
header_title: String,
|
|
|
|
|
header_intervals_text: String,
|
2026-08-18 22:47:03 -07:00
|
|
|
header_key: (String, bool, bool, u128, u128, u16),
|
2026-06-02 13:02:25 -07:00
|
|
|
net_dl_title: String,
|
|
|
|
|
net_dl_key: (u64, u64),
|
|
|
|
|
net_ul_title: String,
|
|
|
|
|
net_ul_key: (u64, u64),
|
|
|
|
|
|
2025-09-15 10:16:47 -07:00
|
|
|
// Modal system
|
|
|
|
|
pub modal_manager: crate::ui::modal::ModalManager,
|
|
|
|
|
|
|
|
|
|
// Connection state tracking
|
|
|
|
|
pub connection_state: ConnectionState,
|
|
|
|
|
last_connection_attempt: Instant,
|
|
|
|
|
original_disconnect_time: Option<Instant>, // Track when we first disconnected
|
|
|
|
|
connection_retry_count: u32,
|
|
|
|
|
last_auto_retry: Option<Instant>, // Track last automatic retry
|
|
|
|
|
replacement_connection: Option<socktop_connector::SocktopConnector>,
|
2025-08-08 12:41:32 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl App {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
last_metrics: None,
|
|
|
|
|
cpu_hist: VecDeque::with_capacity(600),
|
2026-06-02 13:02:25 -07:00
|
|
|
cpu_hist_sum: 0,
|
2025-08-08 12:41:32 -07:00
|
|
|
per_core_hist: PerCoreHistory::new(60),
|
|
|
|
|
last_net_totals: None,
|
2026-08-21 12:45:10 -07:00
|
|
|
last_net_sampled_at_ms: None,
|
|
|
|
|
consecutive_request_timeouts: 0,
|
2025-08-08 12:41:32 -07:00
|
|
|
rx_hist: VecDeque::with_capacity(600),
|
|
|
|
|
tx_hist: VecDeque::with_capacity(600),
|
|
|
|
|
rx_peak: 0,
|
|
|
|
|
tx_peak: 0,
|
|
|
|
|
should_quit: false,
|
2025-08-10 23:32:44 -07:00
|
|
|
per_core_scroll: 0,
|
2025-08-11 22:59:36 -07:00
|
|
|
per_core_drag: None,
|
2025-08-12 15:52:46 -07:00
|
|
|
procs_scroll_offset: 0,
|
|
|
|
|
procs_drag: None,
|
|
|
|
|
procs_sort_by: ProcSortBy::CpuDesc,
|
|
|
|
|
last_procs_area: None,
|
2025-10-02 16:54:27 -07:00
|
|
|
selected_process_pid: None,
|
|
|
|
|
selected_process_index: None,
|
|
|
|
|
prev_selected_process_pid: None,
|
2025-11-17 11:24:32 -08:00
|
|
|
process_search_active: false,
|
|
|
|
|
process_search_query: String::new(),
|
2026-06-02 13:02:25 -07:00
|
|
|
procs_filtered: Vec::new(),
|
|
|
|
|
procs_filter_dirty: true,
|
|
|
|
|
procs_row_cache: Vec::new(),
|
|
|
|
|
procs_row_peak_cpu: 0.0,
|
2026-08-23 08:11:17 -07:00
|
|
|
procs_refresh_due_at: None,
|
|
|
|
|
kill_watch: Vec::new(),
|
|
|
|
|
killed_gone: Vec::new(),
|
2025-08-12 15:52:46 -07:00
|
|
|
last_procs_poll: Instant::now()
|
|
|
|
|
.checked_sub(Duration::from_secs(2))
|
|
|
|
|
.unwrap_or_else(Instant::now), // trigger immediately on first loop
|
|
|
|
|
last_disks_poll: Instant::now()
|
|
|
|
|
.checked_sub(Duration::from_secs(5))
|
|
|
|
|
.unwrap_or_else(Instant::now),
|
|
|
|
|
procs_interval: Duration::from_secs(2),
|
|
|
|
|
disks_interval: Duration::from_secs(5),
|
2025-08-21 16:18:41 -07:00
|
|
|
metrics_interval: Duration::from_millis(500),
|
2025-10-02 16:54:27 -07:00
|
|
|
process_details: None,
|
|
|
|
|
journal_entries: None,
|
|
|
|
|
process_cpu_history: VecDeque::with_capacity(600),
|
2026-06-02 13:02:25 -07:00
|
|
|
process_cpu_history_sum: 0.0,
|
2025-10-02 16:54:27 -07:00
|
|
|
process_mem_history: VecDeque::with_capacity(600),
|
|
|
|
|
process_io_read_history: VecDeque::with_capacity(600),
|
|
|
|
|
process_io_write_history: VecDeque::with_capacity(600),
|
|
|
|
|
last_io_read_bytes: None,
|
|
|
|
|
last_io_write_bytes: None,
|
2025-11-17 11:24:32 -08:00
|
|
|
max_process_mem_bytes: 0,
|
2025-10-02 16:54:27 -07:00
|
|
|
process_details_unsupported: false,
|
2026-08-23 08:11:17 -07:00
|
|
|
process_details_answered: false,
|
2025-10-02 16:54:27 -07:00
|
|
|
last_process_details_poll: Instant::now()
|
|
|
|
|
.checked_sub(Duration::from_secs(10))
|
|
|
|
|
.unwrap_or_else(Instant::now),
|
|
|
|
|
last_journal_poll: Instant::now()
|
|
|
|
|
.checked_sub(Duration::from_secs(10))
|
|
|
|
|
.unwrap_or_else(Instant::now),
|
|
|
|
|
process_details_interval: Duration::from_millis(500),
|
|
|
|
|
journal_interval: Duration::from_secs(5),
|
2025-08-15 19:21:34 -07:00
|
|
|
ws_url: String::new(),
|
2025-09-15 10:16:47 -07:00
|
|
|
tls_ca: None,
|
|
|
|
|
verify_hostname: false,
|
2025-08-21 17:24:41 -07:00
|
|
|
is_tls: false,
|
|
|
|
|
has_token: false,
|
2026-08-24 07:19:37 -07:00
|
|
|
kill_enabled: false,
|
2026-08-23 08:11:17 -07:00
|
|
|
pending_kill: None,
|
2026-08-18 22:29:54 -07:00
|
|
|
force_compact: false,
|
2026-06-02 13:02:25 -07:00
|
|
|
header_title: String::new(),
|
|
|
|
|
header_intervals_text: String::new(),
|
2026-08-18 22:47:03 -07:00
|
|
|
header_key: (String::new(), false, false, u128::MAX, u128::MAX, u16::MAX),
|
2026-06-02 13:02:25 -07:00
|
|
|
net_dl_title: String::new(),
|
|
|
|
|
net_dl_key: (u64::MAX, u64::MAX),
|
|
|
|
|
net_ul_title: String::new(),
|
|
|
|
|
net_ul_key: (u64::MAX, u64::MAX),
|
2025-09-15 10:16:47 -07:00
|
|
|
modal_manager: ModalManager::new(),
|
|
|
|
|
connection_state: ConnectionState::Disconnected,
|
|
|
|
|
last_connection_attempt: Instant::now(),
|
|
|
|
|
original_disconnect_time: None,
|
|
|
|
|
connection_retry_count: 0,
|
|
|
|
|
last_auto_retry: None,
|
|
|
|
|
replacement_connection: None,
|
2025-08-08 12:41:32 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 22:29:54 -07:00
|
|
|
/// Pins the compact layout at any window size (`--compact`).
|
|
|
|
|
pub fn with_compact(mut self, force_compact: bool) -> Self {
|
|
|
|
|
self.force_compact = force_compact;
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Pane rects for the current frame. The draw path and the mouse/key hit-testing
|
|
|
|
|
/// paths all go through here so they cannot disagree about where a pane is.
|
|
|
|
|
fn layout(&self, area: Rect) -> AppLayout {
|
|
|
|
|
let has_gpu = self
|
|
|
|
|
.last_metrics
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|m| m.gpus.as_ref())
|
|
|
|
|
.is_some_and(|g| !g.is_empty());
|
|
|
|
|
compute_layout(area, self.force_compact, has_gpu)
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-21 16:18:41 -07:00
|
|
|
pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self {
|
2025-09-04 05:30:25 -07:00
|
|
|
metrics_ms.inspect(|&m| {
|
|
|
|
|
self.metrics_interval = Duration::from_millis(m.max(MIN_METRICS_INTERVAL_MS));
|
|
|
|
|
});
|
|
|
|
|
procs_ms.inspect(|&p| {
|
|
|
|
|
self.procs_interval = Duration::from_millis(p.max(MIN_PROCESSES_INTERVAL_MS));
|
|
|
|
|
});
|
2025-08-21 16:18:41 -07:00
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-21 17:24:41 -07:00
|
|
|
pub fn with_status(mut self, is_tls: bool, has_token: bool) -> Self {
|
|
|
|
|
self.is_tls = is_tls;
|
|
|
|
|
self.has_token = has_token;
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 08:11:17 -07:00
|
|
|
/// Enable the local process-kill feature. Only set true when the agent has
|
2026-08-24 07:19:37 -07:00
|
|
|
/// been verified to be on this machine (see [`crate::local`]) and no
|
|
|
|
|
/// policy override (`--no-kill`, `SOCKTOP_NO_KILL`) forbids it.
|
|
|
|
|
pub fn with_kill_enabled(mut self, kill_enabled: bool) -> Self {
|
|
|
|
|
self.kill_enabled = kill_enabled;
|
2026-08-23 08:11:17 -07:00
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Look up the display name of a process by PID. Prefers the details
|
|
|
|
|
/// payload, which is the only source that has a name for a process not in
|
|
|
|
|
/// the top-N list — e.g. after walking up to a parent from the details
|
|
|
|
|
/// modal.
|
|
|
|
|
fn process_name_for_pid(&self, pid: u32) -> Option<String> {
|
|
|
|
|
if let Some(details) = self
|
|
|
|
|
.process_details
|
|
|
|
|
.as_ref()
|
|
|
|
|
.filter(|d| d.process.pid == pid)
|
|
|
|
|
{
|
|
|
|
|
return Some(details.process.name.clone());
|
|
|
|
|
}
|
|
|
|
|
self.last_metrics
|
|
|
|
|
.as_ref()?
|
|
|
|
|
.top_processes
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|p| p.pid == pid)
|
|
|
|
|
.map(|p| p.name.clone())
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 07:19:37 -07:00
|
|
|
/// Raise the kill confirmation for `pid`. No-op unless the kill feature is
|
|
|
|
|
/// enabled — the same gate the keybinding uses, repeated here because this
|
2026-08-23 08:11:17 -07:00
|
|
|
/// is also reachable from the details modal.
|
|
|
|
|
fn prompt_kill(&mut self, pid: u32) {
|
2026-08-24 07:19:37 -07:00
|
|
|
if !self.kill_enabled {
|
2026-08-23 08:11:17 -07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let name = self
|
|
|
|
|
.process_name_for_pid(pid)
|
|
|
|
|
.unwrap_or_else(|| "process".to_string());
|
|
|
|
|
self.modal_manager.push_modal(ModalType::Confirmation {
|
|
|
|
|
title: "Confirm signal".to_string(),
|
|
|
|
|
message: format!("Send a signal to {name} (PID {pid})?"),
|
|
|
|
|
confirm_text: "Terminate".to_string(),
|
|
|
|
|
cancel_text: "Cancel".to_string(),
|
|
|
|
|
});
|
|
|
|
|
self.pending_kill = Some((pid, name));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Signal the process the confirmation was raised for, then report the
|
|
|
|
|
/// outcome. Pops the confirmation first so the result lands on top of
|
|
|
|
|
/// whatever was underneath it (the process list, or the details modal).
|
|
|
|
|
fn run_pending_kill(&mut self, signal: KillSignal) {
|
|
|
|
|
let Some((pid, name)) = self.pending_kill.take() else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
self.modal_manager.pop_modal();
|
|
|
|
|
// The name shown in the confirmation doubles as the reuse guard: if
|
|
|
|
|
// the PID has been recycled since, the kill is refused. The "process"
|
|
|
|
|
// fallback from prompt_kill means "name unknown" — no guard possible.
|
|
|
|
|
let expected = (name != "process").then_some(name.as_str());
|
|
|
|
|
let (title, message) = match kill_local_process(pid, expected, signal) {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
self.refresh_after_kill(pid);
|
|
|
|
|
(
|
|
|
|
|
"Signal sent".to_string(),
|
|
|
|
|
format!("Sent {} to {name} (PID {pid}).", signal.label()),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
Err(e) => ("Signal failed".to_string(), e),
|
|
|
|
|
};
|
|
|
|
|
self.modal_manager
|
|
|
|
|
.push_modal(ModalType::Info { title, message });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Bring the process list back in step with reality after a signal.
|
|
|
|
|
///
|
|
|
|
|
/// A single check at signal time is not enough, which is what the first
|
|
|
|
|
/// version got wrong: SIGTERM is a *request*, so the process is usually
|
|
|
|
|
/// still alive for the few hundred milliseconds it takes to wind down. The
|
|
|
|
|
/// row therefore stayed put, and the list looked like the kill had done
|
|
|
|
|
/// nothing.
|
|
|
|
|
///
|
|
|
|
|
/// So the PID goes on a watch list, re-checked every metrics tick until it
|
|
|
|
|
/// exits (or the watch expires). Confirmed-gone PIDs are also remembered
|
|
|
|
|
/// briefly — see `killed_gone` — because the agent serves `Processes` from
|
|
|
|
|
/// a 1500ms cache and would otherwise hand back a snapshot taken before
|
|
|
|
|
/// the kill and put the row straight back.
|
|
|
|
|
fn refresh_after_kill(&mut self, pid: u32) {
|
|
|
|
|
self.kill_watch.retain(|(p, _)| *p != pid);
|
|
|
|
|
self.kill_watch.push((pid, Instant::now() + KILL_WATCH_FOR));
|
|
|
|
|
// Check once right now: SIGKILL, and anything already exiting, is gone
|
|
|
|
|
// by the time the confirmation is dismissed.
|
|
|
|
|
self.poll_kill_watch();
|
|
|
|
|
self.procs_refresh_due_at = Some(Instant::now() + self.proc_refresh_settle());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// How long the post-kill forced refresh waits, and the base of the
|
|
|
|
|
/// tombstone lifetime. Scales with the user's processes interval: someone
|
|
|
|
|
/// who raised the agent's Processes TTL will have raised their client
|
|
|
|
|
/// interval to match (there is no point polling faster than the cache),
|
|
|
|
|
/// so the interval is the best client-side signal for how stale an agent
|
|
|
|
|
/// snapshot can be. Never below the default-TTL floor.
|
|
|
|
|
fn proc_refresh_settle(&self) -> Duration {
|
|
|
|
|
PROC_CACHE_SETTLE_FLOOR.max(self.procs_interval)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// How long a confirmed-dead PID is remembered, so a cached agent snapshot
|
|
|
|
|
/// taken before the kill cannot resurrect its row. Must outlive the settle
|
|
|
|
|
/// window plus one round trip, hence settle + margin.
|
|
|
|
|
fn kill_tombstone_for(&self) -> Duration {
|
|
|
|
|
self.proc_refresh_settle() + TOMBSTONE_MARGIN
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Re-check the processes we have signalled and retire the rows of any that
|
|
|
|
|
/// have since exited. Cheap: one `/proc` lookup per watched PID, and the
|
|
|
|
|
/// list is almost always empty.
|
|
|
|
|
fn poll_kill_watch(&mut self) {
|
|
|
|
|
if self.kill_watch.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let now = Instant::now();
|
|
|
|
|
let mut gone = Vec::new();
|
|
|
|
|
self.kill_watch.retain(|(pid, deadline)| {
|
|
|
|
|
if !crate::proc_kill::process_exists(*pid) {
|
|
|
|
|
gone.push(*pid);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
// Still alive. Keep watching until the deadline — a process that
|
|
|
|
|
// ignores the signal outright should keep its row.
|
|
|
|
|
now < *deadline
|
|
|
|
|
});
|
|
|
|
|
for pid in gone {
|
|
|
|
|
self.forget_process_row(pid);
|
|
|
|
|
// Nothing left to show details for. Also matters mechanically: the
|
|
|
|
|
// details poll keys off the selection, which forget_process_row
|
|
|
|
|
// just cleared, so leaving the modal open would freeze it on the
|
|
|
|
|
// dead process's last sample.
|
|
|
|
|
self.close_details_for_gone_process(pid);
|
|
|
|
|
self.killed_gone.push((pid, now));
|
|
|
|
|
}
|
|
|
|
|
let tombstone_for = self.kill_tombstone_for();
|
|
|
|
|
self.killed_gone
|
|
|
|
|
.retain(|(_, at)| now.duration_since(*at) < tombstone_for);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Drop rows for processes we have confirmed dead. Applied to every process
|
|
|
|
|
/// list the agent sends, because its cached snapshot can predate the kill.
|
|
|
|
|
fn drop_tombstoned_rows(&mut self) {
|
|
|
|
|
if self.killed_gone.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let now = Instant::now();
|
|
|
|
|
let tombstone_for = self.kill_tombstone_for();
|
|
|
|
|
self.killed_gone
|
|
|
|
|
.retain(|(_, at)| now.duration_since(*at) < tombstone_for);
|
|
|
|
|
let pids: Vec<u32> = self.killed_gone.iter().map(|(p, _)| *p).collect();
|
|
|
|
|
for pid in pids {
|
|
|
|
|
self.forget_process_row(pid);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Give up a selection whose process is no longer in the list. `Processes`
|
|
|
|
|
/// carries every process, not a top-N window, so a PID that is absent has
|
|
|
|
|
/// genuinely gone — and a selection pointing at it means the hint offers to
|
|
|
|
|
/// kill a corpse and `t` reports "no longer exists".
|
|
|
|
|
fn drop_vanished_selection(&mut self) {
|
|
|
|
|
let Some(pid) = self.selected_process_pid else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let present = self
|
|
|
|
|
.last_metrics
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(|m| m.top_processes.iter().any(|p| p.pid == pid));
|
|
|
|
|
if !present {
|
|
|
|
|
self.selected_process_pid = None;
|
|
|
|
|
self.selected_process_index = None;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Close the details view for a process that no longer exists, and drop the
|
|
|
|
|
/// data collected for it.
|
|
|
|
|
///
|
|
|
|
|
/// A parent-navigation chain can leave another details view underneath
|
|
|
|
|
/// (child → P → parent killed): the resurfacing view must resume polling,
|
|
|
|
|
/// so retarget the selection to it — the same thing SwitchToParentProcess
|
|
|
|
|
/// does on the way down. Without this the child view came back with no
|
|
|
|
|
/// selection (forget_process_row had just cleared it) and wiped data, and
|
|
|
|
|
/// the selection-gated details poll never refilled it: a frozen, orphaned
|
|
|
|
|
/// window.
|
|
|
|
|
fn close_details_for_gone_process(&mut self, pid: u32) {
|
|
|
|
|
if self.modal_manager.close_process_details(pid) {
|
|
|
|
|
self.clear_process_details();
|
|
|
|
|
if let Some(next_pid) = self.modal_manager.topmost_process_details() {
|
|
|
|
|
self.selected_process_pid = Some(next_pid);
|
|
|
|
|
// Fire the details poll on the next tick rather than waiting
|
|
|
|
|
// out the interval.
|
|
|
|
|
self.last_process_details_poll = Instant::now()
|
|
|
|
|
.checked_sub(self.process_details_interval)
|
|
|
|
|
.unwrap_or_else(Instant::now);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Drop a process from the cached view without waiting for the agent, and
|
|
|
|
|
/// give up any selection pointing at it — a hint offering to kill a process
|
|
|
|
|
/// that no longer exists is worse than no hint.
|
|
|
|
|
fn forget_process_row(&mut self, pid: u32) {
|
|
|
|
|
let Some(m) = self.last_metrics.as_mut() else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let before = m.top_processes.len();
|
|
|
|
|
m.top_processes.retain(|p| p.pid != pid);
|
|
|
|
|
if m.top_processes.len() == before {
|
|
|
|
|
return; // wasn't on screen; nothing to reconcile
|
|
|
|
|
}
|
|
|
|
|
// Keep the header's "(N total)" honest until the next real poll.
|
|
|
|
|
m.process_count = m.process_count.map(|c| c.saturating_sub(1));
|
|
|
|
|
if self.selected_process_pid == Some(pid) {
|
|
|
|
|
self.selected_process_pid = None;
|
|
|
|
|
self.selected_process_index = None;
|
|
|
|
|
}
|
|
|
|
|
self.invalidate_procs_filter();
|
|
|
|
|
if let Some(mm) = self.last_metrics.as_ref() {
|
|
|
|
|
self.procs_row_peak_cpu =
|
|
|
|
|
crate::ui::processes::rebuild_row_cache(mm, &mut self.procs_row_cache);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-15 10:16:47 -07:00
|
|
|
/// Show a connection error modal
|
|
|
|
|
pub fn show_connection_error(&mut self, message: String) {
|
|
|
|
|
if !self.modal_manager.is_active() {
|
|
|
|
|
self.connection_state = ConnectionState::Disconnected;
|
|
|
|
|
// Set original disconnect time if this is the first disconnect
|
|
|
|
|
if self.original_disconnect_time.is_none() {
|
|
|
|
|
self.original_disconnect_time = Some(Instant::now());
|
|
|
|
|
}
|
|
|
|
|
self.modal_manager.push_modal(ModalType::ConnectionError {
|
|
|
|
|
message,
|
|
|
|
|
disconnected_at: self.original_disconnect_time.unwrap(),
|
|
|
|
|
retry_count: self.connection_retry_count,
|
|
|
|
|
auto_retry_countdown: self.seconds_until_next_auto_retry(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Attempt to retry the connection
|
|
|
|
|
pub async fn retry_connection(&mut self) {
|
|
|
|
|
// This method is called from the normal event loop when connection is lost during operation
|
|
|
|
|
self.connection_retry_count += 1;
|
|
|
|
|
self.last_connection_attempt = Instant::now();
|
|
|
|
|
self.connection_state = ConnectionState::Reconnecting;
|
|
|
|
|
|
|
|
|
|
// Show retrying message
|
|
|
|
|
if self.modal_manager.is_active() {
|
|
|
|
|
self.modal_manager.pop_modal(); // Remove old modal
|
|
|
|
|
}
|
|
|
|
|
self.modal_manager.push_modal(ModalType::ConnectionError {
|
|
|
|
|
message: "Retrying connection...".to_string(),
|
|
|
|
|
disconnected_at: self
|
|
|
|
|
.original_disconnect_time
|
|
|
|
|
.unwrap_or(self.last_connection_attempt),
|
|
|
|
|
retry_count: self.connection_retry_count,
|
|
|
|
|
auto_retry_countdown: self.seconds_until_next_auto_retry(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Actually attempt to reconnect using stored parameters
|
|
|
|
|
let tls_ca_ref = self.tls_ca.as_deref();
|
|
|
|
|
match self
|
|
|
|
|
.try_connect(&self.ws_url, tls_ca_ref, self.verify_hostname)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(new_ws) => {
|
|
|
|
|
// Connection successful! Store the new connection for the event loop to pick up
|
|
|
|
|
self.replacement_connection = Some(new_ws);
|
|
|
|
|
self.mark_connected();
|
|
|
|
|
// The event loop will detect this and restart with the new connection
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
// Connection failed, update modal with error
|
|
|
|
|
self.modal_manager.pop_modal(); // Remove retrying modal
|
|
|
|
|
self.modal_manager.push_modal(ModalType::ConnectionError {
|
|
|
|
|
message: format!("Retry failed: {e}"),
|
|
|
|
|
disconnected_at: self
|
|
|
|
|
.original_disconnect_time
|
|
|
|
|
.unwrap_or(self.last_connection_attempt),
|
|
|
|
|
retry_count: self.connection_retry_count,
|
|
|
|
|
auto_retry_countdown: self.seconds_until_next_auto_retry(),
|
|
|
|
|
});
|
|
|
|
|
self.connection_state = ConnectionState::Disconnected;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
/// A request produced no reply in time. Any late reply would desync every
|
|
|
|
|
/// subsequent request/response on this stream (replies are matched to
|
|
|
|
|
/// requests purely by order), so treat the connection as poisoned and go
|
|
|
|
|
/// through the standard reconnect flow — a fresh stream is realigned by
|
|
|
|
|
/// construction.
|
|
|
|
|
async fn poison_connection(&mut self, what: &str) {
|
|
|
|
|
self.show_connection_error(format!("{what}; reconnecting…"));
|
|
|
|
|
self.retry_connection().await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Replace the connection WITHOUT any modal or state churn.
|
|
|
|
|
///
|
|
|
|
|
/// For timeouts on the optional per-process endpoints: an old agent
|
|
|
|
|
/// ignores those messages entirely (no late reply, so no desync), but a
|
|
|
|
|
/// merely-slow agent would desync the stream — indistinguishable at
|
|
|
|
|
/// timeout time, so we still swap to a fresh stream, silently. The
|
|
|
|
|
/// ProcessDetails modal keeps showing its "Agent Update Required"
|
|
|
|
|
/// message instead of being buried under a connection-error modal.
|
|
|
|
|
/// Only a failed reconnect (connection genuinely dead) surfaces loudly.
|
|
|
|
|
async fn quiet_reconnect(&mut self) {
|
|
|
|
|
let tls_ca_ref = self.tls_ca.as_deref();
|
|
|
|
|
match self
|
|
|
|
|
.try_connect(&self.ws_url, tls_ca_ref, self.verify_hostname)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(ws) => {
|
|
|
|
|
self.replacement_connection = Some(ws);
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
self.show_connection_error(format!("Reconnect failed: {e}"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-15 10:16:47 -07:00
|
|
|
/// Mark connection as successful and dismiss any error modals
|
|
|
|
|
pub fn mark_connected(&mut self) {
|
|
|
|
|
if self.connection_state != ConnectionState::Connected {
|
|
|
|
|
self.connection_state = ConnectionState::Connected;
|
|
|
|
|
self.connection_retry_count = 0;
|
|
|
|
|
self.original_disconnect_time = None; // Clear the original disconnect time
|
|
|
|
|
self.last_auto_retry = None; // Clear auto retry timer
|
|
|
|
|
// Remove connection error modal if it exists
|
|
|
|
|
if self.modal_manager.is_active() {
|
|
|
|
|
self.modal_manager.pop_modal();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Compute retry timing using pure policy function.
|
|
|
|
|
fn current_retry_timing(&self) -> RetryTiming {
|
|
|
|
|
compute_retry_timing(
|
|
|
|
|
self.connection_state == ConnectionState::Disconnected,
|
|
|
|
|
self.modal_manager.is_active(),
|
|
|
|
|
self.original_disconnect_time,
|
|
|
|
|
self.last_auto_retry,
|
|
|
|
|
Instant::now(),
|
|
|
|
|
Duration::from_secs(30),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check if we should perform an automatic retry (every 30 seconds)
|
|
|
|
|
pub fn should_auto_retry(&self) -> bool {
|
|
|
|
|
self.current_retry_timing().should_retry_now
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get seconds until next automatic retry (returns None if inactive)
|
|
|
|
|
pub fn seconds_until_next_auto_retry(&self) -> Option<u64> {
|
|
|
|
|
self.current_retry_timing().seconds_until_retry
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Perform automatic retry
|
|
|
|
|
pub async fn auto_retry_connection(&mut self) {
|
|
|
|
|
self.last_auto_retry = Some(Instant::now());
|
|
|
|
|
let tls_ca_ref = self.tls_ca.as_deref();
|
|
|
|
|
|
|
|
|
|
// Increment retry count for auto retries too
|
|
|
|
|
self.connection_retry_count += 1;
|
|
|
|
|
|
|
|
|
|
// Show retrying modal
|
|
|
|
|
self.modal_manager.pop_modal();
|
|
|
|
|
self.modal_manager.push_modal(ModalType::ConnectionError {
|
|
|
|
|
message: "Auto-retrying connection...".to_string(),
|
|
|
|
|
disconnected_at: self.original_disconnect_time.unwrap_or(Instant::now()),
|
|
|
|
|
retry_count: self.connection_retry_count,
|
|
|
|
|
auto_retry_countdown: self.seconds_until_next_auto_retry(),
|
|
|
|
|
});
|
|
|
|
|
self.connection_state = ConnectionState::Reconnecting;
|
|
|
|
|
|
|
|
|
|
// Attempt connection
|
|
|
|
|
match self
|
|
|
|
|
.try_connect(&self.ws_url, tls_ca_ref, self.verify_hostname)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(new_ws) => {
|
|
|
|
|
// Connection successful! Store the new connection for the event loop to pick up
|
|
|
|
|
self.replacement_connection = Some(new_ws);
|
|
|
|
|
self.mark_connected();
|
|
|
|
|
// The event loop will detect this and restart with the new connection
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
// Connection failed, update modal with error
|
|
|
|
|
self.modal_manager.pop_modal(); // Remove retrying modal
|
|
|
|
|
self.modal_manager.push_modal(ModalType::ConnectionError {
|
|
|
|
|
message: format!("Auto-retry failed: {e}"),
|
|
|
|
|
disconnected_at: self
|
|
|
|
|
.original_disconnect_time
|
|
|
|
|
.unwrap_or(self.last_connection_attempt),
|
|
|
|
|
retry_count: self.connection_retry_count,
|
|
|
|
|
auto_retry_countdown: self.seconds_until_next_auto_retry(),
|
|
|
|
|
});
|
|
|
|
|
self.connection_state = ConnectionState::Disconnected;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 01:23:20 -07:00
|
|
|
pub async fn run(
|
|
|
|
|
&mut self,
|
|
|
|
|
url: &str,
|
|
|
|
|
tls_ca: Option<&str>,
|
2025-09-04 05:30:25 -07:00
|
|
|
verify_hostname: bool,
|
2025-08-16 01:23:20 -07:00
|
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
2025-08-15 19:21:34 -07:00
|
|
|
self.ws_url = url.to_string();
|
2025-09-15 10:16:47 -07:00
|
|
|
self.tls_ca = tls_ca.map(|s| s.to_string());
|
|
|
|
|
self.verify_hostname = verify_hostname;
|
2025-08-08 12:41:32 -07:00
|
|
|
|
2025-09-15 10:16:47 -07:00
|
|
|
// Terminal setup first - so we can show connection error modals
|
2025-08-08 12:41:32 -07:00
|
|
|
enable_raw_mode()?;
|
|
|
|
|
let mut stdout = io::stdout();
|
2025-08-10 23:32:44 -07:00
|
|
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
2025-08-08 12:41:32 -07:00
|
|
|
let backend = CrosstermBackend::new(stdout);
|
|
|
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
|
terminal.clear()?;
|
|
|
|
|
|
2025-09-15 10:16:47 -07:00
|
|
|
// Try to connect to agent
|
|
|
|
|
let ws = match self.try_connect(url, tls_ca, verify_hostname).await {
|
|
|
|
|
Ok(connector) => connector,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
// Show initial connection error and enter the error loop until user exits or we connect.
|
|
|
|
|
self.show_connection_error(format!("Initial connection failed: {e}"));
|
|
|
|
|
if let Err(err) = self
|
|
|
|
|
.run_with_connection_error(&mut terminal, url, tls_ca, verify_hostname)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
// Terminal teardown then propagate error
|
|
|
|
|
disable_raw_mode()?;
|
|
|
|
|
execute!(
|
|
|
|
|
terminal.backend_mut(),
|
|
|
|
|
LeaveAlternateScreen,
|
|
|
|
|
DisableMouseCapture
|
|
|
|
|
)?;
|
|
|
|
|
terminal.show_cursor()?;
|
|
|
|
|
return Err(err);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If user chose to exit during error loop, mark quit and teardown.
|
|
|
|
|
if self.should_quit || self.connection_state != ConnectionState::Connected {
|
|
|
|
|
disable_raw_mode()?;
|
|
|
|
|
execute!(
|
|
|
|
|
terminal.backend_mut(),
|
|
|
|
|
LeaveAlternateScreen,
|
|
|
|
|
DisableMouseCapture
|
|
|
|
|
)?;
|
|
|
|
|
terminal.show_cursor()?;
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// We should have a replacement connection after successful retry.
|
|
|
|
|
match self.replacement_connection.take() {
|
|
|
|
|
Some(conn) => conn,
|
|
|
|
|
None => {
|
|
|
|
|
// Defensive: no connector despite Connected state; exit gracefully.
|
|
|
|
|
disable_raw_mode()?;
|
|
|
|
|
execute!(
|
|
|
|
|
terminal.backend_mut(),
|
|
|
|
|
LeaveAlternateScreen,
|
|
|
|
|
DisableMouseCapture
|
|
|
|
|
)?;
|
|
|
|
|
terminal.show_cursor()?;
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Connection successful, mark as connected
|
|
|
|
|
self.mark_connected();
|
|
|
|
|
|
2025-08-08 12:41:32 -07:00
|
|
|
// Main loop
|
2025-09-15 10:16:47 -07:00
|
|
|
let res = self.event_loop(&mut terminal, ws).await;
|
2025-08-08 12:41:32 -07:00
|
|
|
|
|
|
|
|
// Teardown
|
|
|
|
|
disable_raw_mode()?;
|
2025-09-15 10:16:47 -07:00
|
|
|
execute!(
|
|
|
|
|
terminal.backend_mut(),
|
|
|
|
|
LeaveAlternateScreen,
|
|
|
|
|
DisableMouseCapture
|
|
|
|
|
)?;
|
2025-08-08 12:41:32 -07:00
|
|
|
terminal.show_cursor()?;
|
|
|
|
|
res
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-15 10:16:47 -07:00
|
|
|
/// Helper method to attempt connection
|
|
|
|
|
async fn try_connect(
|
|
|
|
|
&self,
|
|
|
|
|
url: &str,
|
|
|
|
|
tls_ca: Option<&str>,
|
|
|
|
|
verify_hostname: bool,
|
|
|
|
|
) -> Result<SocktopConnector, Box<dyn std::error::Error>> {
|
|
|
|
|
if let Some(ca_path) = tls_ca {
|
|
|
|
|
Ok(connect_to_socktop_agent_with_tls(url, ca_path, verify_hostname).await?)
|
|
|
|
|
} else {
|
|
|
|
|
Ok(connect_to_socktop_agent(url).await?)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Run the app with a connection error modal from the start
|
|
|
|
|
async fn run_with_connection_error<B: ratatui::backend::Backend>(
|
|
|
|
|
&mut self,
|
|
|
|
|
terminal: &mut Terminal<B>,
|
|
|
|
|
_url: &str,
|
|
|
|
|
_tls_ca: Option<&str>,
|
|
|
|
|
_verify_hostname: bool,
|
2026-06-02 13:02:25 -07:00
|
|
|
) -> Result<(), Box<dyn std::error::Error>>
|
|
|
|
|
where
|
|
|
|
|
<B as ratatui::backend::Backend>::Error: 'static,
|
|
|
|
|
{
|
2025-09-15 10:16:47 -07:00
|
|
|
loop {
|
|
|
|
|
// Handle input for modal
|
|
|
|
|
while event::poll(Duration::from_millis(10))? {
|
|
|
|
|
if let Event::Key(k) = event::read()? {
|
|
|
|
|
let action = self.modal_manager.handle_key(k.code);
|
|
|
|
|
match action {
|
|
|
|
|
ModalAction::ExitApp => {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
ModalAction::RetryConnection => {
|
|
|
|
|
// Show "Retrying..." message
|
|
|
|
|
self.modal_manager.pop_modal(); // Remove old modal
|
|
|
|
|
self.modal_manager.push_modal(ModalType::ConnectionError {
|
|
|
|
|
message: "Retrying connection...".to_string(),
|
|
|
|
|
disconnected_at: self
|
|
|
|
|
.original_disconnect_time
|
|
|
|
|
.unwrap_or(self.last_connection_attempt),
|
|
|
|
|
retry_count: self.connection_retry_count,
|
|
|
|
|
auto_retry_countdown: self.seconds_until_next_auto_retry(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Force a redraw to show the retrying message
|
|
|
|
|
terminal.draw(|f| self.draw(f))?;
|
|
|
|
|
|
|
|
|
|
// Update retry count
|
|
|
|
|
self.connection_retry_count += 1;
|
|
|
|
|
self.last_connection_attempt = Instant::now();
|
|
|
|
|
|
|
|
|
|
// Try to reconnect using stored parameters
|
|
|
|
|
let tls_ca_ref = self.tls_ca.as_deref();
|
|
|
|
|
match self
|
|
|
|
|
.try_connect(&self.ws_url, tls_ca_ref, self.verify_hostname)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(ws) => {
|
|
|
|
|
// Connection successful!
|
|
|
|
|
// Show success message briefly
|
|
|
|
|
self.modal_manager.pop_modal(); // Remove retrying modal
|
|
|
|
|
self.modal_manager.push_modal(ModalType::ConnectionError {
|
|
|
|
|
message: "Connection restored! Starting...".to_string(),
|
|
|
|
|
disconnected_at: self
|
|
|
|
|
.original_disconnect_time
|
|
|
|
|
.unwrap_or(self.last_connection_attempt),
|
|
|
|
|
retry_count: self.connection_retry_count,
|
|
|
|
|
auto_retry_countdown: self.seconds_until_next_auto_retry(),
|
|
|
|
|
});
|
|
|
|
|
terminal.draw(|f| self.draw(f))?;
|
|
|
|
|
sleep(Duration::from_millis(500)).await; // Brief pause to show success
|
|
|
|
|
|
|
|
|
|
// Explicitly clear all modals first
|
|
|
|
|
while self.modal_manager.is_active() {
|
|
|
|
|
self.modal_manager.pop_modal();
|
|
|
|
|
}
|
|
|
|
|
// Mark as connected (this also clears modals but let's be explicit)
|
|
|
|
|
self.mark_connected();
|
|
|
|
|
// Force a redraw to show the cleared state
|
|
|
|
|
terminal.draw(|f| self.draw(f))?;
|
|
|
|
|
// Start normal event loop
|
|
|
|
|
return self.event_loop(terminal, ws).await;
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
// Update modal with new error and retry count
|
|
|
|
|
self.modal_manager.pop_modal(); // Remove retrying modal
|
|
|
|
|
self.modal_manager.push_modal(ModalType::ConnectionError {
|
|
|
|
|
message: format!("Retry failed: {e}"),
|
|
|
|
|
disconnected_at: self
|
|
|
|
|
.original_disconnect_time
|
|
|
|
|
.unwrap_or(self.last_connection_attempt),
|
|
|
|
|
retry_count: self.connection_retry_count,
|
|
|
|
|
auto_retry_countdown: self.seconds_until_next_auto_retry(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for automatic retry (every 30 seconds)
|
|
|
|
|
if self.should_auto_retry() {
|
|
|
|
|
self.auto_retry_connection().await;
|
|
|
|
|
// If auto-retry succeeded, transition directly into the normal event loop
|
|
|
|
|
if let Some(ws) = self.replacement_connection.take() {
|
|
|
|
|
// Ensure we are marked connected (auto_retry_connection already does this)
|
|
|
|
|
// Start the normal event loop using the newly established connection
|
|
|
|
|
return self.event_loop(terminal, ws).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update countdown for connection error modal if active
|
|
|
|
|
if self.modal_manager.is_active() {
|
|
|
|
|
self.modal_manager
|
|
|
|
|
.update_connection_error_countdown(self.seconds_until_next_auto_retry());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw the modal
|
|
|
|
|
terminal.draw(|f| self.draw(f))?;
|
|
|
|
|
sleep(Duration::from_millis(50)).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-08 12:41:32 -07:00
|
|
|
async fn event_loop<B: ratatui::backend::Backend>(
|
2025-09-15 10:16:47 -07:00
|
|
|
&mut self,
|
|
|
|
|
terminal: &mut Terminal<B>,
|
|
|
|
|
mut ws: SocktopConnector,
|
2026-06-02 13:02:25 -07:00
|
|
|
) -> Result<(), Box<dyn std::error::Error>>
|
|
|
|
|
where
|
|
|
|
|
<B as ratatui::backend::Backend>::Error: 'static,
|
|
|
|
|
{
|
2025-09-15 10:16:47 -07:00
|
|
|
loop {
|
|
|
|
|
// Main event loop
|
|
|
|
|
let result = self.run_event_loop_iteration(terminal, &mut ws).await;
|
|
|
|
|
|
|
|
|
|
// Check if we need to restart with a new connection
|
|
|
|
|
if let Some(new_ws) = self.replacement_connection.take() {
|
|
|
|
|
ws = new_ws;
|
|
|
|
|
continue; // Restart the loop with new connection
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If we get here and there's no replacement, return the result
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
/// Drains and handles every queued terminal event (keys, mouse). Returns
|
|
|
|
|
/// whether the caller must restart the event loop on a replacement
|
|
|
|
|
/// connection. Extracted from the loop body so the tick wait can process
|
|
|
|
|
/// input at ~30ms latency instead of letting it queue for a whole
|
|
|
|
|
/// metrics interval.
|
|
|
|
|
async fn drain_input<B: ratatui::backend::Backend>(
|
2025-08-08 12:41:32 -07:00
|
|
|
&mut self,
|
|
|
|
|
terminal: &mut Terminal<B>,
|
2026-08-21 12:45:10 -07:00
|
|
|
) -> Result<InputFlow, Box<dyn std::error::Error>>
|
2026-06-02 13:02:25 -07:00
|
|
|
where
|
|
|
|
|
<B as ratatui::backend::Backend>::Error: 'static,
|
|
|
|
|
{
|
2026-08-21 12:45:10 -07:00
|
|
|
// Drain everything already queued; the caller has verified (or will
|
|
|
|
|
// verify via poll) that input is or may be pending.
|
|
|
|
|
while event::poll(Duration::ZERO)? {
|
|
|
|
|
match event::read()? {
|
|
|
|
|
Event::Key(k) => {
|
|
|
|
|
// Handle modal input first - if a modal consumes the input, don't process normal keys
|
|
|
|
|
if self.modal_manager.is_active() {
|
|
|
|
|
let action = self.modal_manager.handle_key(k.code);
|
|
|
|
|
match action {
|
|
|
|
|
ModalAction::ExitApp => {
|
|
|
|
|
self.should_quit = true;
|
|
|
|
|
continue; // Skip normal key processing
|
|
|
|
|
}
|
|
|
|
|
ModalAction::RetryConnection => {
|
|
|
|
|
self.retry_connection().await;
|
|
|
|
|
// Check if retry succeeded and we have a replacement connection
|
|
|
|
|
if self.replacement_connection.is_some() {
|
|
|
|
|
// Restart the outer loop on the new connection
|
|
|
|
|
return Ok(InputFlow::RestartConnection);
|
2025-10-02 16:54:27 -07:00
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
continue; // Skip normal key processing
|
|
|
|
|
}
|
|
|
|
|
ModalAction::Cancel | ModalAction::Dismiss => {
|
2026-08-23 08:11:17 -07:00
|
|
|
// If a ProcessDetails view is what we landed on,
|
|
|
|
|
// clear the stale data AND point the poll at it —
|
|
|
|
|
// Esc-ing back from a parent view otherwise left
|
|
|
|
|
// the selection on the parent, refilling the
|
|
|
|
|
// child-titled view with the parent's data.
|
|
|
|
|
if let Some(crate::ui::modal::ModalType::ProcessDetails { pid }) =
|
|
|
|
|
self.modal_manager.current_modal()
|
2026-08-21 12:45:10 -07:00
|
|
|
{
|
2026-08-23 08:11:17 -07:00
|
|
|
let pid = *pid;
|
2026-08-21 12:45:10 -07:00
|
|
|
self.clear_process_details();
|
2026-08-23 08:11:17 -07:00
|
|
|
self.selected_process_pid = Some(pid);
|
|
|
|
|
self.last_process_details_poll = Instant::now()
|
|
|
|
|
.checked_sub(self.process_details_interval)
|
|
|
|
|
.unwrap_or_else(Instant::now);
|
2025-10-02 16:54:27 -07:00
|
|
|
}
|
2026-08-23 08:11:17 -07:00
|
|
|
// Abandon any pending kill the user backed out of.
|
|
|
|
|
self.pending_kill = None;
|
2026-08-21 12:45:10 -07:00
|
|
|
// Modal was dismissed, skip normal key processing
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
ModalAction::Confirm => {
|
2026-08-23 08:11:17 -07:00
|
|
|
// The only confirmation in the app is the
|
|
|
|
|
// process-kill prompt; Confirm is the polite
|
|
|
|
|
// signal, ConfirmForce the forceful one.
|
|
|
|
|
if self.pending_kill.is_some() {
|
|
|
|
|
self.run_pending_kill(KillSignal::Term);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ModalAction::ConfirmForce => {
|
|
|
|
|
if self.pending_kill.is_some() {
|
|
|
|
|
self.run_pending_kill(KillSignal::Kill);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ModalAction::KillSelected(pid) => {
|
|
|
|
|
// `t` from inside the details modal. The
|
|
|
|
|
// confirmation stacks on top of it, so
|
|
|
|
|
// cancelling returns to the details view.
|
|
|
|
|
self.prompt_kill(pid);
|
|
|
|
|
continue;
|
2026-08-21 12:45:10 -07:00
|
|
|
}
|
|
|
|
|
ModalAction::SwitchToParentProcess(_current_pid) => {
|
|
|
|
|
// Get parent PID from current process details
|
|
|
|
|
if let Some(details) = &self.process_details
|
|
|
|
|
&& let Some(parent_pid) = details.process.parent_pid
|
|
|
|
|
{
|
|
|
|
|
// Clear current process details
|
|
|
|
|
self.clear_process_details();
|
|
|
|
|
// Update selected process to parent
|
|
|
|
|
self.selected_process_pid = Some(parent_pid);
|
|
|
|
|
// Open modal for parent process
|
|
|
|
|
self.modal_manager.push_modal(
|
|
|
|
|
crate::ui::modal::ModalType::ProcessDetails {
|
|
|
|
|
pid: parent_pid,
|
|
|
|
|
},
|
|
|
|
|
);
|
2025-09-15 10:16:47 -07:00
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
ModalAction::Handled => {
|
|
|
|
|
// Modal consumed the key, don't pass to main window
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
ModalAction::None => {
|
|
|
|
|
// Modal didn't handle the key, pass through to normal handling
|
2025-09-15 10:16:47 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
}
|
2025-09-15 10:16:47 -07:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Handle search mode
|
|
|
|
|
if self.process_search_active {
|
|
|
|
|
match k.code {
|
|
|
|
|
KeyCode::Esc => {
|
|
|
|
|
// Exit search mode
|
|
|
|
|
self.process_search_active = false;
|
|
|
|
|
self.process_search_query.clear();
|
|
|
|
|
self.invalidate_procs_filter();
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
// Exit search mode, keep filter active, and auto-select first result
|
|
|
|
|
self.process_search_active = false;
|
|
|
|
|
|
|
|
|
|
// Auto-select first filtered result
|
|
|
|
|
let first = self.procs_filter().first().copied();
|
|
|
|
|
if let (Some(first_idx), Some(m)) =
|
|
|
|
|
(first, self.last_metrics.as_ref())
|
|
|
|
|
{
|
|
|
|
|
self.selected_process_index = Some(first_idx);
|
|
|
|
|
self.selected_process_pid =
|
|
|
|
|
Some(m.top_processes[first_idx].pid);
|
2025-11-17 11:24:32 -08:00
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Backspace => {
|
|
|
|
|
self.process_search_query.pop();
|
|
|
|
|
self.invalidate_procs_filter();
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Char(c) => {
|
|
|
|
|
self.process_search_query.push(c);
|
|
|
|
|
self.invalidate_procs_filter();
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Up | KeyCode::Down => {
|
|
|
|
|
// Allow arrow keys to navigate even while in search mode
|
|
|
|
|
// Fall through to normal navigation handling
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
continue; // Block other keys in search mode
|
2025-11-17 11:24:32 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
}
|
2025-11-17 11:24:32 -08:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Normal key handling (only if no modal is active or modal didn't consume the key)
|
|
|
|
|
if matches!(
|
|
|
|
|
k.code,
|
|
|
|
|
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc
|
|
|
|
|
) {
|
|
|
|
|
self.should_quit = true;
|
|
|
|
|
}
|
2025-11-17 11:24:32 -08:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Activate search mode on '/' (clears query if starting new search, or edits existing)
|
|
|
|
|
if matches!(k.code, KeyCode::Char('/')) {
|
|
|
|
|
self.process_search_active = true;
|
|
|
|
|
// Don't clear query - allow editing existing search
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2025-11-17 11:24:32 -08:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Clear search filter on 'c' or 'C' (when not in search mode)
|
|
|
|
|
if matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C'))
|
|
|
|
|
&& !self.process_search_query.is_empty()
|
|
|
|
|
&& !self.process_search_active
|
|
|
|
|
{
|
|
|
|
|
self.process_search_query.clear();
|
|
|
|
|
self.selected_process_pid = None;
|
|
|
|
|
self.selected_process_index = None;
|
|
|
|
|
self.invalidate_procs_filter();
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2025-10-26 02:18:01 -07:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Show About modal on 'a' or 'A'
|
|
|
|
|
if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) {
|
|
|
|
|
self.modal_manager.push_modal(ModalType::About);
|
|
|
|
|
}
|
2025-11-17 00:05:02 -08:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Show Help modal on 'h' or 'H'
|
|
|
|
|
if matches!(k.code, KeyCode::Char('h') | KeyCode::Char('H')) {
|
|
|
|
|
self.modal_manager.push_modal(ModalType::Help);
|
|
|
|
|
}
|
2025-08-10 23:32:44 -07:00
|
|
|
|
2026-08-23 08:11:17 -07:00
|
|
|
// Kill the selected process — local agents only. `t` is the
|
|
|
|
|
// one kill key everywhere: `k` scrolls the thread table in
|
|
|
|
|
// the details modal so it could not be reused there, and one
|
|
|
|
|
// key for both entry points is one thing to remember.
|
|
|
|
|
// SIGTERM vs SIGKILL is chosen in the confirmation modal.
|
2026-08-24 07:19:37 -07:00
|
|
|
if self.kill_enabled
|
2026-08-23 08:11:17 -07:00
|
|
|
&& !self.modal_manager.is_active()
|
|
|
|
|
&& matches!(k.code, KeyCode::Char('t') | KeyCode::Char('T'))
|
|
|
|
|
&& let Some(pid) = self.selected_process_pid
|
|
|
|
|
{
|
|
|
|
|
self.prompt_kill(pid);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End)
|
|
|
|
|
let sz = terminal.size()?;
|
|
|
|
|
let area = Rect::new(0, 0, sz.width, sz.height);
|
|
|
|
|
let layout = self.layout(area);
|
|
|
|
|
let content = per_core_content_area(layout.per_core);
|
|
|
|
|
|
|
|
|
|
// Refresh the filtered+sorted index cache once before we
|
|
|
|
|
// borrow individual fields of `self`.
|
|
|
|
|
let _ = self.procs_filter();
|
|
|
|
|
|
|
|
|
|
// First try process selection (only handles arrows if a process is selected)
|
|
|
|
|
let process_handled = if self.last_procs_area.is_some() {
|
|
|
|
|
processes_handle_key_with_selection(ProcessKeyParams {
|
|
|
|
|
selected_process_pid: &mut self.selected_process_pid,
|
|
|
|
|
selected_process_index: &mut self.selected_process_index,
|
|
|
|
|
key: k,
|
|
|
|
|
metrics: self.last_metrics.as_ref(),
|
|
|
|
|
filtered_indices: &self.procs_filtered,
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
false
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// If process selection didn't handle it, use CPU scrolling
|
|
|
|
|
if !process_handled {
|
|
|
|
|
per_core_handle_key(&mut self.per_core_scroll, k, content.height as usize);
|
|
|
|
|
}
|
2026-06-02 13:02:25 -07:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Auto-scroll to keep selected process visible
|
|
|
|
|
if let (Some(selected_idx), Some(p_area)) =
|
|
|
|
|
(self.selected_process_index, self.last_procs_area)
|
|
|
|
|
&& self.last_metrics.is_some()
|
|
|
|
|
{
|
|
|
|
|
let idxs = &self.procs_filtered;
|
2025-10-02 16:54:27 -07:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Find the display position of the selected process in filtered list
|
|
|
|
|
if let Some(display_pos) = idxs.iter().position(|&idx| idx == selected_idx)
|
2025-11-17 00:05:02 -08:00
|
|
|
{
|
2026-08-21 12:45:10 -07:00
|
|
|
// Calculate viewport size
|
|
|
|
|
// Account for: borders (2) + header (1) + search box if active (3)
|
|
|
|
|
let extra_rows = if self.process_search_active
|
|
|
|
|
|| !self.process_search_query.is_empty()
|
2025-11-17 11:24:32 -08:00
|
|
|
{
|
2026-08-21 12:45:10 -07:00
|
|
|
3 // search box with border
|
|
|
|
|
} else {
|
|
|
|
|
0
|
|
|
|
|
};
|
|
|
|
|
let viewport_rows =
|
|
|
|
|
p_area.height.saturating_sub(3 + extra_rows) as usize;
|
|
|
|
|
|
|
|
|
|
// Adjust scroll offset to keep selection visible
|
|
|
|
|
if display_pos < self.procs_scroll_offset {
|
|
|
|
|
// Selection is above viewport, scroll up
|
|
|
|
|
self.procs_scroll_offset = display_pos;
|
|
|
|
|
} else if display_pos >= self.procs_scroll_offset + viewport_rows {
|
|
|
|
|
// Selection is below viewport, scroll down
|
|
|
|
|
self.procs_scroll_offset =
|
|
|
|
|
display_pos.saturating_sub(viewport_rows - 1);
|
2025-11-17 00:05:02 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
}
|
2025-11-17 00:05:02 -08:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Check if process selection changed and clear details if so
|
|
|
|
|
if self.selected_process_pid != self.prev_selected_process_pid {
|
|
|
|
|
self.clear_process_details();
|
|
|
|
|
self.prev_selected_process_pid = self.selected_process_pid;
|
|
|
|
|
}
|
2025-10-02 16:54:27 -07:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Check if Enter was pressed with a process selected
|
|
|
|
|
if process_handled
|
|
|
|
|
&& k.code == KeyCode::Enter
|
|
|
|
|
&& let Some(selected_pid) = self.selected_process_pid
|
|
|
|
|
{
|
|
|
|
|
self.modal_manager
|
|
|
|
|
.push_modal(ModalType::ProcessDetails { pid: selected_pid });
|
|
|
|
|
}
|
2025-08-10 23:32:44 -07:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
let total_rows = self
|
|
|
|
|
.last_metrics
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|mm| mm.cpu_per_core.len())
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
per_core_clamp(
|
|
|
|
|
&mut self.per_core_scroll,
|
|
|
|
|
total_rows,
|
|
|
|
|
content.height as usize,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Event::Mouse(m) => {
|
|
|
|
|
// If modal is active, don't handle mouse events on the main window
|
|
|
|
|
if self.modal_manager.is_active() {
|
|
|
|
|
continue;
|
2025-08-10 23:32:44 -07:00
|
|
|
}
|
2025-10-02 16:54:27 -07:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Layout to get areas
|
|
|
|
|
let sz = terminal.size()?;
|
|
|
|
|
let area = Rect::new(0, 0, sz.width, sz.height);
|
|
|
|
|
let layout = self.layout(area);
|
|
|
|
|
|
|
|
|
|
// Content wheel scrolling
|
|
|
|
|
let content = per_core_content_area(layout.per_core);
|
|
|
|
|
per_core_handle_mouse(
|
|
|
|
|
&mut self.per_core_scroll,
|
|
|
|
|
m,
|
|
|
|
|
content,
|
|
|
|
|
content.height as usize,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Scrollbar clicks/drag
|
|
|
|
|
let total_rows = self
|
|
|
|
|
.last_metrics
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|mm| mm.cpu_per_core.len())
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
per_core_handle_scrollbar_mouse(
|
|
|
|
|
&mut self.per_core_scroll,
|
|
|
|
|
&mut self.per_core_drag,
|
|
|
|
|
m,
|
|
|
|
|
layout.per_core,
|
|
|
|
|
total_rows,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Clamp to bounds
|
|
|
|
|
per_core_clamp(
|
|
|
|
|
&mut self.per_core_scroll,
|
|
|
|
|
total_rows,
|
|
|
|
|
content.height as usize,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Refresh filter cache before partial borrows of self.
|
|
|
|
|
let _ = self.procs_filter();
|
|
|
|
|
let search_box_visible =
|
|
|
|
|
self.process_search_active || !self.process_search_query.is_empty();
|
|
|
|
|
|
|
|
|
|
// Processes table: sort by column on header click and handle row selection
|
|
|
|
|
if let (Some(_mm), Some(p_area)) =
|
|
|
|
|
(self.last_metrics.as_ref(), self.last_procs_area)
|
|
|
|
|
{
|
|
|
|
|
use crate::ui::processes::ProcessMouseParams;
|
|
|
|
|
let total_rows = self.procs_filtered.len();
|
|
|
|
|
if let Some(new_sort) =
|
|
|
|
|
processes_handle_mouse_with_selection(ProcessMouseParams {
|
|
|
|
|
scroll_offset: &mut self.procs_scroll_offset,
|
|
|
|
|
selected_process_pid: &mut self.selected_process_pid,
|
|
|
|
|
selected_process_index: &mut self.selected_process_index,
|
|
|
|
|
drag: &mut self.procs_drag,
|
|
|
|
|
mouse: m,
|
|
|
|
|
area: p_area,
|
|
|
|
|
total_rows,
|
|
|
|
|
metrics: self.last_metrics.as_ref(),
|
|
|
|
|
search_box_visible,
|
|
|
|
|
filtered_indices: &self.procs_filtered,
|
|
|
|
|
})
|
2025-09-04 05:58:17 -07:00
|
|
|
{
|
2026-08-21 12:45:10 -07:00
|
|
|
self.procs_sort_by = new_sort;
|
|
|
|
|
self.invalidate_procs_filter();
|
2025-10-02 16:54:27 -07:00
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
}
|
2025-10-02 16:54:27 -07:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Check if process selection changed via mouse and clear details if so
|
|
|
|
|
if self.selected_process_pid != self.prev_selected_process_pid {
|
|
|
|
|
self.clear_process_details();
|
|
|
|
|
self.prev_selected_process_pid = self.selected_process_pid;
|
2025-08-08 12:41:32 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
Event::Resize(_, _) => {}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(InputFlow::Continue)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn run_event_loop_iteration<B: ratatui::backend::Backend>(
|
|
|
|
|
&mut self,
|
|
|
|
|
terminal: &mut Terminal<B>,
|
|
|
|
|
ws: &mut SocktopConnector,
|
|
|
|
|
) -> Result<(), Box<dyn std::error::Error>>
|
|
|
|
|
where
|
|
|
|
|
<B as ratatui::backend::Backend>::Error: 'static,
|
|
|
|
|
{
|
|
|
|
|
loop {
|
|
|
|
|
// Input: drain anything already queued
|
|
|
|
|
if matches!(
|
|
|
|
|
self.drain_input(terminal).await?,
|
|
|
|
|
InputFlow::RestartConnection
|
|
|
|
|
) {
|
|
|
|
|
return Ok(());
|
2025-08-08 12:41:32 -07:00
|
|
|
}
|
2025-09-15 10:16:47 -07:00
|
|
|
|
|
|
|
|
// Check for automatic retry (every 30 seconds)
|
|
|
|
|
if self.should_auto_retry() {
|
|
|
|
|
self.auto_retry_connection().await;
|
|
|
|
|
// Check if retry succeeded and we have a replacement connection
|
|
|
|
|
if self.replacement_connection.is_some() {
|
|
|
|
|
// Signal that we want to restart with new connection
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-08 12:41:32 -07:00
|
|
|
if self.should_quit {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Paint the current state BEFORE fetching: a request can stall for
|
|
|
|
|
// the full 5s timeout, and an iteration that ends in a poisoned-
|
|
|
|
|
// stream restart never reaches the draw at the bottom — without
|
|
|
|
|
// this, an agent that never answers left the screen permanently
|
|
|
|
|
// blank. (ratatui diffs make an unchanged repaint nearly free.)
|
|
|
|
|
terminal.draw(|f| self.draw(f))?;
|
|
|
|
|
|
|
|
|
|
// Fetch and update. Skipped while disconnected — the retry paths
|
|
|
|
|
// (manual 'r' or the 30s auto-retry) own recovery, and hammering a
|
|
|
|
|
// dead socket with 5s-timeout requests would stall the loop. The
|
|
|
|
|
// shared draw + responsive wait below still run, so the error
|
|
|
|
|
// modal stays live and input stays snappy.
|
|
|
|
|
if self.connection_state == ConnectionState::Connected {
|
|
|
|
|
match timeout(REQUEST_TIMEOUT, ws.request(AgentRequest::Metrics)).await {
|
|
|
|
|
Err(_) => {
|
|
|
|
|
self.consecutive_request_timeouts += 1;
|
|
|
|
|
if self.consecutive_request_timeouts >= 2 {
|
|
|
|
|
// The agent accepts connections but never answers
|
|
|
|
|
// (wrong protocol era, or wedged): reconnecting
|
|
|
|
|
// can't help, so surface a persistent error and
|
|
|
|
|
// leave recovery to the manual/auto retry paths.
|
|
|
|
|
self.show_connection_error(
|
|
|
|
|
"Agent is not responding to requests".to_string(),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
self.poison_connection("Metrics request timed out").await;
|
2026-06-02 13:02:25 -07:00
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
}
|
|
|
|
|
Ok(Ok(AgentResponse::Metrics(m))) => {
|
|
|
|
|
self.mark_connected(); // Mark as connected on successful request
|
|
|
|
|
self.consecutive_request_timeouts = 0;
|
|
|
|
|
self.update_with_metrics(m);
|
|
|
|
|
|
2026-08-23 08:11:17 -07:00
|
|
|
// A process signalled a moment ago may have exited
|
|
|
|
|
// since. Checked here, on every tick, so its row goes
|
|
|
|
|
// as soon as it is actually gone rather than at the
|
|
|
|
|
// next full process poll.
|
|
|
|
|
self.poll_kill_watch();
|
|
|
|
|
|
|
|
|
|
// Only poll processes every 2s — unless a kill asked for
|
|
|
|
|
// a refresh, which jumps the queue.
|
|
|
|
|
let forced = self
|
|
|
|
|
.procs_refresh_due_at
|
|
|
|
|
.is_some_and(|due| Instant::now() >= due);
|
|
|
|
|
if forced || self.last_procs_poll.elapsed() >= self.procs_interval {
|
|
|
|
|
if forced {
|
|
|
|
|
self.procs_refresh_due_at = None;
|
|
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
let mut updated = false;
|
|
|
|
|
match timeout(REQUEST_TIMEOUT, ws.request(AgentRequest::Processes))
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Err(_) => {
|
|
|
|
|
self.poison_connection("Processes request timed out").await;
|
|
|
|
|
}
|
|
|
|
|
Ok(Ok(AgentResponse::Processes(procs))) => {
|
|
|
|
|
if let Some(mm) = self.last_metrics.as_mut() {
|
|
|
|
|
mm.top_processes = procs.top_processes;
|
|
|
|
|
mm.process_count = Some(procs.process_count);
|
|
|
|
|
updated = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Request error or wrong type: keep stale rows; a
|
|
|
|
|
// broken socket surfaces on the next metrics tick.
|
|
|
|
|
Ok(_) => {}
|
|
|
|
|
}
|
|
|
|
|
if updated {
|
|
|
|
|
self.invalidate_procs_filter();
|
|
|
|
|
// Rebuild the pre-formatted row cache for the next
|
|
|
|
|
// ~N frames. Done once per poll, not per frame.
|
|
|
|
|
if let Some(mm) = self.last_metrics.as_ref() {
|
|
|
|
|
self.procs_row_peak_cpu =
|
|
|
|
|
crate::ui::processes::rebuild_row_cache(
|
|
|
|
|
mm,
|
|
|
|
|
&mut self.procs_row_cache,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-23 08:11:17 -07:00
|
|
|
// The agent's snapshot can predate a kill by up
|
|
|
|
|
// to its cache TTL, so strip anything we already
|
|
|
|
|
// know is gone before it reaches the screen.
|
|
|
|
|
self.drop_tombstoned_rows();
|
|
|
|
|
// And a selection whose process is no longer in
|
|
|
|
|
// the list would otherwise still be the target
|
|
|
|
|
// of `t`.
|
|
|
|
|
self.drop_vanished_selection();
|
2026-06-02 13:02:25 -07:00
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
self.last_procs_poll = Instant::now();
|
2025-09-15 10:16:47 -07:00
|
|
|
}
|
2025-08-12 15:52:46 -07:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Only poll disks every 5s
|
|
|
|
|
if self.connection_state == ConnectionState::Connected
|
|
|
|
|
&& self.last_disks_poll.elapsed() >= self.disks_interval
|
2025-09-15 10:16:47 -07:00
|
|
|
{
|
2026-08-21 12:45:10 -07:00
|
|
|
match timeout(REQUEST_TIMEOUT, ws.request(AgentRequest::Disks)).await {
|
|
|
|
|
Err(_) => {
|
|
|
|
|
self.poison_connection("Disks request timed out").await;
|
|
|
|
|
}
|
|
|
|
|
Ok(Ok(AgentResponse::Disks(mut disks))) => {
|
|
|
|
|
if let Some(mm) = self.last_metrics.as_mut() {
|
|
|
|
|
dedup_disks(&mut disks);
|
|
|
|
|
mm.disks = disks;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(_) => {}
|
|
|
|
|
}
|
|
|
|
|
self.last_disks_poll = Instant::now();
|
2025-09-15 10:16:47 -07:00
|
|
|
}
|
2025-10-02 16:54:27 -07:00
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Poll process details when modal is active and process is selected
|
|
|
|
|
if let Some(pid) = self.selected_process_pid
|
|
|
|
|
&& self.connection_state == ConnectionState::Connected
|
2025-10-02 16:54:27 -07:00
|
|
|
{
|
2026-08-21 12:45:10 -07:00
|
|
|
// Check if ProcessDetails modal is currently active
|
|
|
|
|
if let Some(crate::ui::modal::ModalType::ProcessDetails { .. }) =
|
|
|
|
|
self.modal_manager.current_modal()
|
2025-10-02 16:54:27 -07:00
|
|
|
{
|
2026-08-21 12:45:10 -07:00
|
|
|
// Poll process details every 500ms when modal is
|
|
|
|
|
// active. Skipped once the agent is known not to
|
|
|
|
|
// support the endpoint (flag resets when the modal
|
|
|
|
|
// closes or the selection changes, so a one-off
|
|
|
|
|
// timeout doesn't disable details for the session).
|
|
|
|
|
if self.connection_state == ConnectionState::Connected
|
|
|
|
|
&& !self.process_details_unsupported
|
|
|
|
|
&& self.last_process_details_poll.elapsed()
|
|
|
|
|
>= self.process_details_interval
|
2025-10-02 16:54:27 -07:00
|
|
|
{
|
2026-08-21 12:45:10 -07:00
|
|
|
// Use timeout to prevent blocking the event loop
|
|
|
|
|
match timeout(
|
|
|
|
|
Duration::from_millis(2000),
|
|
|
|
|
ws.request(AgentRequest::ProcessMetrics { pid }),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(Ok(AgentResponse::ProcessMetrics(details))) => {
|
|
|
|
|
// Update history for sparklines
|
|
|
|
|
let cpu_usage = details.process.cpu_usage;
|
|
|
|
|
let evicted_cpu = push_capped(
|
|
|
|
|
&mut self.process_cpu_history,
|
|
|
|
|
cpu_usage,
|
2025-10-02 16:54:27 -07:00
|
|
|
600,
|
|
|
|
|
);
|
2026-08-21 12:45:10 -07:00
|
|
|
self.process_cpu_history_sum =
|
|
|
|
|
self.process_cpu_history_sum + cpu_usage
|
|
|
|
|
- evicted_cpu.unwrap_or(0.0);
|
|
|
|
|
|
|
|
|
|
let mem_bytes = details.process.mem_bytes;
|
2025-10-02 16:54:27 -07:00
|
|
|
push_capped(
|
2026-08-21 12:45:10 -07:00
|
|
|
&mut self.process_mem_history,
|
|
|
|
|
mem_bytes,
|
2025-10-02 16:54:27 -07:00
|
|
|
600,
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Track maximum memory usage
|
|
|
|
|
if mem_bytes > self.max_process_mem_bytes {
|
|
|
|
|
self.max_process_mem_bytes = mem_bytes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// I/O bytes from agent are cumulative, calculate deltas
|
|
|
|
|
if let Some(read) = details.process.read_bytes {
|
|
|
|
|
let delta =
|
|
|
|
|
if let Some(last) = self.last_io_read_bytes {
|
|
|
|
|
read.saturating_sub(last)
|
|
|
|
|
} else {
|
|
|
|
|
0 // First sample, no delta available
|
|
|
|
|
};
|
|
|
|
|
push_capped(
|
|
|
|
|
&mut self.process_io_read_history,
|
|
|
|
|
delta,
|
|
|
|
|
600,
|
|
|
|
|
);
|
|
|
|
|
self.last_io_read_bytes = Some(read);
|
|
|
|
|
}
|
|
|
|
|
if let Some(write) = details.process.write_bytes {
|
|
|
|
|
let delta =
|
|
|
|
|
if let Some(last) = self.last_io_write_bytes {
|
|
|
|
|
write.saturating_sub(last)
|
|
|
|
|
} else {
|
|
|
|
|
0 // First sample, no delta available
|
|
|
|
|
};
|
|
|
|
|
push_capped(
|
|
|
|
|
&mut self.process_io_write_history,
|
|
|
|
|
delta,
|
|
|
|
|
600,
|
|
|
|
|
);
|
|
|
|
|
self.last_io_write_bytes = Some(write);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.process_details = Some(details);
|
|
|
|
|
self.process_details_unsupported = false;
|
2026-08-23 08:11:17 -07:00
|
|
|
// This agent demonstrably answers
|
|
|
|
|
// details requests, which is what
|
|
|
|
|
// lets the error arm below read a
|
|
|
|
|
// later failure as "that process is
|
|
|
|
|
// gone" rather than "old agent".
|
|
|
|
|
self.process_details_answered = true;
|
2026-08-21 12:45:10 -07:00
|
|
|
}
|
|
|
|
|
Ok(Err(_)) => {
|
2026-08-23 08:11:17 -07:00
|
|
|
// An error reply means one of two very
|
|
|
|
|
// different things, and the wire cannot
|
|
|
|
|
// tell them apart: the agent lacks the
|
|
|
|
|
// endpoint, or this PID is gone (the
|
|
|
|
|
// agent sends {"error":"Process N not
|
|
|
|
|
// found"}, which fails to deserialize
|
|
|
|
|
// and arrives here identically).
|
|
|
|
|
//
|
|
|
|
|
// If the agent has already answered a
|
|
|
|
|
// details request this session, the
|
|
|
|
|
// endpoint plainly works, so the PID is
|
|
|
|
|
// the problem — close the view instead
|
|
|
|
|
// of claiming the agent needs updating.
|
|
|
|
|
//
|
|
|
|
|
// Unless the process is still in the
|
|
|
|
|
// agent's own list: then this error is a
|
|
|
|
|
// transient (socket blip, torn frame),
|
|
|
|
|
// not a death — keep the view and let
|
|
|
|
|
// the next poll retry.
|
|
|
|
|
if self.process_details_answered {
|
|
|
|
|
let still_listed =
|
|
|
|
|
self.last_metrics.as_ref().is_some_and(|m| {
|
|
|
|
|
m.top_processes.iter().any(|p| p.pid == pid)
|
|
|
|
|
});
|
|
|
|
|
if !still_listed {
|
|
|
|
|
self.close_details_for_gone_process(pid);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
self.process_details_unsupported = true;
|
|
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
}
|
|
|
|
|
Err(_) => {
|
|
|
|
|
// No reply at all: old agents IGNORE
|
|
|
|
|
// this message, so show the "Agent
|
|
|
|
|
// Update Required" state and refresh
|
|
|
|
|
// the stream quietly (a merely-slow
|
|
|
|
|
// agent's late reply would otherwise
|
|
|
|
|
// desync it).
|
|
|
|
|
self.process_details_unsupported = true;
|
|
|
|
|
self.quiet_reconnect().await;
|
|
|
|
|
}
|
|
|
|
|
Ok(Ok(_)) => {
|
|
|
|
|
// Wrong response type
|
|
|
|
|
self.process_details_unsupported = true;
|
|
|
|
|
}
|
2025-10-02 16:54:27 -07:00
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
self.last_process_details_poll = Instant::now();
|
2025-10-02 16:54:27 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Poll journal entries every 5s when modal is active.
|
|
|
|
|
// Gated on the same unsupported flag: agents that lack
|
|
|
|
|
// process details lack the journal endpoint too.
|
|
|
|
|
if self.connection_state == ConnectionState::Connected
|
|
|
|
|
&& !self.process_details_unsupported
|
|
|
|
|
&& self.last_journal_poll.elapsed() >= self.journal_interval
|
2025-10-02 16:54:27 -07:00
|
|
|
{
|
2026-08-21 12:45:10 -07:00
|
|
|
// Use timeout to prevent blocking the event loop
|
|
|
|
|
match timeout(
|
|
|
|
|
Duration::from_millis(2000),
|
|
|
|
|
ws.request(AgentRequest::JournalEntries { pid }),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(Ok(AgentResponse::JournalEntries(journal))) => {
|
|
|
|
|
self.journal_entries = Some(journal);
|
|
|
|
|
}
|
|
|
|
|
Err(_) => {
|
|
|
|
|
// No reply: same quiet stream refresh
|
|
|
|
|
// as the details endpoint above.
|
|
|
|
|
self.quiet_reconnect().await;
|
|
|
|
|
}
|
|
|
|
|
Ok(Err(_)) | Ok(Ok(_)) => {
|
|
|
|
|
// Endpoint unsupported or wrong type;
|
|
|
|
|
// keep journal_entries as None
|
|
|
|
|
}
|
2025-10-02 16:54:27 -07:00
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
self.last_journal_poll = Instant::now();
|
2025-10-02 16:54:27 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
Ok(Err(e)) => {
|
|
|
|
|
// Connection error - show modal if not already shown
|
|
|
|
|
let error_message = format!("Failed to fetch metrics: {e}");
|
|
|
|
|
self.show_connection_error(error_message);
|
|
|
|
|
}
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
// Unexpected response type
|
|
|
|
|
self.show_connection_error("Unexpected response from agent".to_string());
|
|
|
|
|
}
|
2025-08-12 15:52:46 -07:00
|
|
|
}
|
2026-08-21 12:45:10 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A poisoned connection may have been replaced mid-iteration:
|
|
|
|
|
// restart on the fresh stream before issuing any more requests.
|
|
|
|
|
if self.replacement_connection.is_some() {
|
|
|
|
|
return Ok(());
|
2025-09-15 10:16:47 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update countdown for connection error modal if active
|
|
|
|
|
if self.modal_manager.is_active() {
|
|
|
|
|
self.modal_manager
|
|
|
|
|
.update_connection_error_countdown(self.seconds_until_next_auto_retry());
|
2025-08-08 12:41:32 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw
|
|
|
|
|
terminal.draw(|f| self.draw(f))?;
|
|
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// Tick wait, kept responsive: instead of sleeping the whole
|
|
|
|
|
// metrics interval (which queued keys/wheel events for up to
|
|
|
|
|
// 500ms and applied them in bursts), wait in ≤33ms slices and
|
|
|
|
|
// handle + repaint input the moment it arrives.
|
|
|
|
|
let deadline = Instant::now() + self.metrics_interval;
|
|
|
|
|
loop {
|
|
|
|
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
|
|
|
if remaining.is_zero() || self.should_quit {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if !event::poll(remaining.min(Duration::from_millis(33)))? {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if matches!(
|
|
|
|
|
self.drain_input(terminal).await?,
|
|
|
|
|
InputFlow::RestartConnection
|
|
|
|
|
) {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
terminal.draw(|f| self.draw(f))?;
|
|
|
|
|
}
|
2025-08-08 12:41:32 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 13:02:25 -07:00
|
|
|
/// Mark the filtered-process cache stale. Call this whenever
|
|
|
|
|
/// `procs_sort_by`, `process_search_query`, or the top_processes content
|
|
|
|
|
/// changes — the cache is rebuilt lazily on the next read.
|
|
|
|
|
pub fn invalidate_procs_filter(&mut self) {
|
|
|
|
|
self.procs_filter_dirty = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Lazily refresh and return the cached filtered+sorted process indices.
|
|
|
|
|
/// Empty slice when there are no metrics yet.
|
|
|
|
|
pub fn procs_filter(&mut self) -> &[usize] {
|
|
|
|
|
if self.procs_filter_dirty {
|
|
|
|
|
self.procs_filtered.clear();
|
|
|
|
|
if let Some(m) = self.last_metrics.as_ref() {
|
|
|
|
|
crate::ui::processes::fill_filtered_sorted_indices(
|
|
|
|
|
m,
|
|
|
|
|
&self.process_search_query,
|
|
|
|
|
self.procs_sort_by,
|
|
|
|
|
&mut self.procs_filtered,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
self.procs_filter_dirty = false;
|
|
|
|
|
}
|
|
|
|
|
&self.procs_filtered
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 16:54:27 -07:00
|
|
|
/// Clear process details when modal is closed or selection changes
|
|
|
|
|
pub fn clear_process_details(&mut self) {
|
|
|
|
|
self.process_details = None;
|
|
|
|
|
self.journal_entries = None;
|
|
|
|
|
self.process_cpu_history.clear();
|
2026-06-02 13:02:25 -07:00
|
|
|
self.process_cpu_history_sum = 0.0;
|
2025-10-02 16:54:27 -07:00
|
|
|
self.process_mem_history.clear();
|
|
|
|
|
self.process_io_read_history.clear();
|
|
|
|
|
self.process_io_write_history.clear();
|
|
|
|
|
self.last_io_read_bytes = None;
|
|
|
|
|
self.last_io_write_bytes = None;
|
2025-11-17 11:24:32 -08:00
|
|
|
self.max_process_mem_bytes = 0;
|
2025-10-02 16:54:27 -07:00
|
|
|
self.process_details_unsupported = false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-12 15:52:46 -07:00
|
|
|
fn update_with_metrics(&mut self, mut m: Metrics) {
|
2026-06-02 13:02:25 -07:00
|
|
|
if let Some(prev) = self.last_metrics.as_mut() {
|
|
|
|
|
// Preserve slower fields when the fast payload omits them.
|
|
|
|
|
// prev is about to be dropped so we can move its Vecs instead of cloning.
|
2025-08-12 15:52:46 -07:00
|
|
|
if m.disks.is_empty() {
|
2026-06-02 13:02:25 -07:00
|
|
|
m.disks = std::mem::take(&mut prev.disks);
|
2025-08-12 15:52:46 -07:00
|
|
|
}
|
|
|
|
|
if m.top_processes.is_empty() {
|
2026-06-02 13:02:25 -07:00
|
|
|
m.top_processes = std::mem::take(&mut prev.top_processes);
|
2025-08-12 15:52:46 -07:00
|
|
|
}
|
|
|
|
|
if m.process_count.is_none() {
|
|
|
|
|
m.process_count = prev.process_count;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 13:02:25 -07:00
|
|
|
// CPU avg history with running sum
|
2025-08-08 12:41:32 -07:00
|
|
|
let v = m.cpu_total.clamp(0.0, 100.0).round() as u64;
|
2026-06-02 13:02:25 -07:00
|
|
|
let evicted = push_capped(&mut self.cpu_hist, v, 600);
|
|
|
|
|
self.cpu_hist_sum = self.cpu_hist_sum + v - evicted.unwrap_or(0);
|
2025-08-08 12:41:32 -07:00
|
|
|
|
|
|
|
|
// Per-core history (push current samples)
|
|
|
|
|
self.per_core_hist.ensure_cores(m.cpu_per_core.len());
|
|
|
|
|
self.per_core_hist.push_samples(&m.cpu_per_core);
|
|
|
|
|
|
2026-08-21 12:45:10 -07:00
|
|
|
// NET: sum across all ifaces, compute KB/s. Prefer the agent's sample
|
|
|
|
|
// timestamps (the agent serves TTL-cached snapshots, so client receive
|
|
|
|
|
// time overstates dt on a cache hit and produces a 0-then-2x sawtooth);
|
|
|
|
|
// fall back to the client clock against pre-1.60 agents.
|
2025-08-08 12:41:32 -07:00
|
|
|
let now = Instant::now();
|
|
|
|
|
let rx_total = m.networks.iter().map(|n| n.received).sum::<u64>();
|
|
|
|
|
let tx_total = m.networks.iter().map(|n| n.transmitted).sum::<u64>();
|
|
|
|
|
let (rx_kb, tx_kb) = if let Some((prx, ptx, pts)) = self.last_net_totals {
|
2026-08-21 12:45:10 -07:00
|
|
|
// None = identical agent snapshot (cache hit): repeat the previous
|
|
|
|
|
// rates so the timeline advances without a fake dip to zero.
|
|
|
|
|
let dt = match (m.sampled_at_ms, self.last_net_sampled_at_ms) {
|
|
|
|
|
(Some(a), Some(b)) if a == b => None,
|
|
|
|
|
(Some(a), Some(b)) if a > b => Some((a - b) as f64 / 1000.0),
|
|
|
|
|
// Agent restarted or clock stepped backwards: client clock.
|
|
|
|
|
_ => Some(now.duration_since(pts).as_secs_f64().max(1e-6)),
|
|
|
|
|
};
|
|
|
|
|
match dt {
|
|
|
|
|
None => (
|
|
|
|
|
self.rx_hist.back().copied().unwrap_or(0),
|
|
|
|
|
self.tx_hist.back().copied().unwrap_or(0),
|
|
|
|
|
),
|
|
|
|
|
Some(dt) => {
|
|
|
|
|
let dt = dt.max(1e-6);
|
|
|
|
|
let rx = ((rx_total.saturating_sub(prx)) as f64 / dt / 1024.0).round() as u64;
|
|
|
|
|
let tx = ((tx_total.saturating_sub(ptx)) as f64 / dt / 1024.0).round() as u64;
|
|
|
|
|
(rx, tx)
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-08 17:25:15 -07:00
|
|
|
} else {
|
|
|
|
|
(0, 0)
|
|
|
|
|
};
|
2025-08-08 12:41:32 -07:00
|
|
|
self.last_net_totals = Some((rx_total, tx_total, now));
|
2026-08-21 12:45:10 -07:00
|
|
|
self.last_net_sampled_at_ms = m.sampled_at_ms;
|
2025-08-08 12:41:32 -07:00
|
|
|
push_capped(&mut self.rx_hist, rx_kb, 600);
|
|
|
|
|
push_capped(&mut self.tx_hist, tx_kb, 600);
|
|
|
|
|
self.rx_peak = self.rx_peak.max(rx_kb);
|
|
|
|
|
self.tx_peak = self.tx_peak.max(tx_kb);
|
|
|
|
|
|
2025-08-12 15:52:46 -07:00
|
|
|
// Store merged snapshot
|
2025-08-08 12:41:32 -07:00
|
|
|
self.last_metrics = Some(m);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-11 12:04:55 -07:00
|
|
|
pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) {
|
2025-08-08 12:41:32 -07:00
|
|
|
let area = f.area();
|
2026-08-18 22:29:54 -07:00
|
|
|
let l = self.layout(area);
|
2025-08-08 12:41:32 -07:00
|
|
|
|
2026-06-02 13:02:25 -07:00
|
|
|
// Header — refresh cached strings only when their inputs change so the
|
2026-08-18 22:47:03 -07:00
|
|
|
// ratatui diff renderer can suppress repaints on idle frames. The wording now
|
|
|
|
|
// depends on the row width too, so that is part of the key.
|
2026-06-02 13:02:25 -07:00
|
|
|
{
|
|
|
|
|
let hostname = self.last_metrics.as_ref().map(|mm| mm.hostname.as_str());
|
2026-08-18 22:47:03 -07:00
|
|
|
let state = HeaderState {
|
|
|
|
|
hostname,
|
|
|
|
|
is_tls: self.is_tls,
|
|
|
|
|
has_token: self.has_token,
|
|
|
|
|
metrics_ms: self.metrics_interval.as_millis(),
|
|
|
|
|
procs_ms: self.procs_interval.as_millis(),
|
|
|
|
|
};
|
2026-06-02 13:02:25 -07:00
|
|
|
let key = (
|
|
|
|
|
hostname.unwrap_or("").to_string(),
|
|
|
|
|
self.is_tls,
|
|
|
|
|
self.has_token,
|
2026-08-18 22:47:03 -07:00
|
|
|
state.metrics_ms,
|
|
|
|
|
state.procs_ms,
|
|
|
|
|
l.header.width,
|
2026-06-02 13:02:25 -07:00
|
|
|
);
|
2026-08-18 22:47:03 -07:00
|
|
|
if self.header_key != key {
|
|
|
|
|
let (title, intervals) = build_header(state, l.header.width);
|
|
|
|
|
self.header_title = title;
|
|
|
|
|
self.header_intervals_text = intervals;
|
|
|
|
|
self.header_key = key;
|
2026-06-02 13:02:25 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-18 22:29:54 -07:00
|
|
|
draw_header(f, l.header, &self.header_title, &self.header_intervals_text);
|
2025-08-08 12:41:32 -07:00
|
|
|
|
2026-06-02 13:02:25 -07:00
|
|
|
draw_cpu_avg_graph(
|
|
|
|
|
f,
|
2026-08-18 22:29:54 -07:00
|
|
|
l.cpu,
|
2026-06-02 13:02:25 -07:00
|
|
|
&mut self.cpu_hist,
|
|
|
|
|
self.cpu_hist_sum,
|
|
|
|
|
self.last_metrics.as_ref(),
|
|
|
|
|
);
|
2025-08-10 23:32:44 -07:00
|
|
|
draw_per_core_bars(
|
|
|
|
|
f,
|
2026-08-18 22:29:54 -07:00
|
|
|
l.per_core,
|
2025-08-10 23:32:44 -07:00
|
|
|
self.last_metrics.as_ref(),
|
2026-06-02 13:02:25 -07:00
|
|
|
&mut self.per_core_hist,
|
2025-08-10 23:32:44 -07:00
|
|
|
self.per_core_scroll,
|
|
|
|
|
);
|
2025-08-08 12:41:32 -07:00
|
|
|
|
2026-08-18 22:29:54 -07:00
|
|
|
// Memory + Swap: stacked vertically in the normal layout, side by side in the
|
|
|
|
|
// row Disks vacates in compact mode.
|
|
|
|
|
draw_mem(f, l.mem, self.last_metrics.as_ref());
|
|
|
|
|
draw_swap(f, l.swap, self.last_metrics.as_ref());
|
|
|
|
|
|
|
|
|
|
// GPU: a panel beside Memory/Swap normally, a single full-width line in compact
|
|
|
|
|
// mode, and absent entirely when the host reports no GPU while compact.
|
|
|
|
|
if let Some(gpu_area) = l.gpu {
|
|
|
|
|
if l.mode.is_compact() {
|
|
|
|
|
draw_gpu_compact(f, gpu_area, self.last_metrics.as_ref());
|
|
|
|
|
} else {
|
|
|
|
|
draw_gpu(f, gpu_area, self.last_metrics.as_ref());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(disks_area) = l.disks {
|
|
|
|
|
draw_disks(f, disks_area, self.last_metrics.as_ref());
|
|
|
|
|
}
|
2026-06-02 13:02:25 -07:00
|
|
|
|
|
|
|
|
// Net titles only change when the throughput or peak changes.
|
|
|
|
|
let rx_now = self.rx_hist.back().copied().unwrap_or(0);
|
|
|
|
|
let rx_key = (rx_now, self.rx_peak);
|
|
|
|
|
if self.net_dl_key != rx_key {
|
|
|
|
|
self.net_dl_title = format!("Download (KB/s) — now: {rx_now} | peak: {}", self.rx_peak);
|
|
|
|
|
self.net_dl_key = rx_key;
|
|
|
|
|
}
|
2025-08-08 12:41:32 -07:00
|
|
|
draw_net_spark(
|
|
|
|
|
f,
|
2026-08-18 22:29:54 -07:00
|
|
|
l.download,
|
2026-06-02 13:02:25 -07:00
|
|
|
&self.net_dl_title,
|
|
|
|
|
&mut self.rx_hist,
|
2025-08-08 12:41:32 -07:00
|
|
|
ratatui::style::Color::Green,
|
|
|
|
|
);
|
2026-06-02 13:02:25 -07:00
|
|
|
|
|
|
|
|
let tx_now = self.tx_hist.back().copied().unwrap_or(0);
|
|
|
|
|
let tx_key = (tx_now, self.tx_peak);
|
|
|
|
|
if self.net_ul_key != tx_key {
|
|
|
|
|
self.net_ul_title = format!("Upload (KB/s) — now: {tx_now} | peak: {}", self.tx_peak);
|
|
|
|
|
self.net_ul_key = tx_key;
|
|
|
|
|
}
|
2025-08-08 12:41:32 -07:00
|
|
|
draw_net_spark(
|
|
|
|
|
f,
|
2026-08-18 22:29:54 -07:00
|
|
|
l.upload,
|
2026-06-02 13:02:25 -07:00
|
|
|
&self.net_ul_title,
|
|
|
|
|
&mut self.tx_hist,
|
2025-08-08 12:41:32 -07:00
|
|
|
ratatui::style::Color::Blue,
|
|
|
|
|
);
|
|
|
|
|
|
2025-08-11 12:04:55 -07:00
|
|
|
// Right bottom: Top Processes fills the column
|
2026-08-18 22:29:54 -07:00
|
|
|
let procs_area = l.procs;
|
2025-08-12 15:52:46 -07:00
|
|
|
// Cache for input handlers
|
|
|
|
|
self.last_procs_area = Some(procs_area);
|
2026-06-02 13:02:25 -07:00
|
|
|
// Refresh the filter cache before partial borrows of self.
|
|
|
|
|
let _ = self.procs_filter();
|
2025-08-12 15:52:46 -07:00
|
|
|
crate::ui::processes::draw_top_processes(
|
|
|
|
|
f,
|
|
|
|
|
procs_area,
|
2025-11-17 11:24:32 -08:00
|
|
|
crate::ui::processes::ProcessDisplayParams {
|
|
|
|
|
metrics: self.last_metrics.as_ref(),
|
|
|
|
|
scroll_offset: self.procs_scroll_offset,
|
|
|
|
|
sort_by: self.procs_sort_by,
|
|
|
|
|
selected_process_pid: self.selected_process_pid,
|
|
|
|
|
selected_process_index: self.selected_process_index,
|
|
|
|
|
search_query: &self.process_search_query,
|
|
|
|
|
search_active: self.process_search_active,
|
2026-06-02 13:02:25 -07:00
|
|
|
filtered_indices: &self.procs_filtered,
|
|
|
|
|
cached_rows: &self.procs_row_cache,
|
|
|
|
|
peak_cpu: self.procs_row_peak_cpu,
|
2026-08-24 07:19:37 -07:00
|
|
|
kill_enabled: self.kill_enabled,
|
2025-11-17 11:24:32 -08:00
|
|
|
},
|
2025-08-12 15:52:46 -07:00
|
|
|
);
|
2025-09-15 10:16:47 -07:00
|
|
|
|
|
|
|
|
// Render modals on top of everything else
|
|
|
|
|
if self.modal_manager.is_active() {
|
2025-10-02 16:54:27 -07:00
|
|
|
use crate::ui::modal::{ProcessHistoryData, ProcessModalData};
|
|
|
|
|
self.modal_manager.render(
|
|
|
|
|
f,
|
|
|
|
|
ProcessModalData {
|
|
|
|
|
details: self.process_details.as_ref(),
|
|
|
|
|
journal: self.journal_entries.as_ref(),
|
|
|
|
|
history: ProcessHistoryData {
|
|
|
|
|
cpu: &self.process_cpu_history,
|
2026-06-02 13:02:25 -07:00
|
|
|
cpu_sum: self.process_cpu_history_sum,
|
2025-10-02 16:54:27 -07:00
|
|
|
mem: &self.process_mem_history,
|
|
|
|
|
io_read: &self.process_io_read_history,
|
|
|
|
|
io_write: &self.process_io_write_history,
|
|
|
|
|
},
|
2025-11-17 11:24:32 -08:00
|
|
|
max_mem_bytes: self.max_process_mem_bytes,
|
2025-10-02 16:54:27 -07:00
|
|
|
unsupported: self.process_details_unsupported,
|
2026-08-24 07:19:37 -07:00
|
|
|
kill_enabled: self.kill_enabled,
|
2025-10-02 16:54:27 -07:00
|
|
|
},
|
|
|
|
|
);
|
2025-09-15 10:16:47 -07:00
|
|
|
}
|
2025-08-08 12:41:32 -07:00
|
|
|
}
|
2025-08-08 17:25:15 -07:00
|
|
|
}
|
2025-08-10 23:32:44 -07:00
|
|
|
|
|
|
|
|
impl Default for App {
|
|
|
|
|
fn default() -> Self {
|
2026-06-02 13:02:25 -07:00
|
|
|
Self::new()
|
2025-08-10 23:32:44 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-23 08:11:17 -07:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod kill_refresh_tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use socktop_connector::{Metrics, ProcessInfo};
|
|
|
|
|
|
|
|
|
|
fn proc(pid: u32, name: &str) -> ProcessInfo {
|
|
|
|
|
ProcessInfo {
|
|
|
|
|
pid,
|
|
|
|
|
name: name.into(),
|
|
|
|
|
cpu_usage: 1.0,
|
|
|
|
|
mem_bytes: 1_000,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn app_with(pids: &[u32]) -> App {
|
|
|
|
|
let mut app = App::new();
|
|
|
|
|
app.last_metrics = Some(Metrics {
|
|
|
|
|
sampled_at_ms: None,
|
|
|
|
|
cpu_total: 0.0,
|
|
|
|
|
cpu_per_core: vec![],
|
|
|
|
|
mem_total: 1_000_000,
|
|
|
|
|
mem_used: 0,
|
|
|
|
|
swap_total: 0,
|
|
|
|
|
swap_used: 0,
|
|
|
|
|
hostname: "t".into(),
|
|
|
|
|
cpu_temp_c: None,
|
|
|
|
|
disks: vec![],
|
|
|
|
|
networks: vec![],
|
|
|
|
|
top_processes: pids.iter().map(|p| proc(*p, "victim")).collect(),
|
|
|
|
|
gpus: None,
|
|
|
|
|
process_count: Some(pids.len()),
|
|
|
|
|
});
|
|
|
|
|
app
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn dropping_a_row_updates_the_list_count_and_selection() {
|
|
|
|
|
let mut app = app_with(&[1, 2, 3]);
|
|
|
|
|
app.selected_process_pid = Some(2);
|
|
|
|
|
app.selected_process_index = Some(1);
|
|
|
|
|
|
|
|
|
|
app.forget_process_row(2);
|
|
|
|
|
|
|
|
|
|
let m = app.last_metrics.as_ref().unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
m.top_processes.iter().map(|p| p.pid).collect::<Vec<_>>(),
|
|
|
|
|
vec![1, 3]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(m.process_count, Some(2), "header count went stale");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
app.selected_process_pid, None,
|
|
|
|
|
"selection still points at a dead process"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(app.selected_process_index, None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A process that was never on screen (outside the top-N) must not decrement
|
|
|
|
|
/// the total or disturb the selection.
|
|
|
|
|
#[test]
|
|
|
|
|
fn dropping_an_offscreen_row_changes_nothing() {
|
|
|
|
|
let mut app = app_with(&[1, 2, 3]);
|
|
|
|
|
app.selected_process_pid = Some(1);
|
|
|
|
|
app.forget_process_row(999);
|
|
|
|
|
let m = app.last_metrics.as_ref().unwrap();
|
|
|
|
|
assert_eq!(m.top_processes.len(), 3);
|
|
|
|
|
assert_eq!(m.process_count, Some(3));
|
|
|
|
|
assert_eq!(app.selected_process_pid, Some(1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A dead process disappears immediately, and a refresh is still scheduled
|
|
|
|
|
/// so the agent's own view catches up past its cache TTL.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_confirmed_dead_process_leaves_at_once() {
|
|
|
|
|
let mut child = std::process::Command::new("true").spawn().expect("spawn");
|
|
|
|
|
let pid = child.id();
|
|
|
|
|
child.wait().expect("reap");
|
|
|
|
|
|
|
|
|
|
let mut app = app_with(&[pid, 4242]);
|
|
|
|
|
app.refresh_after_kill(pid);
|
|
|
|
|
|
|
|
|
|
let m = app.last_metrics.as_ref().unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
m.top_processes.iter().map(|p| p.pid).collect::<Vec<_>>(),
|
|
|
|
|
vec![4242],
|
|
|
|
|
"a process known to be gone should not still be listed"
|
|
|
|
|
);
|
|
|
|
|
assert!(app.procs_refresh_due_at.is_some(), "no refresh scheduled");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A process that survived the signal keeps its row — better a row that is
|
|
|
|
|
/// still true than one that vanishes and comes back.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_surviving_process_keeps_its_row_until_the_refresh() {
|
|
|
|
|
let mut child = std::process::Command::new("sleep")
|
|
|
|
|
.arg("30")
|
|
|
|
|
.spawn()
|
|
|
|
|
.expect("spawn");
|
|
|
|
|
let pid = child.id();
|
|
|
|
|
|
|
|
|
|
let mut app = app_with(&[pid]);
|
|
|
|
|
app.refresh_after_kill(pid);
|
|
|
|
|
let listed = app
|
|
|
|
|
.last_metrics
|
|
|
|
|
.as_ref()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.top_processes
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|p| p.pid == pid);
|
|
|
|
|
|
|
|
|
|
let _ = child.kill();
|
|
|
|
|
let _ = child.wait();
|
|
|
|
|
|
|
|
|
|
assert!(listed, "row for a live process was removed optimistically");
|
|
|
|
|
assert!(app.procs_refresh_due_at.is_some());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The scheduled refresh must land after the agent's process cache TTL,
|
|
|
|
|
/// or it just re-reads the pre-kill snapshot.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_forced_refresh_waits_out_the_agent_cache() {
|
|
|
|
|
assert!(
|
|
|
|
|
PROC_CACHE_SETTLE_FLOOR >= Duration::from_millis(1_500),
|
|
|
|
|
"agent serves Processes from a 1500ms cache by default"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Users who raise the agent's Processes TTL raise the client interval to
|
|
|
|
|
/// match, so the settle window (and the tombstone that must outlive it)
|
|
|
|
|
/// scales with the interval instead of assuming the default TTL.
|
|
|
|
|
#[test]
|
|
|
|
|
fn settle_and_tombstone_scale_with_the_processes_interval() {
|
|
|
|
|
// The default processes interval is 2s, which already exceeds the
|
|
|
|
|
// 1.6s floor — so the default settle is the interval itself.
|
|
|
|
|
let mut app = App::new();
|
|
|
|
|
assert_eq!(app.proc_refresh_settle(), Duration::from_secs(2));
|
|
|
|
|
|
|
|
|
|
app = app.with_intervals(None, Some(10_000));
|
|
|
|
|
assert_eq!(app.proc_refresh_settle(), Duration::from_secs(10));
|
|
|
|
|
assert!(app.kill_tombstone_for() > app.proc_refresh_settle());
|
|
|
|
|
|
|
|
|
|
// A tiny interval never drops the settle below the default-TTL floor.
|
|
|
|
|
app = app.with_intervals(None, Some(200));
|
|
|
|
|
assert_eq!(app.proc_refresh_settle(), PROC_CACHE_SETTLE_FLOOR);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod parent_chain_tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::ui::modal::ModalType;
|
|
|
|
|
|
|
|
|
|
/// Kill a parent reached via P-navigation: the child's view resurfaces and
|
|
|
|
|
/// must resume polling. Reported as: "I can still see the orphaned window
|
|
|
|
|
/// if I open a process, hit P, then terminate that process with t".
|
|
|
|
|
#[test]
|
|
|
|
|
fn killing_a_navigated_to_parent_retargets_the_child_view() {
|
|
|
|
|
let mut app = App::new();
|
|
|
|
|
let (child, parent) = (200u32, 100u32);
|
|
|
|
|
app.modal_manager
|
|
|
|
|
.push_modal(ModalType::ProcessDetails { pid: child });
|
|
|
|
|
app.modal_manager
|
|
|
|
|
.push_modal(ModalType::ProcessDetails { pid: parent });
|
|
|
|
|
// The kill flow stacks the "Signal sent" Info on top, and the watch
|
|
|
|
|
// usually confirms the death while it is still up.
|
|
|
|
|
app.modal_manager.push_modal(ModalType::Info {
|
|
|
|
|
title: "Signal sent".into(),
|
|
|
|
|
message: "Sent SIGTERM".into(),
|
|
|
|
|
});
|
|
|
|
|
// forget_process_row has already cleared the selection by this point.
|
|
|
|
|
app.selected_process_pid = None;
|
|
|
|
|
|
|
|
|
|
app.close_details_for_gone_process(parent);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
app.selected_process_pid,
|
|
|
|
|
Some(child),
|
|
|
|
|
"resurfaced child view has no selection: its poll never runs and \
|
|
|
|
|
the window sits frozen"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(app.modal_manager.topmost_process_details(), Some(child));
|
|
|
|
|
assert!(
|
|
|
|
|
app.last_process_details_poll.elapsed() >= app.process_details_interval,
|
|
|
|
|
"poll should be due immediately"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod details_close_tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::ui::modal::ModalType;
|
|
|
|
|
use socktop_connector::{Metrics, ProcessInfo};
|
|
|
|
|
|
|
|
|
|
fn app_viewing(pid: u32) -> App {
|
|
|
|
|
let mut app = App::new();
|
|
|
|
|
app.last_metrics = Some(Metrics {
|
|
|
|
|
sampled_at_ms: None,
|
|
|
|
|
cpu_total: 0.0,
|
|
|
|
|
cpu_per_core: vec![],
|
|
|
|
|
mem_total: 1_000_000,
|
|
|
|
|
mem_used: 0,
|
|
|
|
|
swap_total: 0,
|
|
|
|
|
swap_used: 0,
|
|
|
|
|
hostname: "t".into(),
|
|
|
|
|
cpu_temp_c: None,
|
|
|
|
|
disks: vec![],
|
|
|
|
|
networks: vec![],
|
|
|
|
|
top_processes: vec![ProcessInfo {
|
|
|
|
|
pid,
|
|
|
|
|
name: "victim".into(),
|
|
|
|
|
cpu_usage: 1.0,
|
|
|
|
|
mem_bytes: 1_000,
|
|
|
|
|
}],
|
|
|
|
|
gpus: None,
|
|
|
|
|
process_count: Some(1),
|
|
|
|
|
});
|
|
|
|
|
app.selected_process_pid = Some(pid);
|
|
|
|
|
app.selected_process_index = Some(0);
|
|
|
|
|
app.modal_manager
|
|
|
|
|
.push_modal(ModalType::ProcessDetails { pid });
|
|
|
|
|
app.max_process_mem_bytes = 12_345; // stand-in for collected history
|
|
|
|
|
app
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Killing the process you are looking at should not leave you staring at
|
|
|
|
|
/// its details — especially since the details poll keys off the selection,
|
|
|
|
|
/// which is cleared at the same time.
|
|
|
|
|
#[test]
|
|
|
|
|
fn killing_the_viewed_process_closes_its_details() {
|
|
|
|
|
let mut child = std::process::Command::new("true").spawn().expect("spawn");
|
|
|
|
|
let pid = child.id();
|
|
|
|
|
child.wait().expect("reap");
|
|
|
|
|
|
|
|
|
|
let mut app = app_viewing(pid);
|
|
|
|
|
app.refresh_after_kill(pid);
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
!app.modal_manager.is_active(),
|
|
|
|
|
"details modal stayed open for a dead process"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
app.max_process_mem_bytes, 0,
|
|
|
|
|
"details state was not cleared"
|
|
|
|
|
);
|
|
|
|
|
assert!(app.last_metrics.as_ref().unwrap().top_processes.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A process that survived the signal keeps both its row and its details.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_surviving_process_keeps_its_details_open() {
|
|
|
|
|
let mut child = std::process::Command::new("sleep")
|
|
|
|
|
.arg("30")
|
|
|
|
|
.spawn()
|
|
|
|
|
.expect("spawn");
|
|
|
|
|
let pid = child.id();
|
|
|
|
|
|
|
|
|
|
let mut app = app_viewing(pid);
|
|
|
|
|
app.refresh_after_kill(pid);
|
|
|
|
|
let still_open = app.modal_manager.is_active();
|
|
|
|
|
|
|
|
|
|
let _ = child.kill();
|
|
|
|
|
let _ = child.wait();
|
|
|
|
|
|
|
|
|
|
assert!(still_open, "closed the details of a process still running");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod kill_watch_tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use socktop_connector::{Metrics, ProcessInfo};
|
|
|
|
|
|
|
|
|
|
fn metrics_with(pids: &[(u32, &str)]) -> Metrics {
|
|
|
|
|
Metrics {
|
|
|
|
|
sampled_at_ms: None,
|
|
|
|
|
cpu_total: 0.0,
|
|
|
|
|
cpu_per_core: vec![],
|
|
|
|
|
mem_total: 1_000_000,
|
|
|
|
|
mem_used: 0,
|
|
|
|
|
swap_total: 0,
|
|
|
|
|
swap_used: 0,
|
|
|
|
|
hostname: "t".into(),
|
|
|
|
|
cpu_temp_c: None,
|
|
|
|
|
disks: vec![],
|
|
|
|
|
networks: vec![],
|
|
|
|
|
top_processes: pids
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(pid, name)| ProcessInfo {
|
|
|
|
|
pid: *pid,
|
|
|
|
|
name: (*name).into(),
|
|
|
|
|
cpu_usage: 1.0,
|
|
|
|
|
mem_bytes: 1_000,
|
|
|
|
|
})
|
|
|
|
|
.collect(),
|
|
|
|
|
gpus: None,
|
|
|
|
|
process_count: Some(pids.len()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn listed(app: &App, pid: u32) -> bool {
|
|
|
|
|
app.last_metrics
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(|m| m.top_processes.iter().any(|p| p.pid == pid))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The reported bug: SIGTERM is a request, so the process is normally still
|
|
|
|
|
/// alive at signal time. The row must go when it actually exits, not stay
|
|
|
|
|
/// until the next full poll.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_row_goes_as_soon_as_the_process_actually_exits() {
|
|
|
|
|
let mut child = std::process::Command::new("sleep")
|
|
|
|
|
.arg("30")
|
|
|
|
|
.spawn()
|
|
|
|
|
.expect("spawn");
|
|
|
|
|
let pid = child.id();
|
|
|
|
|
|
|
|
|
|
let mut app = App::new();
|
|
|
|
|
app.last_metrics = Some(metrics_with(&[(pid, "sleep"), (1, "init")]));
|
|
|
|
|
app.selected_process_pid = Some(pid);
|
|
|
|
|
|
|
|
|
|
// Signalled, but it has not exited yet: the row stays.
|
|
|
|
|
app.refresh_after_kill(pid);
|
|
|
|
|
assert!(
|
|
|
|
|
listed(&app, pid),
|
|
|
|
|
"row vanished while the process was alive"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// It exits (as a SIGTERM'd process does, a moment later).
|
|
|
|
|
let _ = child.kill();
|
|
|
|
|
let _ = child.wait();
|
|
|
|
|
|
|
|
|
|
// The next tick notices.
|
|
|
|
|
app.poll_kill_watch();
|
|
|
|
|
assert!(!listed(&app, pid), "row survived the process exiting");
|
|
|
|
|
assert_eq!(app.selected_process_pid, None, "selection left on a corpse");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Also reported: after terminating, the row came back. The agent serves
|
|
|
|
|
/// `Processes` from a 1500ms cache, so its next answer can predate the kill.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_stale_agent_snapshot_cannot_resurrect_a_killed_process() {
|
|
|
|
|
let mut child = std::process::Command::new("true").spawn().expect("spawn");
|
|
|
|
|
let pid = child.id();
|
|
|
|
|
child.wait().expect("reap");
|
|
|
|
|
|
|
|
|
|
let mut app = App::new();
|
|
|
|
|
app.last_metrics = Some(metrics_with(&[(pid, "true"), (1, "init")]));
|
|
|
|
|
app.refresh_after_kill(pid);
|
|
|
|
|
assert!(!listed(&app, pid), "confirmed-dead row should be gone");
|
|
|
|
|
|
|
|
|
|
// The agent answers with a snapshot taken before the kill.
|
|
|
|
|
app.last_metrics = Some(metrics_with(&[(pid, "true"), (1, "init")]));
|
|
|
|
|
app.drop_tombstoned_rows();
|
|
|
|
|
assert!(!listed(&app, pid), "stale snapshot put the row back");
|
|
|
|
|
assert!(listed(&app, 1), "unrelated processes must survive");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// His exact path: find the process with `/`, then kill it. The filtered
|
|
|
|
|
/// view is derived from the same list, so it must lose the row too.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_search_filtered_view_loses_the_row_as_well() {
|
|
|
|
|
let mut child = std::process::Command::new("true").spawn().expect("spawn");
|
|
|
|
|
let pid = child.id();
|
|
|
|
|
child.wait().expect("reap");
|
|
|
|
|
|
|
|
|
|
let mut app = App::new();
|
|
|
|
|
app.last_metrics = Some(metrics_with(&[(pid, "victim"), (1, "init")]));
|
|
|
|
|
app.process_search_query = "victim".into();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
app.procs_filter().len(),
|
|
|
|
|
1,
|
|
|
|
|
"search should match the victim"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
app.refresh_after_kill(pid);
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
app.procs_filter().is_empty(),
|
|
|
|
|
"killed process still present in the filtered list"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_selection_that_leaves_the_list_is_dropped() {
|
|
|
|
|
let mut app = App::new();
|
|
|
|
|
app.last_metrics = Some(metrics_with(&[(1, "init")]));
|
|
|
|
|
app.selected_process_pid = Some(4242);
|
|
|
|
|
app.selected_process_index = Some(7);
|
|
|
|
|
app.drop_vanished_selection();
|
|
|
|
|
assert_eq!(app.selected_process_pid, None);
|
|
|
|
|
assert_eq!(app.selected_process_index, None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_selection_still_in_the_list_is_kept() {
|
|
|
|
|
let mut app = App::new();
|
|
|
|
|
app.last_metrics = Some(metrics_with(&[(1, "init")]));
|
|
|
|
|
app.selected_process_pid = Some(1);
|
|
|
|
|
app.drop_vanished_selection();
|
|
|
|
|
assert_eq!(app.selected_process_pid, Some(1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A process that ignores the signal must not be watched forever.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_watch_expires() {
|
|
|
|
|
let mut app = App::new();
|
|
|
|
|
app.last_metrics = Some(metrics_with(&[(1, "init")]));
|
|
|
|
|
// Already-expired deadline, for a PID that certainly exists (ourselves).
|
|
|
|
|
let me = std::process::id();
|
|
|
|
|
app.kill_watch.push((me, Instant::now()));
|
|
|
|
|
app.poll_kill_watch();
|
|
|
|
|
assert!(app.kill_watch.is_empty(), "expired watch was not dropped");
|
|
|
|
|
}
|
|
|
|
|
}
|