use std::time::{Duration, Instant};
use anyhow::Result;
use clap::{Parser, ValueEnum};
use crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton as CrosstermButton,
MouseEvent, MouseEventKind,
};
use scrin::interaction::{MouseButton, PointerEvent, PointerEventKind, UiEvent};
use scrin::{PresentStrategy, Terminal, TerminalOptions, WidgetId};
use crate::daemon::{self, Daemon, DaemonAction};
#[derive(Clone, Debug, Parser)]
#[command(
name = "dae",
author = "Trevor Knott, Knott Dynamics",
version,
about = "A scrin and scrin-widgets Linux daemon management TUI",
long_about = "dae is a Linux daemon cockpit for systemd services. It uses scrin interaction metadata, scrin-widgets surfaces, a KnottCode-inspired dark palette, and subtle threat signals to view daemon state, journal history, anomaly flags, and quick management actions."
)]
pub struct Cli {
#[arg(short = 'b', long, default_value_t = 24)]
pub lookback_hours: u64,
#[arg(short = 'n', long, default_value_t = 240)]
pub journal_lines: usize,
#[arg(short = 'r', long, default_value_t = 5)]
pub refresh_seconds: u64,
#[arg(long)]
pub no_auto_refresh: bool,
#[arg(short, long)]
pub filter: Option<String>,
#[arg(short = 'a', long)]
pub anomalies: bool,
#[arg(long, value_enum, default_value_t = Page::Services)]
pub page: Page,
#[arg(long, value_enum, default_value_t = SortMode::Signal)]
pub sort: SortMode,
#[arg(long, value_enum, default_value_t = ThemeMode::Auto)]
pub theme: ThemeMode,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum SortMode {
Signal,
Name,
State,
Restarts,
Memory,
}
impl SortMode {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Signal => "signal",
Self::Name => "name",
Self::State => "state",
Self::Restarts => "restarts",
Self::Memory => "memory",
}
}
fn next(self) -> Self {
match self {
Self::Signal => Self::Name,
Self::Name => Self::State,
Self::State => Self::Restarts,
Self::Restarts => Self::Memory,
Self::Memory => Self::Signal,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum ThemeMode {
Auto,
Cypher,
Dream,
Phosphor,
Flare,
}
impl ThemeMode {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Cypher => "cypher",
Self::Dream => "dream",
Self::Phosphor => "phosphor",
Self::Flare => "flare",
}
}
fn next(self) -> Self {
match self {
Self::Auto => Self::Cypher,
Self::Cypher => Self::Dream,
Self::Dream => Self::Phosphor,
Self::Phosphor => Self::Flare,
Self::Flare => Self::Auto,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum Page {
Services,
Anomalies,
}
impl Page {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Services => "services",
Self::Anomalies => "anomalies",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum OverlayCommand {
ServicesPage,
AnomalyPage,
ToggleAnomalyFilter,
Refresh,
DetailJournal,
DetailStatus,
DetailEvents,
ActionLog,
CycleSort,
CycleTheme,
Start,
Stop,
Restart,
Reload,
Enable,
Disable,
Term,
Kill,
Lookback1,
Lookback6,
Lookback24,
Lookback168,
Quit,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct CommandItem {
pub key: char,
pub title: &'static str,
pub detail: &'static str,
pub command: OverlayCommand,
}
pub(crate) fn command_items() -> &'static [CommandItem] {
&[
CommandItem {
key: 'a',
title: "Anomaly page",
detail: "fleet anomaly table",
command: OverlayCommand::AnomalyPage,
},
CommandItem {
key: 's',
title: "Services page",
detail: "return to daemon list",
command: OverlayCommand::ServicesPage,
},
CommandItem {
key: 'f',
title: "Flagged only",
detail: "toggle anomalous services filter",
command: OverlayCommand::ToggleAnomalyFilter,
},
CommandItem {
key: 'r',
title: "Refresh",
detail: "reload services and selected detail",
command: OverlayCommand::Refresh,
},
CommandItem {
key: 'j',
title: "Journal tab",
detail: "open selected service journal",
command: OverlayCommand::DetailJournal,
},
CommandItem {
key: 'u',
title: "Status tab",
detail: "open systemctl status",
command: OverlayCommand::DetailStatus,
},
CommandItem {
key: 'n',
title: "Events tab",
detail: "open notable journal events",
command: OverlayCommand::DetailEvents,
},
CommandItem {
key: 'h',
title: "Action log",
detail: "recent systemctl results",
command: OverlayCommand::ActionLog,
},
CommandItem {
key: 'o',
title: "Sort",
detail: "cycle service sort",
command: OverlayCommand::CycleSort,
},
CommandItem {
key: 'p',
title: "Theme",
detail: "cycle palette",
command: OverlayCommand::CycleTheme,
},
CommandItem {
key: '1',
title: "Lookback 1h",
detail: "reload journal window",
command: OverlayCommand::Lookback1,
},
CommandItem {
key: '6',
title: "Lookback 6h",
detail: "reload journal window",
command: OverlayCommand::Lookback6,
},
CommandItem {
key: '2',
title: "Lookback 24h",
detail: "reload journal window",
command: OverlayCommand::Lookback24,
},
CommandItem {
key: '7',
title: "Lookback 7d",
detail: "reload journal window",
command: OverlayCommand::Lookback168,
},
CommandItem {
key: 'b',
title: "Start",
detail: "systemctl start selected",
command: OverlayCommand::Start,
},
CommandItem {
key: 'x',
title: "Stop",
detail: "systemctl stop selected",
command: OverlayCommand::Stop,
},
CommandItem {
key: 'w',
title: "Restart",
detail: "systemctl restart selected",
command: OverlayCommand::Restart,
},
CommandItem {
key: 'l',
title: "Reload",
detail: "systemctl reload selected",
command: OverlayCommand::Reload,
},
CommandItem {
key: 'e',
title: "Enable",
detail: "systemctl enable selected",
command: OverlayCommand::Enable,
},
CommandItem {
key: 'd',
title: "Disable",
detail: "systemctl disable selected",
command: OverlayCommand::Disable,
},
CommandItem {
key: 't',
title: "TERM",
detail: "systemctl kill --signal=TERM",
command: OverlayCommand::Term,
},
CommandItem {
key: 'k',
title: "KILL",
detail: "systemctl kill --signal=KILL",
command: OverlayCommand::Kill,
},
CommandItem {
key: 'q',
title: "Quit",
detail: "exit dae",
command: OverlayCommand::Quit,
},
]
}
pub(crate) fn context_menu_items() -> &'static [CommandItem] {
&[
CommandItem {
key: 's',
title: "Start",
detail: "systemctl start selected",
command: OverlayCommand::Start,
},
CommandItem {
key: 'x',
title: "Stop",
detail: "systemctl stop selected",
command: OverlayCommand::Stop,
},
CommandItem {
key: 'r',
title: "Restart",
detail: "systemctl restart selected",
command: OverlayCommand::Restart,
},
CommandItem {
key: 'l',
title: "Reload",
detail: "systemctl reload selected",
command: OverlayCommand::Reload,
},
CommandItem {
key: 'e',
title: "Enable",
detail: "systemctl enable selected",
command: OverlayCommand::Enable,
},
CommandItem {
key: 'd',
title: "Disable",
detail: "systemctl disable selected",
command: OverlayCommand::Disable,
},
CommandItem {
key: 't',
title: "TERM",
detail: "systemctl kill --signal=TERM",
command: OverlayCommand::Term,
},
CommandItem {
key: 'k',
title: "KILL",
detail: "systemctl kill --signal=KILL",
command: OverlayCommand::Kill,
},
CommandItem {
key: 'j',
title: "Journal",
detail: "open selected service journal",
command: OverlayCommand::DetailJournal,
},
CommandItem {
key: 'u',
title: "Status",
detail: "open systemctl status",
command: OverlayCommand::DetailStatus,
},
CommandItem {
key: 'n',
title: "Events",
detail: "open notable journal events",
command: OverlayCommand::DetailEvents,
},
CommandItem {
key: 'h',
title: "Action log",
detail: "recent systemctl results",
command: OverlayCommand::ActionLog,
},
]
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct AnomalyRow {
pub daemon_index: usize,
pub unit: String,
pub state: String,
pub severity: u8,
pub label: String,
pub detail: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct PendingAction {
pub action: DaemonAction,
pub unit: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ActionLogEntry {
pub unit: String,
pub command: String,
pub ok: bool,
pub output: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ActivePane {
Services,
Inspector,
Detail,
}
impl ActivePane {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Services => "services",
Self::Inspector => "inspector",
Self::Detail => "detail",
}
}
fn next(self) -> Self {
match self {
Self::Services => Self::Inspector,
Self::Inspector => Self::Detail,
Self::Detail => Self::Services,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum DetailTab {
Journal,
Status,
Events,
}
impl DetailTab {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Journal => "journal",
Self::Status => "status",
Self::Events => "events",
}
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct DetailCache {
pub unit: String,
pub journal: Vec<String>,
pub status: Vec<String>,
pub events: Vec<String>,
}
#[derive(Debug)]
pub(crate) struct App {
pub cli: Cli,
pub daemons: Vec<Daemon>,
pub detail: DetailCache,
pub page: Page,
pub detail_tab: DetailTab,
pub selected: usize,
pub anomaly_selected: usize,
pub active_pane: ActivePane,
pub table_scroll: u16,
pub anomaly_scroll: u16,
pub detail_scroll: u16,
pub inspector_scroll: u16,
pub table_rows_hint: u16,
pub anomaly_rows_hint: u16,
pub detail_rows_hint: u16,
pub inspector_rows_hint: u16,
pub inspector_total_hint: usize,
pub hovered_row: Option<usize>,
pub mouse_position: Option<(u16, u16)>,
pub filter: String,
pub filter_mode: bool,
pub anomalies_only: bool,
pub sort_mode: SortMode,
pub theme_mode: ThemeMode,
pub command_overlay_open: bool,
pub command_overlay_selected: usize,
pub command_overlay_filter: String,
pub command_overlay_filter_mode: bool,
pub context_menu_open: bool,
pub context_menu_hovered: Option<usize>,
pub pending_action: Option<PendingAction>,
pub action_log: Vec<ActionLogEntry>,
pub action_log_open: bool,
pub action_log_scroll: u16,
pub message: String,
pub last_refresh: Instant,
pub tick: u64,
pub running: bool,
}
impl App {
pub fn new(cli: Cli) -> Self {
let filter = cli.filter.clone().unwrap_or_default();
let anomalies_only = cli.anomalies;
let page = cli.page;
let sort_mode = cli.sort;
let theme_mode = cli.theme;
Self {
cli,
daemons: Vec::new(),
detail: DetailCache::default(),
page,
detail_tab: DetailTab::Journal,
selected: 0,
anomaly_selected: 0,
active_pane: ActivePane::Services,
table_scroll: 0,
anomaly_scroll: 0,
detail_scroll: 0,
inspector_scroll: 0,
table_rows_hint: 18,
anomaly_rows_hint: 18,
detail_rows_hint: 12,
inspector_rows_hint: 8,
inspector_total_hint: 0,
hovered_row: None,
mouse_position: None,
filter,
filter_mode: false,
anomalies_only,
sort_mode,
theme_mode,
command_overlay_open: false,
command_overlay_selected: 0,
command_overlay_filter: String::new(),
command_overlay_filter_mode: false,
context_menu_open: false,
context_menu_hovered: None,
pending_action: None,
action_log: Vec::new(),
action_log_open: false,
action_log_scroll: 0,
message: "loading daemons".to_string(),
last_refresh: Instant::now(),
tick: 0,
running: true,
}
}
pub fn run(mut self) -> Result<()> {
self.refresh_daemons();
self.refresh_detail();
let mut terminal = Terminal::init_with(TerminalOptions {
mouse_capture: true,
bracketed_paste: true,
..TerminalOptions::default()
})?;
let result = self.run_terminal(&mut terminal);
terminal.restore()?;
result
}
pub(crate) fn visible_indices(&self) -> Vec<usize> {
let filter = self.filter.trim().to_ascii_lowercase();
let mut indices = self
.daemons
.iter()
.enumerate()
.filter_map(|(idx, daemon)| {
if self.anomalies_only && daemon.anomalies.is_empty() {
return None;
}
if filter.is_empty()
|| daemon.unit.to_ascii_lowercase().contains(&filter)
|| daemon.description.to_ascii_lowercase().contains(&filter)
|| daemon.state_label().to_ascii_lowercase().contains(&filter)
|| daemon
.anomaly_summary()
.to_ascii_lowercase()
.contains(&filter)
{
Some(idx)
} else {
None
}
})
.collect::<Vec<_>>();
indices.sort_by(|left, right| {
let a = &self.daemons[*left];
let b = &self.daemons[*right];
match self.sort_mode {
SortMode::Signal => b
.severity()
.cmp(&a.severity())
.then_with(|| a.unit.cmp(&b.unit)),
SortMode::Name => a.unit.cmp(&b.unit),
SortMode::State => a
.active
.cmp(&b.active)
.then_with(|| a.sub.cmp(&b.sub))
.then_with(|| a.unit.cmp(&b.unit)),
SortMode::Restarts => b
.restarts
.unwrap_or(0)
.cmp(&a.restarts.unwrap_or(0))
.then_with(|| a.unit.cmp(&b.unit)),
SortMode::Memory => b
.memory_bytes
.unwrap_or(0)
.cmp(&a.memory_bytes.unwrap_or(0))
.then_with(|| a.unit.cmp(&b.unit)),
}
});
indices
}
pub(crate) fn anomaly_rows(&self) -> Vec<AnomalyRow> {
let filter = self.filter.trim().to_ascii_lowercase();
let mut rows = self
.daemons
.iter()
.enumerate()
.flat_map(|(daemon_index, daemon)| {
let filter = filter.clone();
daemon.anomalies.iter().map(move |anomaly| {
(
daemon_index,
daemon,
anomaly,
filter.is_empty()
|| daemon_matches_filter(daemon, &filter)
|| anomaly.label.to_ascii_lowercase().contains(&filter)
|| anomaly.detail.to_ascii_lowercase().contains(&filter),
)
})
})
.filter_map(|(daemon_index, daemon, anomaly, matches_filter)| {
matches_filter.then(|| AnomalyRow {
daemon_index,
unit: daemon.unit.clone(),
state: daemon.state_label(),
severity: anomaly.severity,
label: anomaly.label.clone(),
detail: anomaly.detail.clone(),
})
})
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
right
.severity
.cmp(&left.severity)
.then_with(|| left.unit.cmp(&right.unit))
.then_with(|| left.label.cmp(&right.label))
});
rows
}
pub(crate) fn total_anomaly_count(&self) -> usize {
self.daemons
.iter()
.map(|daemon| daemon.anomalies.len())
.sum()
}
pub(crate) fn visible_daemon_at(&self, visible_row: usize) -> Option<&Daemon> {
match self.page {
Page::Services => self
.visible_indices()
.get(visible_row)
.and_then(|idx| self.daemons.get(*idx)),
Page::Anomalies => self
.anomaly_rows()
.get(visible_row)
.and_then(|row| self.daemons.get(row.daemon_index)),
}
}
pub(crate) fn selected_daemon(&self) -> Option<&Daemon> {
match self.page {
Page::Services => self.visible_daemon_at(self.selected),
Page::Anomalies => self.visible_daemon_at(self.anomaly_selected),
}
}
pub(crate) fn selected_unit(&self) -> Option<String> {
self.selected_daemon().map(|daemon| daemon.unit.clone())
}
pub(crate) fn visible_count(&self) -> usize {
match self.page {
Page::Services => self.visible_indices().len(),
Page::Anomalies => self.anomaly_rows().len(),
}
}
pub(crate) fn visible_service_count(&self) -> usize {
self.visible_indices().len()
}
pub(crate) fn selected_anomaly_row(&self) -> Option<AnomalyRow> {
self.anomaly_rows().get(self.anomaly_selected).cloned()
}
pub(crate) fn anomaly_count(&self) -> usize {
self.daemons
.iter()
.filter(|daemon| !daemon.anomalies.is_empty())
.count()
}
pub(crate) fn detail_lines(&self) -> &[String] {
match self.detail_tab {
DetailTab::Journal => &self.detail.journal,
DetailTab::Status => &self.detail.status,
DetailTab::Events => &self.detail.events,
}
}
pub(crate) fn selected_command_item(&self) -> Option<CommandItem> {
self.filtered_command_items()
.get(self.command_overlay_selected)
.copied()
}
pub(crate) fn filtered_command_items(&self) -> Vec<CommandItem> {
let filter = self.command_overlay_filter.trim().to_ascii_lowercase();
command_items()
.iter()
.copied()
.filter(|item| filter.is_empty() || command_item_matches(*item, &filter))
.collect()
}
fn run_terminal(&mut self, terminal: &mut Terminal) -> Result<()> {
let mut needs_render = true;
while self.running {
if self.auto_refresh_if_due() {
needs_render = true;
}
if needs_render {
terminal.draw_with_present_strategy(PresentStrategy::DirtyBounds, |frame| {
crate::ui::render(frame, self);
})?;
needs_render = false;
}
if event::poll(self.event_poll_timeout())? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
self.handle_key(key);
needs_render = true;
}
Event::Mouse(mouse) => {
needs_render |= self.handle_mouse(terminal, mouse);
}
Event::Resize(_, _) => needs_render = true,
_ => {}
}
}
self.tick = self.tick.wrapping_add(1);
}
Ok(())
}
fn event_poll_timeout(&self) -> Duration {
const MAX_IDLE_POLL: Duration = Duration::from_millis(250);
if self.cli.no_auto_refresh {
return MAX_IDLE_POLL;
}
let interval = Duration::from_secs(self.cli.refresh_seconds.max(1));
let remaining = interval.saturating_sub(self.last_refresh.elapsed());
remaining.min(MAX_IDLE_POLL)
}
fn handle_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('q'))
{
self.running = false;
return;
}
self.hovered_row = None;
if self.pending_action.is_some() {
self.handle_confirmation_key(key);
return;
}
if self.context_menu_open {
self.handle_context_menu_key(key);
return;
}
if self.action_log_open {
self.handle_action_log_key(key);
return;
}
if self.command_overlay_open {
self.handle_command_overlay_key(key);
return;
}
if self.filter_mode {
self.handle_filter_key(key);
return;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.running = false,
KeyCode::F(1) | KeyCode::Char('?') => self.open_command_overlay(),
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.open_command_overlay()
}
KeyCode::Char('/') => {
self.filter_mode = true;
self.message =
"filter mode: type to narrow services, Enter applies, Esc clears".to_string();
}
KeyCode::Enter if self.page == Page::Anomalies => self.jump_anomaly_to_service(),
KeyCode::Char('c') => {
self.filter.clear();
self.selected = 0;
self.anomaly_selected = 0;
self.table_scroll = 0;
self.anomaly_scroll = 0;
self.refresh_detail();
}
KeyCode::Char('A') => self.set_page(Page::Anomalies),
KeyCode::Char('S') => self.set_page(Page::Services),
KeyCode::Char('H') => self.open_action_log(),
KeyCode::Char('a') => self.toggle_anomalies_only(),
KeyCode::Char('o') => self.cycle_sort_mode(),
KeyCode::Char('p') => self.cycle_theme_mode(),
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll_active_pane(false, 1)
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll_active_pane(true, 1)
}
KeyCode::Down | KeyCode::Char('j') => self.move_or_scroll(false),
KeyCode::Up | KeyCode::Char('k') => self.move_or_scroll(true),
KeyCode::PageDown => self.page_or_scroll(false),
KeyCode::PageUp => self.page_or_scroll(true),
KeyCode::Home => self.home_active_pane(),
KeyCode::End => self.end_active_pane(),
KeyCode::Tab => self.cycle_active_pane(),
KeyCode::BackTab | KeyCode::Char('v') => self.toggle_detail_tab(),
KeyCode::Char('[') => self.scroll_active_pane(true, 1),
KeyCode::Char(']') => self.scroll_active_pane(false, 1),
KeyCode::Char('1') => self.set_lookback(1),
KeyCode::Char('6') => self.set_lookback(6),
KeyCode::Char('2') => self.set_lookback(24),
KeyCode::Char('7') => self.set_lookback(168),
KeyCode::Char('R') => self.refresh_all(),
KeyCode::Char('s') => self.request_or_run_action(DaemonAction::Start),
KeyCode::Char('x') => self.request_or_run_action(DaemonAction::Stop),
KeyCode::Char('r') => self.request_or_run_action(DaemonAction::Restart),
KeyCode::Char('l') => self.request_or_run_action(DaemonAction::Reload),
KeyCode::Char('e') => self.request_or_run_action(DaemonAction::Enable),
KeyCode::Char('d') => self.request_or_run_action(DaemonAction::Disable),
KeyCode::Char('t') => self.request_or_run_action(DaemonAction::KillTerm),
KeyCode::Char('K') => self.request_or_run_action(DaemonAction::KillKill),
_ => {}
}
}
fn handle_confirmation_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_pending_action(),
KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
self.confirm_pending_action()
}
_ => {}
}
}
fn handle_context_menu_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.close_context_menu(),
KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
let ch = ch.to_ascii_lowercase();
if let Some(item) = context_menu_items().iter().find(|item| item.key == ch) {
self.execute_overlay_command(item.command);
}
}
_ => {}
}
}
fn handle_action_log_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('H') | KeyCode::Char('h') => self.close_action_log(),
KeyCode::Up | KeyCode::Char('k') => self.scroll_action_log(true, 1),
KeyCode::Down | KeyCode::Char('j') => self.scroll_action_log(false, 1),
KeyCode::PageUp => self.scroll_action_log(true, 5),
KeyCode::PageDown => self.scroll_action_log(false, 5),
KeyCode::Home => self.action_log_scroll = 0,
KeyCode::End => self.action_log_scroll = self.action_log_max_scroll(),
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.close_action_log();
self.open_command_overlay();
}
_ => {}
}
}
fn handle_command_overlay_key(&mut self, key: KeyEvent) {
if self.command_overlay_filter_mode {
match key.code {
KeyCode::Esc => self.close_command_overlay(),
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.close_command_overlay()
}
KeyCode::Enter => {
if let Some(item) = self.selected_command_item() {
self.execute_overlay_command(item.command);
}
}
KeyCode::Up => self.move_command_overlay_selection(true, 1),
KeyCode::Down => self.move_command_overlay_selection(false, 1),
KeyCode::PageUp => self.move_command_overlay_selection(true, 5),
KeyCode::PageDown => self.move_command_overlay_selection(false, 5),
KeyCode::Home => self.command_overlay_selected = 0,
KeyCode::End => {
self.command_overlay_selected =
self.filtered_command_items().len().saturating_sub(1);
}
KeyCode::Backspace => {
self.command_overlay_filter.pop();
self.clamp_command_overlay_selection();
}
KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
if !ch.is_control() {
self.command_overlay_filter.push(ch);
self.command_overlay_selected = 0;
self.clamp_command_overlay_selection();
}
}
_ => {}
}
return;
}
match key.code {
KeyCode::Esc | KeyCode::F(1) | KeyCode::Char('?') => self.close_command_overlay(),
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.close_command_overlay()
}
KeyCode::Char('/') => {
self.command_overlay_filter_mode = true;
self.command_overlay_filter.clear();
self.command_overlay_selected = 0;
self.message = "command search: type to filter, Enter runs, Esc closes".to_string();
}
KeyCode::Up | KeyCode::Char('k') => self.move_command_overlay_selection(true, 1),
KeyCode::Down | KeyCode::Char('j') => self.move_command_overlay_selection(false, 1),
KeyCode::PageUp => self.move_command_overlay_selection(true, 5),
KeyCode::PageDown => self.move_command_overlay_selection(false, 5),
KeyCode::Home => self.command_overlay_selected = 0,
KeyCode::End => {
self.command_overlay_selected = command_items().len().saturating_sub(1);
}
KeyCode::Enter => {
if let Some(item) = self.selected_command_item() {
self.execute_overlay_command(item.command);
}
}
KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
let ch = ch.to_ascii_lowercase();
if let Some(item) = command_items().iter().find(|item| item.key == ch) {
self.execute_overlay_command(item.command);
}
}
_ => {}
}
}
fn open_command_overlay(&mut self) {
self.context_menu_open = false;
self.context_menu_hovered = None;
self.command_overlay_open = true;
self.command_overlay_selected = 0;
self.command_overlay_filter.clear();
self.command_overlay_filter_mode = false;
self.message = "command overlay: press key, / filters, Enter runs, Esc closes".to_string();
}
fn close_command_overlay(&mut self) {
self.command_overlay_open = false;
self.command_overlay_filter.clear();
self.command_overlay_filter_mode = false;
self.message = "command overlay closed".to_string();
}
fn open_action_log(&mut self) {
self.context_menu_open = false;
self.context_menu_hovered = None;
self.command_overlay_open = false;
self.command_overlay_filter.clear();
self.command_overlay_filter_mode = false;
self.action_log_open = true;
self.message = "action log open: Esc/H closes".to_string();
}
fn close_action_log(&mut self) {
self.action_log_open = false;
self.message = "action log closed".to_string();
}
fn close_context_menu(&mut self) {
self.context_menu_open = false;
self.context_menu_hovered = None;
self.message = "action menu closed".to_string();
}
fn scroll_action_log(&mut self, up: bool, amount: usize) {
let amount = amount.min(u16::MAX as usize) as u16;
self.action_log_scroll = if up {
self.action_log_scroll.saturating_sub(amount)
} else {
self.action_log_scroll
.saturating_add(amount)
.min(self.action_log_max_scroll())
};
self.message = format!("action log scroll {}", self.action_log_scroll);
}
fn action_log_max_scroll(&self) -> u16 {
self.action_log
.len()
.saturating_sub(1)
.min(u16::MAX as usize) as u16
}
fn move_command_overlay_selection(&mut self, up: bool, amount: usize) {
let max = self.filtered_command_items().len().saturating_sub(1);
self.command_overlay_selected = if up {
self.command_overlay_selected.saturating_sub(amount)
} else {
self.command_overlay_selected
.saturating_add(amount)
.min(max)
};
}
fn clamp_command_overlay_selection(&mut self) {
self.command_overlay_selected = self
.command_overlay_selected
.min(self.filtered_command_items().len().saturating_sub(1));
}
fn execute_overlay_command(&mut self, command: OverlayCommand) {
self.context_menu_open = false;
self.context_menu_hovered = None;
self.command_overlay_open = false;
match command {
OverlayCommand::ServicesPage => self.set_page(Page::Services),
OverlayCommand::AnomalyPage => self.set_page(Page::Anomalies),
OverlayCommand::ToggleAnomalyFilter => self.toggle_anomalies_only(),
OverlayCommand::Refresh => self.refresh_all(),
OverlayCommand::DetailJournal => self.set_detail_tab(DetailTab::Journal),
OverlayCommand::DetailStatus => self.set_detail_tab(DetailTab::Status),
OverlayCommand::DetailEvents => self.set_detail_tab(DetailTab::Events),
OverlayCommand::ActionLog => self.open_action_log(),
OverlayCommand::CycleSort => self.cycle_sort_mode(),
OverlayCommand::CycleTheme => self.cycle_theme_mode(),
OverlayCommand::Start => self.request_or_run_action(DaemonAction::Start),
OverlayCommand::Stop => self.request_or_run_action(DaemonAction::Stop),
OverlayCommand::Restart => self.request_or_run_action(DaemonAction::Restart),
OverlayCommand::Reload => self.request_or_run_action(DaemonAction::Reload),
OverlayCommand::Enable => self.request_or_run_action(DaemonAction::Enable),
OverlayCommand::Disable => self.request_or_run_action(DaemonAction::Disable),
OverlayCommand::Term => self.request_or_run_action(DaemonAction::KillTerm),
OverlayCommand::Kill => self.request_or_run_action(DaemonAction::KillKill),
OverlayCommand::Lookback1 => self.set_lookback(1),
OverlayCommand::Lookback6 => self.set_lookback(6),
OverlayCommand::Lookback24 => self.set_lookback(24),
OverlayCommand::Lookback168 => self.set_lookback(168),
OverlayCommand::Quit => self.running = false,
}
}
fn handle_filter_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.filter_mode = false;
self.filter.clear();
self.selected = 0;
self.anomaly_selected = 0;
self.table_scroll = 0;
self.anomaly_scroll = 0;
self.refresh_detail();
}
KeyCode::Enter => {
self.filter_mode = false;
self.clamp_selection();
self.refresh_detail();
}
KeyCode::Backspace => {
self.filter.pop();
self.clamp_selection();
self.ensure_selected_visible();
}
KeyCode::Char(ch) => {
if !ch.is_control() {
self.filter.push(ch);
self.clamp_selection();
self.ensure_selected_visible();
}
}
_ => {}
}
}
fn handle_mouse(&mut self, terminal: &mut Terminal, mouse: MouseEvent) -> bool {
match mouse.kind {
MouseEventKind::Moved => {
let _ = terminal.handle_pointer_event(PointerEvent::new(
PointerEventKind::Move,
mouse.column,
mouse.row,
));
let hit_id = terminal.hit_test(mouse.column, mouse.row).map(|r| &r.id);
let changed = if self.context_menu_open {
self.set_context_hover_from_hit(hit_id)
} else {
self.set_hover_from_hit(hit_id)
};
if changed {
self.mouse_position = Some((mouse.column, mouse.row));
}
changed
}
MouseEventKind::Down(button) => {
self.mouse_position = Some((mouse.column, mouse.row));
let _ = terminal.handle_pointer_event(PointerEvent::new(
PointerEventKind::Down(map_mouse_button(button)),
mouse.column,
mouse.row,
));
true
}
MouseEventKind::Up(button) => {
self.mouse_position = Some((mouse.column, mouse.row));
let batch = terminal.handle_pointer_event(PointerEvent::new(
PointerEventKind::Up(map_mouse_button(button)),
mouse.column,
mouse.row,
));
for ui_event in batch.events {
if let UiEvent::Click { id, button, .. } = ui_event {
self.handle_click(&id, button);
}
}
let hit_id = terminal.hit_test(mouse.column, mouse.row).map(|r| &r.id);
if self.context_menu_open {
self.set_context_hover_from_hit(hit_id);
} else {
self.set_hover_from_hit(hit_id);
}
true
}
MouseEventKind::Drag(button) => {
self.mouse_position = Some((mouse.column, mouse.row));
let _ = terminal.handle_pointer_event(PointerEvent::new(
PointerEventKind::Drag(map_mouse_button(button)),
mouse.column,
mouse.row,
));
true
}
MouseEventKind::ScrollUp => {
self.mouse_position = Some((mouse.column, mouse.row));
self.scroll_for_pointer(false, terminal, mouse);
true
}
MouseEventKind::ScrollDown => {
self.mouse_position = Some((mouse.column, mouse.row));
self.scroll_for_pointer(true, terminal, mouse);
true
}
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => false,
}
}
fn handle_click(&mut self, id: &WidgetId, button: MouseButton) {
let id = id.as_ref();
if self.context_menu_open {
if button == MouseButton::Left {
if let Some(idx) = crate::ui::context_item_from_id(id) {
if let Some(item) = context_menu_items().get(idx) {
self.execute_overlay_command(item.command);
}
return;
}
}
if id == crate::ui::CONTEXT_ID {
return;
}
self.context_menu_open = false;
self.context_menu_hovered = None;
}
if button == MouseButton::Right {
self.open_context_menu_for_id(id);
return;
}
if button != MouseButton::Left {
return;
}
if let Some(row) = crate::ui::service_row_from_id(id) {
self.page = Page::Services;
self.active_pane = ActivePane::Services;
self.hovered_row = None;
self.selected = row.min(self.visible_indices().len().saturating_sub(1));
self.ensure_selected_visible();
self.refresh_detail();
return;
}
if let Some(row) = crate::ui::anomaly_row_from_id(id) {
self.page = Page::Anomalies;
self.active_pane = ActivePane::Services;
self.hovered_row = None;
self.anomaly_selected = row.min(self.anomaly_rows().len().saturating_sub(1));
self.ensure_selected_visible();
self.refresh_detail();
return;
}
if let Some(pane) = crate::ui::pane_from_id(id) {
self.active_pane = pane;
self.message = format!("active pane: {}", self.active_pane.label());
}
}
fn open_context_menu_for_id(&mut self, id: &str) {
if let Some(row) = crate::ui::service_row_from_id(id) {
self.page = Page::Services;
self.active_pane = ActivePane::Services;
self.hovered_row = None;
self.selected = row.min(self.visible_indices().len().saturating_sub(1));
self.ensure_selected_visible();
self.refresh_detail();
} else if let Some(row) = crate::ui::anomaly_row_from_id(id) {
self.page = Page::Anomalies;
self.active_pane = ActivePane::Services;
self.hovered_row = None;
self.anomaly_selected = row.min(self.anomaly_rows().len().saturating_sub(1));
self.ensure_selected_visible();
self.refresh_detail();
} else if self.selected_unit().is_none() {
return;
}
self.command_overlay_open = false;
self.action_log_open = false;
self.context_menu_open = true;
self.context_menu_hovered = None;
if let Some(unit) = self.selected_unit() {
self.message = format!("action menu for {unit}: click a row or press a key");
}
}
fn set_context_hover_from_hit(&mut self, id: Option<&WidgetId>) -> bool {
let hovered = id.and_then(|id| crate::ui::context_item_from_id(id.as_ref()));
if self.context_menu_hovered == hovered {
return false;
}
self.context_menu_hovered = hovered;
true
}
fn set_hover_from_hit(&mut self, id: Option<&WidgetId>) -> bool {
let hovered_row = id.and_then(|id| {
let id = id.as_ref();
crate::ui::service_row_from_id(id).or_else(|| crate::ui::anomaly_row_from_id(id))
});
if self.hovered_row == hovered_row {
return false;
}
self.hovered_row = hovered_row;
true
}
fn scroll_for_pointer(&mut self, down: bool, terminal: &Terminal, mouse: MouseEvent) {
let hit_id = terminal
.hit_test(mouse.column, mouse.row)
.map(|region| region.id.as_ref());
if let Some(id) = hit_id {
if let Some(pane) = crate::ui::pane_from_id(id) {
self.active_pane = pane;
}
}
self.scroll_active_pane(!down, 1);
}
fn select_next(&mut self) {
let count = self.visible_count();
if count == 0 {
return;
}
match self.page {
Page::Services => self.selected = (self.selected + 1).min(count - 1),
Page::Anomalies => self.anomaly_selected = (self.anomaly_selected + 1).min(count - 1),
}
self.ensure_selected_visible();
self.refresh_detail();
}
fn select_prev(&mut self) {
if self.visible_count() == 0 {
return;
}
match self.page {
Page::Services => self.selected = self.selected.saturating_sub(1),
Page::Anomalies => self.anomaly_selected = self.anomaly_selected.saturating_sub(1),
}
self.ensure_selected_visible();
self.refresh_detail();
}
fn page_down(&mut self) {
let count = self.visible_count();
if count == 0 {
return;
}
let amount = usize::from((self.list_rows_hint() / 2).max(1));
match self.page {
Page::Services => self.selected = (self.selected + amount).min(count - 1),
Page::Anomalies => {
self.anomaly_selected = (self.anomaly_selected + amount).min(count - 1)
}
}
self.ensure_selected_visible();
self.refresh_detail();
}
fn page_up(&mut self) {
if self.visible_count() == 0 {
return;
}
let amount = usize::from((self.list_rows_hint() / 2).max(1));
match self.page {
Page::Services => self.selected = self.selected.saturating_sub(amount),
Page::Anomalies => self.anomaly_selected = self.anomaly_selected.saturating_sub(amount),
}
self.ensure_selected_visible();
self.refresh_detail();
}
fn move_or_scroll(&mut self, up: bool) {
match self.active_pane {
ActivePane::Services => {
if up {
self.select_prev();
} else {
self.select_next();
}
}
ActivePane::Inspector | ActivePane::Detail => self.scroll_active_pane(up, 1),
}
}
fn page_or_scroll(&mut self, up: bool) {
match self.active_pane {
ActivePane::Services => {
if up {
self.page_up();
} else {
self.page_down();
}
}
ActivePane::Inspector | ActivePane::Detail => {
self.scroll_active_pane(up, usize::from((self.active_viewport_rows() / 2).max(3)))
}
}
}
fn active_viewport_rows(&self) -> u16 {
match self.active_pane {
ActivePane::Services => self.list_rows_hint(),
ActivePane::Inspector => self.inspector_rows_hint,
ActivePane::Detail => self.detail_rows_hint,
}
}
fn list_rows_hint(&self) -> u16 {
match self.page {
Page::Services => self.table_rows_hint,
Page::Anomalies => self.anomaly_rows_hint,
}
}
fn scroll_active_pane(&mut self, up: bool, amount: usize) {
match self.active_pane {
ActivePane::Services => match self.page {
Page::Services => self.scroll_services(up, amount),
Page::Anomalies => self.scroll_anomalies(up, amount),
},
ActivePane::Inspector => self.scroll_inspector(up, amount),
ActivePane::Detail => self.scroll_detail(up, amount),
}
}
fn scroll_services(&mut self, up: bool, amount: usize) {
let previous_selected = self.selected;
let max = self
.visible_count()
.saturating_sub(usize::from(self.table_rows_hint.max(1)))
.min(u16::MAX as usize) as u16;
let amount = amount.min(u16::MAX as usize) as u16;
self.table_scroll = if up {
self.table_scroll.saturating_sub(amount)
} else {
self.table_scroll.saturating_add(amount).min(max)
};
let count = self.visible_count();
if count > 0 {
let scroll = usize::from(self.table_scroll);
let rows = usize::from(self.table_rows_hint.max(1));
if self.selected < scroll {
self.selected = scroll.min(count - 1);
} else if self.selected >= scroll.saturating_add(rows) {
self.selected = scroll.saturating_add(rows).saturating_sub(1).min(count - 1);
}
}
if self.selected != previous_selected {
self.refresh_detail();
}
self.message = format!("services pane scrolled to row {}", self.table_scroll);
}
fn scroll_anomalies(&mut self, up: bool, amount: usize) {
let previous_selected = self.anomaly_selected;
let max = self
.visible_count()
.saturating_sub(usize::from(self.anomaly_rows_hint.max(1)))
.min(u16::MAX as usize) as u16;
let amount = amount.min(u16::MAX as usize) as u16;
self.anomaly_scroll = if up {
self.anomaly_scroll.saturating_sub(amount)
} else {
self.anomaly_scroll.saturating_add(amount).min(max)
};
let count = self.visible_count();
if count > 0 {
let scroll = usize::from(self.anomaly_scroll);
let rows = usize::from(self.anomaly_rows_hint.max(1));
if self.anomaly_selected < scroll {
self.anomaly_selected = scroll.min(count - 1);
} else if self.anomaly_selected >= scroll.saturating_add(rows) {
self.anomaly_selected =
scroll.saturating_add(rows).saturating_sub(1).min(count - 1);
}
}
if self.anomaly_selected != previous_selected {
self.refresh_detail();
}
self.message = format!("anomaly page scrolled to row {}", self.anomaly_scroll);
}
fn scroll_inspector(&mut self, up: bool, amount: usize) {
let max = self.inspector_max_scroll();
let amount = amount.min(u16::MAX as usize) as u16;
self.inspector_scroll = if up {
self.inspector_scroll.saturating_sub(amount)
} else {
self.inspector_scroll.saturating_add(amount).min(max)
};
self.message = format!("inspector pane scroll {}", self.inspector_scroll);
}
fn scroll_detail(&mut self, up: bool, amount: usize) {
let max = self.detail_max_scroll();
let amount = amount.min(u16::MAX as usize) as u16;
self.detail_scroll = if up {
self.detail_scroll.saturating_add(amount).min(max)
} else {
self.detail_scroll.saturating_sub(amount)
};
self.message = format!(
"{} pane scroll-back {}",
self.detail_tab.label(),
self.detail_scroll
);
}
fn detail_max_scroll(&self) -> u16 {
self.detail_lines()
.len()
.saturating_sub(usize::from(self.detail_rows_hint.max(1)))
.min(u16::MAX as usize) as u16
}
fn inspector_max_scroll(&self) -> u16 {
self.inspector_total_hint
.saturating_sub(usize::from(self.inspector_rows_hint.max(1)))
.min(u16::MAX as usize) as u16
}
fn select_first(&mut self) {
match self.page {
Page::Services => {
self.selected = 0;
self.table_scroll = 0;
}
Page::Anomalies => {
self.anomaly_selected = 0;
self.anomaly_scroll = 0;
}
}
self.refresh_detail();
}
fn select_last(&mut self) {
let count = self.visible_count();
if count == 0 {
return;
}
match self.page {
Page::Services => self.selected = count - 1,
Page::Anomalies => self.anomaly_selected = count - 1,
}
self.ensure_selected_visible();
self.refresh_detail();
}
pub(crate) fn ensure_selected_visible(&mut self) {
match self.page {
Page::Services => {
let rows = usize::from(self.table_rows_hint.max(1));
let scroll = usize::from(self.table_scroll);
if self.selected < scroll {
self.table_scroll = self.selected.min(u16::MAX as usize) as u16;
} else if self.selected >= scroll.saturating_add(rows) {
let new_scroll = self.selected.saturating_add(1).saturating_sub(rows);
self.table_scroll = new_scroll.min(u16::MAX as usize) as u16;
}
}
Page::Anomalies => {
let rows = usize::from(self.anomaly_rows_hint.max(1));
let scroll = usize::from(self.anomaly_scroll);
if self.anomaly_selected < scroll {
self.anomaly_scroll = self.anomaly_selected.min(u16::MAX as usize) as u16;
} else if self.anomaly_selected >= scroll.saturating_add(rows) {
let new_scroll = self.anomaly_selected.saturating_add(1).saturating_sub(rows);
self.anomaly_scroll = new_scroll.min(u16::MAX as usize) as u16;
}
}
}
}
fn home_active_pane(&mut self) {
match self.active_pane {
ActivePane::Services => self.select_first(),
ActivePane::Inspector => {
self.inspector_scroll = 0;
self.message = "inspector pane scrolled to top".to_string();
}
ActivePane::Detail => {
self.detail_scroll = self.detail_max_scroll();
self.message = format!("{} pane scrolled to oldest entry", self.detail_tab.label());
}
}
}
fn end_active_pane(&mut self) {
match self.active_pane {
ActivePane::Services => self.select_last(),
ActivePane::Inspector => {
self.inspector_scroll = self.inspector_max_scroll();
self.message = "inspector pane scrolled to bottom".to_string();
}
ActivePane::Detail => {
self.detail_scroll = 0;
self.message = format!("{} pane following newest entry", self.detail_tab.label());
}
}
}
fn cycle_active_pane(&mut self) {
self.active_pane = self.active_pane.next();
self.message = format!("active pane: {}", self.active_pane.label());
}
fn clamp_selection(&mut self) {
let service_count = self.visible_indices().len();
if service_count == 0 {
self.selected = 0;
self.table_scroll = 0;
} else {
self.selected = self.selected.min(service_count - 1);
}
let anomaly_count = self.anomaly_rows().len();
if anomaly_count == 0 {
self.anomaly_selected = 0;
self.anomaly_scroll = 0;
} else {
self.anomaly_selected = self.anomaly_selected.min(anomaly_count - 1);
}
self.ensure_selected_visible();
}
fn set_page(&mut self, page: Page) {
self.page = page;
self.active_pane = ActivePane::Services;
self.clamp_selection();
self.refresh_detail();
self.message = format!("page: {}", self.page.label());
}
fn jump_anomaly_to_service(&mut self) {
let Some(row) = self.selected_anomaly_row() else {
self.message = "no anomaly selected".to_string();
return;
};
let position = self
.visible_indices()
.into_iter()
.position(|idx| idx == row.daemon_index)
.unwrap_or(0);
self.page = Page::Services;
self.active_pane = ActivePane::Services;
self.selected = position;
self.ensure_selected_visible();
self.refresh_detail();
self.message = format!("selected {} from anomaly page", row.unit);
}
fn toggle_detail_tab(&mut self) {
self.detail_tab = match self.detail_tab {
DetailTab::Journal => DetailTab::Status,
DetailTab::Status => DetailTab::Events,
DetailTab::Events => DetailTab::Journal,
};
self.detail_scroll = 0;
self.active_pane = ActivePane::Detail;
self.message = format!("detail tab: {}", self.detail_tab.label());
}
fn set_detail_tab(&mut self, tab: DetailTab) {
self.detail_tab = tab;
self.detail_scroll = 0;
self.active_pane = ActivePane::Detail;
self.message = format!("detail tab: {}", self.detail_tab.label());
}
fn set_lookback(&mut self, hours: u64) {
self.cli.lookback_hours = hours;
self.detail_scroll = 0;
self.refresh_detail();
}
fn toggle_anomalies_only(&mut self) {
self.anomalies_only = !self.anomalies_only;
self.selected = 0;
self.anomaly_selected = 0;
self.table_scroll = 0;
self.anomaly_scroll = 0;
self.clamp_selection();
self.refresh_detail();
self.message = if self.anomalies_only {
format!(
"showing {} anomalous services",
self.visible_service_count()
)
} else {
"showing all services".to_string()
};
}
fn cycle_sort_mode(&mut self) {
let selected_unit = self.selected_unit();
self.sort_mode = self.sort_mode.next();
if let Some(unit) = selected_unit {
self.selected = self
.visible_indices()
.into_iter()
.position(|idx| self.daemons[idx].unit == unit)
.unwrap_or(0);
self.anomaly_selected = self
.anomaly_rows()
.into_iter()
.position(|row| row.unit == unit)
.unwrap_or(self.anomaly_selected);
}
self.ensure_selected_visible();
self.refresh_detail();
self.message = format!("sort: {}", self.sort_mode.label());
}
fn cycle_theme_mode(&mut self) {
self.theme_mode = self.theme_mode.next();
self.message = format!("theme: {}", self.theme_mode.label());
}
fn refresh_all(&mut self) {
let selected_unit = self.selected_unit();
self.refresh_daemons();
if let Some(unit) = selected_unit {
self.selected = self
.visible_indices()
.into_iter()
.position(|idx| self.daemons[idx].unit == unit)
.unwrap_or(0);
self.anomaly_selected = self
.anomaly_rows()
.into_iter()
.position(|row| row.unit == unit)
.unwrap_or(self.anomaly_selected);
}
self.clamp_selection();
self.refresh_detail();
}
fn refresh_daemons(&mut self) {
match daemon::load_daemons() {
Ok(daemons) => {
self.daemons = daemons;
self.clamp_selection();
self.last_refresh = Instant::now();
self.message = format!(
"loaded {} services, {} anomalies",
self.daemons.len(),
self.anomaly_count()
);
}
Err(err) => {
self.message = format!("refresh failed: {err:#}");
}
}
}
fn refresh_detail(&mut self) {
let Some(unit) = self.selected_unit() else {
self.detail = DetailCache::default();
return;
};
if self.detail.unit != unit {
self.detail_scroll = 0;
self.inspector_scroll = 0;
}
let status = daemon::load_status(&unit)
.unwrap_or_else(|err| vec![format!("status error for {unit}: {err:#}")]);
let journal = daemon::load_journal(&unit, self.cli.lookback_hours, self.cli.journal_lines)
.unwrap_or_else(|err| vec![format!("journal error for {unit}: {err:#}")]);
let events = daemon::notable_events(&journal);
self.detail = DetailCache {
unit,
status,
journal,
events,
};
}
fn auto_refresh_if_due(&mut self) -> bool {
if self.cli.no_auto_refresh {
return false;
}
if self.last_refresh.elapsed() >= Duration::from_secs(self.cli.refresh_seconds.max(1)) {
let selected_unit = self.selected_unit();
self.refresh_daemons();
if let Some(unit) = selected_unit {
self.selected = self
.visible_indices()
.into_iter()
.position(|idx| self.daemons[idx].unit == unit)
.unwrap_or(self.selected);
self.anomaly_selected = self
.anomaly_rows()
.into_iter()
.position(|row| row.unit == unit)
.unwrap_or(self.anomaly_selected);
self.clamp_selection();
}
return true;
}
false
}
fn request_or_run_action(&mut self, action: DaemonAction) {
let Some(unit) = self.selected_unit() else {
self.message = "no service selected".to_string();
return;
};
if action_requires_confirmation(action) {
self.pending_action = Some(PendingAction {
action,
unit: unit.clone(),
});
self.message = format!(
"confirm {} for {unit}: Enter/y runs, Esc/n cancels",
action.label()
);
return;
}
self.run_action_for_unit(unit, action);
}
fn confirm_pending_action(&mut self) {
let Some(pending) = self.pending_action.take() else {
return;
};
self.run_action_for_unit(pending.unit, pending.action);
}
fn cancel_pending_action(&mut self) {
if let Some(pending) = self.pending_action.take() {
self.message = format!("cancelled {} for {}", pending.action.label(), pending.unit);
}
}
fn run_action_for_unit(&mut self, unit: String, action: DaemonAction) {
match daemon::apply_action(&unit, action) {
Ok(report) => {
let status = if report.ok { "ok" } else { "failed" };
self.push_action_log(ActionLogEntry {
unit: unit.clone(),
command: report.command.clone(),
ok: report.ok,
output: report.output.clone(),
});
self.message = format!("{status}: {} -> {}", report.command, report.output);
}
Err(err) => {
self.push_action_log(ActionLogEntry {
unit: unit.clone(),
command: daemon::action_command(&unit, action),
ok: false,
output: format!("{err:#}"),
});
self.message = format!("{} {unit} failed: {err:#}", action.label());
}
}
let action_message = self.message.clone();
self.refresh_all();
self.message = action_message;
}
fn push_action_log(&mut self, entry: ActionLogEntry) {
const MAX_ACTION_LOG: usize = 24;
self.action_log.insert(0, entry);
self.action_log.truncate(MAX_ACTION_LOG);
self.action_log_scroll = 0;
}
}
fn action_requires_confirmation(action: DaemonAction) -> bool {
matches!(
action,
DaemonAction::Stop
| DaemonAction::Restart
| DaemonAction::Disable
| DaemonAction::KillTerm
| DaemonAction::KillKill
)
}
fn map_mouse_button(button: CrosstermButton) -> MouseButton {
match button {
CrosstermButton::Left => MouseButton::Left,
CrosstermButton::Right => MouseButton::Right,
CrosstermButton::Middle => MouseButton::Middle,
}
}
fn daemon_matches_filter(daemon: &Daemon, filter: &str) -> bool {
daemon.unit.to_ascii_lowercase().contains(filter)
|| daemon.description.to_ascii_lowercase().contains(filter)
|| daemon.state_label().to_ascii_lowercase().contains(filter)
|| daemon
.anomaly_summary()
.to_ascii_lowercase()
.contains(filter)
}
fn command_item_matches(item: CommandItem, filter: &str) -> bool {
item.key.to_string().contains(filter)
|| item.title.to_ascii_lowercase().contains(filter)
|| item.detail.to_ascii_lowercase().contains(filter)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon::Anomaly;
fn test_cli() -> Cli {
Cli {
lookback_hours: 24,
journal_lines: 100,
refresh_seconds: 5,
no_auto_refresh: true,
filter: None,
anomalies: false,
page: Page::Services,
sort: SortMode::Signal,
theme: ThemeMode::Auto,
}
}
fn daemon(unit: &str, severity: u8, label: &str, detail: &str) -> Daemon {
Daemon {
unit: unit.to_string(),
description: format!("{unit} daemon"),
active: "active".to_string(),
sub: "running".to_string(),
anomalies: vec![Anomaly {
label: label.to_string(),
detail: detail.to_string(),
severity,
}],
..Daemon::default()
}
}
#[test]
fn command_overlay_filters_by_title_and_detail() {
let mut app = App::new(test_cli());
app.command_overlay_filter = "selected service journal".to_string();
let items = app.filtered_command_items();
assert_eq!(items.len(), 1);
assert_eq!(items[0].command, OverlayCommand::DetailJournal);
app.command_overlay_filter = "systemctl".to_string();
let items = app.filtered_command_items();
assert!(
items
.iter()
.any(|item| item.command == OverlayCommand::Start)
);
assert!(
items
.iter()
.any(|item| item.command == OverlayCommand::Stop)
);
}
#[test]
fn context_menu_exposes_clickable_actions() {
let commands = context_menu_items()
.iter()
.map(|item| item.command)
.collect::<Vec<_>>();
assert!(commands.contains(&OverlayCommand::Start));
assert!(commands.contains(&OverlayCommand::Stop));
assert!(commands.contains(&OverlayCommand::Restart));
assert!(commands.contains(&OverlayCommand::Kill));
assert_eq!(
crate::ui::context_item_from_id("dae:context:item:0"),
Some(0)
);
assert_eq!(crate::ui::context_item_from_id("dae:context"), None);
}
#[test]
fn context_menu_tracks_hovered_action() {
let mut app = App::new(test_cli());
let action_id = WidgetId::new("dae:context:item:7");
assert!(app.set_context_hover_from_hit(Some(&action_id)));
assert_eq!(app.context_menu_hovered, Some(7));
assert!(!app.set_context_hover_from_hit(Some(&action_id)));
assert!(app.set_context_hover_from_hit(None));
assert_eq!(app.context_menu_hovered, None);
}
#[test]
fn anomaly_rows_sort_by_severity() {
let mut app = App::new(test_cli());
app.daemons = vec![
daemon("low.service", 30, "missing-pid", "no main pid"),
daemon("critical.service", 95, "failed", "unit failed"),
daemon(
"elevated.service",
60,
"restart-storm",
"high restart count",
),
];
let rows = app.anomaly_rows();
assert_eq!(rows[0].unit, "critical.service");
assert_eq!(rows[1].unit, "elevated.service");
assert_eq!(rows[2].unit, "low.service");
}
#[test]
fn anomaly_rows_filter_by_signal_detail_and_unit() {
let mut app = App::new(test_cli());
app.daemons = vec![
daemon("alpha.service", 60, "memory", "high memory pressure"),
daemon("beta.service", 70, "failed", "exit status 1"),
];
app.filter = "memory".to_string();
let rows = app.anomaly_rows();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].unit, "alpha.service");
app.filter = "beta".to_string();
let rows = app.anomaly_rows();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "failed");
}
#[test]
fn cli_page_sets_initial_page() {
let mut cli = test_cli();
cli.page = Page::Anomalies;
let app = App::new(cli);
assert_eq!(app.page, Page::Anomalies);
}
#[test]
fn destructive_actions_request_confirmation() {
let mut app = App::new(test_cli());
app.daemons = vec![daemon("critical.service", 95, "failed", "unit failed")];
app.request_or_run_action(DaemonAction::Stop);
assert_eq!(
app.pending_action,
Some(PendingAction {
action: DaemonAction::Stop,
unit: "critical.service".to_string(),
})
);
assert!(app.message.contains("confirm stop"));
}
#[test]
fn cancelling_pending_action_clears_confirmation() {
let mut app = App::new(test_cli());
app.pending_action = Some(PendingAction {
action: DaemonAction::KillKill,
unit: "critical.service".to_string(),
});
app.cancel_pending_action();
assert!(app.pending_action.is_none());
assert!(app.message.contains("cancelled kill"));
}
#[test]
fn only_disruptive_actions_require_confirmation() {
assert!(!action_requires_confirmation(DaemonAction::Start));
assert!(!action_requires_confirmation(DaemonAction::Reload));
assert!(!action_requires_confirmation(DaemonAction::Enable));
assert!(action_requires_confirmation(DaemonAction::Stop));
assert!(action_requires_confirmation(DaemonAction::Restart));
assert!(action_requires_confirmation(DaemonAction::Disable));
assert!(action_requires_confirmation(DaemonAction::KillTerm));
assert!(action_requires_confirmation(DaemonAction::KillKill));
}
#[test]
fn action_log_keeps_newest_entries() {
let mut app = App::new(test_cli());
for idx in 0..30 {
app.push_action_log(ActionLogEntry {
unit: format!("demo-{idx}.service"),
command: format!("systemctl restart demo-{idx}.service"),
ok: idx % 2 == 0,
output: "done".to_string(),
});
}
assert_eq!(app.action_log.len(), 24);
assert_eq!(app.action_log[0].unit, "demo-29.service");
assert_eq!(app.action_log[23].unit, "demo-6.service");
}
#[test]
fn action_log_scrolls_and_resets_on_new_entry() {
let mut app = App::new(test_cli());
for idx in 0..3 {
app.push_action_log(ActionLogEntry {
unit: format!("demo-{idx}.service"),
command: format!("systemctl restart demo-{idx}.service"),
ok: true,
output: "done".to_string(),
});
}
app.scroll_action_log(false, 2);
assert_eq!(app.action_log_scroll, 2);
app.scroll_action_log(false, 10);
assert_eq!(app.action_log_scroll, 2);
app.scroll_action_log(true, 1);
assert_eq!(app.action_log_scroll, 1);
app.push_action_log(ActionLogEntry {
unit: "new.service".to_string(),
command: "systemctl start new.service".to_string(),
ok: true,
output: "done".to_string(),
});
assert_eq!(app.action_log_scroll, 0);
}
}