[Wip] CLI & Daemon

This commit is contained in:
Alex 2026-07-25 18:23:36 +02:00
commit 6a535099bb
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
44 changed files with 4417 additions and 303 deletions

113
Cargo.lock generated
View file

@ -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"

View file

@ -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,
);
}

View file

@ -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<usize>,
) {
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);
}
}

View file

@ -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;

View file

@ -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) }
}
}
}

View file

@ -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);
}
}
}

View file

@ -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<Mutex<Instant>>,
pending_restore: Arc<Mutex<Option<String>>>,
pending_confirmation: Option<String>,
history: Vec<String>,
history_index: Option<usize>,
history_draft: String,
message: Option<String>,
}
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<Span<'static>> {
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
}
}
}

View file

@ -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
},
);
); }
}
}

View file

@ -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
}

View file

@ -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<UI>) -> JoinHandle<Result<(), String>> {
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>) {
{
ui.request_shutdown();
} else {
ui.handle_input(key).await;
ui.handle_event(UiEvent::Key(key)).await;
}
}

View file

@ -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<Box<dyn Future<Output = Box<dyn Screen>> + Send>>,
},
AppTask {
task: Pin<Box<dyn Future<Output = UiEvent> + 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: _ },

View file

@ -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::<Vec<_>>().join(", ")
}
}
ResponsePayload::Users(users) => {
if users.is_empty() {
"No users.".into()
} else {
users.iter().map(|u| format!("{} ({})", u.username, u.user_id)).collect::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>().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<LocalRequest> {
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::<i64>().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 <name>, user remove <id>, user list, reconnect, regenerate keys, restart, stop"
message: if trimmed == "help" {
"Commands: status, tasks, ping, user add <name>, user remove <id>, 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 {

View file

@ -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;

View file

@ -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);

View file

@ -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<Box<dyn InteractableElement>>,
@ -29,9 +39,38 @@ pub struct MainScreen {
graphs_open: bool,
connection_status_rx: watch::Receiver<IpcConnectionState>,
daemon_status_rx: watch::Receiver<DaemonStatus>,
layout_width: AtomicU16,
}
impl MainScreen {
pub fn connection_status(&self) -> watch::Receiver<IpcConnectionState> {
self.connection_status_rx.clone()
}
pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> {
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<UI>) -> Self {
let mut elements: Vec<Box<dyn InteractableElement>> = 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<Option<usize>> = 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::<GraphCard>())
.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::<ConsoleCard>())
{
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::<GraphCard>() {
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<KeyHint> {
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" },
]
}
}
}

View file

@ -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;

View file

@ -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<GraphCard>,
range_index: usize,
}
impl MetricsScreen {
pub async fn new(ui: std::sync::Arc<UI>) -> Option<Self> {
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<KeyHint> {
vec![
KeyHint {
keys: "Left/Right",
action: "Range",
},
KeyHint {
keys: "Esc/B",
action: "Back",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}

View file

@ -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<IpcConnectionState>,
daemon_rx: watch::Receiver<DaemonStatus>,
_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<IpcConnectionState>,
daemon_rx: watch::Receiver<DaemonStatus>,
) -> 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<Line<'static>> {
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<KeyHint> {
vec![
KeyHint { keys: "Up/Down", action: "Scroll" },
KeyHint { keys: "PgUp/PgDn", action: "Page" },
KeyHint { keys: "Esc/B", action: "Back" },
KeyHint { keys: "F6", action: "Header" },
]
}
}

View file

@ -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<Vec<crate::screens::users::UserEntry>, String>),
UserCreated(Result<crate::screens::users::UserEntry, String>),
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<HitRegion>,
}
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<AppAction> {
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<KeyHint> {
vec![
KeyHint {
keys: "Tab",
action: "Move focus",
},
KeyHint {
keys: "Enter",
action: "Activate",
},
KeyHint {
keys: "Esc",
action: "Back",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}

View file

@ -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<Dialog>,
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<KeyHint> {
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",
},
]
}
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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<UserEntry>,
focused_index: usize,
focus: Focus,
ipc: Arc<IpcClient>,
message: Option<String>,
dialog: Option<Dialog>,
pending_dialog: Option<Dialog>,
loading: bool,
pending: bool,
scroll_offset: usize,
viewport_height: AtomicUsize,
filter: String,
filtering: bool,
}
impl UsersScreen {
pub fn new(ipc: Arc<IpcClient>, users: Vec<UserEntry>) -> 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<IpcClient>) -> 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<usize> {
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<KeyHint> {
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",
},
]
}
}
}

View file

@ -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)]

View file

@ -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);
}
}

View file

@ -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,

View file

@ -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
}
}

View file

@ -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<Arc<ResolvedTheme>>,
pub(crate) invalidation: Notify,
failure: Arc<Mutex<Option<String>>>,
hits: Mutex<HitMap>,
app_event_tx: mpsc::UnboundedSender<UiEvent>,
app_event_rx: Mutex<Option<mpsc::UnboundedReceiver<UiEvent>>>,
header_focus: Mutex<Option<usize>>,
}
pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> {
@ -55,6 +73,24 @@ pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result<TuiSes
fn start_session(ui: UI) -> io::Result<TuiSession> {
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<TuiSession> {
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<TuiSession> {
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<UI>,
renderer_task: JoinHandle<io::Result<()>>,
input_task: JoinHandle<Result<(), String>>,
app_event_task: JoinHandle<()>,
signal_task: Option<JoinHandle<()>>,
restored: AtomicBool,
previous_hook: Arc<Mutex<Option<Box<dyn Fn(&PanicHookInfo<'_>) + 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<Self> {
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<String> {
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<Self>, key_event: KeyEvent) {
self.handle_event(UiEvent::Key(key_event)).await;
}
pub async fn handle_event(self: Arc<Self>, 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<Self>, 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::<MainScreen>())
.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<Self>) {
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::<MainScreen>())
.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::<Vec<_>>()
.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(())
}

View file

@ -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<TestBackend>) -> 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"));
}
}
}

View file

@ -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"] }

View file

@ -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<DaemonRuntime>,
services: Arc<DaemonServices>,
log_buffer: Arc<Mutex<LogBuffer>>,
}
impl CommandRouter {
pub fn new(runtime: Arc<DaemonRuntime>, services: Arc<DaemonServices>) -> Self {
Self { runtime, services }
pub fn new(runtime: Arc<DaemonRuntime>, services: Arc<DaemonServices>, log_buffer: Arc<Mutex<LogBuffer>>) -> 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<LocalRequest> {
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<String> = self
let tasks: Vec<TaskSummary> = 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<String> = user_manager::get_users()
let users: Vec<UserSummary> = 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<ComponentStatusResponse> = 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<CommunitySummary> = stored
.into_iter()
.map(|c| CommunitySummary {
name: c.address,
title: c.title,
})
.collect();
ResponseResult::Ok(ResponsePayload::Communities(summaries))
}
}
}

View file

@ -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<String>,
metric_interval_ms: u64,
}
pub struct IpcServer {
listener: UnixListener,
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
log_buffer: Arc<Mutex<LogBuffer>>,
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
instance_id: String,
_instance_lock: File,
@ -38,6 +53,7 @@ impl IpcServer {
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
log_buffer: Arc<Mutex<LogBuffer>>,
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
) -> Result<Self> {
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<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
log_buffer: Arc<Mutex<LogBuffer>>,
mut state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
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;

View file

@ -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;

View file

@ -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<DaemonMessage>) {
pub fn spawn(
message_tx: broadcast::Sender<DaemonMessage>,
buffer: Arc<Mutex<LogBuffer>>,
) {
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));
}
});
}

View file

@ -0,0 +1,36 @@
use iota_ipc::LogEntry;
use std::collections::VecDeque;
pub struct LogBuffer {
entries: VecDeque<LogEntry>,
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<LogEntry> {
let _len = self.entries.len();
self.entries
.iter()
.rev()
.take(limit)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
}

View file

@ -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(_)

View file

@ -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 ---

View file

@ -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};

View file

@ -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<TaskSummary>),
Users(Vec<UserSummary>),
UserCreated {
user_id: i64,
username: String,
},
UserRemoved {
user_id: i64,
},
Acknowledged {
message: String,
},
DaemonStatus(DaemonStatusResponse),
Config(ConfigResponse),
OmikronStatus(OmikronStatusResponse),
Components(Vec<ComponentStatusResponse>),
UserDetail(UserDetailResponse),
LogEntries(LogEntriesResponse),
UpdateStatus(UpdateStatusResponse),
Communities(Vec<CommunitySummary>),
}
#[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<u64>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ComponentStatusResponse {
pub id: ComponentId,
pub status: HealthStatus,
pub message: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct UserDetailResponse {
pub user_id: i64,
pub username: String,
pub display_name: Option<String>,
pub created_at: i64,
pub trusted_apps: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LogEntriesResponse {
pub entries: Vec<LogEntry>,
}
#[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<String>,
pub degraded_reason: Option<String>,
}
#[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 {

View file

@ -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<String> {
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<LocalRequest> {
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::<i64>().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::<i64>().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"));
}
}

View file

@ -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<PathBuf> = OnceLock::new();
pub fn configure_config_path(path: PathBuf) {

View file

@ -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"] }

View file

@ -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<ThemeName>,
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<CliTheme> 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<CliTheme>,
#[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<CliCommand>,
}
#[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<String> },
}
#[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<String>,
@ -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<Item = String>) -> Result<Self, String> {
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::<Vec<_>>();
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<String> {
fn collect(command: &clap::Command, prefix: &str, paths: &mut Vec<String>) {
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());
}
}

View file

@ -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<theme::ThemeName>,
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 <name>] Open the dashboard\n iota daemon install --bundle <release.zip> [--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<IpcClient>, 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::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
.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<IpcClient>,
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<IpcClient>, 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(())
}