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, aisling, 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, and Aisling effects 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 = 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)]
pub(crate) enum DetailTab {
Journal,
Status,
}
impl DetailTab {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Journal => "journal",
Self::Status => "status",
}
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct DetailCache {
pub unit: String,
pub journal: Vec<String>,
pub status: Vec<String>,
}
#[derive(Debug)]
pub(crate) struct App {
pub cli: Cli,
pub daemons: Vec<Daemon>,
pub detail: DetailCache,
pub detail_tab: DetailTab,
pub selected: usize,
pub table_scroll: u16,
pub detail_scroll: u16,
pub table_rows_hint: u16,
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 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 sort_mode = cli.sort;
let theme_mode = cli.theme;
Self {
cli,
daemons: Vec::new(),
detail: DetailCache::default(),
detail_tab: DetailTab::Journal,
selected: 0,
table_scroll: 0,
detail_scroll: 0,
table_rows_hint: 18,
hovered_row: None,
mouse_position: None,
filter,
filter_mode: false,
anomalies_only,
sort_mode,
theme_mode,
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 visible_daemon_at(&self, visible_row: usize) -> Option<&Daemon> {
self.visible_indices()
.get(visible_row)
.and_then(|idx| self.daemons.get(*idx))
}
pub(crate) fn selected_daemon(&self) -> Option<&Daemon> {
self.visible_daemon_at(self.selected)
}
pub(crate) fn selected_unit(&self) -> Option<String> {
self.selected_daemon().map(|daemon| daemon.unit.clone())
}
pub(crate) fn visible_count(&self) -> usize {
self.visible_indices().len()
}
pub(crate) fn anomaly_count(&self) -> usize {
self.daemons
.iter()
.filter(|daemon| !daemon.anomalies.is_empty())
.count()
}
pub(crate) fn running_count(&self) -> usize {
self.daemons
.iter()
.filter(|daemon| daemon.active == "active" && daemon.sub == "running")
.count()
}
pub(crate) fn detail_lines(&self) -> &[String] {
match self.detail_tab {
DetailTab::Journal => &self.detail.journal,
DetailTab::Status => &self.detail.status,
}
}
fn run_terminal(&mut self, terminal: &mut Terminal) -> Result<()> {
while self.running {
self.auto_refresh_if_due();
terminal.draw_with_present_strategy(PresentStrategy::MarkedDirty, |frame| {
crate::ui::render(frame, self);
})?;
if event::poll(Duration::from_millis(50))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_key(key),
Event::Mouse(mouse) => self.handle_mouse(terminal, mouse),
Event::Resize(_, _) => {}
_ => {}
}
}
self.tick = self.tick.wrapping_add(1);
}
Ok(())
}
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;
}
if self.filter_mode {
self.handle_filter_key(key);
return;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.running = false,
KeyCode::Char('/') => {
self.filter_mode = true;
self.message =
"filter mode: type to narrow services, Enter applies, Esc clears".to_string();
}
KeyCode::Char('c') => {
self.filter.clear();
self.selected = 0;
self.table_scroll = 0;
self.refresh_detail();
}
KeyCode::Char('a') => self.toggle_anomalies_only(),
KeyCode::Char('o') => self.cycle_sort_mode(),
KeyCode::Char('p') => self.cycle_theme_mode(),
KeyCode::Down | KeyCode::Char('j') => self.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.select_prev(),
KeyCode::PageDown => self.page_down(),
KeyCode::PageUp => self.page_up(),
KeyCode::Home => self.select_first(),
KeyCode::End => self.select_last(),
KeyCode::Tab => self.toggle_detail_tab(),
KeyCode::Char('[') => self.scroll_detail_up(),
KeyCode::Char(']') => self.scroll_detail_down(),
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.run_action(DaemonAction::Start),
KeyCode::Char('x') => self.run_action(DaemonAction::Stop),
KeyCode::Char('r') => self.run_action(DaemonAction::Restart),
KeyCode::Char('l') => self.run_action(DaemonAction::Reload),
KeyCode::Char('e') => self.run_action(DaemonAction::Enable),
KeyCode::Char('d') => self.run_action(DaemonAction::Disable),
KeyCode::Char('t') => self.run_action(DaemonAction::KillTerm),
KeyCode::Char('K') => self.run_action(DaemonAction::KillKill),
_ => {}
}
}
fn handle_filter_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.filter_mode = false;
self.filter.clear();
self.selected = 0;
self.table_scroll = 0;
self.refresh_detail();
}
KeyCode::Enter => {
self.filter_mode = false;
self.clamp_selection();
self.refresh_detail();
}
KeyCode::Backspace => {
self.filter.pop();
self.selected = self.selected.min(self.visible_count().saturating_sub(1));
self.ensure_selected_visible();
}
KeyCode::Char(ch) => {
if !ch.is_control() {
self.filter.push(ch);
self.selected = self.selected.min(self.visible_count().saturating_sub(1));
self.ensure_selected_visible();
}
}
_ => {}
}
}
fn handle_mouse(&mut self, terminal: &mut Terminal, mouse: MouseEvent) {
self.mouse_position = Some((mouse.column, mouse.row));
match mouse.kind {
MouseEventKind::Moved => {
let _ = terminal.handle_pointer_event(PointerEvent::new(
PointerEventKind::Move,
mouse.column,
mouse.row,
));
self.set_hover_from_hit(terminal.hit_test(mouse.column, mouse.row).map(|r| &r.id));
}
MouseEventKind::Down(button) => {
let _ = terminal.handle_pointer_event(PointerEvent::new(
PointerEventKind::Down(map_mouse_button(button)),
mouse.column,
mouse.row,
));
}
MouseEventKind::Up(button) => {
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);
}
}
self.set_hover_from_hit(terminal.hit_test(mouse.column, mouse.row).map(|r| &r.id));
}
MouseEventKind::Drag(button) => {
let _ = terminal.handle_pointer_event(PointerEvent::new(
PointerEventKind::Drag(map_mouse_button(button)),
mouse.column,
mouse.row,
));
}
MouseEventKind::ScrollUp => self.scroll_for_pointer(false, terminal, mouse),
MouseEventKind::ScrollDown => self.scroll_for_pointer(true, terminal, mouse),
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {}
}
}
fn handle_click(&mut self, id: &WidgetId, button: MouseButton) {
if button != MouseButton::Left {
return;
}
let id = id.as_ref();
if let Some(row) = crate::ui::service_row_from_id(id) {
self.selected = row.min(self.visible_count().saturating_sub(1));
self.ensure_selected_visible();
self.refresh_detail();
return;
}
if let Some(action) = crate::ui::action_from_id(id) {
self.run_action(action);
}
}
fn set_hover_from_hit(&mut self, id: Option<&WidgetId>) {
self.hovered_row = id.and_then(|id| crate::ui::service_row_from_id(id.as_ref()));
}
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 matches!(hit_id, Some(id) if id.starts_with(crate::ui::DETAIL_PREFIX)) {
if down {
self.scroll_detail_down();
} else {
self.scroll_detail_up();
}
} else if down {
self.table_scroll = self.table_scroll.saturating_add(3);
} else {
self.table_scroll = self.table_scroll.saturating_sub(3);
}
}
fn select_next(&mut self) {
let count = self.visible_count();
if count == 0 {
return;
}
self.selected = (self.selected + 1).min(count - 1);
self.ensure_selected_visible();
self.refresh_detail();
}
fn select_prev(&mut self) {
if self.visible_count() == 0 {
return;
}
self.selected = self.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;
}
self.selected = (self.selected + usize::from(self.table_rows_hint.max(1))).min(count - 1);
self.ensure_selected_visible();
self.refresh_detail();
}
fn page_up(&mut self) {
if self.visible_count() == 0 {
return;
}
self.selected = self
.selected
.saturating_sub(usize::from(self.table_rows_hint.max(1)));
self.ensure_selected_visible();
self.refresh_detail();
}
fn select_first(&mut self) {
self.selected = 0;
self.table_scroll = 0;
self.refresh_detail();
}
fn select_last(&mut self) {
let count = self.visible_count();
if count == 0 {
return;
}
self.selected = count - 1;
self.ensure_selected_visible();
self.refresh_detail();
}
pub(crate) fn ensure_selected_visible(&mut self) {
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;
}
}
fn clamp_selection(&mut self) {
let count = self.visible_count();
if count == 0 {
self.selected = 0;
self.table_scroll = 0;
return;
}
self.selected = self.selected.min(count - 1);
self.ensure_selected_visible();
}
fn toggle_detail_tab(&mut self) {
self.detail_tab = match self.detail_tab {
DetailTab::Journal => DetailTab::Status,
DetailTab::Status => DetailTab::Journal,
};
self.detail_scroll = 0;
}
fn scroll_detail_up(&mut self) {
self.detail_scroll = self.detail_scroll.saturating_sub(3);
}
fn scroll_detail_down(&mut self) {
self.detail_scroll = self.detail_scroll.saturating_add(3);
}
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.table_scroll = 0;
self.clamp_selection();
self.refresh_detail();
self.message = if self.anomalies_only {
format!("showing {} anomalous services", self.visible_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.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.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;
}
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:#}")]);
self.detail = DetailCache {
unit,
status,
journal,
};
}
fn auto_refresh_if_due(&mut self) {
if self.cli.no_auto_refresh {
return;
}
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.clamp_selection();
}
}
}
fn run_action(&mut self, action: DaemonAction) {
let Some(unit) = self.selected_unit() else {
self.message = "no service selected".to_string();
return;
};
match daemon::apply_action(&unit, action) {
Ok(report) => {
let status = if report.ok { "ok" } else { "failed" };
self.message = format!("{status}: {} -> {}", report.command, report.output);
}
Err(err) => {
self.message = format!("{} {unit} failed: {err:#}", action.label());
}
}
let action_message = self.message.clone();
self.refresh_all();
self.message = action_message;
}
}
fn map_mouse_button(button: CrosstermButton) -> MouseButton {
match button {
CrosstermButton::Left => MouseButton::Left,
CrosstermButton::Right => MouseButton::Right,
CrosstermButton::Middle => MouseButton::Middle,
}
}