[Wip] CLI & Daemon
This commit is contained in:
parent
3bc5cc959a
commit
6a535099bb
44 changed files with 4417 additions and 303 deletions
113
Cargo.lock
generated
113
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
62
iota-cli/src/controls/header.rs
Normal file
62
iota-cli/src/controls/header.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
23
iota-cli/src/controls/panel.rs
Normal file
23
iota-cli/src/controls/panel.rs
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
20
iota-cli/src/controls/scroll.rs
Normal file
20
iota-cli/src/controls/scroll.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
);
|
||||
); }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: _ },
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
131
iota-cli/src/screens/metrics.rs
Normal file
131
iota-cli/src/screens/metrics.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
282
iota-cli/src/screens/overview.rs
Normal file
282
iota-cli/src/screens/overview.rs
Normal 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" },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
390
iota-cli/src/screens/settings.rs
Normal file
390
iota-cli/src/screens/settings.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
705
iota-cli/src/screens/users.rs
Normal file
705
iota-cli/src/screens/users.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
76
iota-cli/tests/settings_snapshot.rs
Normal file
76
iota-cli/tests/settings_snapshot.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
36
iota-daemon-lib/src/log_buffer.rs
Normal file
36
iota-daemon-lib/src/log_buffer.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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(_)
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
324
iota-ipc/src/text_commands.rs
Normal file
324
iota-ipc/src/text_commands.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
347
iota/src/main.rs
347
iota/src/main.rs
|
|
@ -2,7 +2,7 @@ use iota_cli::{
|
|||
ipc_client::IpcClient, screens::main_screen::MainScreen, theme,
|
||||
ui::start_bootstrap_tui_with_theme,
|
||||
};
|
||||
use iota_ipc::{LocalRequest, ResponseResult};
|
||||
use iota_ipc::{LocalRequest, ResponsePayload, ResponseResult};
|
||||
use iota_process_manager::detect;
|
||||
use std::{path::Path, process::ExitCode, sync::Arc};
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ mod daemon_setup_flow;
|
|||
mod local_daemon;
|
||||
mod startup_error;
|
||||
|
||||
use cli_args::{CliInvocation, Command};
|
||||
use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat};
|
||||
use startup_error::StartupError;
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
|
|
@ -30,6 +30,13 @@ async fn main() -> ExitCode {
|
|||
async fn run() -> Result<(), StartupError> {
|
||||
let invocation =
|
||||
CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?;
|
||||
let CliInvocation {
|
||||
theme_override,
|
||||
output,
|
||||
color,
|
||||
unicode,
|
||||
command,
|
||||
} = invocation;
|
||||
let local_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
|
||||
.map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))?
|
||||
.ipc_endpoint
|
||||
|
|
@ -57,11 +64,20 @@ async fn run() -> Result<(), StartupError> {
|
|||
system: system_endpoint,
|
||||
};
|
||||
|
||||
match invocation.command {
|
||||
match command {
|
||||
Command::Help => {
|
||||
print_help();
|
||||
Ok(())
|
||||
}
|
||||
Command::Version => {
|
||||
println!("iota {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Command::Completions { shell } => print_completions(&shell),
|
||||
Command::ManPage => {
|
||||
print_man_page();
|
||||
Ok(())
|
||||
}
|
||||
Command::Install { bundle, operator } => {
|
||||
iota_installer::install_linux_bundle_with_operator(
|
||||
Path::new(&bundle),
|
||||
|
|
@ -72,7 +88,12 @@ async fn run() -> Result<(), StartupError> {
|
|||
command => {
|
||||
if matches!(
|
||||
command,
|
||||
Command::DaemonEnable { .. } | Command::DaemonDisableStartup
|
||||
Command::DaemonEnable { .. }
|
||||
| Command::DaemonDisableStartup
|
||||
| Command::DaemonStartupStatus
|
||||
| Command::DaemonStart
|
||||
| Command::DaemonRestartService
|
||||
| Command::DaemonStopService
|
||||
) {
|
||||
return run_startup_command(command).await;
|
||||
}
|
||||
|
|
@ -87,9 +108,9 @@ async fn run() -> Result<(), StartupError> {
|
|||
result = connect_available(&endpoints) => result?,
|
||||
_ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled),
|
||||
};
|
||||
return run_command(ipc, command).await;
|
||||
return run_command(ipc, command, output).await;
|
||||
}
|
||||
run_dashboard(invocation.theme_override, endpoints).await
|
||||
run_dashboard(theme_override, color, unicode, endpoints).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -118,6 +139,42 @@ async fn run_startup_command(command: Command) -> Result<(), StartupError> {
|
|||
.disable_startup()
|
||||
.await
|
||||
.map_err(|e| StartupError::Other(e.to_string()))?,
|
||||
Command::DaemonStartupStatus => {
|
||||
let status = manager
|
||||
.iota_startup_status()
|
||||
.await
|
||||
.map_err(|e| StartupError::Other(e.to_string()))?;
|
||||
println!("service active: {}", status.service.active);
|
||||
println!("service enabled: {}", status.service.enabled);
|
||||
println!("socket active: {}", status.socket.active);
|
||||
println!("socket enabled: {}", status.socket.enabled);
|
||||
println!("detected mode: {:?}", status.detected);
|
||||
return Ok(());
|
||||
}
|
||||
Command::DaemonStart => {
|
||||
let status = manager
|
||||
.process_action(iota_process_manager::ProcessAction::Start)
|
||||
.await
|
||||
.map_err(|e| StartupError::Other(e.to_string()))?;
|
||||
println!("Daemon started. detected mode: {:?}", status.detected);
|
||||
return Ok(());
|
||||
}
|
||||
Command::DaemonRestartService => {
|
||||
let status = manager
|
||||
.process_action(iota_process_manager::ProcessAction::Restart)
|
||||
.await
|
||||
.map_err(|e| StartupError::Other(e.to_string()))?;
|
||||
println!("Daemon restarted. detected mode: {:?}", status.detected);
|
||||
return Ok(());
|
||||
}
|
||||
Command::DaemonStopService => {
|
||||
let status = manager
|
||||
.process_action(iota_process_manager::ProcessAction::Stop)
|
||||
.await
|
||||
.map_err(|e| StartupError::Other(e.to_string()))?;
|
||||
println!("Daemon stopped. detected mode: {:?}", status.detected);
|
||||
return Ok(());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
println!("deployment status: {:?}", status.detected);
|
||||
|
|
@ -149,6 +206,8 @@ async fn connect_available(
|
|||
|
||||
async fn run_dashboard(
|
||||
theme_override: Option<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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue