From 6a535099bb42cb6384c6567ec0a762741f128c56 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 25 Jul 2026 18:23:36 +0200 Subject: [PATCH] [Wip] CLI & Daemon --- Cargo.lock | 113 ++++ iota-cli/src/controls/button.rs | 7 +- iota-cli/src/controls/header.rs | 62 +++ iota-cli/src/controls/mod.rs | 3 + iota-cli/src/controls/panel.rs | 23 + iota-cli/src/controls/scroll.rs | 20 + iota-cli/src/elements/console_card.rs | 151 ++++- iota-cli/src/elements/graph_card.rs | 48 +- iota-cli/src/elements/log_card.rs | 113 +++- iota-cli/src/input_handler.rs | 9 +- iota-cli/src/interaction_result.rs | 9 +- iota-cli/src/ipc_client.rs | 120 +++- iota-cli/src/lib.rs | 4 + iota-cli/src/screens/daemon_setup.rs | 13 +- iota-cli/src/screens/main_screen.rs | 236 +++++++- iota-cli/src/screens/md_viewer.rs | 9 +- iota-cli/src/screens/metrics.rs | 131 +++++ iota-cli/src/screens/overview.rs | 282 ++++++++++ iota-cli/src/screens/screens.rs | 125 ++++- iota-cli/src/screens/settings.rs | 390 +++++++++++++ iota-cli/src/screens/terms_checker.rs | 9 +- iota-cli/src/screens/terms_updater.rs | 9 +- iota-cli/src/screens/users.rs | 705 ++++++++++++++++++++++++ iota-cli/src/theme/config.rs | 13 + iota-cli/src/theme/mod.rs | 73 ++- iota-cli/src/theme/model.rs | 10 + iota-cli/src/theme/presets.rs | 13 +- iota-cli/src/ui.rs | 335 ++++++++++- iota-cli/tests/settings_snapshot.rs | 76 +++ iota-daemon-lib/Cargo.toml | 2 + iota-daemon-lib/src/command_router.rs | 202 +++++-- iota-daemon-lib/src/ipc_server.rs | 73 ++- iota-daemon-lib/src/lib.rs | 1 + iota-daemon-lib/src/log_broadcaster.rs | 15 +- iota-daemon-lib/src/log_buffer.rs | 36 ++ iota-daemon-lib/tests/command_router.rs | 5 +- iota-daemon/src/main.rs | 22 +- iota-ipc/src/lib.rs | 11 +- iota-ipc/src/protocol.rs | 143 ++++- iota-ipc/src/text_commands.rs | 324 +++++++++++ iota-storage/src/util/config_util.rs | 51 ++ iota/Cargo.toml | 3 + iota/src/cli_args.rs | 374 ++++++++++--- iota/src/main.rs | 347 +++++++++++- 44 files changed, 4417 insertions(+), 303 deletions(-) create mode 100644 iota-cli/src/controls/header.rs create mode 100644 iota-cli/src/controls/panel.rs create mode 100644 iota-cli/src/controls/scroll.rs create mode 100644 iota-cli/src/screens/metrics.rs create mode 100644 iota-cli/src/screens/overview.rs create mode 100644 iota-cli/src/screens/settings.rs create mode 100644 iota-cli/src/screens/users.rs create mode 100644 iota-cli/tests/settings_snapshot.rs create mode 100644 iota-daemon-lib/src/log_buffer.rs create mode 100644 iota-ipc/src/text_commands.rs diff --git a/Cargo.lock b/Cargo.lock index c5d5a73..175c62e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,6 +327,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -682,6 +732,46 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "client" version = "0.1.0" @@ -749,6 +839,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -2089,12 +2185,15 @@ dependencies = [ name = "iota" version = "0.1.0" dependencies = [ + "clap", "iota-cli", "iota-core", "iota-installer", "iota-ipc", "iota-paths", "iota-process-manager", + "serde_json", + "serde_yaml", "tokio", "tokio-util", ] @@ -2268,10 +2367,12 @@ dependencies = [ "iota-logger", "iota-state", "iota-storage", + "iota-updater", "iota-util", "libc", "mtp", "omikron-connector", + "serde_yaml", "sysinfo", "tempfile", "tokio", @@ -2465,6 +2566,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -3175,6 +3282,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" diff --git a/iota-cli/src/controls/button.rs b/iota-cli/src/controls/button.rs index 1f35bf5..b57833b 100644 --- a/iota-cli/src/controls/button.rs +++ b/iota-cli/src/controls/button.rs @@ -3,7 +3,7 @@ use ratatui::{ Frame, layout::{Alignment, Rect}, text::Span, - widgets::{Block, Borders, Paragraph}, + widgets::Paragraph, }; use unicode_width::UnicodeWidthStr; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -39,9 +39,8 @@ pub fn render_button( } }; frame.render_widget( - Paragraph::new(Span::styled(button.label, style)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)), + Paragraph::new(Span::styled(if button.focused { format!("› {}", button.label) } else { button.label.to_owned() }, style)) + .alignment(Alignment::Center), area, ); } diff --git a/iota-cli/src/controls/header.rs b/iota-cli/src/controls/header.rs new file mode 100644 index 0000000..71af229 --- /dev/null +++ b/iota-cli/src/controls/header.rs @@ -0,0 +1,62 @@ +use crate::theme::ResolvedTheme; +use crate::{ + controls::button::{ActionButton, ButtonIntent, render_button}, + screens::screens::{AppAction, HitMap}, +}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::Span, + widgets::Paragraph, +}; + +/// Shared application bar. The brand cell is deliberately an action so it is +/// a reliable way home from every screen. +pub fn render_header( + frame: &mut Frame, + area: Rect, + title: &str, + theme: &ResolvedTheme, + hits: &mut HitMap, + focused_action: Option, +) { + let cells = Layout::horizontal([ + Constraint::Min(28), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(8), + ]) + .split(area); + frame.render_widget( + Paragraph::new(Span::styled(format!(" {title}"), theme.surfaces.toolbar)), + cells[0], + ); + hits.register(cells[0], AppAction::OpenMain); + for (index, (area, label, action)) in [ + (cells[1], "Overview", AppAction::OpenOverview), + (cells[2], "Users", AppAction::OpenUsers), + (cells[3], "Settings", AppAction::OpenSettings), + (cells[4], "Quit", AppAction::Quit), + ] + .into_iter() + .enumerate() + { + render_button( + frame, + area, + ActionButton { + label, + intent: if action == AppAction::Quit { + ButtonIntent::Destructive + } else { + ButtonIntent::Neutral + }, + focused: focused_action == Some(index), + enabled: true, + }, + theme, + ); + hits.register(area, action); + } +} diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs index 8af4977..7ce1cdd 100644 --- a/iota-cli/src/controls/mod.rs +++ b/iota-cli/src/controls/mod.rs @@ -1,6 +1,9 @@ pub mod action; pub mod button; pub mod checkbox_group; +pub mod header; pub mod choice; pub mod navigation; +pub mod panel; pub mod radio_group; +pub mod scroll; diff --git a/iota-cli/src/controls/panel.rs b/iota-cli/src/controls/panel.rs new file mode 100644 index 0000000..6e10e91 --- /dev/null +++ b/iota-cli/src/controls/panel.rs @@ -0,0 +1,23 @@ +use crate::theme::{ChromeMode, ResolvedTheme}; +use ratatui::{Frame, layout::Rect, widgets::{Block, Borders, Paragraph}}; + +/// Draw a conventional outlined panel or a filled surface from the same call +/// site. Screens can migrate without embedding theme branches in layouts. +pub fn render_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool, theme: &ResolvedTheme) -> Rect { + match theme.chrome { + ChromeMode::Bordered => { + let block = Block::default().title(title).borders(Borders::ALL).border_style(if focused { theme.borders.focused } else { theme.borders.normal }); + let inner = block.inner(area); + frame.render_widget(block, area); + inner + } + ChromeMode::Surfaces => { + frame.render_widget(Block::default().style(if focused { theme.surfaces.panel_focused } else { theme.surfaces.panel }), area); + let header = Rect { x: area.x, y: area.y, width: area.width, height: area.height.min(1) }; + frame.render_widget(Paragraph::new(format!(" {title}")).style(theme.surfaces.panel_alternate), header); + // Surface panels use a single header row. A one-cell inset keeps + // compact controls such as the console usable at height three. + Rect { x: area.x.saturating_add(1), y: area.y.saturating_add(1), width: area.width.saturating_sub(2), height: area.height.saturating_sub(1) } + } + } +} diff --git a/iota-cli/src/controls/scroll.rs b/iota-cli/src/controls/scroll.rs new file mode 100644 index 0000000..c3036c4 --- /dev/null +++ b/iota-cli/src/controls/scroll.rs @@ -0,0 +1,20 @@ +use ratatui::{Frame, layout::Rect, widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}}; + +/// Reusable viewport policy for long, vertically stacked terminal content. +#[derive(Clone, Copy, Debug)] +pub struct ScrollOptions { pub show_scrollbar: bool, pub render_partial_components: bool } +impl Default for ScrollOptions { fn default() -> Self { Self { show_scrollbar: true, render_partial_components: true } } } + +#[derive(Clone, Debug, Default)] +pub struct ScrollField { pub offset: u16, pub options: ScrollOptions } +impl ScrollField { + pub fn up(&mut self, amount: u16) { self.offset = self.offset.saturating_sub(amount); } + pub fn down(&mut self, amount: u16, content_height: u16, viewport_height: u16) { self.offset = (self.offset.saturating_add(amount)).min(content_height.saturating_sub(viewport_height)); } + pub fn render(&self, frame: &mut Frame, area: Rect, content: Paragraph<'_>, content_height: u16) { + frame.render_widget(content.scroll((self.offset, 0)), area); + if self.options.show_scrollbar && content_height > area.height { + let mut state = ScrollbarState::new(content_height as usize).position(self.offset as usize); + frame.render_stateful_widget(Scrollbar::new(ScrollbarOrientation::VerticalRight), area, &mut state); + } + } +} diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index af69a24..e314f1d 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -1,4 +1,4 @@ -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ Frame, layout::Rect, @@ -35,6 +35,10 @@ pub struct ConsoleCard { last_swap: Arc>, pending_restore: Arc>>, pending_confirmation: Option, + history: Vec, + history_index: Option, + history_draft: String, + message: Option, } impl ConsoleCard { @@ -51,6 +55,10 @@ impl ConsoleCard { last_swap: Arc::new(Mutex::new(Instant::now())), pending_restore: Arc::new(Mutex::new(None)), pending_confirmation: None, + history: Vec::new(), + history_index: None, + history_draft: String::new(), + message: None, } } @@ -151,6 +159,9 @@ impl ConsoleCard { } fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { + if let Some(message) = &self.message { + return vec![Span::styled(message.clone(), theme.console.error)]; + } if let Some(command) = &self.pending_confirmation { return vec![Span::styled( format!("Confirm `{command}`? [y/N]"), @@ -163,11 +174,17 @@ impl ConsoleCard { fn is_destructive(command: &str) -> bool { matches!( command.trim_start_matches('/').trim(), - "restart" | "reload" | "stop" | "shutdown" | "regenerate keys" + "restart" + | "reload" + | "stop" + | "shutdown" + | "regenerate keys" + | "identity rotate" ) || command .trim_start_matches('/') .trim_start() - .starts_with("user remove ") + .split_once(" remove ") + .is_some_and(|(noun, _)| matches!(noun, "user" | "users")) } fn dispatch_command(&self, command: String) { @@ -214,6 +231,69 @@ impl ConsoleCard { self.content.insert(idx, c); self.cursor_position += 1; } + + pub fn handle_paste(&mut self, text: &str) { + let sanitized = text.replace(['\r', '\n'], " "); + let index = self.byte_index(); + self.content.insert_str(index, &sanitized); + self.cursor_position += sanitized.chars().count(); + self.message = None; + } + + fn set_editor(&mut self, value: String) { + self.content = value; + self.cursor_position = self.content.chars().count(); + } + + fn history_previous(&mut self) { + if self.history.is_empty() { + return; + } + let index = match self.history_index { + None => { + self.history_draft = self.content.clone(); + self.history.len() - 1 + } + Some(index) => index.saturating_sub(1), + }; + self.history_index = Some(index); + self.set_editor(self.history[index].clone()); + self.message = None; + } + + fn history_next(&mut self) { + let Some(index) = self.history_index else { + return; + }; + if index + 1 < self.history.len() { + self.history_index = Some(index + 1); + self.set_editor(self.history[index + 1].clone()); + } else { + self.history_index = None; + let draft = std::mem::take(&mut self.history_draft); + self.set_editor(draft); + } + self.message = None; + } + + fn complete(&mut self) -> bool { + let completions = iota_ipc::text_commands::completions(&self.content); + if completions.len() == 1 { + let leading_slash = self.content.starts_with('/'); + self.set_editor(format!( + "{}{}", + if leading_slash { "/" } else { "" }, + completions[0] + )); + self.message = None; + true + } else if completions.len() > 1 { + self.message = Some(format!("Matches: {}", completions.join(", "))); + true + } else { + false + } + } } impl Element for ConsoleCard { @@ -226,6 +306,21 @@ impl Element for ConsoleCard { } fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) { + if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + let inner = crate::controls::panel::render_panel( + f, + r, + &self.title, + self.focused, + context.theme, + ); + f.render_widget( + Paragraph::new(Line::from(self.render_cursor_spans(context.theme))) + .style(context.theme.console.text), + inner, + ); + return; + } let block = Block::default() .borders(self.borders) .title(self.title.clone()) @@ -304,6 +399,7 @@ impl InteractableElement for ConsoleCard { if let Some(restored) = self.pending_restore.lock().unwrap().take() { self.content = restored; self.cursor_position = self.content.chars().count(); + self.message = Some("Command failed; restored for retry.".into()); } if let Some(command) = self.pending_confirmation.take() { @@ -320,6 +416,22 @@ impl InteractableElement for ConsoleCard { } let command = self.content.clone(); + if let Some(error) = iota_ipc::text_commands::validation_error(&command) { + self.message = Some(error); + return InteractionResult::Handled; + } + if command.trim_start_matches('/').trim() == "help" { + self.message = Some(format!( + "Commands: {}", + iota_ipc::text_commands::COMMANDS.join(", ") + )); + return InteractionResult::Handled; + } + if self.history.last() != Some(&command) { + self.history.push(command.clone()); + } + self.history_index = None; + self.history_draft.clear(); self.content.clear(); self.cursor_position = 0; if Self::is_destructive(&command) { @@ -330,10 +442,12 @@ impl InteractableElement for ConsoleCard { InteractionResult::Handled } KeyCode::Backspace => { + self.message = None; self.delete_at_cursor(); InteractionResult::Handled } KeyCode::Delete => { + self.message = None; let len = self.content.chars().count(); if self.cursor_position < len { let start = self.byte_index(); @@ -363,15 +477,36 @@ impl InteractableElement for ConsoleCard { self.cursor_position = self.content.chars().count(); InteractionResult::Handled } - KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled, - _ => { - if let Some(c) = key.code.as_char() { - self.insert_at_cursor(c); + KeyCode::Up => { + self.history_previous(); + InteractionResult::Handled + } + KeyCode::Down => { + self.history_next(); + InteractionResult::Handled + } + KeyCode::Tab if !self.content.is_empty() => { + if self.complete() { InteractionResult::Handled } else { - InteractionResult::Unhandled + self.message = Some("No command completion.".into()); + InteractionResult::Handled } } + KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled, + _ => { + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + { + if let Some(c) = key.code.as_char() { + self.insert_at_cursor(c); + self.message = None; + return InteractionResult::Handled; + } + } + InteractionResult::Unhandled + } } } diff --git a/iota-cli/src/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs index 0902b6d..a336772 100644 --- a/iota-cli/src/elements/graph_card.rs +++ b/iota-cli/src/elements/graph_card.rs @@ -34,15 +34,15 @@ impl GRAPHS { } } - pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> { + pub fn get_graph(&self, state: &ClientState, sample_width: usize) -> Vec<(f64, f64)> { let state = match state.app.try_lock() { Ok(state) => state, Err(_) => return Vec::new(), }; match self { - GRAPHS::Ram => state.with_width(28).ram.clone(), - GRAPHS::Cpu => state.with_width(28).cpu.clone(), - GRAPHS::Ping => state.with_width(28).ping.clone(), + GRAPHS::Ram => state.with_width(sample_width.min(u16::MAX as usize) as u16).ram.clone(), + GRAPHS::Cpu => state.with_width(sample_width.min(u16::MAX as usize) as u16).cpu.clone(), + GRAPHS::Ping => state.with_width(sample_width.min(u16::MAX as usize) as u16).ping.clone(), } } @@ -69,6 +69,7 @@ pub struct GraphCard { joins: Borders, open: bool, + sample_width: usize, } impl GraphCard { @@ -82,12 +83,17 @@ impl GraphCard { borders: Borders::ALL, joins: Borders::NONE, open: true, + sample_width: 28, } } pub fn set_open(&mut self, open: bool) { self.open = open; } + + pub fn set_sample_width(&mut self, sample_width: usize) { + self.sample_width = sample_width.max(1); + } } impl Element for GraphCard { fn as_any(&self) -> &dyn Any { @@ -100,7 +106,24 @@ impl Element for GraphCard { fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) { if self.open { - let graph = self.graph_type.get_graph(&self.state); + let graph = self.graph_type.get_graph(&self.state, self.sample_width); + if graph.is_empty() { + let block = Block::default() + .title(format!(" {} ", self.title)) + .borders(self.borders) + .border_style(if self.focused { + context.theme.graphs.focused_border + } else { + context.theme.graphs.border + }); + f.render_widget( + ratatui::widgets::Paragraph::new("No metric samples yet.") + .style(context.theme.text.muted) + .block(block), + r, + ); + return; + } let unit = self.graph_type.get_unit(); let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0); let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0); @@ -117,16 +140,19 @@ impl Element for GraphCard { GRAPHS::Ping => (max_y * 1.2).max(10.0), }; + let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces); + let title = format!("{}: {}{} {}min/{}max", self.title, graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, unit, min_y as i64, max_y as i64); + let plot_area = if surface { crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme) } else { r }; let block = Block::default() - .title(format!( + .title(if surface { String::new() } else { format!( "{}:─{}{}─{}min/{}max", self.title, graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, unit, min_y as i64, max_y as i64, - )) - .borders(self.borders) + ) }) + .borders(if surface { Borders::NONE } else { self.borders }) .border_style(if self.focused { context.theme.graphs.focused_border } else { @@ -148,7 +174,7 @@ impl Element for GraphCard { }); } }); - f.render_widget(canvas, r); + f.render_widget(canvas, plot_area); } else { let block = Block::default() .title("") @@ -160,7 +186,7 @@ impl Element for GraphCard { }); f.render_widget(block, r); } - draw_block_joins( + if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins( f, r, self.borders, @@ -170,7 +196,7 @@ impl Element for GraphCard { } else { context.theme.borders.normal }, - ); + ); } } } diff --git a/iota-cli/src/elements/log_card.rs b/iota-cli/src/elements/log_card.rs index 23c7107..3d5187b 100755 --- a/iota-cli/src/elements/log_card.rs +++ b/iota-cli/src/elements/log_card.rs @@ -1,7 +1,7 @@ use crate::elements::elements::{Element, InteractableElement, JoinableElement}; use crate::util::borders::draw_block_joins; use crate::{interaction_result::InteractionResult, render_context::RenderContext}; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use iota_state::{ClientState, UiLogEntry}; use ratatui::{ Frame, @@ -10,7 +10,10 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; -use std::any::Any; +use std::{ + any::Any, + sync::atomic::{AtomicUsize, Ordering}, +}; use unicode_width::UnicodeWidthChar; #[derive(Clone, Copy)] @@ -55,8 +58,11 @@ pub struct LogCard { focused: bool, selected: bool, scroll_offset: usize, - last_total_lines: usize, - last_visible_height: usize, + last_total_lines: AtomicUsize, + last_visible_height: AtomicUsize, + last_width: AtomicUsize, + filter: String, + filtering: bool, pub borders: Borders, pub joins: Borders, } @@ -68,8 +74,11 @@ impl LogCard { focused: false, selected: false, scroll_offset: 0, - last_total_lines: 0, - last_visible_height: 10, + last_total_lines: AtomicUsize::new(0), + last_visible_height: AtomicUsize::new(1), + last_width: AtomicUsize::new(1), + filter: String::new(), + filtering: false, borders: Borders::ALL, joins: Borders::NONE, } @@ -80,9 +89,15 @@ impl LogCard { Ok(state) => state, Err(_) => return Vec::new(), }; + let needle = self.filter.to_ascii_lowercase(); state .get_logs() .iter() + .filter(|entry| { + needle.is_empty() + || entry.sender.to_ascii_lowercase().contains(&needle) + || entry.message.to_ascii_lowercase().contains(&needle) + }) .map(|e| UiLogEntry { timestamp_ms: e.timestamp_ms, sender: e.sender.clone(), @@ -214,11 +229,13 @@ impl LogCard { } fn get_title_hints(&self) -> (bool, bool) { - if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height { + let total_lines = self.last_total_lines.load(Ordering::Relaxed); + let visible_height = self.last_visible_height.load(Ordering::Relaxed); + if total_lines == 0 || total_lines <= visible_height { return (false, false); } - let max_offset = self.last_total_lines - self.last_visible_height; + let max_offset = total_lines - visible_height; let can_scroll_up = self.scroll_offset < max_offset; let can_scroll_down = self.scroll_offset > 0; @@ -226,6 +243,12 @@ impl LogCard { } fn build_title(&self) -> String { + if self.filtering { + return format!("Logs filter: {}_", self.filter); + } + if !self.filter.is_empty() { + return format!("Logs [filter: {}]", self.filter); + } if !self.focused { return "Logs".to_string(); } @@ -250,7 +273,8 @@ impl LogCard { fn scroll_up(&mut self) { let max_offset = self .last_total_lines - .saturating_sub(self.last_visible_height); + .load(Ordering::Relaxed) + .saturating_sub(self.last_visible_height.load(Ordering::Relaxed)); self.scroll_offset = (self.scroll_offset + 1).min(max_offset); } @@ -297,17 +321,14 @@ impl Element for LogCard { fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { let entries = self.get_logs(); - let block = Block::default() - .title(self.build_title()) - .borders(self.borders) - .border_style(if self.focused { - context.theme.logs.focused_border - } else { - context.theme.logs.border - }); - - let inner_area = block.inner(area); - f.render_widget(block, area); + let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + crate::controls::panel::render_panel(f, area, &self.build_title(), self.focused, context.theme) + } else { + let block = Block::default().title(self.build_title()).borders(self.borders).border_style(if self.focused { context.theme.logs.focused_border } else { context.theme.logs.border }); + let inner = block.inner(area); + f.render_widget(block, area); + inner + }; if inner_area.width == 0 || inner_area.height == 0 { draw_block_joins( @@ -327,6 +348,11 @@ impl Element for LogCard { let all_lines = self.build_all_lines(entries, inner_area.width as usize); let total_lines = all_lines.len(); let visible_height = inner_area.height as usize; + self.last_width + .store(inner_area.width as usize, Ordering::Relaxed); + self.last_total_lines.store(total_lines, Ordering::Relaxed); + self.last_visible_height + .store(visible_height, Ordering::Relaxed); let (start, end) = self.calculate_view_window(total_lines, visible_height); let visible_lines = &all_lines[start..end]; @@ -337,6 +363,7 @@ impl Element for LogCard { let mut spans = Vec::new(); let (prefix, rest) = Self::split_line_prefix(line); + let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { "" } else { prefix }; if !prefix.is_empty() { spans.push(Span::styled( @@ -377,7 +404,7 @@ impl Element for LogCard { f.render_widget(Paragraph::new(line.clone()), line_area); } - draw_block_joins( + if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins( f, area, self.borders, @@ -387,7 +414,7 @@ impl Element for LogCard { } else { context.theme.borders.normal }, - ); + ); } } } @@ -435,14 +462,44 @@ impl InteractableElement for LogCard { } fn interact(&mut self, key: KeyEvent) -> InteractionResult { + if self.filtering { + match key.code { + KeyCode::Esc => { + self.filtering = false; + self.filter.clear(); + } + KeyCode::Enter => self.filtering = false, + KeyCode::Backspace => { + self.filter.pop(); + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + self.filter.push(c); + } + _ => {} + } + self.scroll_offset = 0; + return InteractionResult::Handled; + } let entries = self.get_logs(); - let estimated_width = 80usize; - let all_lines = self.build_all_lines(entries, estimated_width); + let width = self.last_width.load(Ordering::Relaxed).max(1); + let all_lines = self.build_all_lines(entries, width); - self.last_total_lines = all_lines.len(); - let visible_height = self.last_visible_height.max(1); + self.last_total_lines + .store(all_lines.len(), Ordering::Relaxed); + let total_lines = all_lines.len(); + let visible_height = self.last_visible_height.load(Ordering::Relaxed).max(1); match key.code { + KeyCode::Char('/') => { + self.filtering = true; + self.filter.clear(); + self.scroll_offset = 0; + InteractionResult::Handled + } KeyCode::Enter | KeyCode::Char(' ') => { self.selected = !self.selected; InteractionResult::Handled @@ -476,8 +533,8 @@ impl InteractableElement for LogCard { InteractionResult::Handled } KeyCode::Home => { - if self.last_total_lines > visible_height { - self.scroll_offset = self.last_total_lines - visible_height; + if total_lines > visible_height { + self.scroll_offset = total_lines - visible_height; } InteractionResult::Handled } diff --git a/iota-cli/src/input_handler.rs b/iota-cli/src/input_handler.rs index 3080480..842f89c 100644 --- a/iota-cli/src/input_handler.rs +++ b/iota-cli/src/input_handler.rs @@ -1,4 +1,4 @@ -use crate::ui::UI; +use crate::{screens::screens::UiEvent, ui::UI}; use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; use std::sync::Arc; use std::time::Duration; @@ -27,8 +27,9 @@ pub fn setup_input_handler(ui: Arc) -> JoinHandle> { tokio::select! { event = rx.recv() => match event { Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await, - Some(Event::Resize(_, _)) => ui.invalidate(), - Some(Event::Paste(text)) => ui.handle_paste(text).await, + Some(Event::Mouse(mouse)) => ui.clone().handle_event(UiEvent::Mouse(mouse)).await, + Some(Event::Resize(width, height)) => ui.clone().handle_event(UiEvent::Resize(width, height)).await, + Some(Event::Paste(text)) => ui.clone().handle_event(UiEvent::Paste(text)).await, Some(_) => {}, None => break, }, @@ -55,6 +56,6 @@ pub async fn handle_input(key: KeyEvent, ui: Arc) { { ui.request_shutdown(); } else { - ui.handle_input(key).await; + ui.handle_event(UiEvent::Key(key)).await; } } diff --git a/iota-cli/src/interaction_result.rs b/iota-cli/src/interaction_result.rs index f9ba7e0..8afad00 100644 --- a/iota-cli/src/interaction_result.rs +++ b/iota-cli/src/interaction_result.rs @@ -2,7 +2,7 @@ use std::fmt::{Debug, Formatter}; use std::future::Future; use std::pin::Pin; -use crate::screens::screens::Screen; +use crate::screens::screens::{Screen, UiEvent}; #[allow(unused)] pub enum InteractionResult { @@ -13,6 +13,9 @@ pub enum InteractionResult { OpenFutureScreen { screen: Pin> + Send>>, }, + AppTask { + task: Pin + Send>>, + }, Handled, Unhandled, } @@ -22,6 +25,7 @@ impl Debug for InteractionResult { match self { InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"), InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"), + InteractionResult::AppTask { task: _ } => write!(f, "AppTask"), InteractionResult::CloseScreen => write!(f, "CloseScreen"), InteractionResult::Handled => write!(f, "Handled"), InteractionResult::Unhandled => write!(f, "Unhandled"), @@ -36,6 +40,9 @@ impl PartialEq for InteractionResult { InteractionResult::OpenScreen { screen: _ }, InteractionResult::OpenScreen { screen: _ }, ) => true, + (InteractionResult::AppTask { task: _ }, InteractionResult::AppTask { task: _ }) => { + true + } ( InteractionResult::OpenFutureScreen { screen: _ }, InteractionResult::OpenFutureScreen { screen: _ }, diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 88a1eba..904947c 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -1,6 +1,6 @@ use iota_ipc::{ ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, - RequestEnvelope, ResponseResult, read_msg, write_msg, + RequestEnvelope, ResponsePayload, ResponseResult, read_msg, write_msg, }; use iota_state::{ClientState, UiLogEntry}; use std::collections::HashMap; @@ -443,6 +443,92 @@ impl IpcClient { }); } + fn format_payload(payload: &ResponsePayload) -> String { + match payload { + ResponsePayload::Status(status) => { + let mut msg = format!("Phase: {}", status.phase); + if !status.tasks.is_empty() { + msg.push_str(&format!(", Tasks: {}", status.tasks.join(", "))); + } + if let Some(reason) = &status.degraded_reason { + msg.push_str(&format!(", Degraded: {reason}")); + } + msg + } + ResponsePayload::Tasks(tasks) => { + if tasks.is_empty() { + "No active tasks.".into() + } else { + tasks.iter().map(|t| t.name.as_str()).collect::>().join(", ") + } + } + ResponsePayload::Users(users) => { + if users.is_empty() { + "No users.".into() + } else { + users.iter().map(|u| format!("{} ({})", u.username, u.user_id)).collect::>().join("\n") + } + } + ResponsePayload::UserCreated { user_id, username } => { + format!("Created user {} ({})", username, user_id) + } + ResponsePayload::UserRemoved { user_id } => { + format!("Removed user {}", user_id) + } + ResponsePayload::Acknowledged { message } => message.clone(), + ResponsePayload::DaemonStatus(status) => status.formatted.clone(), + ResponsePayload::Config(config) => config.yaml.clone(), + ResponsePayload::OmikronStatus(status) => { + let mut msg = format!("Connected: {}", status.connected); + if let Some(id) = status.iota_id { + msg.push_str(&format!("\nIota ID: {}", id)); + } + msg + } + ResponsePayload::Components(components) => { + if components.is_empty() { + "No component health data available.".into() + } else { + components.iter().map(|c| { + let status_str = match c.status { + iota_ipc::HealthStatus::Healthy => "healthy", + iota_ipc::HealthStatus::Degraded => "degraded", + iota_ipc::HealthStatus::Failed => "failed", + }; + format!("{:?}: {}", c.id, status_str) + }).collect::>().join("\n") + } + } + ResponsePayload::UserDetail(user) => { + let mut msg = format!("User: {} ({})", user.username, user.user_id); + if let Some(ref name) = user.display_name { + msg.push_str(&format!("\nDisplay Name: {name}")); + } + msg.push_str(&format!("\nCreated At: {}", user.created_at)); + if !user.trusted_apps.is_empty() { + msg.push_str(&format!("\nTrusted Apps: {}", user.trusted_apps.join(", "))); + } + msg + } + ResponsePayload::LogEntries(logs) => { + logs.entries.iter().map(|e| { + let level = if e.is_error { "ERR" } else { "INF" }; + format!("[{}] {level} {}: {}", e.timestamp_ms, e.sender, e.message) + }).collect::>().join("\n") + } + ResponsePayload::UpdateStatus(status) => { + if status.available { "Update available.".into() } else { "Up to date.".into() } + } + ResponsePayload::Communities(communities) => { + if communities.is_empty() { + "No communities.".into() + } else { + communities.iter().map(|c| format!("{} ({})", c.title, c.name)).collect::>().join("\n") + } + } + } + } + fn format_error(code: &iota_ipc::IpcErrorCode) -> &'static str { match code { iota_ipc::IpcErrorCode::InvalidRequest => "The command is not valid.", @@ -496,29 +582,9 @@ impl IpcClient { } /// Parse a legacy console command string into a typed request. + /// Delegates to the shared parser in iota-ipc. pub fn parse_console_command(line: &str) -> Option { - let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect(); - match parts.as_slice() { - ["help"] => None, - ["tasks"] => Some(LocalRequest::ListTasks), - ["user", "add", username] => Some(LocalRequest::CreateUser { - username: username.to_string(), - }), - ["user", "remove", user_id_str] => { - let user_id = user_id_str.parse::().ok()?; - Some(LocalRequest::RemoveUser { user_id }) - } - ["user", "list"] => Some(LocalRequest::ListUsers), - ["reconnect"] => Some(LocalRequest::ReconnectOmikron), - ["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity), - ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { - intent: iota_ipc::ExitIntent::Restart, - }), - ["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit { - intent: iota_ipc::ExitIntent::Stop, - }), - _ => None, - } + iota_ipc::text_commands::parse(line) } /// Legacy command interface: parse text command, send as typed request. @@ -559,7 +625,7 @@ impl IpcClient { Ok(result) => { let mut state = self.state.app.lock().await; let message = match &result { - ResponseResult::Ok(msg) => msg.clone(), + ResponseResult::Ok(payload) => Self::format_payload(payload), ResponseResult::Error(code) => Self::format_error(code).into(), }; state.push_log(UiLogEntry { @@ -595,8 +661,8 @@ impl IpcClient { .unwrap_or_default() .as_millis(), sender: "Console".into(), - message: if trimmed == "help" { - "Commands: status, tasks, ping, user add , user remove , user list, reconnect, regenerate keys, restart, stop" + message: if trimmed == "help" { + "Commands: status, tasks, ping, user add , user remove , user list, users, reconnect, regenerate keys, restart, stop, config get, config reload, omikron status, components" .into() } else { format!("Unknown command: {}", line) @@ -695,7 +761,7 @@ impl IpcClient { } else { let mut state = self.state.app.lock().await; let message = match &response.result { - ResponseResult::Ok(msg) => msg.clone(), + ResponseResult::Ok(payload) => Self::format_payload(payload), ResponseResult::Error(code) => Self::format_error(code).into(), }; state.push_log(UiLogEntry { diff --git a/iota-cli/src/lib.rs b/iota-cli/src/lib.rs index f54720b..eee1487 100644 --- a/iota-cli/src/lib.rs +++ b/iota-cli/src/lib.rs @@ -8,9 +8,13 @@ pub mod screens { pub mod daemon_setup; pub mod main_screen; pub mod md_viewer; + pub mod metrics; + pub mod overview; pub mod screens; + pub mod settings; pub mod terms_checker; pub mod terms_updater; + pub mod users; } pub mod util { pub mod borders; diff --git a/iota-cli/src/screens/daemon_setup.rs b/iota-cli/src/screens/daemon_setup.rs index 2dad4ce..627b1f2 100644 --- a/iota-cli/src/screens/daemon_setup.rs +++ b/iota-cli/src/screens/daemon_setup.rs @@ -6,9 +6,9 @@ use crate::{ }, interaction_result::InteractionResult, render_context::RenderContext, - screens::screens::Screen, + screens::screens::{HitMap, Screen, UiEvent}, }; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::KeyCode; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, @@ -29,7 +29,7 @@ impl Screen for DaemonStartingScreen { fn as_any_mut(&mut self) -> &mut dyn Any { self } - fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) { + fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { let popup = crate::layout::fit::centered_rect( area, crate::layout::fit::RequiredSize { @@ -51,7 +51,7 @@ impl Screen for DaemonStartingScreen { popup, ); } - fn handle_input(&mut self, _: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, _: UiEvent) -> InteractionResult { InteractionResult::Handled } } @@ -191,7 +191,7 @@ impl Screen for DaemonSetupScreen { fn as_any_mut(&mut self) -> &mut dyn Any { self } - fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) { + fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { let popup = crate::layout::fit::centered_rect( area, crate::layout::fit::RequiredSize { @@ -266,7 +266,8 @@ impl Screen for DaemonSetupScreen { context.theme, ); } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; match event.code { KeyCode::Esc => { self.complete(DaemonSetupDecision::Exit); diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs index 19fa093..8676a76 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -1,4 +1,5 @@ use crate::{ + controls::button::{ActionButton, ButtonIntent, render_button}, elements::{ console_card::ConsoleCard, elements::{InteractableElement, JoinableElement}, @@ -8,19 +9,28 @@ use crate::{ interaction_result::InteractionResult, ipc_client::{DaemonStatus, IpcConnectionState}, render_context::RenderContext, - screens::screens::{NavDirection, Screen}, + screens::{ + overview::OverviewScreen, + screens::{AppAction, HitMap, KeyHint, NavDirection, Screen, UiEvent}, + }, ui::UI, }; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, - layout::{Constraint, Layout, Margin, Rect}, - widgets::{Block, Borders}, + layout::{Constraint, Layout, Rect}, + widgets::Borders, }; use tokio::sync::watch; -use std::{any::Any, sync::Arc}; +use std::{ + any::Any, + sync::{ + Arc, + atomic::{AtomicU16, Ordering}, + }, +}; pub struct MainScreen { elements: Vec>, @@ -29,9 +39,38 @@ pub struct MainScreen { graphs_open: bool, connection_status_rx: watch::Receiver, daemon_status_rx: watch::Receiver, + layout_width: AtomicU16, } impl MainScreen { + pub fn connection_status(&self) -> watch::Receiver { + self.connection_status_rx.clone() + } + pub fn daemon_status(&self) -> watch::Receiver { + self.daemon_status_rx.clone() + } + fn status_summary(&self) -> String { + let connection = match self.connection_status_rx.borrow().clone() { + IpcConnectionState::Connected => "[OK] Connected".to_owned(), + IpcConnectionState::Connecting => "[..] Connecting".to_owned(), + IpcConnectionState::Reconnecting { .. } => "[WARN] Reconnecting".to_owned(), + IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => { + "[FAIL] Failed".to_owned() + } + IpcConnectionState::Disconnected => "[WARN] Disconnected".to_owned(), + }; + let daemon = self.daemon_status_rx.borrow().clone(); + let version = if daemon.version.is_empty() { + String::new() + } else { + format!(" v{}", daemon.version) + }; + let ready = daemon + .startup_phase + .map(|phase| format!(" {:?}", phase)) + .unwrap_or_default(); + format!("IOTA{version} {connection}{ready}") + } pub async fn new(ui: Arc) -> Self { let mut elements: Vec> = Vec::new(); @@ -80,6 +119,7 @@ impl MainScreen { graphs_open, connection_status_rx, daemon_status_rx, + layout_width: AtomicU16::new(0), }; screen.focus_current(); screen @@ -172,6 +212,11 @@ impl MainScreen { let mut seen: Vec> = Vec::new(); for (y, row) in self.nav_grid.iter().enumerate() { for (x, elem_opt) in row.iter().enumerate() { + if x == 1 + && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) + { + continue; + } if elem_opt.is_some() && !seen.contains(elem_opt) { seen.push(*elem_opt); positions.push((y, x)); @@ -209,7 +254,8 @@ impl Screen for MainScreen { self } - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) { + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { + self.layout_width.store(rect.width, Ordering::Relaxed); // A watch Ref blocks senders until it is dropped. Rendering may do // terminal I/O, so retain only owned snapshots for the whole frame. let status = self.connection_status_rx.borrow().clone(); @@ -242,25 +288,15 @@ impl Screen for MainScreen { } else { format!(" v{}", daemon.version) }; - let main_block = Block::default() - .title(format!( - "Iota{version} [{status_text}; {readiness}{health}]" - )) - .borders(Borders::ALL) - .border_style(context.theme.borders.normal) - .title_style(context.theme.borders.title); - f.render_widget(main_block, rect); + let _ = (status_text, readiness, health, version); + f.render_widget( + ratatui::widgets::Block::default().style(context.theme.surfaces.canvas), + rect, + ); + let inner = rect; - let inner = rect.inner(Margin { - vertical: 1, - horizontal: 1, - }); - - let graphs_width = if self.graphs_open && inner.width >= 70 { - 30 - } else { - 2 - }; + let metrics_visible = self.graphs_open && inner.width >= 70; + let graphs_width = if metrics_visible { 30 } else { 0 }; let main_width = inner.width.saturating_sub(graphs_width); let horizontal_chunks = Layout::default() @@ -273,9 +309,39 @@ impl Screen for MainScreen { let left_area = horizontal_chunks[0]; let right_area = horizontal_chunks[1]; + hits.register(left_area, AppAction::FocusLogs); + if metrics_visible { + hits.register(right_area, AppAction::FocusMetrics); + } + + if inner.width >= 70 { + let metrics_button = Rect { + x: right_area.x, + y: right_area.y, + width: right_area.width, + height: 1, + }; + render_button( + f, + metrics_button, + ActionButton { + label: if self.graphs_open { + "Hide metrics" + } else { + "Show metrics" + }, + intent: ButtonIntent::Neutral, + focused: false, + enabled: true, + }, + context.theme, + ); + hits.register(metrics_button, AppAction::ToggleMetrics); + } let left_rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area); + hits.register(left_rows[1], AppAction::FocusConsole); if let Some(log) = self.elements.get(0) { log.as_element().render(f, left_rows[0], context); @@ -291,7 +357,7 @@ impl Screen for MainScreen { .filter(|el| el.as_any().is::()) .collect(); - if !graph_elements.is_empty() { + if metrics_visible && !graph_elements.is_empty() { let graph_chunks = Layout::vertical( graph_elements .iter() @@ -306,7 +372,40 @@ impl Screen for MainScreen { } } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + if let UiEvent::Paste(text) = &event { + if self.selected_coords == (2, 0) { + if let Some(console) = self + .elements + .get_mut(1) + .and_then(|element| element.as_any_mut().downcast_mut::()) + { + console.handle_paste(text); + return InteractionResult::Handled; + } + } + return InteractionResult::Unhandled; + } + if let UiEvent::Resize(width, _) = &event { + self.layout_width.store(*width, Ordering::Relaxed); + if *width < 70 && self.selected_coords.1 == 1 { + self.unfocus_current(self.selected_coords.0, self.selected_coords.1); + self.selected_coords = (0, 0); + self.focus_current(); + } + return InteractionResult::Handled; + } + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; + // A focused console consumes text and cursor keys before dashboard + // shortcuts; commands such as `users` must remain typeable. + if self.selected_coords == (2, 0) && !matches!(event.code, KeyCode::Tab | KeyCode::BackTab) + { + if let Some(console) = self.elements.get_mut(1) { + return console.interact(event); + } + } match event.code { KeyCode::Tab => { self.navigate_focus(true); @@ -316,6 +415,27 @@ impl Screen for MainScreen { self.navigate_focus(false); return InteractionResult::Handled; } + KeyCode::Char('o') | KeyCode::Char('O') => { + let conn_rx = self.connection_status_rx.clone(); + let daemon_rx = self.daemon_status_rx.clone(); + return InteractionResult::OpenScreen { + screen: Box::new(OverviewScreen::new(conn_rx, daemon_rx)), + }; + } + KeyCode::Char('u') | KeyCode::Char('U') => { + return InteractionResult::AppTask { + task: Box::pin(async { + UiEvent::App(crate::screens::screens::AppEvent::OpenUsers) + }), + }; + } + KeyCode::Char('m') | KeyCode::Char('M') => { + return InteractionResult::AppTask { + task: Box::pin(async { + UiEvent::App(crate::screens::screens::AppEvent::OpenMetrics) + }), + }; + } KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => { self.graphs_open = !self.graphs_open; for element in self.elements.iter_mut() { @@ -347,4 +467,70 @@ impl Screen for MainScreen { InteractionResult::Handled } + fn handle_action(&mut self, action: AppAction) -> InteractionResult { + match action { + AppAction::ToggleMetrics => { + self.graphs_open = !self.graphs_open; + for element in &mut self.elements { + if let Some(graph) = element.as_any_mut().downcast_mut::() { + graph.set_open(self.graphs_open); + } + } + InteractionResult::Handled + } + AppAction::OpenOverview => { + self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('o')))) + } + AppAction::OpenUsers => { + self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('u')))) + } + AppAction::FocusLogs => { + self.unfocus_current(self.selected_coords.0, self.selected_coords.1); + self.selected_coords = (0, 0); + self.focus_current(); + InteractionResult::Handled + } + AppAction::FocusConsole => { + self.unfocus_current(self.selected_coords.0, self.selected_coords.1); + self.selected_coords = (2, 0); + self.focus_current(); + InteractionResult::Handled + } + AppAction::FocusMetrics => { + self.unfocus_current(self.selected_coords.0, self.selected_coords.1); + self.selected_coords = (0, 1); + self.focus_current(); + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } + fn app_title(&self) -> String { + self.status_summary() + } + fn key_hints(&self) -> Vec { + if self.selected_coords == (2, 0) { + vec![ + KeyHint { keys: "Enter", action: "Send" }, + KeyHint { keys: "Up/Down", action: "History" }, + KeyHint { keys: "Tab", action: "Complete" }, + KeyHint { keys: "F6", action: "Header" }, + ] + } else if self.selected_coords == (0, 0) { + vec![ + KeyHint { keys: "J/K", action: "Scroll logs" }, + KeyHint { keys: "Enter", action: "Lock scroll" }, + KeyHint { keys: "/", action: "Filter" }, + KeyHint { keys: "M", action: "Metrics screen" }, + KeyHint { keys: "Tab", action: "Next panel" }, + KeyHint { keys: "F6", action: "Header" }, + ] + } else { + vec![ + KeyHint { keys: "Enter", action: "Toggle metrics" }, + KeyHint { keys: "Tab", action: "Next panel" }, + KeyHint { keys: "F6", action: "Header" }, + ] + } + } } diff --git a/iota-cli/src/screens/md_viewer.rs b/iota-cli/src/screens/md_viewer.rs index aaf6b9d..c9c4518 100644 --- a/iota-cli/src/screens/md_viewer.rs +++ b/iota-cli/src/screens/md_viewer.rs @@ -1,4 +1,4 @@ -use crossterm::event::{self, Event, KeyCode, KeyEvent}; +use crossterm::event::{self, Event, KeyCode}; use ratatui::{ DefaultTerminal, prelude::*, @@ -10,7 +10,7 @@ use std::{any::Any, time::Duration}; use crate::{ interaction_result::InteractionResult, render_context::RenderContext, - screens::screens::Screen, + screens::screens::{HitMap, Screen, UiEvent}, theme::{ResolvedTheme, TextSemantics, ThemeName}, }; @@ -29,11 +29,12 @@ impl Screen for FileViewer { self } - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) { + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { self.draw(f, rect, context.theme); } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; match event.code { KeyCode::Char('q') | KeyCode::Esc => { return InteractionResult::CloseScreen; diff --git a/iota-cli/src/screens/metrics.rs b/iota-cli/src/screens/metrics.rs new file mode 100644 index 0000000..705560c --- /dev/null +++ b/iota-cli/src/screens/metrics.rs @@ -0,0 +1,131 @@ +use std::any::Any; + +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + widgets::{Block, Borders, Paragraph}, +}; + +use crate::{ + elements::{ + elements::Element, + graph_card::{GRAPHS, GraphCard}, + }, + interaction_result::InteractionResult, + render_context::RenderContext, + screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent}, + ui::UI, +}; + +const RANGES: &[(usize, &str)] = &[(30, "Recent"), (120, "Medium"), (300, "Long")]; + +pub struct MetricsScreen { + graphs: Vec, + range_index: usize, +} + +impl MetricsScreen { + pub async fn new(ui: std::sync::Arc) -> Option { + let state = ui.client_state().await?; + let mut screen = Self { + graphs: vec![ + GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()), + GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()), + GraphCard::new(ui, state, GRAPHS::Ping, "Ping".into()), + ], + range_index: 0, + }; + screen.apply_range(); + Some(screen) + } + + fn apply_range(&mut self) { + let width = RANGES[self.range_index].0; + for graph in &mut self.graphs { + graph.set_sample_width(width); + } + } + + fn change_range(&mut self, delta: isize) { + self.range_index = + (self.range_index as isize + delta).clamp(0, RANGES.len() as isize - 1) as usize; + self.apply_range(); + } +} + +impl Screen for MetricsScreen { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { + let block = Block::default() + .title(" Metrics ") + .borders(Borders::ALL) + .border_style(context.theme.borders.normal); + let inner = block.inner(area); + frame.render_widget(block, area); + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ]) + .split(inner); + frame.render_widget( + Paragraph::new(format!( + "Range: {} ({} samples) Left/Right to change", + RANGES[self.range_index].1, + RANGES[self.range_index].0 + )) + .style(context.theme.text.heading), + rows[0], + ); + for (graph, graph_area) in self.graphs.iter().zip(rows[1..].iter()) { + graph.render(frame, *graph_area, context); + } + hits.register(rows[0], AppAction::OpenMetrics); + } + + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(key) = event else { + return InteractionResult::Unhandled; + }; + match key.code { + KeyCode::Left => { + self.change_range(-1); + InteractionResult::Handled + } + KeyCode::Right => { + self.change_range(1); + InteractionResult::Handled + } + KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { + InteractionResult::CloseScreen + } + _ => InteractionResult::Unhandled, + } + } + + fn key_hints(&self) -> Vec { + vec![ + KeyHint { + keys: "Left/Right", + action: "Range", + }, + KeyHint { + keys: "Esc/B", + action: "Back", + }, + KeyHint { + keys: "F6", + action: "Header", + }, + ] + } +} diff --git a/iota-cli/src/screens/overview.rs b/iota-cli/src/screens/overview.rs new file mode 100644 index 0000000..0c6feda --- /dev/null +++ b/iota-cli/src/screens/overview.rs @@ -0,0 +1,282 @@ +use crate::{ + controls::button::{ActionButton, ButtonIntent, render_button}, + interaction_result::InteractionResult, + ipc_client::{DaemonStatus, IpcConnectionState}, + render_context::RenderContext, + screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent}, +}; +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::Rect, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; +use std::{ + any::Any, + sync::atomic::{AtomicUsize, Ordering}, +}; +use tokio::sync::watch; + +pub struct OverviewScreen { + connection_rx: watch::Receiver, + daemon_rx: watch::Receiver, + _focus: Focus, + scroll_offset: usize, + content_height: AtomicUsize, + viewport_height: AtomicUsize, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Focus { + Back, +} + +impl OverviewScreen { + pub fn new( + connection_rx: watch::Receiver, + daemon_rx: watch::Receiver, + ) -> Self { + Self { + connection_rx, + daemon_rx, + _focus: Focus::Back, + scroll_offset: 0, + content_height: AtomicUsize::new(0), + viewport_height: AtomicUsize::new(1), + } + } + + fn build_lines(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { + let conn = self.connection_rx.borrow().clone(); + let daemon = self.daemon_rx.borrow().clone(); + + let mut lines = Vec::new(); + + lines.push(Line::from(Span::styled( + "Connection", + theme.text.heading, + ))); + lines.push(Line::from(format!(" State: {}", connection_label(&conn)))); + lines.push(Line::from("")); + + lines.push(Line::from(Span::styled( + "Daemon", + theme.text.heading, + ))); + lines.push(Line::from(format!( + " Version: {}", + version_or_unknown(&daemon.version) + ))); + lines.push(Line::from(format!( + " Instance: {}", + truncate_id(&daemon.instance_id) + ))); + + let phase = daemon + .startup_phase + .map(|p| format!("{:?}", p)) + .unwrap_or_else(|| "Unknown".into()); + lines.push(Line::from(format!(" Phase: {}", phase))); + + let lifecycle = daemon + .lifecycle + .map(|l| format!("{:?}", l)) + .unwrap_or_else(|| "Unknown".into()); + lines.push(Line::from(format!(" Lifecycle: {}", lifecycle))); + + let health = match daemon.health { + iota_ipc::HealthStatus::Healthy => "[OK] Healthy", + iota_ipc::HealthStatus::Degraded => "[WARN] Degraded", + iota_ipc::HealthStatus::Failed => "[FAIL] Failed", + }; + lines.push(Line::from(format!(" Health: {health}"))); + + if let Some(ref reason) = daemon.degraded_reason { + lines.push(Line::from(Span::styled( + format!(" Degraded: {reason}"), + theme.status.warning, + ))); + } + + let mode = daemon + .deployment_mode + .map(|m| format!("{:?}", m)) + .unwrap_or_else(|| "Unknown".into()); + lines.push(Line::from(format!(" Deployment: {mode}"))); + + let supervisor = daemon + .supervisor + .map(|s| format!("{:?}", s)) + .unwrap_or_else(|| "Unknown".into()); + lines.push(Line::from(format!(" Supervisor: {supervisor}"))); + + if !daemon.components.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Components", + theme.text.heading, + ))); + for (id, health) in &daemon.components { + let status_str = match health.status { + iota_ipc::HealthStatus::Healthy => "[OK] healthy", + iota_ipc::HealthStatus::Degraded => "[WARN] degraded", + iota_ipc::HealthStatus::Failed => "[FAIL] failed", + }; + let suffix = health + .message + .as_deref() + .map(|m| format!(" ({m})")) + .unwrap_or_default(); + lines.push(Line::from(format!(" {:?}: {}{}", id, status_str, suffix))); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Press Esc or B to return to the dashboard", + theme.text.muted, + ))); + + lines + } +} + +fn connection_label(conn: &IpcConnectionState) -> String { + match conn { + IpcConnectionState::Connected => "Connected".into(), + IpcConnectionState::Connecting => "Connecting...".into(), + IpcConnectionState::Reconnecting { attempt } => { + format!("Reconnecting (attempt {attempt})...") + } + IpcConnectionState::Incompatible { message } => { + format!("Incompatible: {message}") + } + IpcConnectionState::Failed { message } => format!("Failed: {message}"), + IpcConnectionState::Disconnected => "Disconnected".into(), + } +} + +fn version_or_unknown(v: &str) -> String { + if v.is_empty() { + "Unknown".into() + } else { + v.into() + } +} + +fn truncate_id(id: &str) -> String { + if id.len() > 8 { + format!("{}…", &id[..8]) + } else { + id.into() + } +} + +impl Screen for OverviewScreen { + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + let block = Block::default() + .title(" Overview ") + .borders(Borders::ALL) + .border_style(context.theme.borders.normal) + .title_style(context.theme.borders.title); + let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme) } else { let inner = block.inner(rect); f.render_widget(block, rect); inner }; + let rows = ratatui::layout::Layout::vertical([ + ratatui::layout::Constraint::Min(1), + ratatui::layout::Constraint::Length(1), + ]) + .split(inner); + + let lines = self.build_lines(context.theme); + self.content_height.store(lines.len(), Ordering::Relaxed); + self.viewport_height + .store(rows[0].height as usize, Ordering::Relaxed); + let par = Paragraph::new(lines) + .wrap(Wrap { trim: true }) + .scroll((self.scroll_offset as u16, 0)); + f.render_widget(par, rows[0]); + render_button( + f, + rows[1], + ActionButton { + label: "Back", + intent: ButtonIntent::Cancel, + focused: self._focus == Focus::Back, + enabled: true, + }, + context.theme, + ); + _hits.register(rows[1], AppAction::Back); + } + + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; + match event.code { + KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { + InteractionResult::CloseScreen + } + KeyCode::Down | KeyCode::Char('j') => { + let max = self + .content_height + .load(Ordering::Relaxed) + .saturating_sub(self.viewport_height.load(Ordering::Relaxed)); + self.scroll_offset = self.scroll_offset.saturating_add(1).min(max); + InteractionResult::Handled + } + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + InteractionResult::Handled + } + KeyCode::PageDown => { + let page = self.viewport_height.load(Ordering::Relaxed).max(1); + let max = self + .content_height + .load(Ordering::Relaxed) + .saturating_sub(page); + self.scroll_offset = self.scroll_offset.saturating_add(page).min(max); + InteractionResult::Handled + } + KeyCode::PageUp => { + let page = self.viewport_height.load(Ordering::Relaxed).max(1); + self.scroll_offset = self.scroll_offset.saturating_sub(page); + InteractionResult::Handled + } + KeyCode::Home => { + self.scroll_offset = 0; + InteractionResult::Handled + } + KeyCode::End => { + self.scroll_offset = self + .content_height + .load(Ordering::Relaxed) + .saturating_sub(self.viewport_height.load(Ordering::Relaxed)); + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } + fn handle_action(&mut self, action: AppAction) -> InteractionResult { + if action == AppAction::Back { + InteractionResult::CloseScreen + } else { + InteractionResult::Unhandled + } + } + fn key_hints(&self) -> Vec { + vec![ + KeyHint { keys: "Up/Down", action: "Scroll" }, + KeyHint { keys: "PgUp/PgDn", action: "Page" }, + KeyHint { keys: "Esc/B", action: "Back" }, + KeyHint { keys: "F6", action: "Header" }, + ] + } +} diff --git a/iota-cli/src/screens/screens.rs b/iota-cli/src/screens/screens.rs index b69d980..f2e61f5 100644 --- a/iota-cli/src/screens/screens.rs +++ b/iota-cli/src/screens/screens.rs @@ -1,10 +1,103 @@ use std::any::Any; -use crossterm::event::KeyEvent; +use crossterm::event::{KeyEvent, MouseEvent}; use ratatui::{Frame, layout::Rect}; use crate::{interaction_result::InteractionResult, render_context::RenderContext}; +/// All terminal input that can affect the UI. Keeping this as one type makes +/// it impossible for screens to accidentally ignore a newly supported event. +#[derive(Debug, Clone)] +pub enum UiEvent { + Key(KeyEvent), + Mouse(MouseEvent), + Paste(String), + Resize(u16, u16), + App(AppEvent), +} + +/// Completion of background UI work. Keeping it in the regular event stream +/// gives screens an explicit success/failure path instead of detached tasks. +#[derive(Debug, Clone)] +pub enum AppEvent { + OpenUsers, + OpenMetrics, + ApplyTheme { + theme: crate::theme::ThemeName, + persist: bool, + }, + SaveSettings { + theme: crate::theme::ThemeName, + color: crate::theme::TerminalPolicy, + unicode: crate::theme::TerminalPolicy, + }, + ThemeSaved(Result<(), String>), + UsersLoaded(Result, String>), + UserCreated(Result), + UserRemoved { + user_id: i64, + result: Result<(), String>, + }, + RegenerateKeysRequested, + KeysRegenerated(Result<(), String>), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppAction { + OpenOverview, + OpenUsers, + OpenSettings, + OpenMetrics, + ToggleMetrics, + AddUser, + RemoveUser, + Back, + Quit, + FocusLogs, + FocusConsole, + FocusMetrics, + OpenMain, + SelectUser(usize), + ConfirmDialog, + CancelDialog, + RegenerateKeys, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeyHint { + pub keys: &'static str, + pub action: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HitRegion { + pub area: Rect, + pub action: AppAction, +} + +#[derive(Debug, Default, Clone)] +pub struct HitMap { + regions: Vec, +} + +impl HitMap { + pub fn register(&mut self, area: Rect, action: AppAction) { + self.regions.push(HitRegion { area, action }); + } + pub fn action_at(&self, column: u16, row: u16) -> Option { + self.regions + .iter() + .rev() + .find(|region| { + column >= region.area.x + && column < region.area.x.saturating_add(region.area.width) + && row >= region.area.y + && row < region.area.y.saturating_add(region.area.height) + }) + .map(|region| region.action) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NavDirection { Up, @@ -20,6 +113,32 @@ pub trait Screen: Send + Sync + Any { fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>); - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult; + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap); + fn handle_event(&mut self, event: UiEvent) -> InteractionResult; + fn handle_action(&mut self, _action: AppAction) -> InteractionResult { + InteractionResult::Unhandled + } + fn app_title(&self) -> String { + "IOTA".to_owned() + } + fn key_hints(&self) -> Vec { + vec![ + KeyHint { + keys: "Tab", + action: "Move focus", + }, + KeyHint { + keys: "Enter", + action: "Activate", + }, + KeyHint { + keys: "Esc", + action: "Back", + }, + KeyHint { + keys: "F6", + action: "Header", + }, + ] + } } diff --git a/iota-cli/src/screens/settings.rs b/iota-cli/src/screens/settings.rs new file mode 100644 index 0000000..474dfae --- /dev/null +++ b/iota-cli/src/screens/settings.rs @@ -0,0 +1,390 @@ +use std::any::Any; + +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +use crate::{ + controls::button::{ActionButton, ButtonIntent, render_button}, + interaction_result::InteractionResult, + render_context::RenderContext, + screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, + theme::{TerminalPolicy, ThemeName, UiConfig}, +}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Focus { + Theme, + RegenerateKeys, + Back, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Dialog { + ConfirmRegenerateKeys, +} + +pub struct SettingsScreen { + selected: usize, + saved: ThemeName, + message: String, + color: TerminalPolicy, + unicode: TerminalPolicy, + focus: Focus, + dialog: Option, + pending: bool, +} + +impl SettingsScreen { + pub fn new(current: ThemeName) -> Self { + let selected = ThemeName::ALL + .iter() + .position(|theme| *theme == current) + .unwrap_or(0); + Self { + selected, + saved: current, + message: "Left/Right previews. Enter saves.".into(), + color: UiConfig::load().map(|config| config.color).unwrap_or_default(), + unicode: UiConfig::load().map(|config| config.unicode).unwrap_or_default(), + focus: Focus::Theme, + dialog: None, + pending: false, + } + } + + fn selected_theme(&self) -> ThemeName { + ThemeName::ALL[self.selected] + } + + fn apply(&self, persist: bool) -> InteractionResult { + let theme = self.selected_theme(); + InteractionResult::AppTask { + task: Box::pin(async move { + UiEvent::App(AppEvent::ApplyTheme { theme, persist }) + }), + } + } + + fn next_policy(policy: TerminalPolicy) -> TerminalPolicy { + match policy { TerminalPolicy::Auto => TerminalPolicy::Always, TerminalPolicy::Always => TerminalPolicy::Never, TerminalPolicy::Never => TerminalPolicy::Auto } + } + + fn next_focus(&mut self) { + self.focus = match self.focus { + Focus::Theme => Focus::RegenerateKeys, + Focus::RegenerateKeys => Focus::Back, + Focus::Back => Focus::Theme, + }; + } + + fn prev_focus(&mut self) { + self.focus = match self.focus { + Focus::Theme => Focus::Back, + Focus::Back => Focus::RegenerateKeys, + Focus::RegenerateKeys => Focus::Theme, + }; + } + + fn activate(&mut self) -> InteractionResult { + if self.pending { + return InteractionResult::Handled; + } + if let Some(dialog) = self.dialog.take() { + match dialog { + Dialog::ConfirmRegenerateKeys => { + self.pending = true; + self.message = "Regenerating keys…".into(); + return InteractionResult::AppTask { + task: Box::pin(async { + UiEvent::App(AppEvent::RegenerateKeysRequested) + }), + }; + } + } + } + match self.focus { + Focus::Theme => { + self.message = "Saving theme…".into(); + let theme = self.selected_theme(); + let color = self.color; + let unicode = self.unicode; + InteractionResult::AppTask { task: Box::pin(async move { UiEvent::App(AppEvent::SaveSettings { theme, color, unicode }) }) } + } + Focus::RegenerateKeys => { + self.dialog = Some(Dialog::ConfirmRegenerateKeys); + InteractionResult::Handled + } + Focus::Back => InteractionResult::CloseScreen, + } + } +} + +impl Screen for SettingsScreen { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render( + &self, + frame: &mut Frame, + area: Rect, + context: &RenderContext<'_>, + hits: &mut HitMap, + ) { + let block = Block::default() + .title(" Settings ") + .borders(Borders::ALL) + .border_style(context.theme.borders.focused); + let inner = block.inner(area); + frame.render_widget(block, area); + let rows = + Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner); + frame.render_widget( + Paragraph::new(format!( + "Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)", + self.selected_theme(), + if self.selected_theme() == self.saved { + " [saved]" + } else { + " [preview]" + }, self.color, self.unicode + )) + .style(context.theme.text.heading), + rows[0], + ); + + let bottom_rows = + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(rows[1]); + + let lines = vec![ + Line::from(Span::styled(&self.message, context.theme.text.normal)), + Line::from(""), + Line::from("Preview"), + Line::from("[OK] Healthy"), + Line::from("[WARN] Degraded"), + Line::from("[FAIL] Failed"), + Line::from("> Focused action <"), + ]; + frame.render_widget( + Paragraph::new(lines).style(context.theme.text.normal), + bottom_rows[0], + ); + + let buttons_area = Layout::horizontal([ + Constraint::Percentage(33), + Constraint::Percentage(34), + Constraint::Percentage(33), + ]) + .split(bottom_rows[1]); + + render_button( + frame, + buttons_area[0], + ActionButton { + label: "Back", + intent: ButtonIntent::Cancel, + focused: self.focus == Focus::Back && self.dialog.is_none(), + enabled: true, + }, + context.theme, + ); + hits.register(buttons_area[0], AppAction::Back); + + render_button( + frame, + buttons_area[1], + ActionButton { + label: "Regenerate Keys", + intent: ButtonIntent::Destructive, + focused: self.focus == Focus::RegenerateKeys && self.dialog.is_none(), + enabled: !self.pending, + }, + context.theme, + ); + hits.register(buttons_area[1], AppAction::RegenerateKeys); + + if self.dialog.is_some() { + frame.render_widget(Block::default().style(context.theme.surfaces.overlay), area); + let popup = crate::layout::fit::centered_rect( + area, + crate::layout::fit::RequiredSize { + width: 42, + height: 7, + }, + ); + let block = Block::default() + .title(" Confirm ") + .borders(Borders::ALL) + .border_style(context.theme.borders.focused) + .style(context.theme.surfaces.overlay); + let popup_inner = block.inner(popup); + frame.render_widget(block, popup); + let dialog_rows = + Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner); + frame.render_widget( + Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(context.theme.text.normal), + dialog_rows[0], + ); + let dialog_buttons = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(dialog_rows[1]); + render_button( + frame, + dialog_buttons[0], + ActionButton { + label: "Cancel", + intent: ButtonIntent::Cancel, + focused: false, + enabled: true, + }, + context.theme, + ); + render_button( + frame, + dialog_buttons[1], + ActionButton { + label: "Regenerate", + intent: ButtonIntent::Destructive, + focused: true, + enabled: true, + }, + context.theme, + ); + hits.register(dialog_buttons[0], AppAction::CancelDialog); + hits.register(dialog_buttons[1], AppAction::ConfirmDialog); + } + } + + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let event = match event { + UiEvent::App(AppEvent::ThemeSaved(result)) => { + match result { + Ok(()) => { + self.saved = self.selected_theme(); + self.message = "Theme saved to ui.yaml.".into(); + } + Err(error) => self.message = error, + } + return InteractionResult::Handled; + } + UiEvent::App(AppEvent::KeysRegenerated(result)) => { + self.pending = false; + self.dialog = None; + match result { + Ok(()) => self.message = "Keys regenerated successfully.".into(), + Err(error) => self.message = error, + } + return InteractionResult::Handled; + } + event => event, + }; + + if self.dialog.is_some() { + let UiEvent::Key(key) = event else { + return InteractionResult::Unhandled; + }; + return match key.code { + KeyCode::Esc => { + self.dialog = None; + InteractionResult::Handled + } + KeyCode::Enter => self.activate(), + _ => InteractionResult::Handled, + }; + } + + let UiEvent::Key(key) = event else { + return InteractionResult::Unhandled; + }; + match key.code { + KeyCode::Left => { + if self.focus == Focus::Theme { + self.selected = self.selected.saturating_sub(1); + self.apply(false) + } else { + InteractionResult::Handled + } + } + KeyCode::Right => { + if self.focus == Focus::Theme { + self.selected = (self.selected + 1).min(ThemeName::ALL.len() - 1); + self.apply(false) + } else { + InteractionResult::Handled + } + } + KeyCode::Enter | KeyCode::Char(' ') => self.activate(), + KeyCode::Tab => { + self.next_focus(); + InteractionResult::Handled + } + KeyCode::BackTab => { + self.prev_focus(); + InteractionResult::Handled + } + KeyCode::Char('c') | KeyCode::Char('C') => { self.color = Self::next_policy(self.color); InteractionResult::Handled } + KeyCode::Char('u') | KeyCode::Char('U') => { self.unicode = Self::next_policy(self.unicode); InteractionResult::Handled } + KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { + InteractionResult::CloseScreen + } + _ => InteractionResult::Unhandled, + } + } + + fn handle_action(&mut self, action: AppAction) -> InteractionResult { + match action { + AppAction::Back => InteractionResult::CloseScreen, + AppAction::RegenerateKeys => { + self.focus = Focus::RegenerateKeys; + self.activate() + } + AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(), + AppAction::CancelDialog if self.dialog.is_some() => { + self.dialog = None; + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } + + fn key_hints(&self) -> Vec { + if self.dialog.is_some() { + vec![ + KeyHint { + keys: "Enter", + action: "Confirm", + }, + KeyHint { + keys: "Esc", + action: "Cancel", + }, + ] + } else { + vec![ + KeyHint { + keys: "Left/Right", + action: "Preview theme", + }, + KeyHint { + keys: "Enter", + action: "Save/Activate", + }, + KeyHint { keys: "Tab", action: "Move focus" }, + KeyHint { keys: "C/U", action: "Color/Unicode" }, + KeyHint { + keys: "Esc/B", + action: "Back", + }, + ] + } + } +} diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index c44cbcb..99e28e3 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -2,10 +2,10 @@ use crate::{ controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, interaction_result::InteractionResult, render_context::RenderContext, - screens::{md_viewer::FileViewer, screens::Screen}, + screens::{md_viewer::FileViewer, screens::{HitMap, Screen, UiEvent}}, util::{buttons::draw_buttons, terms_focus::Focus}, }; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::KeyCode; use iota_terms::{TermsType, get_link, get_terms}; use ratatui::{ Frame, @@ -53,7 +53,7 @@ impl Screen for TermsCheckerScreen { self } - fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) { + fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { let mut needed_height = 5; if size.height < 6 || size.width < 27 { @@ -270,7 +270,8 @@ impl Screen for TermsCheckerScreen { ); } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel]; if self.eula { diff --git a/iota-cli/src/screens/terms_updater.rs b/iota-cli/src/screens/terms_updater.rs index f1701b5..c298e9a 100644 --- a/iota-cli/src/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -3,11 +3,11 @@ use crate::{ controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, interaction_result::InteractionResult, render_context::RenderContext, - screens::{md_viewer::FileViewer, screens::Screen}, + screens::{md_viewer::FileViewer, screens::{HitMap, Screen, UiEvent}}, util::{buttons::draw_buttons, terms_focus::Focus}, }; use chrono::{Local, TimeZone, Utc}; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::KeyCode; use iota_terms::{Doc, TermsType, get_newest_link, get_terms}; use ratatui::{ Frame, @@ -121,7 +121,7 @@ impl Screen for TermsUpdaterScreen { self } - fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) { + fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { let checkbox = |label, selected, focused, enabled| { render_choice_line( label, @@ -593,7 +593,8 @@ impl Screen for TermsUpdaterScreen { ); } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; let mut possible_states = Vec::new(); if self.eula_needed { diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs new file mode 100644 index 0000000..14f1662 --- /dev/null +++ b/iota-cli/src/screens/users.rs @@ -0,0 +1,705 @@ +use crate::{ + controls::{ + button::{ActionButton, ButtonIntent, render_button}, + choice::{ChoiceKind, render_choice_line}, + }, + interaction_result::InteractionResult, + ipc_client::IpcClient, + render_context::RenderContext, + screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, +}; +use crossterm::event::{KeyCode, KeyModifiers}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; +use std::{ + any::Any, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +#[derive(Clone, Debug)] +pub struct UserEntry { + pub user_id: i64, + pub username: String, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Focus { + List, + AddButton, + RemoveButton, + Back, +} +#[derive(Clone, Debug)] +enum Dialog { + Add { username: String }, + Remove { user: UserEntry }, +} + +pub struct UsersScreen { + users: Vec, + focused_index: usize, + focus: Focus, + ipc: Arc, + message: Option, + dialog: Option, + pending_dialog: Option, + loading: bool, + pending: bool, + scroll_offset: usize, + viewport_height: AtomicUsize, + filter: String, + filtering: bool, +} + +impl UsersScreen { + pub fn new(ipc: Arc, users: Vec) -> Self { + Self { + users, + focused_index: 0, + focus: Focus::List, + ipc, + message: None, + dialog: None, + pending_dialog: None, + loading: false, + pending: false, + scroll_offset: 0, + viewport_height: AtomicUsize::new(1), + filter: String::new(), + filtering: false, + } + } + + pub fn loading(ipc: Arc) -> Self { + let mut screen = Self::new(ipc, Vec::new()); + screen.loading = true; + screen.message = Some("Loading users…".into()); + screen + } + + fn render_user_list(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { + let visible_indices = self.filtered_indices(); + let title = if self.filter.is_empty() { + format!("Users ({})", self.users.len()) + } else { + format!("Users ({}/{}) filter: {}", visible_indices.len(), self.users.len(), self.filter) + }; + let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + crate::controls::panel::render_panel( + f, + area, + &title, + self.focus == Focus::List, + context.theme, + ) + } else { + let block = Block::default() + .title(format!(" {title} ")) + .borders(Borders::ALL) + .border_style(context.theme.borders.normal); + let inner = block.inner(area); + f.render_widget(block, area); + inner + }; + + if self.loading { + f.render_widget(Paragraph::new("Loading users…"), inner); + return; + } + if visible_indices.is_empty() { + let par = Paragraph::new(if self.users.is_empty() { + "No users found." + } else { + "No users match the filter." + }); + f.render_widget(par, inner); + return; + } + + let mut lines = Vec::new(); + self.viewport_height.store(inner.height as usize, Ordering::Relaxed); + let labels: Vec<(usize, String)> = visible_indices + .iter() + .skip(self.scroll_offset) + .take(inner.height as usize) + .map(|user_index| { + let user = &self.users[*user_index]; + (*user_index, format!("{:>6} {}", user.user_id, user.username)) + }) + .collect(); + for (user_index, label) in &labels { + let visual = crate::controls::choice::ChoiceVisualState { + selected: false, + focused: self.focus == Focus::List && *user_index == self.focused_index, + enabled: !self.loading && !self.pending, + }; + lines.push(render_choice_line( + &label, + ChoiceKind::Radio, + visual, + context.theme, + )); + } + let par = Paragraph::new(lines); + f.render_widget(par, inner); + } + + fn render_actions(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { + let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(area); + + if let Some(msg) = &self.message { + let par = Paragraph::new(Line::from(Span::styled( + msg.as_str(), + context.theme.text.muted, + ))); + f.render_widget(par, rows[0]); + } + + let buttons_area = Layout::horizontal([ + Constraint::Percentage(33), + Constraint::Percentage(33), + Constraint::Percentage(34), + ]) + .split(rows[1]); + + render_button( + f, + buttons_area[0], + ActionButton { + label: "Back", + intent: ButtonIntent::Cancel, + focused: self.focus == Focus::Back, + enabled: true, + }, + context.theme, + ); + render_button( + f, + buttons_area[1], + ActionButton { + label: "Add", + intent: ButtonIntent::Primary, + focused: self.focus == Focus::AddButton, + enabled: true, + }, + context.theme, + ); + render_button( + f, + buttons_area[2], + ActionButton { + label: "Remove", + intent: ButtonIntent::Destructive, + focused: self.focus == Focus::RemoveButton, + enabled: !self.loading && !self.pending && !self.users.is_empty(), + }, + context.theme, + ); + } + + fn activate(&mut self) -> InteractionResult { + if self.loading || self.pending { + return InteractionResult::Handled; + } + if let Some(dialog) = self.dialog.take() { + match dialog { + Dialog::Add { username } if !username.trim().is_empty() => { + let name = username.trim().to_owned(); + self.pending_dialog = Some(Dialog::Add { username }); + self.pending = true; + self.message = Some("Creating user…".into()); + let ipc = self.ipc.clone(); + return InteractionResult::AppTask { + task: Box::pin(async move { + let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username }), + Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")), + Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()), + Err(error) => Err(format!("Cannot create user: {error}")), + }; + UiEvent::App(AppEvent::UserCreated(result)) + }), + }; + } + Dialog::Remove { user } => { + self.pending_dialog = Some(Dialog::Remove { user: user.clone() }); + let ipc = self.ipc.clone(); + let id = user.user_id; + self.pending = true; + self.message = Some(format!("Removing {}…", user.username)); + return InteractionResult::AppTask { + task: Box::pin(async move { + let result = match ipc.send_request(iota_ipc::LocalRequest::RemoveUser { user_id: id }).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserRemoved { .. })) => Ok(()), + Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot remove user: {error}")), + Ok(_) => Err("Daemon returned an unexpected response while removing the user.".into()), + Err(error) => Err(format!("Cannot remove user: {error}")), + }; + UiEvent::App(AppEvent::UserRemoved { + user_id: id, + result, + }) + }), + }; + } + Dialog::Add { .. } => self.message = Some("A username is required.".into()), + } + return InteractionResult::Handled; + } + match self.focus { + Focus::Back => InteractionResult::CloseScreen, + Focus::AddButton => { + self.dialog = Some(Dialog::Add { + username: String::new(), + }); + InteractionResult::Handled + } + Focus::RemoveButton => { + if let Some(user) = self.users.get(self.focused_index) { + self.dialog = Some(Dialog::Remove { user: user.clone() }); + } + InteractionResult::Handled + } + Focus::List => InteractionResult::Handled, + } + } + + fn next_focus(&mut self) { + self.focus = match self.focus { + Focus::List => Focus::AddButton, + Focus::AddButton => Focus::RemoveButton, + Focus::RemoveButton => Focus::Back, + Focus::Back => Focus::List, + }; + } + + fn prev_focus(&mut self) { + self.focus = match self.focus { + Focus::List => Focus::Back, + Focus::Back => Focus::RemoveButton, + Focus::RemoveButton => Focus::AddButton, + Focus::AddButton => Focus::List, + }; + } + + fn keep_focused_user_visible(&mut self) { + let indices = self.filtered_indices(); + let Some(position) = indices.iter().position(|index| *index == self.focused_index) else { + self.scroll_offset = 0; + return; + }; + let height = self.viewport_height.load(Ordering::Relaxed).max(1); + if position < self.scroll_offset { + self.scroll_offset = position; + } else if position >= self.scroll_offset + height { + self.scroll_offset = position + 1 - height; + } + } + + fn move_user_focus(&mut self, index: usize) { + if !self.users.is_empty() { + self.focused_index = index.min(self.users.len() - 1); + self.keep_focused_user_visible(); + } + } + + fn filtered_indices(&self) -> Vec { + let needle = self.filter.to_ascii_lowercase(); + self.users + .iter() + .enumerate() + .filter(|(_, user)| { + needle.is_empty() + || user.username.to_ascii_lowercase().contains(&needle) + || user.user_id.to_string().contains(&needle) + }) + .map(|(index, _)| index) + .collect() + } + + fn move_visible(&mut self, delta: isize) { + let indices = self.filtered_indices(); + if indices.is_empty() { + return; + } + let current = indices + .iter() + .position(|index| *index == self.focused_index) + .unwrap_or(0); + let next = (current as isize + delta).clamp(0, indices.len() as isize - 1) as usize; + self.move_user_focus(indices[next]); + } + + fn reset_focus_to_filter(&mut self) { + self.scroll_offset = 0; + if let Some(index) = self.filtered_indices().first().copied() { + self.focused_index = index; + } + } +} + +impl Screen for UsersScreen { + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { + let outer_block = Block::default() + .title(" Users ") + .borders(Borders::ALL) + .border_style(context.theme.borders.normal) + .title_style(context.theme.borders.title); + let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + crate::controls::panel::render_panel(f, rect, "Users", false, context.theme) + } else { + let inner = outer_block.inner(rect); + f.render_widget(outer_block, rect); + inner + }; + + let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(inner); + + self.render_user_list(f, chunks[0], context); + self.render_actions(f, chunks[1], context); + if let Some(dialog) = &self.dialog { + f.render_widget(Block::default().style(context.theme.surfaces.overlay), rect); + let popup = crate::layout::fit::centered_rect( + rect, + crate::layout::fit::RequiredSize { + width: 42, + height: 7, + }, + ); + let text = match dialog { + Dialog::Add { username } => { + format!("Add user\nUsername: {username}") + } + Dialog::Remove { user } => format!( + "Remove user {} (ID {})?\nThis removes the local user record.", + user.username, user.user_id + ), + }; + let block = Block::default() + .title(" Confirm ") + .borders(Borders::ALL) + .border_style(context.theme.borders.focused) + .style(context.theme.surfaces.overlay); + let popup_inner = block.inner(popup); + f.render_widget(block, popup); + let dialog_rows = + Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner); + f.render_widget( + Paragraph::new(text).style(context.theme.text.normal), + dialog_rows[0], + ); + let dialog_buttons = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(dialog_rows[1]); + render_button( + f, + dialog_buttons[0], + ActionButton { + label: "Cancel", + intent: ButtonIntent::Cancel, + focused: false, + enabled: true, + }, + context.theme, + ); + render_button( + f, + dialog_buttons[1], + ActionButton { + label: match dialog { + Dialog::Add { .. } => "Create", + Dialog::Remove { .. } => "Remove", + }, + intent: match dialog { + Dialog::Add { .. } => ButtonIntent::Primary, + Dialog::Remove { .. } => ButtonIntent::Destructive, + }, + focused: true, + enabled: true, + }, + context.theme, + ); + hits.register(dialog_buttons[0], AppAction::CancelDialog); + hits.register(dialog_buttons[1], AppAction::ConfirmDialog); + } + let buttons = Layout::horizontal([ + Constraint::Percentage(33), + Constraint::Percentage(33), + Constraint::Percentage(34), + ]) + .split(chunks[1]); + if self.dialog.is_none() { + hits.register(buttons[0], AppAction::Back); + } + if self.dialog.is_none() && !self.loading && !self.pending { + hits.register(buttons[1], AppAction::AddUser); + } + if self.dialog.is_none() && !self.loading && !self.pending && !self.users.is_empty() { + hits.register(buttons[2], AppAction::RemoveUser); + } + if self.dialog.is_none() { + let list_height = chunks[0].height.saturating_sub(2) as usize; + let filtered_indices = self.filtered_indices(); + for visible in 0..list_height { + let position = self.scroll_offset + visible; + let Some(index) = filtered_indices.get(position).copied() else { + break; + }; + hits.register( + Rect { + x: chunks[0].x.saturating_add(1), + y: chunks[0].y.saturating_add(1 + visible as u16), + width: chunks[0].width.saturating_sub(2), + height: 1, + }, + AppAction::SelectUser(index), + ); + } + } + } + + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let event = match event { + UiEvent::App(AppEvent::UsersLoaded(result)) => { + self.loading = false; + match result { + Ok(users) => { + self.users = users; + self.message = None; + } + Err(error) => self.message = Some(error), + } + return InteractionResult::Handled; + } + UiEvent::App(AppEvent::UserCreated(result)) => { + self.pending = false; + match result { + Ok(user) => { + self.pending_dialog = None; + self.focused_index = self.users.len(); + self.users.push(user.clone()); + self.message = Some(format!( + "Created user {} ({}).", + user.username, user.user_id + )); + } + Err(error) => { + self.dialog = self.pending_dialog.take(); + self.message = Some(error); + } + } + return InteractionResult::Handled; + } + UiEvent::App(AppEvent::UserRemoved { user_id, result }) => { + self.pending = false; + match result { + Ok(()) => { + self.pending_dialog = None; + self.users.retain(|user| user.user_id != user_id); + self.focused_index = + self.focused_index.min(self.users.len().saturating_sub(1)); + self.message = Some(format!("Removed user {user_id}.")); + } + Err(error) => { + self.dialog = self.pending_dialog.take(); + self.message = Some(error); + } + } + return InteractionResult::Handled; + } + UiEvent::Paste(text) if matches!(self.dialog, Some(Dialog::Add { .. })) => { + if let Some(Dialog::Add { username }) = self.dialog.as_mut() { + username.push_str(&text.replace(['\r', '\n'], " ")); + } + return InteractionResult::Handled; + } + UiEvent::Key(event) => event, + _ => return InteractionResult::Unhandled, + }; + if self.filtering && self.dialog.is_none() { + match event.code { + KeyCode::Esc => { + self.filtering = false; + self.filter.clear(); + self.reset_focus_to_filter(); + } + KeyCode::Enter => self.filtering = false, + KeyCode::Backspace => { + self.filter.pop(); + self.reset_focus_to_filter(); + } + KeyCode::Char(c) + if !event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + self.filter.push(c); + self.reset_focus_to_filter(); + } + _ => {} + } + return InteractionResult::Handled; + } + if let Some(Dialog::Add { username }) = self.dialog.as_mut() { + match event.code { + KeyCode::Esc => { + self.dialog = None; + return InteractionResult::Handled; + } + KeyCode::Enter => return self.activate(), + KeyCode::Backspace => { + username.pop(); + return InteractionResult::Handled; + } + KeyCode::Char(c) + if !c.is_control() + && !event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + username.push(c); + return InteractionResult::Handled; + } + _ => return InteractionResult::Handled, + } + } + if self.dialog.is_some() { + return match event.code { + KeyCode::Esc => { + self.dialog = None; + InteractionResult::Handled + } + KeyCode::Enter => self.activate(), + _ => InteractionResult::Handled, + }; + } + match event.code { + KeyCode::Esc => InteractionResult::CloseScreen, + KeyCode::Char('/') if self.focus == Focus::List => { + self.filtering = true; + self.filter.clear(); + self.reset_focus_to_filter(); + InteractionResult::Handled + } + KeyCode::Tab => { + self.next_focus(); + InteractionResult::Handled + } + KeyCode::BackTab => { + self.prev_focus(); + InteractionResult::Handled + } + KeyCode::Down | KeyCode::Char('j') => { + if self.focus == Focus::List { + self.move_visible(1); + } + InteractionResult::Handled + } + KeyCode::Up | KeyCode::Char('k') => { + if self.focus == Focus::List { + self.move_visible(-1); + } + InteractionResult::Handled + } + KeyCode::PageDown if self.focus == Focus::List && !self.users.is_empty() => { + let page = self.viewport_height.load(Ordering::Relaxed).max(1); + self.move_visible(page as isize); + InteractionResult::Handled + } + KeyCode::PageUp if self.focus == Focus::List && !self.users.is_empty() => { + let page = self.viewport_height.load(Ordering::Relaxed).max(1); + self.move_visible(-(page as isize)); + InteractionResult::Handled + } + KeyCode::Home if self.focus == Focus::List => { + if let Some(index) = self.filtered_indices().first().copied() { + self.move_user_focus(index); + } + InteractionResult::Handled + } + KeyCode::End if self.focus == Focus::List && !self.users.is_empty() => { + if let Some(index) = self.filtered_indices().last().copied() { + self.move_user_focus(index); + } + InteractionResult::Handled + } + KeyCode::Enter | KeyCode::Char(' ') => self.activate(), + _ => InteractionResult::Unhandled, + } + } + fn handle_action(&mut self, action: AppAction) -> InteractionResult { + match action { + AppAction::Back => InteractionResult::CloseScreen, + AppAction::AddUser => { + self.focus = Focus::AddButton; + self.activate() + } + AppAction::RemoveUser => { + self.focus = Focus::RemoveButton; + self.activate() + } + AppAction::SelectUser(index) if self.dialog.is_none() => { + self.focus = Focus::List; + self.move_user_focus(index); + InteractionResult::Handled + } + AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(), + AppAction::CancelDialog if self.dialog.is_some() => { + self.dialog = None; + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } + fn key_hints(&self) -> Vec { + if self.dialog.is_some() { + vec![ + KeyHint { + keys: "Enter", + action: "Confirm", + }, + KeyHint { + keys: "Esc", + action: "Cancel", + }, + ] + } else { + vec![ + KeyHint { + keys: "Up/Down", + action: "Select user", + }, + KeyHint { + keys: "PgUp/PgDn", + action: "Page", + }, + KeyHint { + keys: "/", + action: "Filter", + }, + KeyHint { + keys: "Tab", + action: "Move focus", + }, + KeyHint { + keys: "F6", + action: "Header", + }, + ] + } + } +} diff --git a/iota-cli/src/theme/config.rs b/iota-cli/src/theme/config.rs index e77c7e6..79d1990 100644 --- a/iota-cli/src/theme/config.rs +++ b/iota-cli/src/theme/config.rs @@ -13,6 +13,19 @@ pub struct UiConfig { /// Whether opening the interactive UI should launch a locally installed daemon. #[serde(default)] pub daemon_start_policy: DaemonStartPolicy, + #[serde(default)] + pub color: TerminalPolicy, + #[serde(default)] + pub unicode: TerminalPolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum TerminalPolicy { + #[default] + Auto, + Always, + Never, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/iota-cli/src/theme/mod.rs b/iota-cli/src/theme/mod.rs index d103e58..d185c6f 100644 --- a/iota-cli/src/theme/mod.rs +++ b/iota-cli/src/theme/mod.rs @@ -3,10 +3,81 @@ mod model; mod name; mod presets; -pub use config::{DaemonStartPolicy, UiConfig}; +pub use config::{DaemonStartPolicy, TerminalPolicy, UiConfig}; pub use model::*; pub use name::ThemeName; pub fn resolve(name: ThemeName) -> ResolvedTheme { presets::resolve(name) } + +pub fn resolve_with_capabilities( + name: ThemeName, + color_enabled: bool, + unicode_enabled: bool, +) -> ResolvedTheme { + let mut theme = if color_enabled { + presets::resolve(name) + } else { + presets::resolve(ThemeName::Monospace) + }; + theme.name = name; + theme.unicode = unicode_enabled; + if !unicode_enabled { + if matches!(theme.console.cursor, CursorPresentation::Character { .. }) { + theme.console.cursor = CursorPresentation::Character { + glyph: "|", + style: theme.console.text, + }; + } + } + theme +} + +/// Resolve a theme against the terminal's color depth. Surface uses RGB +/// colors, so a portable ANSI preset is selected when truecolor is absent. +pub fn resolve_with_terminal_profile( + name: ThemeName, + color_enabled: bool, + unicode_enabled: bool, + truecolor_enabled: bool, +) -> ResolvedTheme { + let effective = if color_enabled && !truecolor_enabled && matches!(name, ThemeName::Surface) { + ThemeName::Ansi + } else { + name + }; + resolve_with_capabilities(effective, color_enabled, unicode_enabled) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::Color; + + #[test] + fn no_color_policy_removes_palette_dependencies() { + let theme = resolve_with_capabilities(ThemeName::Surface, false, true); + assert_eq!(theme.name, ThemeName::Surface); + assert_eq!(theme.status.error.fg, None); + assert_eq!(theme.surfaces.panel.bg, None); + } + + #[test] + fn ascii_policy_replaces_character_cursor() { + let theme = resolve_with_capabilities(ThemeName::Monospace, false, false); + assert!(!theme.unicode); + match theme.console.cursor { + CursorPresentation::Character { glyph, .. } => assert_eq!(glyph, "|"), + CursorPresentation::StyledCell(_) => panic!("expected an ASCII character cursor"), + } + assert_ne!(theme.graphs.ram, Color::Blue); + } + + #[test] + fn surface_uses_ansi_fallback_without_truecolor() { + let theme = resolve_with_terminal_profile(ThemeName::Surface, true, true, false); + assert_eq!(theme.name, ThemeName::Ansi); + assert_eq!(theme.surfaces.panel.bg, None); + } +} diff --git a/iota-cli/src/theme/model.rs b/iota-cli/src/theme/model.rs index fd631dc..e107f39 100644 --- a/iota-cli/src/theme/model.rs +++ b/iota-cli/src/theme/model.rs @@ -24,6 +24,13 @@ pub struct BorderStyles { pub title: Style, } #[derive(Clone, Debug)] +pub struct SurfaceStyles { + pub canvas: Style, pub toolbar: Style, pub panel: Style, pub panel_alternate: Style, + pub panel_focused: Style, pub panel_selected: Style, pub footer: Style, pub overlay: Style, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChromeMode { Bordered, Surfaces } +#[derive(Clone, Debug)] pub struct ChoiceItemStyle { pub marker: Style, pub label: Style, @@ -119,6 +126,9 @@ pub struct TextSemantics { #[derive(Clone, Debug)] pub struct ResolvedTheme { pub name: ThemeName, + pub unicode: bool, + pub surfaces: SurfaceStyles, + pub chrome: ChromeMode, pub text: TextStyles, pub status: StatusStyles, pub choices: ChoiceStyles, diff --git a/iota-cli/src/theme/presets.rs b/iota-cli/src/theme/presets.rs index 333a221..33b8ff0 100644 --- a/iota-cli/src/theme/presets.rs +++ b/iota-cli/src/theme/presets.rs @@ -1,6 +1,6 @@ use super::{ BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ConsoleStyles, CursorPresentation, - GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, TextStyles, + ChromeMode, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, SurfaceStyles, TextStyles, ThemeName, }; use ratatui::style::{Color, Modifier, Style}; @@ -45,6 +45,9 @@ fn base( }; ResolvedTheme { name, + unicode: true, + surfaces: SurfaceStyles { canvas: Style::default(), toolbar: Style::default(), panel: Style::default(), panel_alternate: Style::default(), panel_focused: focused, panel_selected: selected, footer: Style::default(), overlay: Style::default() }, + chrome: ChromeMode::Bordered, text: TextStyles { normal, muted, @@ -295,6 +298,14 @@ pub fn resolve(name: ThemeName) -> ResolvedTheme { ); theme.console.cursor = CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow)); + theme.chrome = ChromeMode::Surfaces; + theme.surfaces = SurfaceStyles { + canvas: plain.bg(Color::Black), toolbar: plain.fg(Color::White).bg(Color::DarkGray), + panel: plain.fg(Color::White).bg(Color::Rgb(30, 35, 45)), + panel_alternate: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)), + panel_focused: plain.fg(Color::White).bg(Color::Rgb(48, 58, 78)), + panel_selected: selected, footer: plain.fg(Color::DarkGray).bg(Color::Black), overlay: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)), + }; theme } } diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index 21d8bd4..25f7405 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -1,14 +1,28 @@ use crate::{ + controls::header::render_header, input_handler::setup_input_handler, interaction_result::InteractionResult, ipc_client::IpcClient, render_context::RenderContext, - screens::screens::Screen, + screens::{ + main_screen::MainScreen, + metrics::MetricsScreen, + overview::OverviewScreen, + screens::{AppAction, AppEvent, HitMap, Screen, UiEvent}, + settings::SettingsScreen, + users::{UserEntry, UsersScreen}, + }, theme::{self, ResolvedTheme, ThemeName}, }; -use crossterm::event::KeyEvent; +use crossterm::event::{ + DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, MouseEventKind, +}; use once_cell::sync::Lazy; -use ratatui::{Terminal, backend::CrosstermBackend}; +use ratatui::{ + Terminal, + backend::CrosstermBackend, + layout::{Constraint, Layout}, +}; use std::{ io, io::Stdout, @@ -18,7 +32,7 @@ use std::{ atomic::{AtomicBool, Ordering}, }, }; -use tokio::sync::{Notify, RwLock}; +use tokio::sync::{Notify, RwLock, mpsc}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -35,6 +49,10 @@ pub struct UI { theme: RwLock>, pub(crate) invalidation: Notify, failure: Arc>>, + hits: Mutex, + app_event_tx: mpsc::UnboundedSender, + app_event_rx: Mutex>>, + header_focus: Mutex>, } pub fn start_tui(ipc: Arc) -> io::Result { @@ -55,6 +73,24 @@ pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result io::Result { let ui = Arc::new(ui); + let mut app_event_rx = ui + .app_event_rx + .lock() + .map_err(|_| io::Error::other("application event queue poisoned"))? + .take() + .ok_or_else(|| io::Error::other("application event queue already started"))?; + let app_ui = ui.clone(); + let app_event_task = tokio::spawn(async move { + loop { + tokio::select! { + _ = app_ui.cancellation.cancelled() => break, + event = app_event_rx.recv() => match event { + Some(event) => app_ui.clone().handle_event(event).await, + None => break, + }, + } + } + }); let uic = ui.clone(); let renderer_task = tokio::spawn(async move { let cancellation = uic.cancellation_token(); @@ -62,7 +98,6 @@ fn start_session(ui: UI) -> io::Result { tokio::select! { _ = cancellation.cancelled() => break Ok(()), _ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } }, - _ = tokio::time::sleep(std::time::Duration::from_millis(250)) => { if !uic.is_shutdown() { uic.render().await?; } }, } }; if let Err(error) = &result { @@ -101,6 +136,7 @@ fn start_session(ui: UI) -> io::Result { ui, renderer_task, input_task, + app_event_task, signal_task, restored: AtomicBool::new(false), previous_hook, @@ -111,6 +147,7 @@ pub struct TuiSession { ui: Arc, renderer_task: JoinHandle>, input_task: JoinHandle>, + app_event_task: JoinHandle<()>, signal_task: Option>, restored: AtomicBool, previous_hook: Arc) + Send + Sync + 'static>>>>, @@ -129,6 +166,7 @@ impl TuiSession { tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.renderer_task).await; let input = tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.input_task).await; + self.app_event_task.abort(); if renderer.is_err() { self.renderer_task.abort(); } @@ -154,6 +192,7 @@ impl TuiSession { } fn restore_terminal_once(&self) { if !self.restored.swap(true, Ordering::AcqRel) { + let _ = crossterm::execute!(io::stdout(), DisableMouseCapture); ratatui::restore(); } } @@ -168,6 +207,7 @@ impl Drop for TuiSession { self.ui.request_shutdown(); self.renderer_task.abort(); self.input_task.abort(); + self.app_event_task.abort(); if let Some(task) = self.signal_task.as_ref() { task.abort(); } @@ -182,6 +222,8 @@ impl UI { theme: ResolvedTheme, ) -> io::Result { let terminal = ratatui::try_init()?; + crossterm::execute!(io::stdout(), EnableMouseCapture)?; + let (app_event_tx, app_event_rx) = mpsc::unbounded_channel(); Ok(Self { ipc: RwLock::new(ipc), shutdown_on_empty, @@ -191,6 +233,10 @@ impl UI { theme: RwLock::new(Arc::new(theme)), invalidation: Notify::new(), failure: Arc::new(Mutex::new(None)), + hits: Mutex::new(HitMap::default()), + app_event_tx, + app_event_rx: Mutex::new(Some(app_event_rx)), + header_focus: Mutex::new(None), }) } @@ -228,10 +274,6 @@ impl UI { pub fn failure(&self) -> Option { self.failure.lock().ok().and_then(|f| f.clone()) } - pub async fn handle_paste(&self, _text: String) { - self.invalidate(); - } - /// Lets bootstrap operations race their work against Ctrl+C without /// blocking the input task or leaving the terminal in raw mode. pub async fn wait_for_shutdown(&self) { @@ -259,10 +301,157 @@ impl UI { self.invalidate(); } pub async fn handle_input(self: Arc, key_event: KeyEvent) { + self.handle_event(UiEvent::Key(key_event)).await; + } + pub async fn handle_event(self: Arc, event: UiEvent) { + if matches!(&event, UiEvent::App(AppEvent::OpenUsers)) { + self.open_users().await; + return; + } + if matches!(&event, UiEvent::App(AppEvent::OpenMetrics)) { + if let Some(screen) = MetricsScreen::new(self.clone()).await { + self.set_screen(Box::new(screen)).await; + } + return; + } + if let UiEvent::App(AppEvent::ApplyTheme { theme, persist }) = &event { + self.set_theme(theme::resolve(*theme)).await; + if *persist { + let mut config = theme::UiConfig::load().unwrap_or_default(); + config.theme = *theme; + let result = config + .save() + .map_err(|error| format!("Could not save UI settings: {error}")); + let _ = self + .app_event_tx + .send(UiEvent::App(AppEvent::ThemeSaved(result))); + } + return; + } + if let UiEvent::App(AppEvent::SaveSettings { theme, color, unicode }) = &event { + self.set_theme(theme::resolve(*theme)).await; + let mut config = theme::UiConfig::load().unwrap_or_default(); + config.theme = *theme; + config.color = *color; + config.unicode = *unicode; + let result = config + .save() + .map_err(|error| format!("Could not save UI settings: {error}")); + let _ = self.app_event_tx.send(UiEvent::App(AppEvent::ThemeSaved(result))); + return; + } + if matches!(&event, UiEvent::App(AppEvent::RegenerateKeysRequested)) { + let Some(ipc) = self.ipc().await else { + let _ = self + .app_event_tx + .send(UiEvent::App(AppEvent::KeysRegenerated(Err( + "Not connected to daemon.".into(), + )))); + return; + }; + let sender = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = match ipc + .send_request(iota_ipc::LocalRequest::RotateIotaIdentity) + .await + { + Ok(iota_ipc::ResponseResult::Ok(_)) => Ok(()), + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Cannot regenerate keys: {error}")) + } + Err(error) => Err(format!("Cannot regenerate keys: {error}")), + }; + let _ = sender.send(UiEvent::App(AppEvent::KeysRegenerated(result))); + }); + return; + } + if let UiEvent::Key(key) = &event { + let header_is_focused = self + .header_focus + .lock() + .map(|focus| focus.is_some()) + .unwrap_or(false); + if key.code == KeyCode::F(6) { + if let Ok(mut focus) = self.header_focus.lock() { + *focus = if focus.is_some() { None } else { Some(0) }; + } + self.invalidate(); + return; + } + if header_is_focused { + let mut action = None; + if let Ok(mut focus) = self.header_focus.lock() { + let index = focus.unwrap_or(0); + match key.code { + KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4), + KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4), + KeyCode::Enter | KeyCode::Char(' ') => { + action = Some([ + AppAction::OpenOverview, + AppAction::OpenUsers, + AppAction::OpenSettings, + AppAction::Quit, + ][index]); + *focus = None; + } + KeyCode::Esc => *focus = None, + _ => {} + } + } + if let Some(action) = action { + self.dispatch_action(action).await; + } else { + self.invalidate(); + } + return; + } + } + if let UiEvent::Mouse(mouse) = &event { + if matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) { + let action = self + .hits + .lock() + .ok() + .and_then(|hits| hits.action_at(mouse.column, mouse.row)); + if action == Some(AppAction::FocusLogs) { + self.dispatch_action(AppAction::FocusLogs).await; + let key = if matches!(mouse.kind, MouseEventKind::ScrollUp) { + KeyCode::Up + } else { + KeyCode::Down + }; + // Log scrolling is a local, handled interaction; route it + // directly rather than recursively constructing another + // async UI event future. + if let Some(screen) = self.screen_stack.write().await.last_mut() { + let _ = screen.handle_event(UiEvent::Key(KeyEvent::from(key))); + } + self.invalidate(); + return; + } + } + if matches!( + mouse.kind, + MouseEventKind::Down(crossterm::event::MouseButton::Left) + ) { + if let Some(action) = self + .hits + .lock() + .ok() + .and_then(|hits| hits.action_at(mouse.column, mouse.row)) + { + self.dispatch_action(action).await; + return; + } + } + } let result = { let mut stack = self.screen_stack.write().await; if let Some(screen) = stack.last_mut() { - screen.handle_input(key_event) + screen.handle_event(event) } else { return; } @@ -278,6 +467,13 @@ impl UI { _ = ui.cancellation.cancelled() => return, } } + InteractionResult::AppTask { task } => { + let sender = self.app_event_tx.clone(); + tokio::spawn(async move { + let event = task.await; + let _ = sender.send(event); + }); + } InteractionResult::CloseScreen => { let mut stack = self.screen_stack.write().await; stack.pop(); @@ -292,6 +488,78 @@ impl UI { self.invalidate(); } + async fn dispatch_action(self: &Arc, action: AppAction) { + match action { + AppAction::Quit => self.request_shutdown(), + AppAction::OpenMain => { + let mut stack = self.screen_stack.write().await; + if stack.len() > 1 { + stack.truncate(1); + } + drop(stack); + self.invalidate(); + } + AppAction::OpenOverview => { + let status = { + let stack = self.screen_stack.read().await; + stack + .iter() + .rev() + .find_map(|s| s.as_any().downcast_ref::()) + .map(|main| (main.connection_status(), main.daemon_status())) + }; + if let Some((connection, daemon)) = status { + self.set_screen(Box::new(OverviewScreen::new(connection, daemon))) + .await; + } + } + AppAction::OpenUsers => self.open_users().await, + AppAction::OpenSettings => { + let current = self.theme_name().await; + self.set_screen(Box::new(SettingsScreen::new(current))).await; + } + AppAction::OpenMetrics => { + if let Some(screen) = MetricsScreen::new(self.clone()).await { + self.set_screen(Box::new(screen)).await; + } + } + action => { + let result = { + let mut stack = self.screen_stack.write().await; + stack.last_mut().map(|screen| screen.handle_action(action)) + }; + if matches!(result, Some(InteractionResult::CloseScreen)) { + let mut stack = self.screen_stack.write().await; + stack.pop(); + } + self.invalidate(); + } + } + } + async fn open_users(self: &Arc) { + let Some(ipc) = self.ipc().await else { return }; + self.set_screen(Box::new(UsersScreen::loading(ipc.clone()))) + .await; + let sender = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { + Ok(users + .into_iter() + .map(|u| UserEntry { + user_id: u.user_id, + username: u.username, + }) + .collect()) + } + Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot load users: {error}")), + Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), + Err(error) => Err(format!("Cannot load users: {error}")), + }; + let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result))); + }); + } + pub async fn render(&self) -> io::Result<()> { let theme = self.theme.read().await.clone(); let context = RenderContext { @@ -304,9 +572,54 @@ impl UI { .terminal .lock() .map_err(|_| io::Error::other("terminal mutex poisoned"))?; + let mut hits = HitMap::default(); terminal.draw(|f| { - screen.render(f, f.area(), &context); + let rows = Layout::vertical([ + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(f.area()); + let header_title = self + .screen_stack + .try_read() + .ok() + .and_then(|stack| { + stack + .iter() + .find_map(|item| item.as_any().downcast_ref::()) + .map(|main| main.app_title()) + }) + .unwrap_or_else(|| screen.app_title()); + let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus); + render_header( + f, + rows[0], + &header_title, + context.theme, + &mut hits, + header_focus, + ); + let hints = if header_focus.is_some() { + " Left/Right: choose Enter: activate Esc/F6: screen".to_owned() + } else { + screen + .key_hints() + .into_iter() + .map(|hint| format!("{}: {}", hint.keys, hint.action)) + .collect::>() + .join(" ") + }; + f.render_widget( + ratatui::widgets::Paragraph::new(format!(" {hints}")) + .style(context.theme.surfaces.footer.patch(context.theme.text.muted)), + rows[2], + ); + screen.render(f, rows[1], &context, &mut hits); })?; + if let Ok(mut current) = self.hits.lock() { + *current = hits; + } } Ok(()) } diff --git a/iota-cli/tests/settings_snapshot.rs b/iota-cli/tests/settings_snapshot.rs new file mode 100644 index 0000000..0161e2d --- /dev/null +++ b/iota-cli/tests/settings_snapshot.rs @@ -0,0 +1,76 @@ +use iota_cli::{ + interaction_result::InteractionResult, + render_context::RenderContext, + screens::{ + screens::{AppEvent, HitMap, Screen, UiEvent}, + settings::SettingsScreen, + }, + theme::{ThemeName, resolve}, +}; +use ratatui::{Terminal, backend::TestBackend}; +use crossterm::event::{KeyCode, KeyEvent}; + +fn buffer_text(terminal: &Terminal) -> String { + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect() +} + +#[tokio::test] +async fn settings_preview_and_save_emit_typed_application_events() { + let mut screen = SettingsScreen::new(ThemeName::Ansi); + let preview = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Right))); + let InteractionResult::AppTask { task } = preview else { + panic!("theme preview should emit an application task"); + }; + assert!(matches!( + task.await, + UiEvent::App(AppEvent::ApplyTheme { + theme: ThemeName::Surface, + persist: false + }) + )); + + let save = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Enter))); + let InteractionResult::AppTask { task } = save else { + panic!("theme save should emit an application task"); + }; + assert!(matches!( + task.await, + UiEvent::App(AppEvent::SaveSettings { + theme: ThemeName::Surface, + color: _, + unicode: _ + }) + )); +} + +#[test] +fn settings_is_readable_in_every_theme_and_layout() { + for theme_name in ThemeName::ALL { + for (width, height) in [(42, 12), (72, 20), (100, 28)] { + let theme = resolve(theme_name); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let screen = SettingsScreen::new(theme_name); + terminal + .draw(|frame| { + screen.render( + frame, + frame.area(), + &RenderContext { theme: &theme }, + &mut HitMap::default(), + ); + }) + .unwrap(); + let rendered = buffer_text(&terminal); + assert!(rendered.contains("Settings")); + assert!(rendered.contains("Theme:")); + assert!(rendered.contains("[OK] Healthy")); + assert!(rendered.contains("[FAIL] Failed")); + } + } +} diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml index f039440..5e0c67c 100644 --- a/iota-daemon-lib/Cargo.toml +++ b/iota-daemon-lib/Cargo.toml @@ -9,12 +9,14 @@ iota-ipc = { path = "../iota-ipc" } iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-storage = { path = "../iota-storage" } +iota-updater = { path = "../iota-updater" } iota-util = { path = "../iota-util" } omikron-connector = { path = "../omikron-connector" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } dashmap = "6.1.0" libc = "0.2" sysinfo = "0.38.3" +serde_yaml = "0.9" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } uuid = { version = "*", features = ["v4"] } diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 6fe4c5f..f5d4d6d 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -1,10 +1,16 @@ use crate::{DaemonRuntime, DaemonServices}; -use iota_ipc::{ExitIntent, IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult}; +use crate::log_buffer::LogBuffer; +use iota_ipc::{ + ComponentStatusResponse, CommunitySummary, ConfigResponse, ExitIntent, IpcErrorCode, + LogEntriesResponse, LocalRequest, OmikronStatusResponse, ResponseEnvelope, ResponsePayload, + ResponseResult, StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse, + UserSummary, +}; use iota_logger::{log, log_command}; use iota_storage::users::user_manager; -use iota_storage::util::config_util::modify_config; +use iota_storage::util::config_util::{self, modify_config}; use mtp::codec::{CommunicationType, CommunicationValue}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; use crate::daemon_state::{ShutdownReason, StartupPhase}; @@ -13,11 +19,12 @@ use crate::daemon_state::{ShutdownReason, StartupPhase}; pub struct CommandRouter { runtime: Arc, services: Arc, + log_buffer: Arc>, } impl CommandRouter { - pub fn new(runtime: Arc, services: Arc) -> Self { - Self { runtime, services } + pub fn new(runtime: Arc, services: Arc, log_buffer: Arc>) -> Self { + Self { runtime, services, log_buffer } } pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope { @@ -26,35 +33,6 @@ impl CommandRouter { ResponseEnvelope { request_id, result } } - /// Parse a legacy console command string into a typed request. - pub fn parse_console_command(line: &str) -> Option { - let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect(); - match parts.as_slice() { - ["help"] => None, - ["tasks"] => Some(LocalRequest::ListTasks), - ["ping", _] | ["ping"] => None, - ["user", "add", username] => Some(LocalRequest::CreateUser { - username: username.to_string(), - }), - ["user", "remove", username] => { - let user = user_manager::get_user_by_username(username)?; - Some(LocalRequest::RemoveUser { - user_id: user.user_id, - }) - } - ["user", "list"] => Some(LocalRequest::ListUsers), - ["reconnect"] => Some(LocalRequest::ReconnectOmikron), - ["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity), - ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { - intent: ExitIntent::Restart, - }), - ["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit { - intent: ExitIntent::Stop, - }), - _ => None, - } - } - async fn execute(&self, request: LocalRequest) -> ResponseResult { let needs_omikron = matches!( request, @@ -83,28 +61,33 @@ impl CommandRouter { .iter() .map(|task| task.to_string()) .collect(); - let mut info = format!("Phase: {:?}, Tasks: {}", phase, tasks.join(", ")); - if let Some(reason) = degraded { - info.push_str(&format!(", Degraded: {}", reason)); - } - ResponseResult::Ok(info) + ResponseResult::Ok(ResponsePayload::Status(StatusResponse { + phase: format!("{:?}", phase), + tasks: tasks.clone(), + degraded_reason: degraded, + })) } LocalRequest::ListTasks => { - let tasks: Vec = self + let tasks: Vec = self .runtime .state .active_tasks .iter() - .map(|task| task.to_string()) + .map(|task| TaskSummary { + name: task.to_string(), + }) .collect(); - ResponseResult::Ok(tasks.join(", ")) + ResponseResult::Ok(ResponsePayload::Tasks(tasks)) } LocalRequest::ListUsers => { - let users: Vec = user_manager::get_users() + let users: Vec = user_manager::get_users() .into_iter() - .map(|user| format!("{} ({})", user.username, user.user_id)) + .map(|user| UserSummary { + user_id: user.user_id, + username: user.username, + }) .collect(); - ResponseResult::Ok(users.join("\n")) + ResponseResult::Ok(ResponsePayload::Users(users)) } LocalRequest::CreateUser { username } => { match omikron_connector::user_ops::create_user( @@ -113,7 +96,12 @@ impl CommandRouter { ) .await { - (Some(user), _) => ResponseResult::Ok(format!("Created user {}", user.user_id)), + (Some(user), _) => { + ResponseResult::Ok(ResponsePayload::UserCreated { + user_id: user.user_id, + username: user.username, + }) + } _ => ResponseResult::Error(IpcErrorCode::StorageFailure), } } @@ -128,10 +116,12 @@ impl CommandRouter { return ResponseResult::Error(IpcErrorCode::OmikronUnavailable); } user_manager::remove_user(user.user_id); - ResponseResult::Ok(format!("Removed user {}", user.user_id)) + ResponseResult::Ok(ResponsePayload::UserRemoved { user_id }) } LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await { - Ok(()) => ResponseResult::Ok("Reconnected to Omikron server".into()), + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "Reconnected to Omikron server".into(), + }), Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), }, LocalRequest::RotateIotaIdentity => { @@ -141,9 +131,9 @@ impl CommandRouter { config.iota_id = None; }); match self.services.omikron.reconnect().await { - Ok(()) => ResponseResult::Ok( - "Key pair regenerated and Omikron reconnection requested".into(), - ), + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "Key pair regenerated and Omikron reconnection requested".into(), + }), Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), } } @@ -160,18 +150,120 @@ impl CommandRouter { ExitIntent::Stop => ShutdownReason::Stop, ExitIntent::Restart => ShutdownReason::Restart, }); - ResponseResult::Ok("process exit accepted".into()) + ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "process exit accepted".into(), + }) } LocalRequest::GetDaemonStatus => { - ResponseResult::Ok(format!("{:?}", self.runtime.snapshot())) + ResponseResult::Ok(ResponsePayload::DaemonStatus( + iota_ipc::DaemonStatusResponse { + formatted: format!("{:?}", self.runtime.snapshot()), + }, + )) } LocalRequest::RestartDaemon => { self.runtime.shutdown(ShutdownReason::Restart); - ResponseResult::Ok("Daemon restart requested".into()) + ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "Daemon restart requested".into(), + }) } LocalRequest::StopDaemon => { self.runtime.shutdown(ShutdownReason::Stop); - ResponseResult::Ok("Daemon shutdown requested".into()) + ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "Daemon shutdown requested".into(), + }) + } + LocalRequest::GetConfig => { + let cfg = config_util::CONFIG.load(); + let yaml = serde_yaml::to_string(&**cfg).unwrap_or_default(); + ResponseResult::Ok(ResponsePayload::Config(ConfigResponse { yaml })) + } + LocalRequest::SetConfig { key, value } => { + match config_util::modify_config_value(&key, &value) { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Set {key} = {value}"), + }), + Err(_e) => ResponseResult::Error(IpcErrorCode::InvalidRequest), + } + } + LocalRequest::ReloadConfig => { + config_util::load_config(); + ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "Configuration reloaded".into(), + }) + } + LocalRequest::GetOmikronStatus => { + let connected = self.services.omikron.is_connected().await; + let iota_id = config_util::CONFIG.load().iota_id; + ResponseResult::Ok(ResponsePayload::OmikronStatus( + OmikronStatusResponse { + connected, + iota_id, + }, + )) + } + LocalRequest::ListComponents => { + let snapshot = self.runtime.snapshot(); + let components: Vec = snapshot + .components + .into_iter() + .map(|(id, health)| ComponentStatusResponse { + id, + status: health.status, + message: health.message, + }) + .collect(); + ResponseResult::Ok(ResponsePayload::Components(components)) + } + LocalRequest::GetUser { user_id } => { + match user_manager::get_user(user_id) { + Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail( + UserDetailResponse { + user_id: user.user_id, + username: user.username, + display_name: user.display_name, + created_at: user.created_at, + trusted_apps: user.trusted_apps.keys().cloned().collect(), + }, + )), + None => ResponseResult::Error(IpcErrorCode::NotFound), + } + } + LocalRequest::ImportUser { username } => { + match user_manager::load_from_tu(&username).await { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Imported user {username}"), + }), + Err(()) => ResponseResult::Error(IpcErrorCode::StorageFailure), + } + } + LocalRequest::GetLogs { limit } => { + let entries = if let Ok(buf) = self.log_buffer.lock() { + buf.recent(limit) + } else { + Vec::new() + }; + ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { entries })) + } + LocalRequest::CheckUpdate => { + match iota_updater::check_update().await { + Ok(available) => ResponseResult::Ok(ResponsePayload::UpdateStatus( + UpdateStatusResponse { available }, + )), + Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure), + } + } + LocalRequest::ListCommunities => { + let iota_id = config_util::CONFIG.load().iota_id.map(|id| id as i64).unwrap_or(0); + let stored = iota_storage::util::communities_util::CommunitiesUtil::get_communities(iota_id); + let summaries: Vec = stored + .into_iter() + .map(|c| CommunitySummary { + name: c.address, + title: c.title, + }) + .collect(); + ResponseResult::Ok(ResponsePayload::Communities(summaries)) } } } diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs index b6ea1cb..d138107 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -1,3 +1,4 @@ +use crate::log_buffer::LogBuffer; use crate::deployment::from_environment; use crate::{CommandRouter, DaemonRuntime, DaemonServices}; use iota_ipc::{ @@ -8,7 +9,7 @@ use iota_logger::log; use std::io::Result; use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener}; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::{broadcast, mpsc, watch}; @@ -22,11 +23,25 @@ const CLIENT_CHANNEL_SIZE: usize = 256; const MAX_HANDSHAKE_RETRIES: u32 = 1; const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +/// Minimum metric subscription interval to prevent excessive update rates. +const MIN_METRIC_INTERVAL_MS: u64 = 100; +/// Maximum metric subscription interval. +const MAX_METRIC_INTERVAL_MS: u64 = 60_000; +/// Default metric interval if the client does not specify one. +const DEFAULT_METRIC_INTERVAL_MS: u64 = 500; + +/// Per-client subscription state. +struct ClientSubscription { + log_classes: Vec, + metric_interval_ms: u64, +} + pub struct IpcServer { listener: UnixListener, runtime: Arc, services: Arc, log_tx: broadcast::Sender, + log_buffer: Arc>, state_rx: watch::Receiver, instance_id: String, _instance_lock: File, @@ -38,6 +53,7 @@ impl IpcServer { runtime: Arc, services: Arc, log_tx: broadcast::Sender, + log_buffer: Arc>, state_rx: watch::Receiver, ) -> Result { let path = path.into(); @@ -90,6 +106,7 @@ impl IpcServer { runtime, services, log_tx, + log_buffer, state_rx, instance_id: Uuid::new_v4().to_string(), _instance_lock: lock, @@ -101,6 +118,7 @@ impl IpcServer { runtime, services, log_tx, + log_buffer, state_rx, instance_id: Uuid::new_v4().to_string(), _instance_lock: File::options().read(true).open("/dev/null")?, @@ -114,11 +132,12 @@ impl IpcServer { let runtime = self.runtime.clone(); let services = self.services.clone(); let log_tx = self.log_tx.clone(); + let log_buffer = self.log_buffer.clone(); let state_rx = self.state_rx.clone(); let instance_id = self.instance_id.clone(); tokio::spawn(async move { if let Err(error) = - handle_client(stream, runtime, services, log_tx, state_rx, instance_id).await + handle_client(stream, runtime, services, log_tx, log_buffer, state_rx, instance_id).await { eprintln!("IPC client error: {error}"); } @@ -252,6 +271,7 @@ async fn handle_client( runtime: Arc, services: Arc, log_tx: broadcast::Sender, + log_buffer: Arc>, mut state_rx: watch::Receiver, instance_id: String, ) -> Result<()> { @@ -337,12 +357,18 @@ async fn handle_client( // --- Writer task: merge directed responses + shared log events --- let mut log_rx = log_tx.subscribe(); + let (sub_tx, mut sub_rx) = tokio::sync::watch::channel(ClientSubscription { + log_classes: Vec::new(), + metric_interval_ms: DEFAULT_METRIC_INTERVAL_MS, + }); let writer_task = { let runtime = runtime.clone(); let session_cancellation = session_cancellation.clone(); tokio::spawn(async move { let mut directed_rx = directed_rx; + let mut last_metric_sent = tokio::time::Instant::now(); loop { + let metric_interval = sub_rx.borrow().metric_interval_ms; tokio::select! { // Directed messages (responses to this client's requests) msg = directed_rx.recv() => { @@ -360,9 +386,35 @@ async fn handle_client( // Shared log events result = log_rx.recv() => { match result { + Ok(DaemonMessage::LogEntry(entry)) => { + // Filter by subscribed log classes + let log_classes = sub_rx.borrow().log_classes.clone(); + if log_classes.is_empty() + || log_classes.iter().any(|c| entry.sender == *c) + { + if let Err(error) = write_client_message(&mut writer, &DaemonMessage::LogEntry(entry)).await { + eprintln!("IPC client writer stopped while sending log message: {error}"); + session_cancellation.cancel(); + break; + } + } + } + Ok(DaemonMessage::MetricSample(sample)) => { + // Rate-limit metric samples based on subscription interval + let now = tokio::time::Instant::now(); + if now.duration_since(last_metric_sent) >= std::time::Duration::from_millis(metric_interval) { + last_metric_sent = now; + if let Err(error) = write_client_message(&mut writer, &DaemonMessage::MetricSample(sample)).await { + eprintln!("IPC client writer stopped while sending metric sample: {error}"); + session_cancellation.cancel(); + break; + } + } + } Ok(message) => { + // Forward other broadcast messages as-is if let Err(error) = write_client_message(&mut writer, &message).await { - eprintln!("IPC client writer stopped while sending log message: {error}"); + eprintln!("IPC client writer stopped while sending broadcast message: {error}"); session_cancellation.cancel(); break; } @@ -389,13 +441,14 @@ async fn handle_client( break; } } + _ = sub_rx.changed() => {} } } }) }; // --- Reader loop --- - let router = CommandRouter::new(runtime.clone(), services); + let router = CommandRouter::new(runtime.clone(), services, log_buffer); loop { let message = tokio::select! { _ = session_cancellation.cancelled() => break, @@ -442,7 +495,17 @@ async fn handle_client( break; } } - Ok(ClientMessage::Subscribe { .. }) => { + Ok(ClientMessage::Subscribe { + log_classes, + metric_interval_ms, + }) => { + let interval = metric_interval_ms + .unwrap_or(DEFAULT_METRIC_INTERVAL_MS) + .clamp(MIN_METRIC_INTERVAL_MS, MAX_METRIC_INTERVAL_MS); + let _ = sub_tx.send(ClientSubscription { + log_classes, + metric_interval_ms: interval, + }); let snapshot = DaemonMessage::StateUpdate(runtime.snapshot()); let _ = directed_tx.send(snapshot).await; let _ = directed_tx.send(DaemonMessage::Subscribed).await; diff --git a/iota-daemon-lib/src/lib.rs b/iota-daemon-lib/src/lib.rs index 5a40295..6ccd977 100644 --- a/iota-daemon-lib/src/lib.rs +++ b/iota-daemon-lib/src/lib.rs @@ -3,6 +3,7 @@ pub mod daemon_state; pub mod deployment; pub mod ipc_server; pub mod log_broadcaster; +pub mod log_buffer; pub mod services; pub mod task_registry; diff --git a/iota-daemon-lib/src/log_broadcaster.rs b/iota-daemon-lib/src/log_broadcaster.rs index 1ce3c88..b00eb9d 100644 --- a/iota-daemon-lib/src/log_broadcaster.rs +++ b/iota-daemon-lib/src/log_broadcaster.rs @@ -1,21 +1,30 @@ +use crate::log_buffer::LogBuffer; use iota_ipc::{DaemonMessage, LogEntry}; use iota_logger::subscribe; +use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; /* The daemon adapts logger output to the wire protocol so the logger stays * independent from both the socket implementation and TUI state. */ -pub fn spawn(message_tx: broadcast::Sender) { +pub fn spawn( + message_tx: broadcast::Sender, + buffer: Arc>, +) { let Some(mut logs) = subscribe() else { return; }; tokio::spawn(async move { while let Ok(entry) = logs.recv().await { - let _ = message_tx.send(DaemonMessage::LogEntry(LogEntry { + let entry = LogEntry { timestamp_ms: entry.timestamp_ms, sender: entry.sender, message: entry.message, is_error: entry.is_error, - })); + }; + if let Ok(mut buf) = buffer.lock() { + buf.push(entry.clone()); + } + let _ = message_tx.send(DaemonMessage::LogEntry(entry)); } }); } diff --git a/iota-daemon-lib/src/log_buffer.rs b/iota-daemon-lib/src/log_buffer.rs new file mode 100644 index 0000000..bb2cdc6 --- /dev/null +++ b/iota-daemon-lib/src/log_buffer.rs @@ -0,0 +1,36 @@ +use iota_ipc::LogEntry; +use std::collections::VecDeque; + +pub struct LogBuffer { + entries: VecDeque, + capacity: usize, +} + +impl LogBuffer { + pub fn new(capacity: usize) -> Self { + Self { + entries: VecDeque::with_capacity(capacity), + capacity, + } + } + + pub fn push(&mut self, entry: LogEntry) { + if self.entries.len() == self.capacity { + self.entries.pop_front(); + } + self.entries.push_back(entry); + } + + pub fn recent(&self, limit: usize) -> Vec { + let _len = self.entries.len(); + self.entries + .iter() + .rev() + .take(limit) + .cloned() + .collect::>() + .into_iter() + .rev() + .collect() + } +} diff --git a/iota-daemon-lib/tests/command_router.rs b/iota-daemon-lib/tests/command_router.rs index 525bca1..2a67cf1 100644 --- a/iota-daemon-lib/tests/command_router.rs +++ b/iota-daemon-lib/tests/command_router.rs @@ -1,10 +1,11 @@ use async_trait::async_trait; use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices}; +use iota_daemon_lib::log_buffer::LogBuffer; use iota_ipc::{LocalRequest, ResponseResult}; use mtp::codec::CommunicationValue; use omikron_connector::{OmikronClient, OmikronError}; use std::sync::{ - Arc, + Arc, Mutex, atomic::{AtomicUsize, Ordering}, }; use std::time::Duration; @@ -43,7 +44,7 @@ async fn reconnect_uses_the_injected_client() { users: Default::default(), config: Default::default(), }); - let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services); + let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services, Arc::new(Mutex::new(LogBuffer::new(100)))); assert!(matches!( router.route(1, LocalRequest::ReconnectOmikron).await.result, ResponseResult::Ok(_) diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index 12d24fd..0204f7d 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -1,11 +1,12 @@ use iota_daemon_lib::{ DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster, + log_buffer::LogBuffer, }; use iota_logger::{self as logger, log}; use iota_storage::users::user_manager; use iota_storage::util::config_util::CONFIG; use std::process::ExitCode; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::{broadcast, watch}; #[tokio::main(flavor = "multi_thread")] @@ -41,7 +42,8 @@ async fn main() -> ExitCode { let runtime = Arc::new(DaemonRuntime::new()); // --- IPC infrastructure --- let (log_tx, _) = broadcast::channel(512); - log_broadcaster::spawn(log_tx.clone()); + let log_buffer = Arc::new(Mutex::new(LogBuffer::new(1024))); + log_broadcaster::spawn(log_tx.clone(), log_buffer.clone()); let (state_tx, state_rx) = watch::channel(runtime.snapshot()); runtime.set_startup_phase(StartupPhase::LoadingUsers); @@ -101,6 +103,7 @@ async fn main() -> ExitCode { runtime.clone(), services, log_tx.clone(), + log_buffer.clone(), state_rx, ) .await @@ -128,6 +131,21 @@ async fn main() -> ExitCode { .await; log!("iota-daemon IPC server ready"); + log!( + "iota-daemon paths (scope={:?}): config={} state={} storage={} identity={} cache={} log={} asset={} ipc={}", + paths.scope, + paths.config_file.display(), + paths.state_dir.display(), + paths.storage_dir.display(), + paths.identity_dir.display(), + paths.cache_dir.display(), + paths.log_dir.display(), + paths.asset_dir.display(), + match &paths.ipc_endpoint { + iota_paths::IpcEndpoint::UnixSocket(p) => p.display().to_string(), + iota_paths::IpcEndpoint::WindowsPipe(n) => n.clone(), + }, + ); runtime.set_startup_phase(StartupPhase::StartingServices); // --- System monitor --- diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index 1118fc2..ed5c3c1 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -1,11 +1,14 @@ pub mod protocol; +pub mod text_commands; pub mod transport; pub use protocol::{ - ClientMessage, ComponentHealth, ComponentId, ConnectionStatus, DaemonMessage, DeploymentMode, - ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, - LogEntry, MetricSample, RequestEnvelope, ResponseEnvelope, ResponseResult, StartupPhase, - StateSnapshot, SupervisorKind, + ClientMessage, ComponentHealth, ComponentId, ComponentStatusResponse, ConfigResponse, + ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, ExitIntent, + HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, LogEntry, + LogEntriesResponse, MetricSample, OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, + ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, + TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, CommunitySummary, }; pub use transport::{read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 7a95a3d..10b1d27 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -49,6 +49,25 @@ pub enum LocalRequest { RestartDaemon, #[serde(skip)] StopDaemon, + GetConfig, + SetConfig { + key: String, + value: String, + }, + ReloadConfig, + GetOmikronStatus, + ListComponents, + GetUser { + user_id: i64, + }, + ImportUser { + username: String, + }, + GetLogs { + limit: usize, + }, + CheckUpdate, + ListCommunities, } #[derive(Clone, Copy, Debug, Deserialize, Serialize)] @@ -107,10 +126,102 @@ pub struct ResponseEnvelope { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum ResponseResult { - Ok(String), + Ok(ResponsePayload), Error(IpcErrorCode), } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ResponsePayload { + Status(StatusResponse), + Tasks(Vec), + Users(Vec), + UserCreated { + user_id: i64, + username: String, + }, + UserRemoved { + user_id: i64, + }, + Acknowledged { + message: String, + }, + DaemonStatus(DaemonStatusResponse), + Config(ConfigResponse), + OmikronStatus(OmikronStatusResponse), + Components(Vec), + UserDetail(UserDetailResponse), + LogEntries(LogEntriesResponse), + UpdateStatus(UpdateStatusResponse), + Communities(Vec), +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ConfigResponse { + pub yaml: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct OmikronStatusResponse { + pub connected: bool, + pub iota_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ComponentStatusResponse { + pub id: ComponentId, + pub status: HealthStatus, + pub message: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct UserDetailResponse { + pub user_id: i64, + pub username: String, + pub display_name: Option, + pub created_at: i64, + pub trusted_apps: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct LogEntriesResponse { + pub entries: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct UpdateStatusResponse { + pub available: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CommunitySummary { + pub name: String, + pub title: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct StatusResponse { + pub phase: String, + pub tasks: Vec, + pub degraded_reason: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TaskSummary { + pub name: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct UserSummary { + pub user_id: i64, + pub username: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DaemonStatusResponse { + pub formatted: String, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum IpcErrorCode { @@ -128,6 +239,36 @@ pub enum IpcErrorCode { InternalFailure, } +impl std::fmt::Display for IpcErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::InvalidRequest => "the daemon rejected the request", + Self::NotFound => "the requested resource was not found", + Self::Conflict => "the request conflicts with current daemon state", + Self::StorageFailure => "the daemon could not access local storage", + Self::OmikronUnavailable => "Omikron is unavailable", + Self::UnsupportedVersion => "the client and daemon protocol versions are incompatible", + Self::NotReady => "the daemon is not ready yet", + Self::Disconnected => "the daemon connection was lost", + Self::Timeout => "the daemon did not respond in time", + Self::Cancelled => "the daemon cancelled the request", + Self::Unauthorized => "the daemon denied this operation", + Self::InternalFailure => "the daemon encountered an internal failure", + }) + } +} + +#[cfg(test)] +mod error_tests { + use super::IpcErrorCode; + + #[test] + fn error_codes_have_operator_facing_messages() { + assert_eq!(IpcErrorCode::NotReady.to_string(), "the daemon is not ready yet"); + assert!(!IpcErrorCode::InternalFailure.to_string().contains("InternalFailure")); + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum LifecycleEvent { diff --git a/iota-ipc/src/text_commands.rs b/iota-ipc/src/text_commands.rs new file mode 100644 index 0000000..1e824e3 --- /dev/null +++ b/iota-ipc/src/text_commands.rs @@ -0,0 +1,324 @@ +use crate::LocalRequest; + +pub const COMMANDS: &[&str] = &[ + "status", + "tasks", + "users list", + "users show ", + "users add ", + "users remove ", + "users import ", + "omikron status", + "reconnect", + "identity rotate", + "daemon status", + "config get", + "config set ", + "config reload", + "components", + "logs", + "update check", + "community list", + "restart", + "stop", +]; + +pub fn completions(prefix: &str) -> Vec<&'static str> { + let normalized = prefix.trim_start_matches('/'); + COMMANDS + .iter() + .copied() + .filter(|command| command.starts_with(normalized)) + .collect() +} + +pub fn validation_error(line: &str) -> Option { + let normalized = line.trim_start_matches('/').trim(); + if normalized == "help" || parse(normalized).is_some() { + None + } else { + Some(format!("Unknown command `{normalized}`. Use /help or Tab completion.")) + } +} + +/// Parse a text command string into a typed IPC request. +/// +/// Both the CLI console and the TUI command palette use this single parser. +/// Commands are case-insensitive and support an optional leading `/`. +pub fn parse(line: &str) -> Option { + let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect(); + match parts.as_slice() { + ["help"] => None, + ["status"] => Some(LocalRequest::GetStatus), + ["tasks"] => Some(LocalRequest::ListTasks), + ["users"] | ["user", "list"] | ["users", "list"] => Some(LocalRequest::ListUsers), + ["user" | "users", "show", id_str] => { + let user_id = id_str.parse::().ok()?; + Some(LocalRequest::GetUser { user_id }) + } + ["user" | "users", "add", username] => Some(LocalRequest::CreateUser { + username: username.to_string(), + }), + ["user" | "users", "remove", id_str] => { + let user_id = id_str.parse::().ok()?; + Some(LocalRequest::RemoveUser { user_id }) + } + ["user" | "users", "import", username] => Some(LocalRequest::ImportUser { + username: username.to_string(), + }), + ["reconnect"] => Some(LocalRequest::ReconnectOmikron), + ["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity), + ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { + intent: crate::ExitIntent::Restart, + }), + ["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit { + intent: crate::ExitIntent::Stop, + }), + ["daemon", "status"] => Some(LocalRequest::GetDaemonStatus), + ["config", "get"] => Some(LocalRequest::GetConfig), + ["config", "set", key, value] => Some(LocalRequest::SetConfig { + key: key.to_string(), + value: value.to_string(), + }), + ["config", "reload"] => Some(LocalRequest::ReloadConfig), + ["omikron", "status"] => Some(LocalRequest::GetOmikronStatus), + ["components"] => Some(LocalRequest::ListComponents), + ["logs"] => Some(LocalRequest::GetLogs { limit: 100 }), + ["update", "check"] => Some(LocalRequest::CheckUpdate), + ["community", "list"] | ["communities"] => Some(LocalRequest::ListCommunities), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_status() { + assert!(matches!(parse("status"), Some(LocalRequest::GetStatus))); + } + + #[test] + fn parses_tasks() { + assert!(matches!(parse("tasks"), Some(LocalRequest::ListTasks))); + } + + #[test] + fn parses_user_list_shortcuts() { + assert!(matches!(parse("users"), Some(LocalRequest::ListUsers))); + assert!(matches!(parse("user list"), Some(LocalRequest::ListUsers))); + } + + #[test] + fn parses_user_add() { + let req = parse("user add alice").unwrap(); + match req { + LocalRequest::CreateUser { username } => assert_eq!(username, "alice"), + _ => panic!("expected CreateUser"), + } + } + + #[test] + fn accepts_the_headless_cli_user_vocabulary() { + assert!(matches!( + parse("users list"), + Some(LocalRequest::ListUsers) + )); + assert!(matches!( + parse("users add alice"), + Some(LocalRequest::CreateUser { .. }) + )); + assert!(matches!( + parse("users remove 42"), + Some(LocalRequest::RemoveUser { user_id: 42 }) + )); + assert!(matches!( + parse("identity rotate"), + Some(LocalRequest::RotateIotaIdentity) + )); + } + + #[test] + fn parses_user_remove_by_id() { + let req = parse("user remove 42").unwrap(); + match req { + LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42), + _ => panic!("expected RemoveUser"), + } + } + + #[test] + fn user_remove_requires_numeric_id() { + assert!(parse("user remove alice").is_none()); + } + + #[test] + fn parses_reconnect() { + assert!(matches!( + parse("reconnect"), + Some(LocalRequest::ReconnectOmikron) + )); + } + + #[test] + fn parses_regenerate_keys() { + assert!(matches!( + parse("regenerate keys"), + Some(LocalRequest::RotateIotaIdentity) + )); + } + + #[test] + fn parses_restart_aliases() { + assert!(matches!( + parse("restart"), + Some(LocalRequest::RequestProcessExit { .. }) + )); + assert!(matches!( + parse("reload"), + Some(LocalRequest::RequestProcessExit { .. }) + )); + } + + #[test] + fn parses_stop_aliases() { + assert!(matches!( + parse("stop"), + Some(LocalRequest::RequestProcessExit { .. }) + )); + assert!(matches!( + parse("shutdown"), + Some(LocalRequest::RequestProcessExit { .. }) + )); + } + + #[test] + fn parses_daemon_status() { + assert!(matches!( + parse("daemon status"), + Some(LocalRequest::GetDaemonStatus) + )); + } + + #[test] + fn parses_config_get() { + assert!(matches!( + parse("config get"), + Some(LocalRequest::GetConfig) + )); + } + + #[test] + fn parses_config_reload() { + assert!(matches!( + parse("config reload"), + Some(LocalRequest::ReloadConfig) + )); + } + + #[test] + fn parses_omikron_status() { + assert!(matches!( + parse("omikron status"), + Some(LocalRequest::GetOmikronStatus) + )); + } + + #[test] + fn parses_components() { + assert!(matches!( + parse("components"), + Some(LocalRequest::ListComponents) + )); + } + + #[test] + fn parses_users_show() { + let req = parse("users show 42").unwrap(); + match req { + LocalRequest::GetUser { user_id } => assert_eq!(user_id, 42), + _ => panic!("expected GetUser"), + } + } + + #[test] + fn user_show_requires_numeric_id() { + assert!(parse("users show alice").is_none()); + } + + #[test] + fn parses_config_set() { + let req = parse("config set port 8080").unwrap(); + match req { + LocalRequest::SetConfig { key, value } => { + assert_eq!(key, "port"); + assert_eq!(value, "8080"); + } + _ => panic!("expected SetConfig"), + } + } + + #[test] + fn parses_logs() { + assert!(matches!(parse("logs"), Some(LocalRequest::GetLogs { .. }))); + } + + #[test] + fn parses_update_check() { + assert!(matches!( + parse("update check"), + Some(LocalRequest::CheckUpdate) + )); + } + + #[test] + fn parses_community_list() { + assert!(matches!( + parse("community list"), + Some(LocalRequest::ListCommunities) + )); + } + + #[test] + fn parses_communities_alias() { + assert!(matches!( + parse("communities"), + Some(LocalRequest::ListCommunities) + )); + } + + #[test] + fn parses_users_import() { + let req = parse("users import alice").unwrap(); + match req { + LocalRequest::ImportUser { username } => assert_eq!(username, "alice"), + _ => panic!("expected ImportUser"), + } + } + + #[test] + fn parses_with_slash_prefix() { + assert!(matches!(parse("/status"), Some(LocalRequest::GetStatus))); + assert!(matches!(parse("/tasks"), Some(LocalRequest::ListTasks))); + } + + #[test] + fn unknown_returns_none() { + assert!(parse("nonexistent").is_none()); + } + + #[test] + fn completion_is_prefix_based_and_deterministic() { + assert_eq!(completions("identity r"), vec!["identity rotate"]); + assert_eq!(completions("/users a"), vec!["users add "]); + assert!(completions("definitely-unknown").is_empty()); + } + + #[test] + fn validation_distinguishes_help_and_unknown_commands() { + assert_eq!(validation_error("/help"), None); + assert!(validation_error("status").is_none()); + assert!(validation_error("statuz").unwrap().contains("Unknown command")); + } +} diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 380ba6c..60b1cda 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -172,6 +172,57 @@ pub fn modify_config(f: impl FnOnce(&mut IotaConfig)) { CONFIG.store(Arc::new(cfg)); save_config(); } + +pub fn modify_config_value(key: &str, value: &str) -> Result<(), &'static str> { + match key { + "iota_id" => { + let parsed: u64 = value.parse().map_err(|_| "invalid iota_id")?; + modify_config(|cfg| cfg.iota_id = Some(parsed)); + Ok(()) + } + "port" => { + let parsed: u16 = value.parse().map_err(|_| "invalid port")?; + modify_config(|cfg| cfg.port = parsed); + Ok(()) + } + "omikron_host" => { + let host = value.to_string(); + modify_config(|cfg| cfg.omikron_host = Some(host)); + Ok(()) + } + "omikron_port" => { + let parsed: u16 = value.parse().map_err(|_| "invalid omikron_port")?; + modify_config(|cfg| cfg.omikron_port = Some(parsed)); + Ok(()) + } + "read_receipts_enabled" => { + let parsed: bool = value.parse().map_err(|_| "invalid boolean")?; + modify_config(|cfg| cfg.read_receipts_enabled = parsed); + Ok(()) + } + "web.mode" => { + let mode = match value { + "disabled" => WebMode::Disabled, + "loopback" => WebMode::Loopback, + "network" => WebMode::Network, + _ => return Err("invalid web.mode; use disabled, loopback, or network"), + }; + modify_config(|cfg| cfg.web.mode = mode); + Ok(()) + } + "web.port" => { + let parsed: u16 = value.parse().map_err(|_| "invalid web.port")?; + modify_config(|cfg| cfg.web.port = parsed); + Ok(()) + } + "web.bind" => { + let bind = value.to_string(); + modify_config(|cfg| cfg.web.bind = bind); + Ok(()) + } + _ => Err("unknown config key"), + } +} static CONFIG_PATH: OnceLock = OnceLock::new(); pub fn configure_config_path(path: PathBuf) { diff --git a/iota/Cargo.toml b/iota/Cargo.toml index 64de533..cc4494b 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -12,3 +12,6 @@ iota-process-manager = { path = "../iota-process-manager" } iota-paths = { path = "../iota-paths" } tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } +serde_json = "1" +serde_yaml = "0.9" +clap = { version = "4.5", features = ["derive"] } diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index cedefb0..005d9fb 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -1,15 +1,86 @@ +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; use iota_cli::theme::ThemeName; #[derive(Debug)] pub struct CliInvocation { pub theme_override: Option, + pub output: OutputFormat, + pub color: CapabilityPolicy, + pub unicode: CapabilityPolicy, pub command: Command, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum CapabilityPolicy { + Auto, + Always, + Never, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum OutputFormat { + Text, + Json, + Yaml, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum CliTheme { Monospace, Binary, Ansi, Surface } +impl From for ThemeName { + fn from(value: CliTheme) -> Self { + match value { CliTheme::Monospace => Self::Monospace, CliTheme::Binary => Self::Binary, CliTheme::Ansi => Self::Ansi, CliTheme::Surface => Self::Surface } + } +} + +#[derive(Parser, Debug)] +#[command(name = "iota", version, about = "Iota operator console", arg_required_else_help = false)] +struct Cli { + #[arg(long, global = true, value_enum)] theme: Option, + #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Text)] output: OutputFormat, + #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] color: CapabilityPolicy, + #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] unicode: CapabilityPolicy, + #[arg(long, global = true)] no_color: bool, + #[command(subcommand)] command: Option, +} + +#[derive(Subcommand, Debug)] +enum CliCommand { + Status, Tasks, + Users(UsersArgs), Omikron(OmikronArgs), Identity(IdentityArgs), Daemon(DaemonArgs), Config(ConfigArgs), + RegenerateKeys { #[arg(long)] yes: bool }, + Components, + Logs { #[arg(long, default_value_t = 100)] limit: usize }, + Update(UpdateArgs), + Community(CommunityArgs), + Completions { shell: String }, + Man, +} +#[derive(Args, Debug)] struct UsersArgs { #[command(subcommand)] action: UsersAction } +#[derive(Subcommand, Debug)] enum UsersAction { List, Show { user_id: i64 }, Add { username: String }, Remove { user_id: i64, #[arg(long)] yes: bool }, Import { username: String } } +#[derive(Args, Debug)] struct OmikronArgs { #[command(subcommand)] action: OmikronAction } +#[derive(Subcommand, Debug)] enum OmikronAction { Reconnect, Status } +#[derive(Args, Debug)] struct IdentityArgs { #[command(subcommand)] action: IdentityAction } +#[derive(Subcommand, Debug)] enum IdentityAction { Rotate { #[arg(long)] yes: bool } } +#[derive(Args, Debug)] struct ConfigArgs { #[command(subcommand)] action: ConfigAction } +#[derive(Subcommand, Debug)] enum ConfigAction { Get, Set { key: String, value: String }, Reload } +#[derive(Args, Debug)] struct DaemonArgs { #[command(subcommand)] action: DaemonAction } +#[derive(Subcommand, Debug)] enum DaemonAction { + Restart { #[arg(long)] yes: bool }, Stop { #[arg(long)] yes: bool }, + Enable { #[arg(long, value_parser = ["socket", "always-on"])] mode: String }, DisableStartup, Status, StartupStatus, Start, RestartService, StopService, + Install { #[arg(long)] bundle: String, #[arg(long)] operator: Option }, +} +#[derive(Args, Debug)] struct UpdateArgs { #[command(subcommand)] action: UpdateAction } +#[derive(Subcommand, Debug)] enum UpdateAction { Check } +#[derive(Args, Debug)] struct CommunityArgs { #[command(subcommand)] action: CommunityAction } +#[derive(Subcommand, Debug)] enum CommunityAction { List } + #[derive(Debug, PartialEq, Eq)] pub enum Command { Dashboard, Help, + Version, + Completions { shell: String }, + ManPage, Install { bundle: String, operator: Option, @@ -17,95 +88,125 @@ pub enum Command { Status, Tasks, UsersList, + UsersShow { user_id: i64 }, + UsersAdd { + username: String, + }, + UsersRemove { + user_id: i64, + confirmed: bool, + }, + UsersImport { username: String }, + OmikronReconnect, + IdentityRotate { + confirmed: bool, + }, DaemonRestart { confirmed: bool, }, DaemonStop { confirmed: bool, }, - DaemonStopProcess, DaemonEnable { mode: String, }, DaemonDisableStartup, DaemonDaemonStatus, + DaemonStartupStatus, + DaemonStart, + DaemonRestartService, + DaemonStopService, + ConfigGet, + ConfigSet { key: String, value: String }, + ConfigReload, + OmikronStatus, + RegenerateKeys { + confirmed: bool, + }, + Components, + Logs { limit: usize }, + UpdateCheck, + CommunityList, } impl CliInvocation { pub fn parse(args: impl IntoIterator) -> Result { - let mut theme_override = None; - let mut command = Vec::new(); - let mut args = args.into_iter(); - while let Some(argument) = args.next() { - if argument == "--theme" { - let value = args.next().ok_or_else(|| { - format!( - "--theme requires a value ({})", - ThemeName::supported_names() - ) - })?; - theme_override = Some(value.parse()?); - } else if let Some(value) = argument.strip_prefix("--theme=") { - theme_override = Some(value.parse()?); - } else { - command.push(argument); - } + let args = args.into_iter().collect::>(); + if args.as_slice() == ["help"] { + return Ok(Self::special(Command::Help)); } - let command = match command.as_slice() { - [] => Command::Dashboard, - [help] if help == "help" || help == "--help" => Command::Help, - [status] if status == "status" => Command::Status, - [tasks] if tasks == "tasks" => Command::Tasks, - [noun, verb] if noun == "users" && verb == "list" => Command::UsersList, - [noun, verb, flag] if noun == "daemon" && verb == "restart" => Command::DaemonRestart { - confirmed: flag == "--yes", + let parsed = Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| { + match error.kind() { + ErrorKind::DisplayHelp => return "__help__".to_owned(), + ErrorKind::DisplayVersion => return "__version__".to_owned(), + _ => error.to_string(), + } + }); + let parsed = match parsed { + Ok(parsed) => parsed, + Err(marker) if marker == "__help__" => return Ok(Self::special(Command::Help)), + Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)), + Err(error) => return Err(error), + }; + let command = match parsed.command { + None => Command::Dashboard, + Some(CliCommand::Status) => Command::Status, + Some(CliCommand::Tasks) => Command::Tasks, + Some(CliCommand::Components) => Command::Components, + Some(CliCommand::Completions { shell }) => Command::Completions { shell }, + Some(CliCommand::Man) => Command::ManPage, + Some(CliCommand::Users(users)) => match users.action { UsersAction::List => Command::UsersList, UsersAction::Show { user_id } => Command::UsersShow { user_id }, UsersAction::Add { username } => Command::UsersAdd { username }, UsersAction::Remove { user_id, yes } => Command::UsersRemove { user_id, confirmed: yes }, UsersAction::Import { username } => Command::UsersImport { username } }, + Some(CliCommand::Omikron(omikron)) => match omikron.action { OmikronAction::Reconnect => Command::OmikronReconnect, OmikronAction::Status => Command::OmikronStatus }, + Some(CliCommand::Identity(identity)) => match identity.action { IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes } }, + Some(CliCommand::Config(config)) => match config.action { ConfigAction::Get => Command::ConfigGet, ConfigAction::Set { key, value } => Command::ConfigSet { key, value }, ConfigAction::Reload => Command::ConfigReload }, + Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { confirmed: yes }, + Some(CliCommand::Logs { limit }) => Command::Logs { limit }, + Some(CliCommand::Update(update)) => match update.action { UpdateAction::Check => Command::UpdateCheck }, + Some(CliCommand::Community(community)) => match community.action { CommunityAction::List => Command::CommunityList }, + Some(CliCommand::Daemon(daemon)) => match daemon.action { + DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes }, DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes }, + DaemonAction::Enable { mode } => Command::DaemonEnable { mode }, DaemonAction::DisableStartup => Command::DaemonDisableStartup, + DaemonAction::Status => Command::DaemonDaemonStatus, DaemonAction::StartupStatus => Command::DaemonStartupStatus, + DaemonAction::Start => Command::DaemonStart, DaemonAction::RestartService => Command::DaemonRestartService, DaemonAction::StopService => Command::DaemonStopService, + DaemonAction::Install { bundle, operator } => Command::Install { bundle, operator }, }, - [noun, verb] if noun == "daemon" && verb == "restart" => { - Command::DaemonRestart { confirmed: false } - } - [noun, verb, flag] if noun == "daemon" && verb == "stop" => Command::DaemonStop { - confirmed: flag == "--yes", - }, - [noun, verb] if noun == "daemon" && verb == "stop" => { - Command::DaemonStop { confirmed: false } - } - [noun, verb] if noun == "daemon" && verb == "stop-process" => { - Command::DaemonStopProcess - } - [noun, verb] if noun == "daemon" && verb == "disable-startup" => { - Command::DaemonDisableStartup - } - [noun, verb] if noun == "daemon" && verb == "status" => Command::DaemonDaemonStatus, - [noun, verb, flag, mode] - if noun == "daemon" && verb == "enable" && flag == "--mode" => - { - Command::DaemonEnable { mode: mode.clone() } - } - [noun, verb, bundle_flag, bundle] - if noun == "daemon" && verb == "install" && bundle_flag == "--bundle" => - { - Command::Install { - bundle: bundle.clone(), - operator: None, - } - } - [noun, verb, bundle_flag, bundle, operator_flag, operator] - if noun == "daemon" - && verb == "install" - && bundle_flag == "--bundle" - && operator_flag == "--operator" => - { - Command::Install { - bundle: bundle.clone(), - operator: Some(operator.clone()), - } - } - _ => return Err("Unknown command. Run `iota --help`.".into()), }; Ok(Self { - theme_override, + theme_override: parsed.theme.map(Into::into), + output: parsed.output, + color: if parsed.no_color { CapabilityPolicy::Never } else { parsed.color }, + unicode: parsed.unicode, command, }) } + + fn special(command: Command) -> Self { + Self { theme_override: None, output: OutputFormat::Text, color: CapabilityPolicy::Auto, unicode: CapabilityPolicy::Auto, command } + } + + pub fn help_text() -> String { + Cli::command().render_long_help().to_string() + } + + pub fn command_paths() -> Vec { + fn collect(command: &clap::Command, prefix: &str, paths: &mut Vec) { + for subcommand in command.get_subcommands() { + let path = if prefix.is_empty() { + subcommand.get_name().to_owned() + } else { + format!("{prefix} {}", subcommand.get_name()) + }; + if subcommand.get_subcommands().next().is_some() { + collect(subcommand, &path, paths); + } else { + paths.push(path); + } + } + } + let command = Cli::command(); + let mut paths = Vec::new(); + collect(&command, "", &mut paths); + paths + } } #[cfg(test)] @@ -126,6 +227,55 @@ mod tests { assert!(error.contains(ThemeName::supported_names())); } + #[test] + fn parses_structured_output_as_a_global_option() { + let invocation = + CliInvocation::parse(["users".into(), "list".into(), "--output=json".into()]).unwrap(); + assert_eq!(invocation.output, OutputFormat::Json); + assert_eq!(invocation.command, Command::UsersList); + } + + #[test] + fn parses_terminal_capability_overrides() { + let invocation = CliInvocation::parse([ + "--color=never".into(), + "--unicode".into(), + "always".into(), + ]) + .unwrap(); + assert_eq!(invocation.color, CapabilityPolicy::Never); + assert_eq!(invocation.unicode, CapabilityPolicy::Always); + assert_eq!(invocation.command, Command::Dashboard); + } + + #[test] + fn no_color_is_a_compatible_alias() { + let invocation = CliInvocation::parse(["--no-color".into()]).unwrap(); + assert_eq!(invocation.color, CapabilityPolicy::Never); + } + + #[test] + fn supports_standard_help_and_version_flags() { + assert_eq!( + CliInvocation::parse(["-h".into()]).unwrap().command, + Command::Help + ); + assert_eq!( + CliInvocation::parse(["--version".into()]).unwrap().command, + Command::Version + ); + } + + #[test] + fn command_schema_drives_help_and_completion_paths() { + let paths = CliInvocation::command_paths(); + assert!(paths.contains(&"users remove".to_owned())); + assert!(paths.contains(&"daemon install".to_owned())); + let help = CliInvocation::help_text(); + assert!(help.contains("users")); + assert!(help.contains("--output")); + } + #[test] fn parses_install_operator_without_raw_slice_matching() { let invocation = CliInvocation::parse([ @@ -151,4 +301,92 @@ mod tests { let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); assert_eq!(invocation.command, Command::DaemonStop { confirmed: false }); } + + #[test] + fn parses_users_add() { + let invocation = + CliInvocation::parse(["users".into(), "add".into(), "alice".into()]).unwrap(); + assert_eq!( + invocation.command, + Command::UsersAdd { + username: "alice".into() + } + ); + } + + #[test] + fn rejects_malformed_users_add_shape() { + assert!( + CliInvocation::parse([ + "users".into(), + "incorrect".into(), + "add".into(), + "alice".into() + ]) + .is_err() + ); + } + + #[test] + fn rejects_unknown_destructive_option() { + assert!(CliInvocation::parse(["daemon".into(), "stop".into(), "--later".into()]).is_err()); + } + + #[test] + fn parses_users_remove_without_confirmation() { + let invocation = + CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap(); + assert_eq!( + invocation.command, + Command::UsersRemove { + user_id: 42, + confirmed: false, + } + ); + } + + #[test] + fn parses_users_remove_with_confirmation() { + let invocation = + CliInvocation::parse(["users".into(), "remove".into(), "42".into(), "--yes".into()]) + .unwrap(); + assert_eq!( + invocation.command, + Command::UsersRemove { + user_id: 42, + confirmed: true, + } + ); + } + + #[test] + fn parses_omikron_reconnect() { + let invocation = CliInvocation::parse(["omikron".into(), "reconnect".into()]).unwrap(); + assert_eq!(invocation.command, Command::OmikronReconnect); + } + + #[test] + fn parses_identity_rotate_requires_yes() { + let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap(); + assert_eq!( + invocation.command, + Command::IdentityRotate { confirmed: false } + ); + let invocation = + CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap(); + assert_eq!( + invocation.command, + Command::IdentityRotate { confirmed: true } + ); + } + + #[test] + fn rejects_daemon_ping_until_protocol_supports_a_ping_contract() { + assert!(CliInvocation::parse(["daemon".into(), "ping".into()]).is_err()); + } + + #[test] + fn rejects_daemon_diagnostics_until_protocol_supports_diagnostics() { + assert!(CliInvocation::parse(["daemon".into(), "diagnostics".into()]).is_err()); + } } diff --git a/iota/src/main.rs b/iota/src/main.rs index 0e85d36..0df1c0f 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -2,7 +2,7 @@ use iota_cli::{ ipc_client::IpcClient, screens::main_screen::MainScreen, theme, ui::start_bootstrap_tui_with_theme, }; -use iota_ipc::{LocalRequest, ResponseResult}; +use iota_ipc::{LocalRequest, ResponsePayload, ResponseResult}; use iota_process_manager::detect; use std::{path::Path, process::ExitCode, sync::Arc}; @@ -11,7 +11,7 @@ mod daemon_setup_flow; mod local_daemon; mod startup_error; -use cli_args::{CliInvocation, Command}; +use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat}; use startup_error::StartupError; #[tokio::main(flavor = "multi_thread")] @@ -30,6 +30,13 @@ async fn main() -> ExitCode { async fn run() -> Result<(), StartupError> { let invocation = CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?; + let CliInvocation { + theme_override, + output, + color, + unicode, + command, + } = invocation; let local_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User) .map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))? .ipc_endpoint @@ -57,11 +64,20 @@ async fn run() -> Result<(), StartupError> { system: system_endpoint, }; - match invocation.command { + match command { Command::Help => { print_help(); Ok(()) } + Command::Version => { + println!("iota {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Command::Completions { shell } => print_completions(&shell), + Command::ManPage => { + print_man_page(); + Ok(()) + } Command::Install { bundle, operator } => { iota_installer::install_linux_bundle_with_operator( Path::new(&bundle), @@ -72,7 +88,12 @@ async fn run() -> Result<(), StartupError> { command => { if matches!( command, - Command::DaemonEnable { .. } | Command::DaemonDisableStartup + Command::DaemonEnable { .. } + | Command::DaemonDisableStartup + | Command::DaemonStartupStatus + | Command::DaemonStart + | Command::DaemonRestartService + | Command::DaemonStopService ) { return run_startup_command(command).await; } @@ -87,9 +108,9 @@ async fn run() -> Result<(), StartupError> { result = connect_available(&endpoints) => result?, _ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled), }; - return run_command(ipc, command).await; + return run_command(ipc, command, output).await; } - run_dashboard(invocation.theme_override, endpoints).await + run_dashboard(theme_override, color, unicode, endpoints).await } } } @@ -118,6 +139,42 @@ async fn run_startup_command(command: Command) -> Result<(), StartupError> { .disable_startup() .await .map_err(|e| StartupError::Other(e.to_string()))?, + Command::DaemonStartupStatus => { + let status = manager + .iota_startup_status() + .await + .map_err(|e| StartupError::Other(e.to_string()))?; + println!("service active: {}", status.service.active); + println!("service enabled: {}", status.service.enabled); + println!("socket active: {}", status.socket.active); + println!("socket enabled: {}", status.socket.enabled); + println!("detected mode: {:?}", status.detected); + return Ok(()); + } + Command::DaemonStart => { + let status = manager + .process_action(iota_process_manager::ProcessAction::Start) + .await + .map_err(|e| StartupError::Other(e.to_string()))?; + println!("Daemon started. detected mode: {:?}", status.detected); + return Ok(()); + } + Command::DaemonRestartService => { + let status = manager + .process_action(iota_process_manager::ProcessAction::Restart) + .await + .map_err(|e| StartupError::Other(e.to_string()))?; + println!("Daemon restarted. detected mode: {:?}", status.detected); + return Ok(()); + } + Command::DaemonStopService => { + let status = manager + .process_action(iota_process_manager::ProcessAction::Stop) + .await + .map_err(|e| StartupError::Other(e.to_string()))?; + println!("Daemon stopped. detected mode: {:?}", status.detected); + return Ok(()); + } _ => unreachable!(), }; println!("deployment status: {:?}", status.detected); @@ -149,6 +206,8 @@ async fn connect_available( async fn run_dashboard( theme_override: Option, + color_policy: CapabilityPolicy, + unicode_policy: CapabilityPolicy, endpoints: daemon_setup_flow::DaemonEndpoints, ) -> Result<(), StartupError> { use std::io::IsTerminal; @@ -162,9 +221,55 @@ async fn run_dashboard( "TERM=dumb does not support the interactive dashboard".into(), )); } - let session = start_bootstrap_tui_with_theme(theme::resolve(theme::UiConfig::resolve_theme( - theme_override, - ))) + let stored_terminal = theme::UiConfig::load().unwrap_or_default(); + let color_policy = match color_policy { + CapabilityPolicy::Auto => match stored_terminal.color { + theme::TerminalPolicy::Auto => CapabilityPolicy::Auto, + theme::TerminalPolicy::Always => CapabilityPolicy::Always, + theme::TerminalPolicy::Never => CapabilityPolicy::Never, + }, + policy => policy, + }; + let unicode_policy = match unicode_policy { + CapabilityPolicy::Auto => match stored_terminal.unicode { + theme::TerminalPolicy::Auto => CapabilityPolicy::Auto, + theme::TerminalPolicy::Always => CapabilityPolicy::Always, + theme::TerminalPolicy::Never => CapabilityPolicy::Never, + }, + policy => policy, + }; + let color_enabled = match color_policy { + CapabilityPolicy::Always => true, + CapabilityPolicy::Never => false, + CapabilityPolicy::Auto => { + std::env::var_os("NO_COLOR").is_none() + && std::env::var("TERM").as_deref() != Ok("dumb") + } + }; + let unicode_enabled = match unicode_policy { + CapabilityPolicy::Always => true, + CapabilityPolicy::Never => false, + CapabilityPolicy::Auto => std::env::var("LC_ALL") + .or_else(|_| std::env::var("LC_CTYPE")) + .or_else(|_| std::env::var("LANG")) + .map(|locale| { + let locale = locale.to_ascii_lowercase(); + locale.contains("utf-8") || locale.contains("utf8") + }) + .unwrap_or(false), + }; + let truecolor_enabled = std::env::var("COLORTERM") + .map(|value| { + let value = value.to_ascii_lowercase(); + value.contains("truecolor") || value.contains("24bit") + }) + .unwrap_or(false); + let session = start_bootstrap_tui_with_theme(theme::resolve_with_terminal_profile( + theme::UiConfig::resolve_theme(theme_override), + color_enabled, + unicode_enabled, + truecolor_enabled, + )) .map_err(|error| StartupError::Terminal(error.to_string()))?; let ui = session.ui(); let result = async { @@ -258,32 +363,105 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> { } fn print_help() { - println!( - "Iota operator console\n\nUsage:\n iota [--theme ] Open the dashboard\n iota daemon install --bundle [--operator USER]\n iota status Print daemon readiness and tasks\n iota tasks Print active tasks\n iota users list List users\n iota daemon restart --yes\n iota daemon stop --yes\n\nRun the dashboard in an interactive terminal to review required terms." - ); + println!("{}", CliInvocation::help_text()); } -async fn run_command(ipc: Arc, command: Command) -> Result<(), StartupError> { +fn print_completions(shell: &str) -> Result<(), StartupError> { + let command_paths = CliInvocation::command_paths(); + let words = command_paths + .iter() + .flat_map(|command| command.split_whitespace()) + .collect::>() + .into_iter() + .collect::>() + .join(" "); + match shell { + "bash" => println!( + "_iota() {{ local words='{} --help --version --theme --output --color --unicode --yes --mode --bundle --operator'; COMPREPLY=( $(compgen -W \"$words\" -- \"${{COMP_WORDS[COMP_CWORD]}}\") ); }}\ncomplete -F _iota iota", + words + ), + "zsh" => println!( + "#compdef iota\n_arguments '1:command:({})' '*::argument:->args'", + words + ), + "fish" => { + for command in words.split_whitespace() { + println!("complete -c iota -f -a '{command}'"); + } + } + _ => { + return Err(StartupError::InvalidCommand( + "completion shell must be bash, zsh, or fish".into(), + )); + } + } + Ok(()) +} + +fn print_man_page() { + println!(".TH IOTA 1"); + println!(".SH NAME\n iota \\- Iota operator console"); + println!(".SH SYNOPSIS\n.B iota\n[global options] [command]"); + println!(".SH COMMANDS"); + for command in CliInvocation::command_paths() { + println!(".TP\n.B {command}"); + } + println!(".SH GLOBAL OPTIONS"); + println!(".TP\n.B --output text|json|yaml"); + println!(".TP\n.B --color auto|always|never"); + println!(".TP\n.B --unicode auto|always|never"); +} + +async fn run_command( + ipc: Arc, + command: Command, + output: OutputFormat, +) -> Result<(), StartupError> { let request = match command { Command::Status => LocalRequest::GetStatus, Command::Tasks => LocalRequest::ListTasks, Command::UsersList => LocalRequest::ListUsers, + Command::UsersShow { user_id } => LocalRequest::GetUser { user_id }, + Command::UsersAdd { username } => LocalRequest::CreateUser { username }, + Command::UsersRemove { + user_id, + confirmed: true, + } => LocalRequest::RemoveUser { user_id }, + Command::UsersImport { username } => LocalRequest::ImportUser { username }, + Command::OmikronReconnect => LocalRequest::ReconnectOmikron, + Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, + Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity, Command::DaemonRestart { confirmed: true } => LocalRequest::RequestProcessExit { intent: iota_ipc::ExitIntent::Restart, }, Command::DaemonStop { confirmed: true } => LocalRequest::RequestProcessExit { intent: iota_ipc::ExitIntent::Stop, }, - Command::DaemonStopProcess => LocalRequest::RequestProcessExit { - intent: iota_ipc::ExitIntent::Stop, - }, Command::DaemonDaemonStatus => LocalRequest::GetDaemonStatus, - Command::DaemonRestart { confirmed: false } | Command::DaemonStop { confirmed: false } => { + Command::OmikronStatus => LocalRequest::GetOmikronStatus, + Command::ConfigGet => LocalRequest::GetConfig, + Command::ConfigSet { key, value } => LocalRequest::SetConfig { key, value }, + Command::ConfigReload => LocalRequest::ReloadConfig, + Command::Components => LocalRequest::ListComponents, + Command::Logs { limit } => LocalRequest::GetLogs { limit }, + Command::UpdateCheck => LocalRequest::CheckUpdate, + Command::CommunityList => LocalRequest::ListCommunities, + Command::UsersRemove { + confirmed: false, .. + } + | Command::IdentityRotate { confirmed: false } + | Command::RegenerateKeys { confirmed: false } + | Command::DaemonRestart { confirmed: false } + | Command::DaemonStop { confirmed: false } => { return Err(StartupError::InvalidCommand( "Refusing destructive command without --yes.".into(), )); } - _ => { + Command::Dashboard | Command::Help | Command::Version | Command::Completions { .. } + | Command::ManPage | Command::Install { .. } + | Command::DaemonEnable { .. } | Command::DaemonDisableStartup + | Command::DaemonStartupStatus | Command::DaemonStart + | Command::DaemonRestartService | Command::DaemonStopService => { return Err(StartupError::InvalidCommand( "Command cannot be run headlessly.".into(), )); @@ -294,12 +472,139 @@ async fn run_command(ipc: Arc, command: Command) -> Result<(), Startu .await .map_err(|e| StartupError::Other(e.to_string()))? { - ResponseResult::Ok(message) => { - println!("{message}"); + ResponseResult::Ok(payload) => { + if !matches!(output, OutputFormat::Text) { + return render_structured(&payload, output); + } + match payload { + ResponsePayload::Status(status) => { + print!("Phase: {}", status.phase); + if !status.tasks.is_empty() { + print!(", Tasks: {}", status.tasks.join(", ")); + } + if let Some(reason) = status.degraded_reason { + print!(", Degraded: {}", reason); + } + println!(); + } + ResponsePayload::Tasks(tasks) => { + if tasks.is_empty() { + println!("No active tasks."); + } else { + for task in &tasks { + println!("{}", task.name); + } + } + } + ResponsePayload::Users(users) => { + if users.is_empty() { + println!("No users."); + } else { + for user in &users { + println!("{} ({})", user.username, user.user_id); + } + } + } + ResponsePayload::UserCreated { user_id, username } => { + println!("Created user {} ({})", username, user_id); + } + ResponsePayload::UserRemoved { user_id } => { + println!("Removed user {}", user_id); + } + ResponsePayload::Acknowledged { message } => { + println!("{}", message); + } + ResponsePayload::DaemonStatus(status) => { + println!("{}", status.formatted); + } + ResponsePayload::Config(config) => { + println!("{}", config.yaml); + } + ResponsePayload::OmikronStatus(status) => { + println!("Connected: {}", status.connected); + if let Some(id) = status.iota_id { + println!("Iota ID: {}", id); + } + } + ResponsePayload::Components(components) => { + if components.is_empty() { + println!("No component health data available."); + } else { + for comp in &components { + let status_str = match comp.status { + iota_ipc::HealthStatus::Healthy => "healthy", + iota_ipc::HealthStatus::Degraded => "degraded", + iota_ipc::HealthStatus::Failed => "failed", + }; + let suffix = comp + .message + .as_deref() + .map(|m| format!(" ({m})")) + .unwrap_or_default(); + println!("{:?}: {}{}", comp.id, status_str, suffix); + } + } + } + ResponsePayload::UserDetail(user) => { + println!("User: {} ({})", user.username, user.user_id); + if let Some(ref name) = user.display_name { + println!("Display Name: {name}"); + } + println!("Created At: {}", user.created_at); + if !user.trusted_apps.is_empty() { + println!("Trusted Apps: {}", user.trusted_apps.join(", ")); + } + } + ResponsePayload::LogEntries(logs) => { + for entry in &logs.entries { + let ts = entry.timestamp_ms; + let level = if entry.is_error { "ERR" } else { "INF" }; + println!("[{ts}] {level} {}: {}", entry.sender, entry.message); + } + } + ResponsePayload::UpdateStatus(status) => { + if status.available { + println!("Update available."); + } else { + println!("Up to date."); + } + } + ResponsePayload::Communities(communities) => { + if communities.is_empty() { + println!("No communities."); + } else { + for c in &communities { + println!("{} ({})", c.title, c.name); + } + } + } + } Ok(()) } ResponseResult::Error(code) => Err(StartupError::Other(format!( - "Daemon request failed: {code:?}" + "Daemon request failed: {code}" ))), } } + +/// The IPC payload is the versioned, tagged schema used by headless clients. +/// Text remains an operator-oriented presentation; JSON and YAML must never +/// require consumers to parse it. +fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<(), StartupError> { + match output { + OutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(payload).map_err(|error| StartupError::Other(format!( + "Cannot encode JSON output: {error}" + )))? + ), + OutputFormat::Yaml => print!( + "{}", + serde_yaml::to_string(payload).map_err(|error| StartupError::Other(format!( + "Cannot encode YAML output: {error}" + )))? + ), + OutputFormat::Text => unreachable!(), + } + Ok(()) +}