Compare commits

...
Author SHA1 Message Date
6b4c144b73
Merge branch 'main' of ssh://git.methanium.net/tensamin/iota 2026-07-25 22:59:35 +02:00
009173a97d
caching 2026-07-25 22:59:25 +02:00
6a535099bb
[Wip] CLI & Daemon 2026-07-25 18:23:36 +02:00
60 changed files with 5915 additions and 402 deletions

115
Cargo.lock generated
View file

@ -327,6 +327,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.104"
@ -682,6 +732,46 @@ dependencies = [
"zeroize",
]
[[package]]
name = "clap"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "client"
version = "0.1.0"
@ -749,6 +839,12 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
@ -2089,12 +2185,16 @@ dependencies = [
name = "iota"
version = "0.1.0"
dependencies = [
"clap",
"iota-cli",
"iota-core",
"iota-installer",
"iota-ipc",
"iota-paths",
"iota-process-manager",
"iota-terms",
"serde_json",
"serde_yaml",
"tokio",
"tokio-util",
]
@ -2251,6 +2351,7 @@ dependencies = [
"iota-paths",
"iota-state",
"iota-storage",
"iota-terms",
"iota-util",
"omikron-connector",
"tokio",
@ -2268,10 +2369,12 @@ dependencies = [
"iota-logger",
"iota-state",
"iota-storage",
"iota-updater",
"iota-util",
"libc",
"mtp",
"omikron-connector",
"serde_yaml",
"sysinfo",
"tempfile",
"tokio",
@ -2465,6 +2568,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 +3284,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"

View file

@ -28,6 +28,27 @@ theme: surface
An invalid `ui.yaml` value is reported and Iota falls back to ANSI so the TUI can still start.
## Accepting terms without the TUI
Iota services do not start until the required agreements have been accepted for
the deployment. Use the terminal flow to read each current document and type
the document-specific acceptance phrase:
```text
iota terms accept
```
For a system-managed daemon, accept its deployment-scoped terms as an account
that can write the system Iota state directory (normally via `sudo`):
```text
sudo iota terms accept --system
```
`iota terms status` reports the stored state, and `iota terms show eula`,
`iota terms show tos`, or `iota terms show privacy` displays an individual
document without accepting it.
# Linux daemon installation
The system-managed daemon runs as the dedicated `iota` account and listens on

View file

@ -250,6 +250,12 @@ impl ClientConnection {
return;
}
if cv.is_type(CommunicationType::ClientStateAck) {
self.send_message(&message_handlers::handle_client_state_ack(&cv))
.await;
return;
}
// ************************************************ //
// Direct messages //
// ************************************************ //

View file

@ -3,7 +3,7 @@ use ratatui::{
Frame,
layout::{Alignment, Rect},
text::Span,
widgets::{Block, Borders, Paragraph},
widgets::Paragraph,
};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -39,9 +39,15 @@ pub fn render_button(
}
};
frame.render_widget(
Paragraph::new(Span::styled(button.label, style))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL)),
Paragraph::new(Span::styled(
if button.focused {
format!(" {}", button.label)
} else {
button.label.to_owned()
},
style,
))
.alignment(Alignment::Center),
area,
);
}

View file

@ -0,0 +1,62 @@
use crate::theme::ResolvedTheme;
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
screens::screens::{AppAction, HitMap},
};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::Span,
widgets::Paragraph,
};
/// Shared application bar. The brand cell is deliberately an action so it is
/// a reliable way home from every screen.
pub fn render_header(
frame: &mut Frame,
area: Rect,
title: &str,
theme: &ResolvedTheme,
hits: &mut HitMap,
focused_action: Option<usize>,
) {
let cells = Layout::horizontal([
Constraint::Min(28),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(8),
])
.split(area);
frame.render_widget(
Paragraph::new(Span::styled(format!(" {title}"), theme.surfaces.toolbar)),
cells[0],
);
hits.register(cells[0], AppAction::OpenMain);
for (index, (area, label, action)) in [
(cells[1], "Overview", AppAction::OpenOverview),
(cells[2], "Users", AppAction::OpenUsers),
(cells[3], "Settings", AppAction::OpenSettings),
(cells[4], "Quit", AppAction::Quit),
]
.into_iter()
.enumerate()
{
render_button(
frame,
area,
ActionButton {
label,
intent: if action == AppAction::Quit {
ButtonIntent::Destructive
} else {
ButtonIntent::Neutral
},
focused: focused_action == Some(index),
enabled: true,
},
theme,
);
hits.register(area, action);
}
}

View file

@ -2,5 +2,8 @@ pub mod action;
pub mod button;
pub mod checkbox_group;
pub mod choice;
pub mod header;
pub mod navigation;
pub mod panel;
pub mod radio_group;
pub mod scroll;

View file

@ -0,0 +1,60 @@
use crate::theme::{ChromeMode, ResolvedTheme};
use ratatui::{
Frame,
layout::Rect,
widgets::{Block, Borders, Paragraph},
};
/// Draw a conventional outlined panel or a filled surface from the same call
/// site. Screens can migrate without embedding theme branches in layouts.
pub fn render_panel(
frame: &mut Frame,
area: Rect,
title: &str,
focused: bool,
theme: &ResolvedTheme,
) -> Rect {
match theme.chrome {
ChromeMode::Bordered => {
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(if focused {
theme.borders.focused
} else {
theme.borders.normal
});
let inner = block.inner(area);
frame.render_widget(block, area);
inner
}
ChromeMode::Surfaces => {
frame.render_widget(
Block::default().style(if focused {
theme.surfaces.panel_focused
} else {
theme.surfaces.panel
}),
area,
);
let header = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.min(1),
};
frame.render_widget(
Paragraph::new(format!(" {title}")).style(theme.surfaces.panel_alternate),
header,
);
// Surface panels use a single header row. A one-cell inset keeps
// compact controls such as the console usable at height three.
Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(1),
}
}
}
}

View file

@ -0,0 +1,53 @@
use ratatui::{
Frame,
layout::Rect,
widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
};
/// Reusable viewport policy for long, vertically stacked terminal content.
#[derive(Clone, Copy, Debug)]
pub struct ScrollOptions {
pub show_scrollbar: bool,
pub render_partial_components: bool,
}
impl Default for ScrollOptions {
fn default() -> Self {
Self {
show_scrollbar: true,
render_partial_components: true,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct ScrollField {
pub offset: u16,
pub options: ScrollOptions,
}
impl ScrollField {
pub fn up(&mut self, amount: u16) {
self.offset = self.offset.saturating_sub(amount);
}
pub fn down(&mut self, amount: u16, content_height: u16, viewport_height: u16) {
self.offset = (self.offset.saturating_add(amount))
.min(content_height.saturating_sub(viewport_height));
}
pub fn render(
&self,
frame: &mut Frame,
area: Rect,
content: Paragraph<'_>,
content_height: u16,
) {
frame.render_widget(content.scroll((self.offset, 0)), area);
if self.options.show_scrollbar && content_height > area.height {
let mut state =
ScrollbarState::new(content_height as usize).position(self.offset as usize);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight),
area,
&mut state,
);
}
}
}

View file

@ -1,4 +1,4 @@
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Frame,
layout::Rect,
@ -35,6 +35,10 @@ pub struct ConsoleCard {
last_swap: Arc<Mutex<Instant>>,
pending_restore: Arc<Mutex<Option<String>>>,
pending_confirmation: Option<String>,
history: Vec<String>,
history_index: Option<usize>,
history_draft: String,
message: Option<String>,
}
impl ConsoleCard {
@ -51,6 +55,10 @@ impl ConsoleCard {
last_swap: Arc::new(Mutex::new(Instant::now())),
pending_restore: Arc::new(Mutex::new(None)),
pending_confirmation: None,
history: Vec::new(),
history_index: None,
history_draft: String::new(),
message: None,
}
}
@ -151,6 +159,9 @@ impl ConsoleCard {
}
fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
if let Some(message) = &self.message {
return vec![Span::styled(message.clone(), theme.console.error)];
}
if let Some(command) = &self.pending_confirmation {
return vec![Span::styled(
format!("Confirm `{command}`? [y/N]"),
@ -163,11 +174,12 @@ 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 +226,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 +301,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 +394,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 +411,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 +437,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 +472,36 @@ impl InteractableElement for ConsoleCard {
self.cursor_position = self.content.chars().count();
InteractionResult::Handled
}
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
_ => {
if let Some(c) = key.code.as_char() {
self.insert_at_cursor(c);
KeyCode::Up => {
self.history_previous();
InteractionResult::Handled
}
KeyCode::Down => {
self.history_next();
InteractionResult::Handled
}
KeyCode::Tab if !self.content.is_empty() => {
if self.complete() {
InteractionResult::Handled
} else {
InteractionResult::Unhandled
self.message = Some("No command completion.".into());
InteractionResult::Handled
}
}
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
_ => {
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
if let Some(c) = key.code.as_char() {
self.insert_at_cursor(c);
self.message = None;
return InteractionResult::Handled;
}
}
InteractionResult::Unhandled
}
}
}

View file

@ -34,15 +34,24 @@ 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 +78,7 @@ pub struct GraphCard {
joins: Borders,
open: bool,
sample_width: usize,
}
impl GraphCard {
@ -82,12 +92,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 +115,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 +149,34 @@ 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!(
"{}:─{}{}─{}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)
.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(if surface { Borders::NONE } else { self.borders })
.border_style(if self.focused {
context.theme.graphs.focused_border
} else {
@ -148,7 +198,7 @@ impl Element for GraphCard {
});
}
});
f.render_widget(canvas, r);
f.render_widget(canvas, plot_area);
} else {
let block = Block::default()
.title("")
@ -160,17 +210,19 @@ impl Element for GraphCard {
});
f.render_widget(block, r);
}
draw_block_joins(
f,
r,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
draw_block_joins(
f,
r,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
}
}
}

View file

@ -1,7 +1,7 @@
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
use crate::util::borders::draw_block_joins;
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use iota_state::{ClientState, UiLogEntry};
use ratatui::{
Frame,
@ -10,7 +10,10 @@ use ratatui::{
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::any::Any;
use std::{
any::Any,
sync::atomic::{AtomicUsize, Ordering},
};
use unicode_width::UnicodeWidthChar;
#[derive(Clone, Copy)]
@ -55,8 +58,11 @@ pub struct LogCard {
focused: bool,
selected: bool,
scroll_offset: usize,
last_total_lines: usize,
last_visible_height: usize,
last_total_lines: AtomicUsize,
last_visible_height: AtomicUsize,
last_width: AtomicUsize,
filter: String,
filtering: bool,
pub borders: Borders,
pub joins: Borders,
}
@ -68,8 +74,11 @@ impl LogCard {
focused: false,
selected: false,
scroll_offset: 0,
last_total_lines: 0,
last_visible_height: 10,
last_total_lines: AtomicUsize::new(0),
last_visible_height: AtomicUsize::new(1),
last_width: AtomicUsize::new(1),
filter: String::new(),
filtering: false,
borders: Borders::ALL,
joins: Borders::NONE,
}
@ -80,9 +89,15 @@ impl LogCard {
Ok(state) => state,
Err(_) => return Vec::new(),
};
let needle = self.filter.to_ascii_lowercase();
state
.get_logs()
.iter()
.filter(|entry| {
needle.is_empty()
|| entry.sender.to_ascii_lowercase().contains(&needle)
|| entry.message.to_ascii_lowercase().contains(&needle)
})
.map(|e| UiLogEntry {
timestamp_ms: e.timestamp_ms,
sender: e.sender.clone(),
@ -214,11 +229,13 @@ impl LogCard {
}
fn get_title_hints(&self) -> (bool, bool) {
if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height {
let total_lines = self.last_total_lines.load(Ordering::Relaxed);
let visible_height = self.last_visible_height.load(Ordering::Relaxed);
if total_lines == 0 || total_lines <= visible_height {
return (false, false);
}
let max_offset = self.last_total_lines - self.last_visible_height;
let max_offset = total_lines - visible_height;
let can_scroll_up = self.scroll_offset < max_offset;
let can_scroll_down = self.scroll_offset > 0;
@ -226,6 +243,12 @@ impl LogCard {
}
fn build_title(&self) -> String {
if self.filtering {
return format!("Logs filter: {}_", self.filter);
}
if !self.filter.is_empty() {
return format!("Logs [filter: {}]", self.filter);
}
if !self.focused {
return "Logs".to_string();
}
@ -250,7 +273,8 @@ impl LogCard {
fn scroll_up(&mut self) {
let max_offset = self
.last_total_lines
.saturating_sub(self.last_visible_height);
.load(Ordering::Relaxed)
.saturating_sub(self.last_visible_height.load(Ordering::Relaxed));
self.scroll_offset = (self.scroll_offset + 1).min(max_offset);
}
@ -297,17 +321,27 @@ 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 +361,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 +376,11 @@ 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,17 +421,19 @@ impl Element for LogCard {
f.render_widget(Paragraph::new(line.clone()), line_area);
}
draw_block_joins(
f,
area,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
draw_block_joins(
f,
area,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
}
}
}
@ -435,14 +481,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 +552,8 @@ impl InteractableElement for LogCard {
InteractionResult::Handled
}
KeyCode::Home => {
if self.last_total_lines > visible_height {
self.scroll_offset = self.last_total_lines - visible_height;
if total_lines > visible_height {
self.scroll_offset = total_lines - visible_height;
}
InteractionResult::Handled
}

View file

@ -1,4 +1,4 @@
use crate::ui::UI;
use crate::{screens::screens::UiEvent, ui::UI};
use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
use std::sync::Arc;
use std::time::Duration;
@ -27,8 +27,9 @@ pub fn setup_input_handler(ui: Arc<UI>) -> JoinHandle<Result<(), String>> {
tokio::select! {
event = rx.recv() => match event {
Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await,
Some(Event::Resize(_, _)) => ui.invalidate(),
Some(Event::Paste(text)) => ui.handle_paste(text).await,
Some(Event::Mouse(mouse)) => ui.clone().handle_event(UiEvent::Mouse(mouse)).await,
Some(Event::Resize(width, height)) => ui.clone().handle_event(UiEvent::Resize(width, height)).await,
Some(Event::Paste(text)) => ui.clone().handle_event(UiEvent::Paste(text)).await,
Some(_) => {},
None => break,
},
@ -55,6 +56,6 @@ pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
{
ui.request_shutdown();
} else {
ui.handle_input(key).await;
ui.handle_event(UiEvent::Key(key)).await;
}
}

View file

@ -2,7 +2,7 @@ use std::fmt::{Debug, Formatter};
use std::future::Future;
use std::pin::Pin;
use crate::screens::screens::Screen;
use crate::screens::screens::{Screen, UiEvent};
#[allow(unused)]
pub enum InteractionResult {
@ -13,6 +13,9 @@ pub enum InteractionResult {
OpenFutureScreen {
screen: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>>,
},
AppTask {
task: Pin<Box<dyn Future<Output = UiEvent> + Send>>,
},
Handled,
Unhandled,
}
@ -22,6 +25,7 @@ impl Debug for InteractionResult {
match self {
InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"),
InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"),
InteractionResult::AppTask { task: _ } => write!(f, "AppTask"),
InteractionResult::CloseScreen => write!(f, "CloseScreen"),
InteractionResult::Handled => write!(f, "Handled"),
InteractionResult::Unhandled => write!(f, "Unhandled"),
@ -36,6 +40,9 @@ impl PartialEq for InteractionResult {
InteractionResult::OpenScreen { screen: _ },
InteractionResult::OpenScreen { screen: _ },
) => true,
(InteractionResult::AppTask { task: _ }, InteractionResult::AppTask { task: _ }) => {
true
}
(
InteractionResult::OpenFutureScreen { screen: _ },
InteractionResult::OpenFutureScreen { screen: _ },

View file

@ -1,6 +1,6 @@
use iota_ipc::{
ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
RequestEnvelope, ResponseResult, read_msg, write_msg,
RequestEnvelope, ResponsePayload, ResponseResult, read_msg, write_msg,
};
use iota_state::{ClientState, UiLogEntry};
use std::collections::HashMap;
@ -443,6 +443,115 @@ 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 +605,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 +648,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 +684,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 +784,7 @@ impl IpcClient {
} else {
let mut state = self.state.app.lock().await;
let message = match &response.result {
ResponseResult::Ok(msg) => msg.clone(),
ResponseResult::Ok(payload) => Self::format_payload(payload),
ResponseResult::Error(code) => Self::format_error(code).into(),
};
state.push_log(UiLogEntry {

View file

@ -8,9 +8,13 @@ pub mod screens {
pub mod daemon_setup;
pub mod main_screen;
pub mod md_viewer;
pub mod metrics;
pub mod overview;
pub mod screens;
pub mod settings;
pub mod terms_checker;
pub mod terms_updater;
pub mod users;
}
pub mod util {
pub mod borders;

View file

@ -6,9 +6,9 @@ use crate::{
},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::Screen,
screens::screens::{HitMap, Screen, UiEvent},
};
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
@ -29,7 +29,13 @@ 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 +57,7 @@ impl Screen for DaemonStartingScreen {
popup,
);
}
fn handle_input(&mut self, _: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, _: UiEvent) -> InteractionResult {
InteractionResult::Handled
}
}
@ -191,7 +197,13 @@ 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 +278,10 @@ impl Screen for DaemonSetupScreen {
context.theme,
);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
match event.code {
KeyCode::Esc => {
self.complete(DaemonSetupDecision::Exit);

View file

@ -1,4 +1,5 @@
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
elements::{
console_card::ConsoleCard,
elements::{InteractableElement, JoinableElement},
@ -8,19 +9,28 @@ use crate::{
interaction_result::InteractionResult,
ipc_client::{DaemonStatus, IpcConnectionState},
render_context::RenderContext,
screens::screens::{NavDirection, Screen},
screens::{
overview::OverviewScreen,
screens::{AppAction, HitMap, KeyHint, NavDirection, Screen, UiEvent},
},
ui::UI,
};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::{Constraint, Layout, Margin, Rect},
widgets::{Block, Borders},
layout::{Constraint, Layout, Rect},
widgets::Borders,
};
use tokio::sync::watch;
use std::{any::Any, sync::Arc};
use std::{
any::Any,
sync::{
Arc,
atomic::{AtomicU16, Ordering},
},
};
pub struct MainScreen {
elements: Vec<Box<dyn InteractableElement>>,
@ -29,9 +39,38 @@ pub struct MainScreen {
graphs_open: bool,
connection_status_rx: watch::Receiver<IpcConnectionState>,
daemon_status_rx: watch::Receiver<DaemonStatus>,
layout_width: AtomicU16,
}
impl MainScreen {
pub fn connection_status(&self) -> watch::Receiver<IpcConnectionState> {
self.connection_status_rx.clone()
}
pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> {
self.daemon_status_rx.clone()
}
fn status_summary(&self) -> String {
let connection = match self.connection_status_rx.borrow().clone() {
IpcConnectionState::Connected => "[OK] Connected".to_owned(),
IpcConnectionState::Connecting => "[..] Connecting".to_owned(),
IpcConnectionState::Reconnecting { .. } => "[WARN] Reconnecting".to_owned(),
IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => {
"[FAIL] Failed".to_owned()
}
IpcConnectionState::Disconnected => "[WARN] Disconnected".to_owned(),
};
let daemon = self.daemon_status_rx.borrow().clone();
let version = if daemon.version.is_empty() {
String::new()
} else {
format!(" v{}", daemon.version)
};
let ready = daemon
.startup_phase
.map(|phase| format!(" {:?}", phase))
.unwrap_or_default();
format!("IOTA{version} {connection}{ready}")
}
pub async fn new(ui: Arc<UI>) -> Self {
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
@ -80,6 +119,7 @@ impl MainScreen {
graphs_open,
connection_status_rx,
daemon_status_rx,
layout_width: AtomicU16::new(0),
};
screen.focus_current();
screen
@ -172,6 +212,9 @@ 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 +252,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 +286,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 +307,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 +355,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 +370,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 +413,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 +465,109 @@ impl Screen for MainScreen {
InteractionResult::Handled
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
match action {
AppAction::ToggleMetrics => {
self.graphs_open = !self.graphs_open;
for element in &mut self.elements {
if let Some(graph) = element.as_any_mut().downcast_mut::<GraphCard>() {
graph.set_open(self.graphs_open);
}
}
InteractionResult::Handled
}
AppAction::OpenOverview => {
self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('o'))))
}
AppAction::OpenUsers => {
self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('u'))))
}
AppAction::FocusLogs => {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (0, 0);
self.focus_current();
InteractionResult::Handled
}
AppAction::FocusConsole => {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (2, 0);
self.focus_current();
InteractionResult::Handled
}
AppAction::FocusMetrics => {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (0, 1);
self.focus_current();
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn app_title(&self) -> String {
self.status_summary()
}
fn key_hints(&self) -> Vec<KeyHint> {
if self.selected_coords == (2, 0) {
vec![
KeyHint {
keys: "Enter",
action: "Send",
},
KeyHint {
keys: "Up/Down",
action: "History",
},
KeyHint {
keys: "Tab",
action: "Complete",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
} else if self.selected_coords == (0, 0) {
vec![
KeyHint {
keys: "J/K",
action: "Scroll logs",
},
KeyHint {
keys: "Enter",
action: "Lock scroll",
},
KeyHint {
keys: "/",
action: "Filter",
},
KeyHint {
keys: "M",
action: "Metrics screen",
},
KeyHint {
keys: "Tab",
action: "Next panel",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
} else {
vec![
KeyHint {
keys: "Enter",
action: "Toggle metrics",
},
KeyHint {
keys: "Tab",
action: "Next panel",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}
}

View file

@ -1,4 +1,4 @@
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
DefaultTerminal,
prelude::*,
@ -10,7 +10,7 @@ use std::{any::Any, time::Duration};
use crate::{
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::Screen,
screens::screens::{HitMap, Screen, UiEvent},
theme::{ResolvedTheme, TextSemantics, ThemeName},
};
@ -29,11 +29,14 @@ impl Screen for FileViewer {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
self.draw(f, rect, context.theme);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
match event.code {
KeyCode::Char('q') | KeyCode::Esc => {
return InteractionResult::CloseScreen;

View file

@ -0,0 +1,136 @@
use std::any::Any;
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
widgets::{Block, Borders, Paragraph},
};
use crate::{
elements::{
elements::Element,
graph_card::{GRAPHS, GraphCard},
},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent},
ui::UI,
};
const RANGES: &[(usize, &str)] = &[(30, "Recent"), (120, "Medium"), (300, "Long")];
pub struct MetricsScreen {
graphs: Vec<GraphCard>,
range_index: usize,
}
impl MetricsScreen {
pub async fn new(ui: std::sync::Arc<UI>) -> Option<Self> {
let state = ui.client_state().await?;
let mut screen = Self {
graphs: vec![
GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()),
GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()),
GraphCard::new(ui, state, GRAPHS::Ping, "Ping".into()),
],
range_index: 0,
};
screen.apply_range();
Some(screen)
}
fn apply_range(&mut self) {
let width = RANGES[self.range_index].0;
for graph in &mut self.graphs {
graph.set_sample_width(width);
}
}
fn change_range(&mut self, delta: isize) {
self.range_index =
(self.range_index as isize + delta).clamp(0, RANGES.len() as isize - 1) as usize;
self.apply_range();
}
}
impl Screen for MetricsScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
context: &RenderContext<'_>,
hits: &mut HitMap,
) {
let block = Block::default()
.title(" Metrics ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal);
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(inner);
frame.render_widget(
Paragraph::new(format!(
"Range: {} ({} samples) Left/Right to change",
RANGES[self.range_index].1, RANGES[self.range_index].0
))
.style(context.theme.text.heading),
rows[0],
);
for (graph, graph_area) in self.graphs.iter().zip(rows[1..].iter()) {
graph.render(frame, *graph_area, context);
}
hits.register(rows[0], AppAction::OpenMetrics);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
};
match key.code {
KeyCode::Left => {
self.change_range(-1);
InteractionResult::Handled
}
KeyCode::Right => {
self.change_range(1);
InteractionResult::Handled
}
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
vec![
KeyHint {
keys: "Left/Right",
action: "Range",
},
KeyHint {
keys: "Esc/B",
action: "Back",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}

View file

@ -0,0 +1,291 @@
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
interaction_result::InteractionResult,
ipc_client::{DaemonStatus, IpcConnectionState},
render_context::RenderContext,
screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent},
};
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
use std::{
any::Any,
sync::atomic::{AtomicUsize, Ordering},
};
use tokio::sync::watch;
pub struct OverviewScreen {
connection_rx: watch::Receiver<IpcConnectionState>,
daemon_rx: watch::Receiver<DaemonStatus>,
_focus: Focus,
scroll_offset: usize,
content_height: AtomicUsize,
viewport_height: AtomicUsize,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
Back,
}
impl OverviewScreen {
pub fn new(
connection_rx: watch::Receiver<IpcConnectionState>,
daemon_rx: watch::Receiver<DaemonStatus>,
) -> Self {
Self {
connection_rx,
daemon_rx,
_focus: Focus::Back,
scroll_offset: 0,
content_height: AtomicUsize::new(0),
viewport_height: AtomicUsize::new(1),
}
}
fn build_lines(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Line<'static>> {
let conn = self.connection_rx.borrow().clone();
let daemon = self.daemon_rx.borrow().clone();
let mut lines = Vec::new();
lines.push(Line::from(Span::styled("Connection", theme.text.heading)));
lines.push(Line::from(format!(" State: {}", connection_label(&conn))));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Daemon", theme.text.heading)));
lines.push(Line::from(format!(
" Version: {}",
version_or_unknown(&daemon.version)
)));
lines.push(Line::from(format!(
" Instance: {}",
truncate_id(&daemon.instance_id)
)));
let phase = daemon
.startup_phase
.map(|p| format!("{:?}", p))
.unwrap_or_else(|| "Unknown".into());
lines.push(Line::from(format!(" Phase: {}", phase)));
let lifecycle = daemon
.lifecycle
.map(|l| format!("{:?}", l))
.unwrap_or_else(|| "Unknown".into());
lines.push(Line::from(format!(" Lifecycle: {}", lifecycle)));
let health = match daemon.health {
iota_ipc::HealthStatus::Healthy => "[OK] Healthy",
iota_ipc::HealthStatus::Degraded => "[WARN] Degraded",
iota_ipc::HealthStatus::Failed => "[FAIL] Failed",
};
lines.push(Line::from(format!(" Health: {health}")));
if let Some(ref reason) = daemon.degraded_reason {
lines.push(Line::from(Span::styled(
format!(" Degraded: {reason}"),
theme.status.warning,
)));
}
let mode = daemon
.deployment_mode
.map(|m| format!("{:?}", m))
.unwrap_or_else(|| "Unknown".into());
lines.push(Line::from(format!(" Deployment: {mode}")));
let supervisor = daemon
.supervisor
.map(|s| format!("{:?}", s))
.unwrap_or_else(|| "Unknown".into());
lines.push(Line::from(format!(" Supervisor: {supervisor}")));
if !daemon.components.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Components", theme.text.heading)));
for (id, health) in &daemon.components {
let status_str = match health.status {
iota_ipc::HealthStatus::Healthy => "[OK] healthy",
iota_ipc::HealthStatus::Degraded => "[WARN] degraded",
iota_ipc::HealthStatus::Failed => "[FAIL] failed",
};
let suffix = health
.message
.as_deref()
.map(|m| format!(" ({m})"))
.unwrap_or_default();
lines.push(Line::from(format!(" {:?}: {}{}", id, status_str, suffix)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Press Esc or B to return to the dashboard",
theme.text.muted,
)));
lines
}
}
fn connection_label(conn: &IpcConnectionState) -> String {
match conn {
IpcConnectionState::Connected => "Connected".into(),
IpcConnectionState::Connecting => "Connecting...".into(),
IpcConnectionState::Reconnecting { attempt } => {
format!("Reconnecting (attempt {attempt})...")
}
IpcConnectionState::Incompatible { message } => {
format!("Incompatible: {message}")
}
IpcConnectionState::Failed { message } => format!("Failed: {message}"),
IpcConnectionState::Disconnected => "Disconnected".into(),
}
}
fn version_or_unknown(v: &str) -> String {
if v.is_empty() {
"Unknown".into()
} else {
v.into()
}
}
fn truncate_id(id: &str) -> String {
if id.len() > 8 {
format!("{}", &id[..8])
} else {
id.into()
}
}
impl Screen for OverviewScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
let block = Block::default()
.title(" Overview ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal)
.title_style(context.theme.borders.title);
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme)
} else {
let inner = block.inner(rect);
f.render_widget(block, rect);
inner
};
let rows = ratatui::layout::Layout::vertical([
ratatui::layout::Constraint::Min(1),
ratatui::layout::Constraint::Length(1),
])
.split(inner);
let lines = self.build_lines(context.theme);
self.content_height.store(lines.len(), Ordering::Relaxed);
self.viewport_height
.store(rows[0].height as usize, Ordering::Relaxed);
let par = Paragraph::new(lines)
.wrap(Wrap { trim: true })
.scroll((self.scroll_offset as u16, 0));
f.render_widget(par, rows[0]);
render_button(
f,
rows[1],
ActionButton {
label: "Back",
intent: ButtonIntent::Cancel,
focused: self._focus == Focus::Back,
enabled: true,
},
context.theme,
);
_hits.register(rows[1], AppAction::Back);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
match event.code {
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen
}
KeyCode::Down | KeyCode::Char('j') => {
let max = self
.content_height
.load(Ordering::Relaxed)
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
self.scroll_offset = self.scroll_offset.saturating_add(1).min(max);
InteractionResult::Handled
}
KeyCode::Up | KeyCode::Char('k') => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
InteractionResult::Handled
}
KeyCode::PageDown => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
let max = self
.content_height
.load(Ordering::Relaxed)
.saturating_sub(page);
self.scroll_offset = self.scroll_offset.saturating_add(page).min(max);
InteractionResult::Handled
}
KeyCode::PageUp => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
self.scroll_offset = self.scroll_offset.saturating_sub(page);
InteractionResult::Handled
}
KeyCode::Home => {
self.scroll_offset = 0;
InteractionResult::Handled
}
KeyCode::End => {
self.scroll_offset = self
.content_height
.load(Ordering::Relaxed)
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
if action == AppAction::Back {
InteractionResult::CloseScreen
} else {
InteractionResult::Unhandled
}
}
fn key_hints(&self) -> Vec<KeyHint> {
vec![
KeyHint {
keys: "Up/Down",
action: "Scroll",
},
KeyHint {
keys: "PgUp/PgDn",
action: "Page",
},
KeyHint {
keys: "Esc/B",
action: "Back",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}

View file

@ -1,10 +1,103 @@
use std::any::Any;
use crossterm::event::KeyEvent;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::{Frame, layout::Rect};
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
/// All terminal input that can affect the UI. Keeping this as one type makes
/// it impossible for screens to accidentally ignore a newly supported event.
#[derive(Debug, Clone)]
pub enum UiEvent {
Key(KeyEvent),
Mouse(MouseEvent),
Paste(String),
Resize(u16, u16),
App(AppEvent),
}
/// Completion of background UI work. Keeping it in the regular event stream
/// gives screens an explicit success/failure path instead of detached tasks.
#[derive(Debug, Clone)]
pub enum AppEvent {
OpenUsers,
OpenMetrics,
ApplyTheme {
theme: crate::theme::ThemeName,
persist: bool,
},
SaveSettings {
theme: crate::theme::ThemeName,
color: crate::theme::TerminalPolicy,
unicode: crate::theme::TerminalPolicy,
},
ThemeSaved(Result<(), String>),
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
UserCreated(Result<crate::screens::users::UserEntry, String>),
UserRemoved {
user_id: i64,
result: Result<(), String>,
},
RegenerateKeysRequested,
KeysRegenerated(Result<(), String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppAction {
OpenOverview,
OpenUsers,
OpenSettings,
OpenMetrics,
ToggleMetrics,
AddUser,
RemoveUser,
Back,
Quit,
FocusLogs,
FocusConsole,
FocusMetrics,
OpenMain,
SelectUser(usize),
ConfirmDialog,
CancelDialog,
RegenerateKeys,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyHint {
pub keys: &'static str,
pub action: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HitRegion {
pub area: Rect,
pub action: AppAction,
}
#[derive(Debug, Default, Clone)]
pub struct HitMap {
regions: Vec<HitRegion>,
}
impl HitMap {
pub fn register(&mut self, area: Rect, action: AppAction) {
self.regions.push(HitRegion { area, action });
}
pub fn action_at(&self, column: u16, row: u16) -> Option<AppAction> {
self.regions
.iter()
.rev()
.find(|region| {
column >= region.area.x
&& column < region.area.x.saturating_add(region.area.width)
&& row >= region.area.y
&& row < region.area.y.saturating_add(region.area.height)
})
.map(|region| region.action)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavDirection {
Up,
@ -20,6 +113,32 @@ pub trait Screen: Send + Sync + Any {
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>);
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult;
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap);
fn handle_event(&mut self, event: UiEvent) -> InteractionResult;
fn handle_action(&mut self, _action: AppAction) -> InteractionResult {
InteractionResult::Unhandled
}
fn app_title(&self) -> String {
"IOTA".to_owned()
}
fn key_hints(&self) -> Vec<KeyHint> {
vec![
KeyHint {
keys: "Tab",
action: "Move focus",
},
KeyHint {
keys: "Enter",
action: "Activate",
},
KeyHint {
keys: "Esc",
action: "Back",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}

View file

@ -0,0 +1,415 @@
use std::any::Any;
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
theme::{TerminalPolicy, ThemeName, UiConfig},
};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
Theme,
RegenerateKeys,
Back,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Dialog {
ConfirmRegenerateKeys,
}
pub struct SettingsScreen {
selected: usize,
saved: ThemeName,
message: String,
color: TerminalPolicy,
unicode: TerminalPolicy,
focus: Focus,
dialog: Option<Dialog>,
pending: bool,
}
impl SettingsScreen {
pub fn new(current: ThemeName) -> Self {
let selected = ThemeName::ALL
.iter()
.position(|theme| *theme == current)
.unwrap_or(0);
Self {
selected,
saved: current,
message: "Left/Right previews. Enter saves.".into(),
color: UiConfig::load()
.map(|config| config.color)
.unwrap_or_default(),
unicode: UiConfig::load()
.map(|config| config.unicode)
.unwrap_or_default(),
focus: Focus::Theme,
dialog: None,
pending: false,
}
}
fn selected_theme(&self) -> ThemeName {
ThemeName::ALL[self.selected]
}
fn apply(&self, persist: bool) -> InteractionResult {
let theme = self.selected_theme();
InteractionResult::AppTask {
task: Box::pin(async move { UiEvent::App(AppEvent::ApplyTheme { theme, persist }) }),
}
}
fn next_policy(policy: TerminalPolicy) -> TerminalPolicy {
match policy {
TerminalPolicy::Auto => TerminalPolicy::Always,
TerminalPolicy::Always => TerminalPolicy::Never,
TerminalPolicy::Never => TerminalPolicy::Auto,
}
}
fn next_focus(&mut self) {
self.focus = match self.focus {
Focus::Theme => Focus::RegenerateKeys,
Focus::RegenerateKeys => Focus::Back,
Focus::Back => Focus::Theme,
};
}
fn prev_focus(&mut self) {
self.focus = match self.focus {
Focus::Theme => Focus::Back,
Focus::Back => Focus::RegenerateKeys,
Focus::RegenerateKeys => Focus::Theme,
};
}
fn activate(&mut self) -> InteractionResult {
if self.pending {
return InteractionResult::Handled;
}
if let Some(dialog) = self.dialog.take() {
match dialog {
Dialog::ConfirmRegenerateKeys => {
self.pending = true;
self.message = "Regenerating keys…".into();
return InteractionResult::AppTask {
task: Box::pin(async { UiEvent::App(AppEvent::RegenerateKeysRequested) }),
};
}
}
}
match self.focus {
Focus::Theme => {
self.message = "Saving theme…".into();
let theme = self.selected_theme();
let color = self.color;
let unicode = self.unicode;
InteractionResult::AppTask {
task: Box::pin(async move {
UiEvent::App(AppEvent::SaveSettings {
theme,
color,
unicode,
})
}),
}
}
Focus::RegenerateKeys => {
self.dialog = Some(Dialog::ConfirmRegenerateKeys);
InteractionResult::Handled
}
Focus::Back => InteractionResult::CloseScreen,
}
}
}
impl Screen for SettingsScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
context: &RenderContext<'_>,
hits: &mut HitMap,
) {
let block = Block::default()
.title(" Settings ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused);
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner);
frame.render_widget(
Paragraph::new(format!(
"Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)",
self.selected_theme(),
if self.selected_theme() == self.saved {
" [saved]"
} else {
" [preview]"
},
self.color,
self.unicode
))
.style(context.theme.text.heading),
rows[0],
);
let bottom_rows =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(rows[1]);
let lines = vec![
Line::from(Span::styled(&self.message, context.theme.text.normal)),
Line::from(""),
Line::from("Preview"),
Line::from("[OK] Healthy"),
Line::from("[WARN] Degraded"),
Line::from("[FAIL] Failed"),
Line::from("> Focused action <"),
];
frame.render_widget(
Paragraph::new(lines).style(context.theme.text.normal),
bottom_rows[0],
);
let buttons_area = Layout::horizontal([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(bottom_rows[1]);
render_button(
frame,
buttons_area[0],
ActionButton {
label: "Back",
intent: ButtonIntent::Cancel,
focused: self.focus == Focus::Back && self.dialog.is_none(),
enabled: true,
},
context.theme,
);
hits.register(buttons_area[0], AppAction::Back);
render_button(
frame,
buttons_area[1],
ActionButton {
label: "Regenerate Keys",
intent: ButtonIntent::Destructive,
focused: self.focus == Focus::RegenerateKeys && self.dialog.is_none(),
enabled: !self.pending,
},
context.theme,
);
hits.register(buttons_area[1], AppAction::RegenerateKeys);
if self.dialog.is_some() {
frame.render_widget(Block::default().style(context.theme.surfaces.overlay), area);
let popup = crate::layout::fit::centered_rect(
area,
crate::layout::fit::RequiredSize {
width: 42,
height: 7,
},
);
let block = Block::default()
.title(" Confirm ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused)
.style(context.theme.surfaces.overlay);
let popup_inner = block.inner(popup);
frame.render_widget(block, popup);
let dialog_rows =
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner);
frame.render_widget(
Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(context.theme.text.normal),
dialog_rows[0],
);
let dialog_buttons =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(dialog_rows[1]);
render_button(
frame,
dialog_buttons[0],
ActionButton {
label: "Cancel",
intent: ButtonIntent::Cancel,
focused: false,
enabled: true,
},
context.theme,
);
render_button(
frame,
dialog_buttons[1],
ActionButton {
label: "Regenerate",
intent: ButtonIntent::Destructive,
focused: true,
enabled: true,
},
context.theme,
);
hits.register(dialog_buttons[0], AppAction::CancelDialog);
hits.register(dialog_buttons[1], AppAction::ConfirmDialog);
}
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let event = match event {
UiEvent::App(AppEvent::ThemeSaved(result)) => {
match result {
Ok(()) => {
self.saved = self.selected_theme();
self.message = "Theme saved to ui.yaml.".into();
}
Err(error) => self.message = error,
}
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::KeysRegenerated(result)) => {
self.pending = false;
self.dialog = None;
match result {
Ok(()) => self.message = "Keys regenerated successfully.".into(),
Err(error) => self.message = error,
}
return InteractionResult::Handled;
}
event => event,
};
if self.dialog.is_some() {
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
};
return match key.code {
KeyCode::Esc => {
self.dialog = None;
InteractionResult::Handled
}
KeyCode::Enter => self.activate(),
_ => InteractionResult::Handled,
};
}
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
};
match key.code {
KeyCode::Left => {
if self.focus == Focus::Theme {
self.selected = self.selected.saturating_sub(1);
self.apply(false)
} else {
InteractionResult::Handled
}
}
KeyCode::Right => {
if self.focus == Focus::Theme {
self.selected = (self.selected + 1).min(ThemeName::ALL.len() - 1);
self.apply(false)
} else {
InteractionResult::Handled
}
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
KeyCode::Tab => {
self.next_focus();
InteractionResult::Handled
}
KeyCode::BackTab => {
self.prev_focus();
InteractionResult::Handled
}
KeyCode::Char('c') | KeyCode::Char('C') => {
self.color = Self::next_policy(self.color);
InteractionResult::Handled
}
KeyCode::Char('u') | KeyCode::Char('U') => {
self.unicode = Self::next_policy(self.unicode);
InteractionResult::Handled
}
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen
}
_ => InteractionResult::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
match action {
AppAction::Back => InteractionResult::CloseScreen,
AppAction::RegenerateKeys => {
self.focus = Focus::RegenerateKeys;
self.activate()
}
AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(),
AppAction::CancelDialog if self.dialog.is_some() => {
self.dialog = None;
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
if self.dialog.is_some() {
vec![
KeyHint {
keys: "Enter",
action: "Confirm",
},
KeyHint {
keys: "Esc",
action: "Cancel",
},
]
} else {
vec![
KeyHint {
keys: "Left/Right",
action: "Preview theme",
},
KeyHint {
keys: "Enter",
action: "Save/Activate",
},
KeyHint {
keys: "Tab",
action: "Move focus",
},
KeyHint {
keys: "C/U",
action: "Color/Unicode",
},
KeyHint {
keys: "Esc/B",
action: "Back",
},
]
}
}
}

View file

@ -2,10 +2,13 @@ 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 +56,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 +273,10 @@ impl Screen for TermsCheckerScreen {
);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
if self.eula {

View file

@ -3,11 +3,14 @@ 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 +124,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 +596,10 @@ impl Screen for TermsUpdaterScreen {
);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
let mut possible_states = Vec::new();
if self.eula_needed {

View file

@ -0,0 +1,717 @@
use crate::{
controls::{
button::{ActionButton, ButtonIntent, render_button},
choice::{ChoiceKind, render_choice_line},
},
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
};
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::{
any::Any,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
};
#[derive(Clone, Debug)]
pub struct UserEntry {
pub user_id: i64,
pub username: String,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
List,
AddButton,
RemoveButton,
Back,
}
#[derive(Clone, Debug)]
enum Dialog {
Add { username: String },
Remove { user: UserEntry },
}
pub struct UsersScreen {
users: Vec<UserEntry>,
focused_index: usize,
focus: Focus,
ipc: Arc<IpcClient>,
message: Option<String>,
dialog: Option<Dialog>,
pending_dialog: Option<Dialog>,
loading: bool,
pending: bool,
scroll_offset: usize,
viewport_height: AtomicUsize,
filter: String,
filtering: bool,
}
impl UsersScreen {
pub fn new(ipc: Arc<IpcClient>, users: Vec<UserEntry>) -> Self {
Self {
users,
focused_index: 0,
focus: Focus::List,
ipc,
message: None,
dialog: None,
pending_dialog: None,
loading: false,
pending: false,
scroll_offset: 0,
viewport_height: AtomicUsize::new(1),
filter: String::new(),
filtering: false,
}
}
pub fn loading(ipc: Arc<IpcClient>) -> Self {
let mut screen = Self::new(ipc, Vec::new());
screen.loading = true;
screen.message = Some("Loading users…".into());
screen
}
fn render_user_list(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let visible_indices = self.filtered_indices();
let title = if self.filter.is_empty() {
format!("Users ({})", self.users.len())
} else {
format!(
"Users ({}/{}) filter: {}",
visible_indices.len(),
self.users.len(),
self.filter
)
};
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(
f,
area,
&title,
self.focus == Focus::List,
context.theme,
)
} else {
let block = Block::default()
.title(format!(" {title} "))
.borders(Borders::ALL)
.border_style(context.theme.borders.normal);
let inner = block.inner(area);
f.render_widget(block, area);
inner
};
if self.loading {
f.render_widget(Paragraph::new("Loading users…"), inner);
return;
}
if visible_indices.is_empty() {
let par = Paragraph::new(if self.users.is_empty() {
"No users found."
} else {
"No users match the filter."
});
f.render_widget(par, inner);
return;
}
let mut lines = Vec::new();
self.viewport_height
.store(inner.height as usize, Ordering::Relaxed);
let labels: Vec<(usize, String)> = visible_indices
.iter()
.skip(self.scroll_offset)
.take(inner.height as usize)
.map(|user_index| {
let user = &self.users[*user_index];
(
*user_index,
format!("{:>6} {}", user.user_id, user.username),
)
})
.collect();
for (user_index, label) in &labels {
let visual = crate::controls::choice::ChoiceVisualState {
selected: false,
focused: self.focus == Focus::List && *user_index == self.focused_index,
enabled: !self.loading && !self.pending,
};
lines.push(render_choice_line(
&label,
ChoiceKind::Radio,
visual,
context.theme,
));
}
let par = Paragraph::new(lines);
f.render_widget(par, inner);
}
fn render_actions(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(area);
if let Some(msg) = &self.message {
let par = Paragraph::new(Line::from(Span::styled(
msg.as_str(),
context.theme.text.muted,
)));
f.render_widget(par, rows[0]);
}
let buttons_area = Layout::horizontal([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(34),
])
.split(rows[1]);
render_button(
f,
buttons_area[0],
ActionButton {
label: "Back",
intent: ButtonIntent::Cancel,
focused: self.focus == Focus::Back,
enabled: true,
},
context.theme,
);
render_button(
f,
buttons_area[1],
ActionButton {
label: "Add",
intent: ButtonIntent::Primary,
focused: self.focus == Focus::AddButton,
enabled: true,
},
context.theme,
);
render_button(
f,
buttons_area[2],
ActionButton {
label: "Remove",
intent: ButtonIntent::Destructive,
focused: self.focus == Focus::RemoveButton,
enabled: !self.loading && !self.pending && !self.users.is_empty(),
},
context.theme,
);
}
fn activate(&mut self) -> InteractionResult {
if self.loading || self.pending {
return InteractionResult::Handled;
}
if let Some(dialog) = self.dialog.take() {
match dialog {
Dialog::Add { username } if !username.trim().is_empty() => {
let name = username.trim().to_owned();
self.pending_dialog = Some(Dialog::Add { username });
self.pending = true;
self.message = Some("Creating user…".into());
let ipc = self.ipc.clone();
return InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username }),
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")),
Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()),
Err(error) => Err(format!("Cannot create user: {error}")),
};
UiEvent::App(AppEvent::UserCreated(result))
}),
};
}
Dialog::Remove { user } => {
self.pending_dialog = Some(Dialog::Remove { user: user.clone() });
let ipc = self.ipc.clone();
let id = user.user_id;
self.pending = true;
self.message = Some(format!("Removing {}", user.username));
return InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::RemoveUser { user_id: id }).await {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserRemoved { .. })) => Ok(()),
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot remove user: {error}")),
Ok(_) => Err("Daemon returned an unexpected response while removing the user.".into()),
Err(error) => Err(format!("Cannot remove user: {error}")),
};
UiEvent::App(AppEvent::UserRemoved {
user_id: id,
result,
})
}),
};
}
Dialog::Add { .. } => self.message = Some("A username is required.".into()),
}
return InteractionResult::Handled;
}
match self.focus {
Focus::Back => InteractionResult::CloseScreen,
Focus::AddButton => {
self.dialog = Some(Dialog::Add {
username: String::new(),
});
InteractionResult::Handled
}
Focus::RemoveButton => {
if let Some(user) = self.users.get(self.focused_index) {
self.dialog = Some(Dialog::Remove { user: user.clone() });
}
InteractionResult::Handled
}
Focus::List => InteractionResult::Handled,
}
}
fn next_focus(&mut self) {
self.focus = match self.focus {
Focus::List => Focus::AddButton,
Focus::AddButton => Focus::RemoveButton,
Focus::RemoveButton => Focus::Back,
Focus::Back => Focus::List,
};
}
fn prev_focus(&mut self) {
self.focus = match self.focus {
Focus::List => Focus::Back,
Focus::Back => Focus::RemoveButton,
Focus::RemoveButton => Focus::AddButton,
Focus::AddButton => Focus::List,
};
}
fn keep_focused_user_visible(&mut self) {
let indices = self.filtered_indices();
let Some(position) = indices
.iter()
.position(|index| *index == self.focused_index)
else {
self.scroll_offset = 0;
return;
};
let height = self.viewport_height.load(Ordering::Relaxed).max(1);
if position < self.scroll_offset {
self.scroll_offset = position;
} else if position >= self.scroll_offset + height {
self.scroll_offset = position + 1 - height;
}
}
fn move_user_focus(&mut self, index: usize) {
if !self.users.is_empty() {
self.focused_index = index.min(self.users.len() - 1);
self.keep_focused_user_visible();
}
}
fn filtered_indices(&self) -> Vec<usize> {
let needle = self.filter.to_ascii_lowercase();
self.users
.iter()
.enumerate()
.filter(|(_, user)| {
needle.is_empty()
|| user.username.to_ascii_lowercase().contains(&needle)
|| user.user_id.to_string().contains(&needle)
})
.map(|(index, _)| index)
.collect()
}
fn move_visible(&mut self, delta: isize) {
let indices = self.filtered_indices();
if indices.is_empty() {
return;
}
let current = indices
.iter()
.position(|index| *index == self.focused_index)
.unwrap_or(0);
let next = (current as isize + delta).clamp(0, indices.len() as isize - 1) as usize;
self.move_user_focus(indices[next]);
}
fn reset_focus_to_filter(&mut self) {
self.scroll_offset = 0;
if let Some(index) = self.filtered_indices().first().copied() {
self.focused_index = index;
}
}
}
impl Screen for UsersScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) {
let outer_block = Block::default()
.title(" Users ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal)
.title_style(context.theme.borders.title);
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(f, rect, "Users", false, context.theme)
} else {
let inner = outer_block.inner(rect);
f.render_widget(outer_block, rect);
inner
};
let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(inner);
self.render_user_list(f, chunks[0], context);
self.render_actions(f, chunks[1], context);
if let Some(dialog) = &self.dialog {
f.render_widget(Block::default().style(context.theme.surfaces.overlay), rect);
let popup = crate::layout::fit::centered_rect(
rect,
crate::layout::fit::RequiredSize {
width: 42,
height: 7,
},
);
let text = match dialog {
Dialog::Add { username } => {
format!("Add user\nUsername: {username}")
}
Dialog::Remove { user } => format!(
"Remove user {} (ID {})?\nThis removes the local user record.",
user.username, user.user_id
),
};
let block = Block::default()
.title(" Confirm ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused)
.style(context.theme.surfaces.overlay);
let popup_inner = block.inner(popup);
f.render_widget(block, popup);
let dialog_rows =
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner);
f.render_widget(
Paragraph::new(text).style(context.theme.text.normal),
dialog_rows[0],
);
let dialog_buttons =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(dialog_rows[1]);
render_button(
f,
dialog_buttons[0],
ActionButton {
label: "Cancel",
intent: ButtonIntent::Cancel,
focused: false,
enabled: true,
},
context.theme,
);
render_button(
f,
dialog_buttons[1],
ActionButton {
label: match dialog {
Dialog::Add { .. } => "Create",
Dialog::Remove { .. } => "Remove",
},
intent: match dialog {
Dialog::Add { .. } => ButtonIntent::Primary,
Dialog::Remove { .. } => ButtonIntent::Destructive,
},
focused: true,
enabled: true,
},
context.theme,
);
hits.register(dialog_buttons[0], AppAction::CancelDialog);
hits.register(dialog_buttons[1], AppAction::ConfirmDialog);
}
let buttons = Layout::horizontal([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(34),
])
.split(chunks[1]);
if self.dialog.is_none() {
hits.register(buttons[0], AppAction::Back);
}
if self.dialog.is_none() && !self.loading && !self.pending {
hits.register(buttons[1], AppAction::AddUser);
}
if self.dialog.is_none() && !self.loading && !self.pending && !self.users.is_empty() {
hits.register(buttons[2], AppAction::RemoveUser);
}
if self.dialog.is_none() {
let list_height = chunks[0].height.saturating_sub(2) as usize;
let filtered_indices = self.filtered_indices();
for visible in 0..list_height {
let position = self.scroll_offset + visible;
let Some(index) = filtered_indices.get(position).copied() else {
break;
};
hits.register(
Rect {
x: chunks[0].x.saturating_add(1),
y: chunks[0].y.saturating_add(1 + visible as u16),
width: chunks[0].width.saturating_sub(2),
height: 1,
},
AppAction::SelectUser(index),
);
}
}
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let event = match event {
UiEvent::App(AppEvent::UsersLoaded(result)) => {
self.loading = false;
match result {
Ok(users) => {
self.users = users;
self.message = None;
}
Err(error) => self.message = Some(error),
}
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::UserCreated(result)) => {
self.pending = false;
match result {
Ok(user) => {
self.pending_dialog = None;
self.focused_index = self.users.len();
self.users.push(user.clone());
self.message = Some(format!(
"Created user {} ({}).",
user.username, user.user_id
));
}
Err(error) => {
self.dialog = self.pending_dialog.take();
self.message = Some(error);
}
}
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::UserRemoved { user_id, result }) => {
self.pending = false;
match result {
Ok(()) => {
self.pending_dialog = None;
self.users.retain(|user| user.user_id != user_id);
self.focused_index =
self.focused_index.min(self.users.len().saturating_sub(1));
self.message = Some(format!("Removed user {user_id}."));
}
Err(error) => {
self.dialog = self.pending_dialog.take();
self.message = Some(error);
}
}
return InteractionResult::Handled;
}
UiEvent::Paste(text) if matches!(self.dialog, Some(Dialog::Add { .. })) => {
if let Some(Dialog::Add { username }) = self.dialog.as_mut() {
username.push_str(&text.replace(['\r', '\n'], " "));
}
return InteractionResult::Handled;
}
UiEvent::Key(event) => event,
_ => return InteractionResult::Unhandled,
};
if self.filtering && self.dialog.is_none() {
match event.code {
KeyCode::Esc => {
self.filtering = false;
self.filter.clear();
self.reset_focus_to_filter();
}
KeyCode::Enter => self.filtering = false,
KeyCode::Backspace => {
self.filter.pop();
self.reset_focus_to_filter();
}
KeyCode::Char(c)
if !event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.filter.push(c);
self.reset_focus_to_filter();
}
_ => {}
}
return InteractionResult::Handled;
}
if let Some(Dialog::Add { username }) = self.dialog.as_mut() {
match event.code {
KeyCode::Esc => {
self.dialog = None;
return InteractionResult::Handled;
}
KeyCode::Enter => return self.activate(),
KeyCode::Backspace => {
username.pop();
return InteractionResult::Handled;
}
KeyCode::Char(c)
if !c.is_control()
&& !event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
username.push(c);
return InteractionResult::Handled;
}
_ => return InteractionResult::Handled,
}
}
if self.dialog.is_some() {
return match event.code {
KeyCode::Esc => {
self.dialog = None;
InteractionResult::Handled
}
KeyCode::Enter => self.activate(),
_ => InteractionResult::Handled,
};
}
match event.code {
KeyCode::Esc => InteractionResult::CloseScreen,
KeyCode::Char('/') if self.focus == Focus::List => {
self.filtering = true;
self.filter.clear();
self.reset_focus_to_filter();
InteractionResult::Handled
}
KeyCode::Tab => {
self.next_focus();
InteractionResult::Handled
}
KeyCode::BackTab => {
self.prev_focus();
InteractionResult::Handled
}
KeyCode::Down | KeyCode::Char('j') => {
if self.focus == Focus::List {
self.move_visible(1);
}
InteractionResult::Handled
}
KeyCode::Up | KeyCode::Char('k') => {
if self.focus == Focus::List {
self.move_visible(-1);
}
InteractionResult::Handled
}
KeyCode::PageDown if self.focus == Focus::List && !self.users.is_empty() => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
self.move_visible(page as isize);
InteractionResult::Handled
}
KeyCode::PageUp if self.focus == Focus::List && !self.users.is_empty() => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
self.move_visible(-(page as isize));
InteractionResult::Handled
}
KeyCode::Home if self.focus == Focus::List => {
if let Some(index) = self.filtered_indices().first().copied() {
self.move_user_focus(index);
}
InteractionResult::Handled
}
KeyCode::End if self.focus == Focus::List && !self.users.is_empty() => {
if let Some(index) = self.filtered_indices().last().copied() {
self.move_user_focus(index);
}
InteractionResult::Handled
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
_ => InteractionResult::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
match action {
AppAction::Back => InteractionResult::CloseScreen,
AppAction::AddUser => {
self.focus = Focus::AddButton;
self.activate()
}
AppAction::RemoveUser => {
self.focus = Focus::RemoveButton;
self.activate()
}
AppAction::SelectUser(index) if self.dialog.is_none() => {
self.focus = Focus::List;
self.move_user_focus(index);
InteractionResult::Handled
}
AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(),
AppAction::CancelDialog if self.dialog.is_some() => {
self.dialog = None;
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
if self.dialog.is_some() {
vec![
KeyHint {
keys: "Enter",
action: "Confirm",
},
KeyHint {
keys: "Esc",
action: "Cancel",
},
]
} else {
vec![
KeyHint {
keys: "Up/Down",
action: "Select user",
},
KeyHint {
keys: "PgUp/PgDn",
action: "Page",
},
KeyHint {
keys: "/",
action: "Filter",
},
KeyHint {
keys: "Tab",
action: "Move focus",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}
}

View file

@ -13,6 +13,19 @@ pub struct UiConfig {
/// Whether opening the interactive UI should launch a locally installed daemon.
#[serde(default)]
pub daemon_start_policy: DaemonStartPolicy,
#[serde(default)]
pub color: TerminalPolicy,
#[serde(default)]
pub unicode: TerminalPolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum TerminalPolicy {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]

View file

@ -3,10 +3,81 @@ mod model;
mod name;
mod presets;
pub use config::{DaemonStartPolicy, UiConfig};
pub use config::{DaemonStartPolicy, TerminalPolicy, UiConfig};
pub use model::*;
pub use name::ThemeName;
pub fn resolve(name: ThemeName) -> ResolvedTheme {
presets::resolve(name)
}
pub fn resolve_with_capabilities(
name: ThemeName,
color_enabled: bool,
unicode_enabled: bool,
) -> ResolvedTheme {
let mut theme = if color_enabled {
presets::resolve(name)
} else {
presets::resolve(ThemeName::Monospace)
};
theme.name = name;
theme.unicode = unicode_enabled;
if !unicode_enabled {
if matches!(theme.console.cursor, CursorPresentation::Character { .. }) {
theme.console.cursor = CursorPresentation::Character {
glyph: "|",
style: theme.console.text,
};
}
}
theme
}
/// Resolve a theme against the terminal's color depth. Surface uses RGB
/// colors, so a portable ANSI preset is selected when truecolor is absent.
pub fn resolve_with_terminal_profile(
name: ThemeName,
color_enabled: bool,
unicode_enabled: bool,
truecolor_enabled: bool,
) -> ResolvedTheme {
let effective = if color_enabled && !truecolor_enabled && matches!(name, ThemeName::Surface) {
ThemeName::Ansi
} else {
name
};
resolve_with_capabilities(effective, color_enabled, unicode_enabled)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
#[test]
fn no_color_policy_removes_palette_dependencies() {
let theme = resolve_with_capabilities(ThemeName::Surface, false, true);
assert_eq!(theme.name, ThemeName::Surface);
assert_eq!(theme.status.error.fg, None);
assert_eq!(theme.surfaces.panel.bg, None);
}
#[test]
fn ascii_policy_replaces_character_cursor() {
let theme = resolve_with_capabilities(ThemeName::Monospace, false, false);
assert!(!theme.unicode);
match theme.console.cursor {
CursorPresentation::Character { glyph, .. } => assert_eq!(glyph, "|"),
CursorPresentation::StyledCell(_) => panic!("expected an ASCII character cursor"),
}
assert_ne!(theme.graphs.ram, Color::Blue);
}
#[test]
fn surface_uses_ansi_fallback_without_truecolor() {
let theme = resolve_with_terminal_profile(ThemeName::Surface, true, true, false);
assert_eq!(theme.name, ThemeName::Ansi);
assert_eq!(theme.surfaces.panel.bg, None);
}
}

View file

@ -24,6 +24,22 @@ 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 +135,9 @@ pub struct TextSemantics {
#[derive(Clone, Debug)]
pub struct ResolvedTheme {
pub name: ThemeName,
pub unicode: bool,
pub surfaces: SurfaceStyles,
pub chrome: ChromeMode,
pub text: TextStyles,
pub status: StatusStyles,
pub choices: ChoiceStyles,

View file

@ -1,7 +1,7 @@
use super::{
BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ConsoleStyles, CursorPresentation,
GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, TextStyles,
ThemeName,
BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ChromeMode, ConsoleStyles,
CursorPresentation, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme,
StatusStyles, SurfaceStyles, TextStyles, ThemeName,
};
use ratatui::style::{Color, Modifier, Style};
@ -45,6 +45,18 @@ 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 +307,17 @@ pub fn resolve(name: ThemeName) -> ResolvedTheme {
);
theme.console.cursor =
CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow));
theme.chrome = ChromeMode::Surfaces;
theme.surfaces = SurfaceStyles {
canvas: plain.bg(Color::Black),
toolbar: plain.fg(Color::White).bg(Color::DarkGray),
panel: plain.fg(Color::White).bg(Color::Rgb(30, 35, 45)),
panel_alternate: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)),
panel_focused: plain.fg(Color::White).bg(Color::Rgb(48, 58, 78)),
panel_selected: selected,
footer: plain.fg(Color::DarkGray).bg(Color::Black),
overlay: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)),
};
theme
}
}

View file

@ -1,14 +1,28 @@
use crate::{
controls::header::render_header,
input_handler::setup_input_handler,
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
screens::screens::Screen,
screens::{
main_screen::MainScreen,
metrics::MetricsScreen,
overview::OverviewScreen,
screens::{AppAction, AppEvent, HitMap, Screen, UiEvent},
settings::SettingsScreen,
users::{UserEntry, UsersScreen},
},
theme::{self, ResolvedTheme, ThemeName},
};
use crossterm::event::KeyEvent;
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, MouseEventKind,
};
use once_cell::sync::Lazy;
use ratatui::{Terminal, backend::CrosstermBackend};
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout},
};
use std::{
io,
io::Stdout,
@ -18,7 +32,7 @@ use std::{
atomic::{AtomicBool, Ordering},
},
};
use tokio::sync::{Notify, RwLock};
use tokio::sync::{Notify, RwLock, mpsc};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
@ -35,6 +49,10 @@ pub struct UI {
theme: RwLock<Arc<ResolvedTheme>>,
pub(crate) invalidation: Notify,
failure: Arc<Mutex<Option<String>>>,
hits: Mutex<HitMap>,
app_event_tx: mpsc::UnboundedSender<UiEvent>,
app_event_rx: Mutex<Option<mpsc::UnboundedReceiver<UiEvent>>>,
header_focus: Mutex<Option<usize>>,
}
pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> {
@ -55,6 +73,24 @@ pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result<TuiSes
fn start_session(ui: UI) -> io::Result<TuiSession> {
let ui = Arc::new(ui);
let mut app_event_rx = ui
.app_event_rx
.lock()
.map_err(|_| io::Error::other("application event queue poisoned"))?
.take()
.ok_or_else(|| io::Error::other("application event queue already started"))?;
let app_ui = ui.clone();
let app_event_task = tokio::spawn(async move {
loop {
tokio::select! {
_ = app_ui.cancellation.cancelled() => break,
event = app_event_rx.recv() => match event {
Some(event) => app_ui.clone().handle_event(event).await,
None => break,
},
}
}
});
let uic = ui.clone();
let renderer_task = tokio::spawn(async move {
let cancellation = uic.cancellation_token();
@ -62,7 +98,6 @@ fn start_session(ui: UI) -> io::Result<TuiSession> {
tokio::select! {
_ = cancellation.cancelled() => break Ok(()),
_ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } },
_ = tokio::time::sleep(std::time::Duration::from_millis(250)) => { if !uic.is_shutdown() { uic.render().await?; } },
}
};
if let Err(error) = &result {
@ -101,6 +136,7 @@ fn start_session(ui: UI) -> io::Result<TuiSession> {
ui,
renderer_task,
input_task,
app_event_task,
signal_task,
restored: AtomicBool::new(false),
previous_hook,
@ -111,6 +147,7 @@ pub struct TuiSession {
ui: Arc<UI>,
renderer_task: JoinHandle<io::Result<()>>,
input_task: JoinHandle<Result<(), String>>,
app_event_task: JoinHandle<()>,
signal_task: Option<JoinHandle<()>>,
restored: AtomicBool,
previous_hook: Arc<Mutex<Option<Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>>>>,
@ -129,6 +166,7 @@ impl TuiSession {
tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.renderer_task).await;
let input =
tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.input_task).await;
self.app_event_task.abort();
if renderer.is_err() {
self.renderer_task.abort();
}
@ -154,6 +192,7 @@ impl TuiSession {
}
fn restore_terminal_once(&self) {
if !self.restored.swap(true, Ordering::AcqRel) {
let _ = crossterm::execute!(io::stdout(), DisableMouseCapture);
ratatui::restore();
}
}
@ -168,6 +207,7 @@ impl Drop for TuiSession {
self.ui.request_shutdown();
self.renderer_task.abort();
self.input_task.abort();
self.app_event_task.abort();
if let Some(task) = self.signal_task.as_ref() {
task.abort();
}
@ -182,6 +222,8 @@ impl UI {
theme: ResolvedTheme,
) -> io::Result<Self> {
let terminal = ratatui::try_init()?;
crossterm::execute!(io::stdout(), EnableMouseCapture)?;
let (app_event_tx, app_event_rx) = mpsc::unbounded_channel();
Ok(Self {
ipc: RwLock::new(ipc),
shutdown_on_empty,
@ -191,6 +233,10 @@ impl UI {
theme: RwLock::new(Arc::new(theme)),
invalidation: Notify::new(),
failure: Arc::new(Mutex::new(None)),
hits: Mutex::new(HitMap::default()),
app_event_tx,
app_event_rx: Mutex::new(Some(app_event_rx)),
header_focus: Mutex::new(None),
})
}
@ -228,10 +274,6 @@ impl UI {
pub fn failure(&self) -> Option<String> {
self.failure.lock().ok().and_then(|f| f.clone())
}
pub async fn handle_paste(&self, _text: String) {
self.invalidate();
}
/// Lets bootstrap operations race their work against Ctrl+C without
/// blocking the input task or leaving the terminal in raw mode.
pub async fn wait_for_shutdown(&self) {
@ -259,10 +301,166 @@ 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 +476,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 +497,81 @@ 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 +584,59 @@ impl UI {
.terminal
.lock()
.map_err(|_| io::Error::other("terminal mutex poisoned"))?;
let mut hits = HitMap::default();
terminal.draw(|f| {
screen.render(f, f.area(), &context);
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(1),
])
.split(f.area());
let header_title = self
.screen_stack
.try_read()
.ok()
.and_then(|stack| {
stack
.iter()
.find_map(|item| item.as_any().downcast_ref::<MainScreen>())
.map(|main| main.app_title())
})
.unwrap_or_else(|| screen.app_title());
let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus);
render_header(
f,
rows[0],
&header_title,
context.theme,
&mut hits,
header_focus,
);
let hints = if header_focus.is_some() {
" Left/Right: choose Enter: activate Esc/F6: screen".to_owned()
} else {
screen
.key_hints()
.into_iter()
.map(|hint| format!("{}: {}", hint.keys, hint.action))
.collect::<Vec<_>>()
.join(" ")
};
f.render_widget(
ratatui::widgets::Paragraph::new(format!(" {hints}")).style(
context
.theme
.surfaces
.footer
.patch(context.theme.text.muted),
),
rows[2],
);
screen.render(f, rows[1], &context, &mut hits);
})?;
if let Ok(mut current) = self.hits.lock() {
*current = hits;
}
}
Ok(())
}

View file

@ -0,0 +1,76 @@
use crossterm::event::{KeyCode, KeyEvent};
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};
fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect()
}
#[tokio::test]
async fn settings_preview_and_save_emit_typed_application_events() {
let mut screen = SettingsScreen::new(ThemeName::Ansi);
let preview = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Right)));
let InteractionResult::AppTask { task } = preview else {
panic!("theme preview should emit an application task");
};
assert!(matches!(
task.await,
UiEvent::App(AppEvent::ApplyTheme {
theme: ThemeName::Surface,
persist: false
})
));
let save = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Enter)));
let InteractionResult::AppTask { task } = save else {
panic!("theme save should emit an application task");
};
assert!(matches!(
task.await,
UiEvent::App(AppEvent::SaveSettings {
theme: ThemeName::Surface,
color: _,
unicode: _
})
));
}
#[test]
fn settings_is_readable_in_every_theme_and_layout() {
for theme_name in ThemeName::ALL {
for (width, height) in [(42, 12), (72, 20), (100, 28)] {
let theme = resolve(theme_name);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
let screen = SettingsScreen::new(theme_name);
terminal
.draw(|frame| {
screen.render(
frame,
frame.area(),
&RenderContext { theme: &theme },
&mut HitMap::default(),
);
})
.unwrap();
let rendered = buffer_text(&terminal);
assert!(rendered.contains("Settings"));
assert!(rendered.contains("Theme:"));
assert!(rendered.contains("[OK] Healthy"));
assert!(rendered.contains("[FAIL] Failed"));
}
}
}

View file

@ -108,6 +108,10 @@ fn stored_message_value(
partner_id: i64,
) -> DataValue {
let mut fields = vec![
(
DataType::MessageId,
DataValue::SignedNumber(message.id as i128),
),
(
DataType::SendTime,
DataValue::SignedNumber(message.message_time as i128),
@ -261,56 +265,160 @@ pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue {
.with_receiver(sender_id as u64)
}
pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue {
let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64;
let contacts = chats_util::get_users(user_id);
let mut contacts_array = Vec::new();
for (i, contact) in contacts.iter().enumerate() {
let mut contact_container = Vec::new();
contact_container.push((
DataType::UserId,
DataValue::SignedNumber(contact.user_id as i128),
));
contact_container.push((
DataType::LastMessageAt,
DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128),
));
if let Some(ref name) = contact.user_name {
contact_container.push((DataType::Username, DataValue::Str(name.clone())));
}
let amount = if i < 10 { 20 } else { 1 };
let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount);
let mut msg_array = Vec::new();
for m in &messages {
msg_array.push(stored_message_value(m, user_id, contact.user_id));
if msg_array.len() == 1 {
let sender_id = if m.sent_by_self {
user_id
} else {
contact.user_id
};
let mut last_msg = Vec::new();
last_msg.push((DataType::Content, DataValue::Str(m.content.clone())));
last_msg.push((
DataType::SenderId,
DataValue::SignedNumber(sender_id as i128),
));
contact_container.push((DataType::LastMessage, typed_container(last_msg)));
}
}
contact_container.push((DataType::Messages, DataValue::Array(msg_array)));
contacts_array.push(typed_container(contact_container));
fn contact_value(contact: &iota_storage::users::contact::Contact) -> DataValue {
let mut fields = vec![(
DataType::UserId,
DataValue::SignedNumber(contact.user_id as i128),
)];
if let Some(name) = &contact.user_name {
fields.push((DataType::Username, DataValue::Str(name.clone())));
}
if let Some(last_message_at) = contact.last_message_at {
fields.push((
DataType::LastMessageAt,
DataValue::SignedNumber(last_message_at as i128),
));
}
typed_container(fields)
}
CommunicationValue::new(CommunicationType::ClientConnected)
fn sync_error(cv: &CommunicationValue) -> CommunicationValue {
error_response(cv, CommunicationType::ErrorInvalidData).add_typed_default(
DataType::SessionId,
cv.get_data(DataType::SessionId).clone(),
)
}
/// The sender is authenticated by MTP; a UserId embedded by a client is never trusted here.
pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue {
use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION};
let user_id = match i64::try_from(cv.get_sender()) {
Ok(id) if id > 0 => id,
_ => return sync_error(cv),
};
let session_id = match data_i64(cv, DataType::SessionId) {
Some(id) if id > 0 => id,
_ => return sync_error(cv),
};
let reported_version = match data_i64(cv, DataType::VersionNumber) {
Some(version) if version >= 0 => version,
_ => return sync_error(cv),
};
let cache_valid = cv.get_data(DataType::CacheValid).as_bool().unwrap_or(false);
let schema = data_i64(cv, DataType::CacheSchemaVersion).unwrap_or(0);
let head = match sync::head(user_id) {
Ok(version) => version,
Err(_) => return sync_error(cv),
};
let known_session = sync::has_session(user_id, session_id).unwrap_or(false);
let full = !cache_valid
|| reported_version == 0
|| !known_session
|| reported_version > head
|| schema != CACHE_SCHEMA_VERSION;
let (contacts, messages, deleted_messages, deleted_contacts, mode) = if full {
(
chats_util::get_users(user_id),
chat_files::get_all_messages(user_id),
Vec::new(),
Vec::new(),
"full",
)
} else {
match sync::delta(user_id, reported_version, head) {
Ok(delta) => (
chats_util::get_users_by_ids(user_id, &delta.contact_upserts),
chat_files::get_messages_by_ids(user_id, &delta.message_upserts),
delta.deleted_message_ids,
delta.deleted_contact_ids,
"delta",
),
Err(_) => (
chats_util::get_users(user_id),
chat_files::get_all_messages(user_id),
Vec::new(),
Vec::new(),
"full",
),
}
};
let all_contact_ids = chats_util::get_users(user_id)
.into_iter()
.map(|contact| DataValue::SignedNumber(contact.user_id as i128))
.collect();
let message_values = messages
.iter()
.map(|message| stored_message_value(message, user_id, message.external_user))
.collect();
CommunicationValue::new(CommunicationType::ClientStateSync)
.with_id(cv.get_id())
.add_typed_default(DataType::Contacts, DataValue::Array(contacts_array))
.with_receiver(cv.get_sender())
.add_typed_default(
DataType::SessionId,
DataValue::SignedNumber(session_id as i128),
)
.add_typed_default(
DataType::VersionNumber,
DataValue::SignedNumber(head as i128),
)
.add_typed_default(
DataType::CacheSchemaVersion,
DataValue::SignedNumber(CACHE_SCHEMA_VERSION as i128),
)
.add_typed_default(DataType::SyncMode, DataValue::Str(mode.into()))
.add_typed_default(
DataType::Contacts,
DataValue::Array(contacts.iter().map(contact_value).collect()),
)
.add_typed_default(DataType::Messages, DataValue::Array(message_values))
.add_typed_default(
DataType::DeletedMessageIds,
DataValue::Array(
deleted_messages
.into_iter()
.map(|id| DataValue::SignedNumber(id as i128))
.collect(),
),
)
.add_typed_default(
DataType::DeletedContactIds,
DataValue::Array(
deleted_contacts
.into_iter()
.map(|id| DataValue::SignedNumber(id as i128))
.collect(),
),
)
.add_typed_default(DataType::UserIds, DataValue::Array(all_contact_ids))
.add_typed_default(DataType::Calls, DataValue::Array(Vec::new()))
}
pub fn handle_client_state_ack(cv: &CommunicationValue) -> CommunicationValue {
use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION};
let user_id = match i64::try_from(cv.get_sender()) {
Ok(id) if id > 0 => id,
_ => return sync_error(cv),
};
let session_id = match data_i64(cv, DataType::SessionId) {
Some(id) if id > 0 => id,
_ => return sync_error(cv),
};
let version = match data_i64(cv, DataType::VersionNumber) {
Some(version) if version >= 0 => version,
_ => return sync_error(cv),
};
if sync::acknowledge(user_id, session_id, version, CACHE_SCHEMA_VERSION).is_err() {
return sync_error(cv);
}
success_response(cv)
.add_typed_default(
DataType::SessionId,
DataValue::SignedNumber(session_id as i128),
)
.add_typed_default(
DataType::VersionNumber,
DataValue::SignedNumber(version as i128),
)
}
pub fn handle_message_state(cv: &CommunicationValue) {

View file

@ -9,12 +9,14 @@ iota-ipc = { path = "../iota-ipc" }
iota-logger = { path = "../iota-logger" }
iota-state = { path = "../iota-state" }
iota-storage = { path = "../iota-storage" }
iota-updater = { path = "../iota-updater" }
iota-util = { path = "../iota-util" }
omikron-connector = { path = "../omikron-connector" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
dashmap = "6.1.0"
libc = "0.2"
sysinfo = "0.38.3"
serde_yaml = "0.9"
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
uuid = { version = "*", features = ["v4"] }

View file

@ -1,10 +1,16 @@
use crate::log_buffer::LogBuffer;
use crate::{DaemonRuntime, DaemonServices};
use iota_ipc::{ExitIntent, IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult};
use iota_ipc::{
CommunitySummary, ComponentStatusResponse, ConfigResponse, ExitIntent, IpcErrorCode,
LocalRequest, LogEntriesResponse, 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,20 @@ 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,36 +41,15 @@ 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 {
if !self.services.active
&& !matches!(
request,
LocalRequest::GetStatus | LocalRequest::GetDaemonStatus
)
{
return ResponseResult::Error(IpcErrorCode::Unauthorized);
}
let needs_omikron = matches!(
request,
LocalRequest::CreateUser { .. }
@ -83,28 +77,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 +112,10 @@ 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 +130,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 +145,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 +164,117 @@ impl CommandRouter {
ExitIntent::Stop => ShutdownReason::Stop,
ExitIntent::Restart => ShutdownReason::Restart,
});
ResponseResult::Ok("process exit accepted".into())
}
LocalRequest::GetDaemonStatus => {
ResponseResult::Ok(format!("{:?}", self.runtime.snapshot()))
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "process exit accepted".into(),
})
}
LocalRequest::GetDaemonStatus => ResponseResult::Ok(ResponsePayload::DaemonStatus(
iota_ipc::DaemonStatusResponse {
formatted: format!("{:?}", self.runtime.snapshot()),
},
)),
LocalRequest::RestartDaemon => {
self.runtime.shutdown(ShutdownReason::Restart);
ResponseResult::Ok("Daemon restart requested".into())
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "Daemon restart requested".into(),
})
}
LocalRequest::StopDaemon => {
self.runtime.shutdown(ShutdownReason::Stop);
ResponseResult::Ok("Daemon shutdown requested".into())
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "Daemon shutdown requested".into(),
})
}
LocalRequest::GetConfig => {
let cfg = config_util::CONFIG.load();
let yaml = serde_yaml::to_string(&**cfg).unwrap_or_default();
ResponseResult::Ok(ResponsePayload::Config(ConfigResponse { yaml }))
}
LocalRequest::SetConfig { key, value } => {
match config_util::modify_config_value(&key, &value) {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!("Set {key} = {value}"),
}),
Err(_e) => ResponseResult::Error(IpcErrorCode::InvalidRequest),
}
}
LocalRequest::ReloadConfig => {
config_util::load_config();
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "Configuration reloaded".into(),
})
}
LocalRequest::GetOmikronStatus => {
let connected = self.services.omikron.is_connected().await;
let iota_id = config_util::CONFIG.load().iota_id;
ResponseResult::Ok(ResponsePayload::OmikronStatus(OmikronStatusResponse {
connected,
iota_id,
}))
}
LocalRequest::ListComponents => {
let snapshot = self.runtime.snapshot();
let components: Vec<ComponentStatusResponse> = snapshot
.components
.into_iter()
.map(|(id, health)| ComponentStatusResponse {
id,
status: health.status,
message: health.message,
})
.collect();
ResponseResult::Ok(ResponsePayload::Components(components))
}
LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) {
Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse {
user_id: user.user_id,
username: user.username,
display_name: user.display_name,
created_at: user.created_at,
trusted_apps: user.trusted_apps.keys().cloned().collect(),
})),
None => ResponseResult::Error(IpcErrorCode::NotFound),
},
LocalRequest::ImportUser { username } => {
match user_manager::load_from_tu(&username).await {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!("Imported user {username}"),
}),
Err(()) => ResponseResult::Error(IpcErrorCode::StorageFailure),
}
}
LocalRequest::GetLogs { limit } => {
let entries = if let Ok(buf) = self.log_buffer.lock() {
buf.recent(limit)
} else {
Vec::new()
};
ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { entries }))
}
LocalRequest::CheckUpdate => match iota_updater::check_update().await {
Ok(available) => {
ResponseResult::Ok(ResponsePayload::UpdateStatus(UpdateStatusResponse {
available,
}))
}
Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure),
},
LocalRequest::ListCommunities => {
let iota_id = config_util::CONFIG
.load()
.iota_id
.map(|id| id as i64)
.unwrap_or(0);
let stored =
iota_storage::util::communities_util::CommunitiesUtil::get_communities(iota_id);
let summaries: Vec<CommunitySummary> = stored
.into_iter()
.map(|c| CommunitySummary {
name: c.address,
title: c.title,
})
.collect();
ResponseResult::Ok(ResponsePayload::Communities(summaries))
}
}
}

View file

@ -1,4 +1,5 @@
use crate::deployment::from_environment;
use crate::log_buffer::LogBuffer;
use crate::{CommandRouter, DaemonRuntime, DaemonServices};
use iota_ipc::{
ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg,
@ -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,20 @@ 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
if let Err(error) = handle_client(
stream,
runtime,
services,
log_tx,
log_buffer,
state_rx,
instance_id,
)
.await
{
eprintln!("IPC client error: {error}");
}
@ -252,6 +279,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 +365,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 +394,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 +449,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 +503,17 @@ async fn handle_client(
break;
}
}
Ok(ClientMessage::Subscribe { .. }) => {
Ok(ClientMessage::Subscribe {
log_classes,
metric_interval_ms,
}) => {
let interval = metric_interval_ms
.unwrap_or(DEFAULT_METRIC_INTERVAL_MS)
.clamp(MIN_METRIC_INTERVAL_MS, MAX_METRIC_INTERVAL_MS);
let _ = sub_tx.send(ClientSubscription {
log_classes,
metric_interval_ms: interval,
});
let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
let _ = directed_tx.send(snapshot).await;
let _ = directed_tx.send(DaemonMessage::Subscribed).await;

View file

@ -3,6 +3,7 @@ pub mod daemon_state;
pub mod deployment;
pub mod ipc_server;
pub mod log_broadcaster;
pub mod log_buffer;
pub mod services;
pub mod task_registry;

View file

@ -1,21 +1,27 @@
use crate::log_buffer::LogBuffer;
use iota_ipc::{DaemonMessage, LogEntry};
use iota_logger::subscribe;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
/* The daemon adapts logger output to the wire protocol so the logger stays
* independent from both the socket implementation and TUI state. */
pub fn spawn(message_tx: broadcast::Sender<DaemonMessage>) {
pub fn spawn(message_tx: broadcast::Sender<DaemonMessage>, buffer: Arc<Mutex<LogBuffer>>) {
let Some(mut logs) = subscribe() else {
return;
};
tokio::spawn(async move {
while let Ok(entry) = logs.recv().await {
let _ = message_tx.send(DaemonMessage::LogEntry(LogEntry {
let entry = LogEntry {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
message: entry.message,
is_error: entry.is_error,
}));
};
if let Ok(mut buf) = buffer.lock() {
buf.push(entry.clone());
}
let _ = message_tx.send(DaemonMessage::LogEntry(entry));
}
});
}

View file

@ -0,0 +1,36 @@
use iota_ipc::LogEntry;
use std::collections::VecDeque;
pub struct LogBuffer {
entries: VecDeque<LogEntry>,
capacity: usize,
}
impl LogBuffer {
pub fn new(capacity: usize) -> Self {
Self {
entries: VecDeque::with_capacity(capacity),
capacity,
}
}
pub fn push(&mut self, entry: LogEntry) {
if self.entries.len() == self.capacity {
self.entries.pop_front();
}
self.entries.push_back(entry);
}
pub fn recent(&self, limit: usize) -> Vec<LogEntry> {
let _len = self.entries.len();
self.entries
.iter()
.rev()
.take(limit)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
}

View file

@ -1,5 +1,8 @@
use omikron_connector::{OmikronClient, OmikronConnection};
use async_trait::async_trait;
use mtp::codec::CommunicationValue;
use omikron_connector::{OmikronClient, OmikronConnection, OmikronError};
use std::sync::Arc;
use std::time::Duration;
#[derive(Default)]
pub struct UserService;
@ -10,6 +13,7 @@ pub struct DaemonServices {
pub omikron: Arc<dyn OmikronClient>,
pub users: Arc<UserService>,
pub config: Arc<ConfigService>,
pub active: bool,
}
impl DaemonServices {
@ -18,6 +22,46 @@ impl DaemonServices {
omikron,
users: Arc::new(UserService),
config: Arc::new(ConfigService),
active: true,
})
}
/// Services used while the daemon is awaiting terms acceptance. They can
/// never initiate a connection; the command router exposes status only.
pub fn inactive() -> Arc<Self> {
Arc::new(Self {
omikron: Arc::new(InactiveOmikron),
users: Arc::new(UserService),
config: Arc::new(ConfigService),
active: false,
})
}
}
struct InactiveOmikron;
#[async_trait]
impl OmikronClient for InactiveOmikron {
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
Err(OmikronError::Disconnected(
"terms have not been accepted".into(),
))
}
async fn await_response(
&self,
_: &CommunicationValue,
_: Duration,
) -> Result<CommunicationValue, OmikronError> {
Err(OmikronError::Disconnected(
"terms have not been accepted".into(),
))
}
async fn reconnect(&self) -> Result<(), OmikronError> {
Err(OmikronError::Disconnected(
"terms have not been accepted".into(),
))
}
async fn is_connected(&self) -> bool {
false
}
}

View file

@ -1,10 +1,11 @@
use async_trait::async_trait;
use iota_daemon_lib::log_buffer::LogBuffer;
use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices};
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,11 @@ async fn reconnect_uses_the_injected_client() {
users: Default::default(),
config: Default::default(),
});
let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services);
let router = CommandRouter::new(
Arc::new(DaemonRuntime::new()),
services,
Arc::new(Mutex::new(LogBuffer::new(100))),
);
assert!(matches!(
router.route(1, LocalRequest::ReconnectOmikron).await.result,
ResponseResult::Ok(_)

View file

@ -11,6 +11,7 @@ iota-state = { path = "../iota-state" }
iota-paths = { path = "../iota-paths" }
iota-storage = { path = "../iota-storage" }
iota-util = { path = "../iota-util" }
iota-terms = { path = "../iota-terms" }
omikron-connector = { path = "../omikron-connector" }
web-server = { path = "../web-server" }
tokio = { version = "1.50.0", features = ["full"] }

View file

@ -1,11 +1,12 @@
use iota_daemon_lib::{
DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster,
log_buffer::LogBuffer,
};
use iota_logger::{self as logger, log};
use iota_storage::users::user_manager;
use iota_storage::util::config_util::CONFIG;
use std::process::ExitCode;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{broadcast, watch};
#[tokio::main(flavor = "multi_thread")]
@ -21,6 +22,61 @@ async fn main() -> ExitCode {
return ExitCode::FAILURE;
}
};
// Bind a deliberately dormant IPC daemon before terms are accepted. This
// makes socket activation and `iota terms accept --system` usable, while
// the router exposes status only and the inactive service cannot connect.
if !iota_terms::consent::load(&paths.state_dir).has_all_required() {
let socket = match &paths.ipc_endpoint {
iota_paths::IpcEndpoint::UnixSocket(path) => path.clone(),
iota_paths::IpcEndpoint::WindowsPipe(_) => {
eprintln!("Windows named-pipe daemon transport is not implemented yet");
return ExitCode::FAILURE;
}
};
let runtime = Arc::new(DaemonRuntime::new());
let (log_tx, _) = broadcast::channel(64);
let log_buffer = Arc::new(Mutex::new(LogBuffer::new(64)));
let (_, state_rx) = watch::channel(runtime.snapshot());
let server = match IpcServer::bind(
socket,
runtime.clone(),
DaemonServices::inactive(),
log_tx,
log_buffer,
state_rx,
)
.await
{
Ok(server) => server,
Err(error) => {
eprintln!("Cannot bind dormant daemon IPC socket: {error}");
return ExitCode::FAILURE;
}
};
tokio::spawn(async move {
let _ = server.serve().await;
});
eprintln!(
"Iota daemon is awaiting terms acceptance. Run `iota terms accept{}` in an interactive terminal.",
if paths.scope == iota_paths::Scope::System {
" --system"
} else {
""
}
);
loop {
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(1)) => {
if iota_terms::consent::load(&paths.state_dir).has_all_required() {
// systemd restarts this daemon; a locally-launched daemon can
// simply be started again after accepting the documents.
return ExitCode::from(75);
}
}
_ = tokio::signal::ctrl_c() => return ExitCode::SUCCESS,
}
}
}
if let Err(error) = paths.migrate_legacy_layout() {
eprintln!("Cannot migrate legacy Iota layout: {error}");
return ExitCode::FAILURE;
@ -41,7 +97,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 +158,7 @@ async fn main() -> ExitCode {
runtime.clone(),
services,
log_tx.clone(),
log_buffer.clone(),
state_rx,
)
.await
@ -128,6 +186,21 @@ async fn main() -> ExitCode {
.await;
log!("iota-daemon IPC server ready");
log!(
"iota-daemon paths (scope={:?}): config={} state={} storage={} identity={} cache={} log={} asset={} ipc={}",
paths.scope,
paths.config_file.display(),
paths.state_dir.display(),
paths.storage_dir.display(),
paths.identity_dir.display(),
paths.cache_dir.display(),
paths.log_dir.display(),
paths.asset_dir.display(),
match &paths.ipc_endpoint {
iota_paths::IpcEndpoint::UnixSocket(p) => p.display().to_string(),
iota_paths::IpcEndpoint::WindowsPipe(n) => n.clone(),
},
);
runtime.set_startup_phase(StartupPhase::StartingServices);
// --- System monitor ---

View file

@ -1,11 +1,14 @@
pub mod protocol;
pub mod text_commands;
pub mod transport;
pub use protocol::{
ClientMessage, ComponentHealth, ComponentId, ConnectionStatus, DaemonMessage, DeploymentMode,
ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse,
ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode,
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest,
LogEntry, MetricSample, RequestEnvelope, ResponseEnvelope, ResponseResult, StartupPhase,
StateSnapshot, SupervisorKind,
LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope,
ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse,
SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary,
};
pub use transport::{read_msg, write_msg};

View file

@ -49,6 +49,25 @@ pub enum LocalRequest {
RestartDaemon,
#[serde(skip)]
StopDaemon,
GetConfig,
SetConfig {
key: String,
value: String,
},
ReloadConfig,
GetOmikronStatus,
ListComponents,
GetUser {
user_id: i64,
},
ImportUser {
username: String,
},
GetLogs {
limit: usize,
},
CheckUpdate,
ListCommunities,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
@ -107,10 +126,95 @@ 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 +232,43 @@ pub enum IpcErrorCode {
InternalFailure,
}
impl std::fmt::Display for IpcErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::InvalidRequest => "the daemon rejected the request",
Self::NotFound => "the requested resource was not found",
Self::Conflict => "the request conflicts with current daemon state",
Self::StorageFailure => "the daemon could not access local storage",
Self::OmikronUnavailable => "Omikron is unavailable",
Self::UnsupportedVersion => "the client and daemon protocol versions are incompatible",
Self::NotReady => "the daemon is not ready yet",
Self::Disconnected => "the daemon connection was lost",
Self::Timeout => "the daemon did not respond in time",
Self::Cancelled => "the daemon cancelled the request",
Self::Unauthorized => "the daemon denied this operation",
Self::InternalFailure => "the daemon encountered an internal failure",
})
}
}
#[cfg(test)]
mod error_tests {
use super::IpcErrorCode;
#[test]
fn error_codes_have_operator_facing_messages() {
assert_eq!(
IpcErrorCode::NotReady.to_string(),
"the daemon is not ready yet"
);
assert!(
!IpcErrorCode::InternalFailure
.to_string()
.contains("InternalFailure")
);
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum LifecycleEvent {

View file

@ -0,0 +1,324 @@
use crate::LocalRequest;
pub const COMMANDS: &[&str] = &[
"status",
"tasks",
"users list",
"users show ",
"users add ",
"users remove ",
"users import ",
"omikron status",
"reconnect",
"identity rotate",
"daemon status",
"config get",
"config set ",
"config reload",
"components",
"logs",
"update check",
"community list",
"restart",
"stop",
];
pub fn completions(prefix: &str) -> Vec<&'static str> {
let normalized = prefix.trim_start_matches('/');
COMMANDS
.iter()
.copied()
.filter(|command| command.starts_with(normalized))
.collect()
}
pub fn validation_error(line: &str) -> Option<String> {
let normalized = line.trim_start_matches('/').trim();
if normalized == "help" || parse(normalized).is_some() {
None
} else {
Some(format!(
"Unknown command `{normalized}`. Use /help or Tab completion."
))
}
}
/// Parse a text command string into a typed IPC request.
///
/// Both the CLI console and the TUI command palette use this single parser.
/// Commands are case-insensitive and support an optional leading `/`.
pub fn parse(line: &str) -> Option<LocalRequest> {
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
match parts.as_slice() {
["help"] => None,
["status"] => Some(LocalRequest::GetStatus),
["tasks"] => Some(LocalRequest::ListTasks),
["users"] | ["user", "list"] | ["users", "list"] => Some(LocalRequest::ListUsers),
["user" | "users", "show", id_str] => {
let user_id = id_str.parse::<i64>().ok()?;
Some(LocalRequest::GetUser { user_id })
}
["user" | "users", "add", username] => Some(LocalRequest::CreateUser {
username: username.to_string(),
}),
["user" | "users", "remove", id_str] => {
let user_id = id_str.parse::<i64>().ok()?;
Some(LocalRequest::RemoveUser { user_id })
}
["user" | "users", "import", username] => Some(LocalRequest::ImportUser {
username: username.to_string(),
}),
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity),
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
intent: crate::ExitIntent::Restart,
}),
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
intent: crate::ExitIntent::Stop,
}),
["daemon", "status"] => Some(LocalRequest::GetDaemonStatus),
["config", "get"] => Some(LocalRequest::GetConfig),
["config", "set", key, value] => Some(LocalRequest::SetConfig {
key: key.to_string(),
value: value.to_string(),
}),
["config", "reload"] => Some(LocalRequest::ReloadConfig),
["omikron", "status"] => Some(LocalRequest::GetOmikronStatus),
["components"] => Some(LocalRequest::ListComponents),
["logs"] => Some(LocalRequest::GetLogs { limit: 100 }),
["update", "check"] => Some(LocalRequest::CheckUpdate),
["community", "list"] | ["communities"] => Some(LocalRequest::ListCommunities),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_status() {
assert!(matches!(parse("status"), Some(LocalRequest::GetStatus)));
}
#[test]
fn parses_tasks() {
assert!(matches!(parse("tasks"), Some(LocalRequest::ListTasks)));
}
#[test]
fn parses_user_list_shortcuts() {
assert!(matches!(parse("users"), Some(LocalRequest::ListUsers)));
assert!(matches!(parse("user list"), Some(LocalRequest::ListUsers)));
}
#[test]
fn parses_user_add() {
let req = parse("user add alice").unwrap();
match req {
LocalRequest::CreateUser { username } => assert_eq!(username, "alice"),
_ => panic!("expected CreateUser"),
}
}
#[test]
fn accepts_the_headless_cli_user_vocabulary() {
assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers)));
assert!(matches!(
parse("users add alice"),
Some(LocalRequest::CreateUser { .. })
));
assert!(matches!(
parse("users remove 42"),
Some(LocalRequest::RemoveUser { user_id: 42 })
));
assert!(matches!(
parse("identity rotate"),
Some(LocalRequest::RotateIotaIdentity)
));
}
#[test]
fn parses_user_remove_by_id() {
let req = parse("user remove 42").unwrap();
match req {
LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42),
_ => panic!("expected RemoveUser"),
}
}
#[test]
fn user_remove_requires_numeric_id() {
assert!(parse("user remove alice").is_none());
}
#[test]
fn parses_reconnect() {
assert!(matches!(
parse("reconnect"),
Some(LocalRequest::ReconnectOmikron)
));
}
#[test]
fn parses_regenerate_keys() {
assert!(matches!(
parse("regenerate keys"),
Some(LocalRequest::RotateIotaIdentity)
));
}
#[test]
fn parses_restart_aliases() {
assert!(matches!(
parse("restart"),
Some(LocalRequest::RequestProcessExit { .. })
));
assert!(matches!(
parse("reload"),
Some(LocalRequest::RequestProcessExit { .. })
));
}
#[test]
fn parses_stop_aliases() {
assert!(matches!(
parse("stop"),
Some(LocalRequest::RequestProcessExit { .. })
));
assert!(matches!(
parse("shutdown"),
Some(LocalRequest::RequestProcessExit { .. })
));
}
#[test]
fn parses_daemon_status() {
assert!(matches!(
parse("daemon status"),
Some(LocalRequest::GetDaemonStatus)
));
}
#[test]
fn parses_config_get() {
assert!(matches!(parse("config get"), Some(LocalRequest::GetConfig)));
}
#[test]
fn parses_config_reload() {
assert!(matches!(
parse("config reload"),
Some(LocalRequest::ReloadConfig)
));
}
#[test]
fn parses_omikron_status() {
assert!(matches!(
parse("omikron status"),
Some(LocalRequest::GetOmikronStatus)
));
}
#[test]
fn parses_components() {
assert!(matches!(
parse("components"),
Some(LocalRequest::ListComponents)
));
}
#[test]
fn parses_users_show() {
let req = parse("users show 42").unwrap();
match req {
LocalRequest::GetUser { user_id } => assert_eq!(user_id, 42),
_ => panic!("expected GetUser"),
}
}
#[test]
fn user_show_requires_numeric_id() {
assert!(parse("users show alice").is_none());
}
#[test]
fn parses_config_set() {
let req = parse("config set port 8080").unwrap();
match req {
LocalRequest::SetConfig { key, value } => {
assert_eq!(key, "port");
assert_eq!(value, "8080");
}
_ => panic!("expected SetConfig"),
}
}
#[test]
fn parses_logs() {
assert!(matches!(parse("logs"), Some(LocalRequest::GetLogs { .. })));
}
#[test]
fn parses_update_check() {
assert!(matches!(
parse("update check"),
Some(LocalRequest::CheckUpdate)
));
}
#[test]
fn parses_community_list() {
assert!(matches!(
parse("community list"),
Some(LocalRequest::ListCommunities)
));
}
#[test]
fn parses_communities_alias() {
assert!(matches!(
parse("communities"),
Some(LocalRequest::ListCommunities)
));
}
#[test]
fn parses_users_import() {
let req = parse("users import alice").unwrap();
match req {
LocalRequest::ImportUser { username } => assert_eq!(username, "alice"),
_ => panic!("expected ImportUser"),
}
}
#[test]
fn parses_with_slash_prefix() {
assert!(matches!(parse("/status"), Some(LocalRequest::GetStatus)));
assert!(matches!(parse("/tasks"), Some(LocalRequest::ListTasks)));
}
#[test]
fn unknown_returns_none() {
assert!(parse("nonexistent").is_none());
}
#[test]
fn completion_is_prefix_based_and_deterministic() {
assert_eq!(completions("identity r"), vec!["identity rotate"]);
assert_eq!(completions("/users a"), vec!["users add "]);
assert!(completions("definitely-unknown").is_empty());
}
#[test]
fn validation_distinguishes_help_and_unknown_commands() {
assert_eq!(validation_error("/help"), None);
assert!(validation_error("status").is_none());
assert!(
validation_error("statuz")
.unwrap()
.contains("Unknown command")
);
}
}

View file

@ -1,5 +1,6 @@
use crate::storage_error::StorageError;
use crate::util::db;
use crate::util::sync::{self, EntityType, Operation};
use iota_logger::log;
use rusqlite::params;
@ -46,6 +47,7 @@ impl MessageState {
#[derive(Debug, Clone)]
pub struct StoredMessage {
pub id: i64,
pub external_user: i64,
pub message_time: i64,
pub content: String,
pub edited: bool,
@ -150,7 +152,8 @@ fn update_message_content(
.unwrap()
.as_millis() as i64;
conn.execute(
let tx = conn.unchecked_transaction()?;
tx.execute(
r#"
INSERT INTO message_edits (message_id, content_before, content_after, edited_at, edited_by)
VALUES (?1, ?2, ?3, ?4, ?5)
@ -158,7 +161,7 @@ fn update_message_content(
params![msg_id, old_content, new_content, now, editor_id],
)?;
conn.execute(
tx.execute(
r#"
UPDATE messages
SET content = ?1, edited_count = edited_count + 1
@ -166,6 +169,14 @@ fn update_message_content(
"#,
params![new_content, msg_id],
)?;
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Upsert,
)?;
tx.commit()?;
Ok(())
})
@ -187,15 +198,24 @@ pub fn hard_delete_message(
|row| row.get(0),
)?;
conn.execute(
let tx = conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM message_edits WHERE message_id = ?1",
params![msg_id],
)?;
conn.execute(
tx.execute(
"DELETE FROM reactions WHERE message_id = ?1",
params![msg_id],
)?;
conn.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?;
tx.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?;
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Delete,
)?;
tx.commit()?;
Ok(())
})
}
@ -261,7 +281,8 @@ pub fn flag_deleted_by_external(
message_time: i64,
) -> Result<(), StorageError> {
db::with_db(|conn| {
let affected = conn.execute(
let tx = conn.unchecked_transaction()?;
let affected = tx.execute(
r#"
UPDATE messages
SET deleted_by_external = 1
@ -272,6 +293,15 @@ pub fn flag_deleted_by_external(
if affected == 0 {
return Err(StorageError::Other("Message not found".into()));
}
let msg_id: i64 = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 ORDER BY id DESC LIMIT 1", params![storage_owner, external_user, message_time], |r| r.get(0))?;
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Delete,
)?;
tx.commit()?;
Ok(())
})
}
@ -297,10 +327,19 @@ pub fn delete_edit_history(
|row| row.get(0),
)?;
conn.execute(
let tx = conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM message_edits WHERE message_id = ?1",
params![msg_id],
)?;
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Upsert,
)?;
tx.commit()?;
Ok(())
})
}
@ -328,13 +367,22 @@ pub fn add_reaction(
.unwrap()
.as_millis() as i64;
conn.execute(
let tx = conn.unchecked_transaction()?;
tx.execute(
r#"
INSERT OR IGNORE INTO reactions (message_id, user_id, reaction, created_at)
VALUES (?1, ?2, ?3, ?4)
"#,
params![msg_id, user_id, reaction, now],
)?;
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Upsert,
)?;
tx.commit()?;
Ok(())
})
}
@ -357,10 +405,19 @@ pub fn remove_reaction(
|row| row.get(0),
)?;
conn.execute(
let tx = conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM reactions WHERE message_id = ?1 AND user_id = ?2 AND reaction = ?3",
params![msg_id, user_id, reaction],
)?;
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Upsert,
)?;
tx.commit()?;
Ok(())
})
}
@ -383,7 +440,8 @@ pub fn add_message(
};
if let Err(e) = db::with_db(|conn| {
conn.execute(
let tx = conn.unchecked_transaction()?;
tx.execute(
r#"
INSERT INTO messages (
storage_owner, external_user, message_time, content,
@ -405,6 +463,15 @@ pub fn add_message(
reply_to,
],
)?;
let msg_id = tx.last_insert_rowid();
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Upsert,
)?;
tx.commit()?;
Ok(())
}) {
log!("Failed to insert message into sqlite: {}", e);
@ -447,7 +514,8 @@ pub fn change_message_state(
.as_str()
.to_string();
conn.execute(
let tx = conn.unchecked_transaction()?;
tx.execute(
r#"
UPDATE messages
SET message_state = ?1
@ -459,6 +527,13 @@ pub fn change_message_state(
"#,
params![upgraded, storage_owner, external_user, timestamp],
)?;
let msg_id: i64 = tx.query_row(
"SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 ORDER BY id DESC LIMIT 1",
params![storage_owner, external_user, timestamp],
|row| row.get(0),
)?;
sync::record_event(&tx, storage_owner, EntityType::Message, msg_id, Operation::Upsert)?;
tx.commit()?;
Ok(())
})
.map_err(|e: StorageError| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
@ -532,6 +607,7 @@ pub fn get_messages(
|row| {
Ok(StoredMessage {
id: row.get(0)?,
external_user,
message_time: row.get(1)?,
content: row.get(2)?,
sent_by_self: row.get::<_, i64>(3)? != 0,
@ -568,6 +644,69 @@ pub fn get_messages(
}
}
pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec<StoredMessage> {
if ids.is_empty() {
return Vec::new();
}
let wanted: std::collections::HashSet<i64> = ids.iter().copied().collect();
// A journal id uniquely identifies a row. Load all messages for this owner and retain only
// those ids; this keeps reaction hydration identical to normal message loading.
match db::with_db(|conn| {
let mut stmt = conn.prepare("SELECT id, message_time, content, sent_by_self, message_state, height, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0")?;
let rows = stmt.query_map([storage_owner], |row| {
let external_user: i64 = row.get(8)?;
Ok(StoredMessage {
id: row.get(0)?,
external_user,
message_time: row.get(1)?,
content: row.get(2)?,
sent_by_self: row.get::<_, i64>(3)? != 0,
message_state: row.get(4)?,
height: row.get(5).unwrap_or(0),
reply_to: row.get(6).ok().flatten(),
edited: row.get::<_, i64>(7).unwrap_or(0) > 0,
reactions: Vec::new(),
})
})?;
let mut messages = Vec::new();
for row in rows {
let message = row?;
if wanted.contains(&message.id) {
messages.push(message);
}
}
let reaction_map = load_reactions(conn, &messages.iter().map(|m| m.id).collect::<Vec<_>>());
for message in &mut messages {
message.reactions = reaction_map.get(&message.id).cloned().unwrap_or_default();
}
Ok(messages)
}) {
Ok(messages) => messages,
Err(e) => {
log!("Failed to query messages by id: {}", e);
Vec::new()
}
}
}
pub fn get_all_messages(storage_owner: i64) -> Vec<StoredMessage> {
let ids = match db::with_db(|conn| {
let mut stmt = conn.prepare(
"SELECT id FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0",
)?;
Ok(stmt
.query_map([storage_owner], |row| row.get::<_, i64>(0))?
.collect::<Result<Vec<_>, _>>()?)
}) {
Ok(ids) => ids,
Err(e) => {
log!("Failed to query all messages: {}", e);
return Vec::new();
}
};
get_messages_by_ids(storage_owner, &ids)
}
#[cfg(test)]
mod tests {
use super::MessageState;

View file

@ -1,10 +1,12 @@
use crate::users::contact::Contact;
use crate::util::db;
use crate::util::sync::{self, EntityType, Operation};
use rusqlite::params;
pub fn mod_user(storage_owner: i64, contact: &Contact) {
if let Err(e) = db::with_db(|conn| {
conn.execute(
let tx = conn.unchecked_transaction()?;
tx.execute(
r#"
INSERT INTO contacts (storage_owner, user_id, user_name, last_message_at)
VALUES (?1, ?2, ?3, ?4)
@ -19,12 +21,31 @@ pub fn mod_user(storage_owner: i64, contact: &Contact) {
contact.last_message_at,
],
)?;
sync::record_event(
&tx,
storage_owner,
EntityType::Contact,
contact.user_id,
Operation::Upsert,
)?;
tx.commit()?;
Ok(())
}) {
eprintln!("Failed to mod_user: {}", e);
}
}
pub fn get_users_by_ids(storage_owner: i64, ids: &[i64]) -> Vec<Contact> {
if ids.is_empty() {
return Vec::new();
}
let wanted: std::collections::HashSet<i64> = ids.iter().copied().collect();
get_users(storage_owner)
.into_iter()
.filter(|contact| wanted.contains(&contact.user_id))
.collect()
}
pub fn get_user(storage_owner: i64, user_id: i64) -> Option<Contact> {
match db::with_db(|conn| {
match conn.query_row(

View file

@ -172,6 +172,57 @@ pub fn modify_config(f: impl FnOnce(&mut IotaConfig)) {
CONFIG.store(Arc::new(cfg));
save_config();
}
pub fn modify_config_value(key: &str, value: &str) -> Result<(), &'static str> {
match key {
"iota_id" => {
let parsed: u64 = value.parse().map_err(|_| "invalid iota_id")?;
modify_config(|cfg| cfg.iota_id = Some(parsed));
Ok(())
}
"port" => {
let parsed: u16 = value.parse().map_err(|_| "invalid port")?;
modify_config(|cfg| cfg.port = parsed);
Ok(())
}
"omikron_host" => {
let host = value.to_string();
modify_config(|cfg| cfg.omikron_host = Some(host));
Ok(())
}
"omikron_port" => {
let parsed: u16 = value.parse().map_err(|_| "invalid omikron_port")?;
modify_config(|cfg| cfg.omikron_port = Some(parsed));
Ok(())
}
"read_receipts_enabled" => {
let parsed: bool = value.parse().map_err(|_| "invalid boolean")?;
modify_config(|cfg| cfg.read_receipts_enabled = parsed);
Ok(())
}
"web.mode" => {
let mode = match value {
"disabled" => WebMode::Disabled,
"loopback" => WebMode::Loopback,
"network" => WebMode::Network,
_ => return Err("invalid web.mode; use disabled, loopback, or network"),
};
modify_config(|cfg| cfg.web.mode = mode);
Ok(())
}
"web.port" => {
let parsed: u16 = value.parse().map_err(|_| "invalid web.port")?;
modify_config(|cfg| cfg.web.port = parsed);
Ok(())
}
"web.bind" => {
let bind = value.to_string();
modify_config(|cfg| cfg.web.bind = bind);
Ok(())
}
_ => Err("unknown config key"),
}
}
static CONFIG_PATH: OnceLock<PathBuf> = OnceLock::new();
pub fn configure_config_path(path: PathBuf) {

View file

@ -220,6 +220,37 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
)?;
}
if current_version < 6 {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS sync_heads (
user_id INTEGER PRIMARY KEY,
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_events (
user_id INTEGER NOT NULL,
version INTEGER NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER NOT NULL,
operation TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, version)
);
CREATE INDEX IF NOT EXISTS idx_sync_events_user_version
ON sync_events (user_id, version);
CREATE TABLE IF NOT EXISTS client_sync_state (
user_id INTEGER NOT NULL,
session_id INTEGER NOT NULL,
acknowledged_version INTEGER NOT NULL,
cache_schema_version INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, session_id)
);
PRAGMA user_version = 6;
"#,
)?;
}
Ok(())
}
@ -289,7 +320,7 @@ mod tests {
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 5);
assert_eq!(version, 6);
for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
@ -298,4 +329,23 @@ mod tests {
Ok(())
}
#[test]
fn migrates_version_five_once_and_is_idempotent() -> Result<(), StorageError> {
let conn = Connection::open_in_memory()?;
conn.execute_batch("PRAGMA user_version = 5;")?;
run_migrations_on_connection(&conn)?;
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 6);
for table in ["sync_heads", "sync_events", "client_sync_state"] {
let exists: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|row| row.get(0),
)?;
assert_eq!(exists, 1);
}
Ok(())
}
}

View file

@ -5,3 +5,4 @@ pub mod config_util;
pub mod db;
pub mod e2ee_storage;
pub mod settings;
pub mod sync;

View file

@ -0,0 +1,160 @@
//! Durable per-user state journal used by device cache synchronization.
use crate::storage_error::StorageError;
use crate::util::db;
use rusqlite::{Transaction, params};
use std::collections::BTreeMap;
pub const CACHE_SCHEMA_VERSION: i64 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntityType {
Message,
Contact,
}
impl EntityType {
fn as_str(self) -> &'static str {
match self {
Self::Message => "message",
Self::Contact => "contact",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operation {
Upsert,
Delete,
}
impl Operation {
fn as_str(self) -> &'static str {
match self {
Self::Upsert => "upsert",
Self::Delete => "delete",
}
}
}
#[derive(Debug, Default, Clone)]
pub struct Delta {
pub message_upserts: Vec<i64>,
pub deleted_message_ids: Vec<i64>,
pub contact_upserts: Vec<i64>,
pub deleted_contact_ids: Vec<i64>,
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
pub fn record_event(
tx: &Transaction<'_>,
user_id: i64,
entity: EntityType,
entity_id: i64,
operation: Operation,
) -> Result<i64, StorageError> {
tx.execute(
"INSERT INTO sync_heads (user_id, version) VALUES (?1, 0) ON CONFLICT(user_id) DO NOTHING",
[user_id],
)?;
let previous: i64 = tx.query_row(
"SELECT version FROM sync_heads WHERE user_id = ?1",
[user_id],
|r| r.get(0),
)?;
let version = previous
.checked_add(1)
.ok_or_else(|| StorageError::Other("sync version overflow".into()))?;
tx.execute(
"UPDATE sync_heads SET version = ?2 WHERE user_id = ?1",
params![user_id, version],
)?;
tx.execute("INSERT INTO sync_events (user_id, version, entity_type, entity_id, operation, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![user_id, version, entity.as_str(), entity_id, operation.as_str(), now_millis()])?;
Ok(version)
}
pub fn head(user_id: i64) -> Result<i64, StorageError> {
db::with_db(|conn| {
Ok(conn
.query_row(
"SELECT version FROM sync_heads WHERE user_id = ?1",
[user_id],
|r| r.get(0),
)
.unwrap_or(0))
})
}
pub fn has_session(user_id: i64, session_id: i64) -> Result<bool, StorageError> {
db::with_db(|conn| {
Ok(conn
.query_row(
"SELECT 1 FROM client_sync_state WHERE user_id = ?1 AND session_id = ?2",
params![user_id, session_id],
|_| Ok(()),
)
.is_ok())
})
}
pub fn acknowledge(
user_id: i64,
session_id: i64,
version: i64,
cache_schema_version: i64,
) -> Result<(), StorageError> {
if user_id <= 0 || session_id <= 0 || version < 0 {
return Err(StorageError::Other("invalid sync acknowledgement".into()));
}
db::with_db(|conn| {
let head = conn
.query_row(
"SELECT version FROM sync_heads WHERE user_id = ?1",
[user_id],
|r| r.get(0),
)
.unwrap_or(0);
if version > head {
return Err(StorageError::Other(
"acknowledgement is ahead of head".into(),
));
}
conn.execute("INSERT INTO client_sync_state (user_id, session_id, acknowledged_version, cache_schema_version, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(user_id, session_id) DO UPDATE SET acknowledged_version = MAX(acknowledged_version, excluded.acknowledged_version), cache_schema_version = excluded.cache_schema_version, updated_at = excluded.updated_at", params![user_id, session_id, version, cache_schema_version, now_millis()])?;
Ok(())
})
}
/// Returns the final operation for each entity after `from_version`.
pub fn delta(user_id: i64, from_version: i64, captured_head: i64) -> Result<Delta, StorageError> {
if from_version < 0 || from_version > captured_head {
return Err(StorageError::Other("invalid sync cursor".into()));
}
db::with_db(|conn| {
let mut stmt = conn.prepare("SELECT entity_type, entity_id, operation FROM sync_events WHERE user_id = ?1 AND version > ?2 AND version <= ?3 ORDER BY version ASC")?;
let mut final_events = BTreeMap::<(String, i64), String>::new();
for row in stmt.query_map(params![user_id, from_version, captured_head], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
))
})? {
let (kind, id, operation) = row?;
final_events.insert((kind, id), operation);
}
let mut out = Delta::default();
for ((kind, id), operation) in final_events {
match (kind.as_str(), operation.as_str()) {
("message", "delete") => out.deleted_message_ids.push(id),
("message", _) => out.message_upserts.push(id),
("contact", "delete") => out.deleted_contact_ids.push(id),
("contact", _) => out.contact_upserts.push(id),
_ => {}
}
}
Ok(out)
})
}

89
iota-terms/src/consent.rs Normal file
View file

@ -0,0 +1,89 @@
//! Durable, deployment-scoped consent records.
//!
//! This deliberately contains no UI code. Both the terminal client and the
//! daemon use the same record so a UI-local decision can never start services.
use crate::{Doc, TermsType as Type};
use std::fs;
use std::io;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
const FILE_NAME: &str = "terms-consent-v1";
#[derive(Clone, Debug, Default)]
pub struct ConsentRecord {
pub eula: Option<(String, String)>,
pub tos: Option<(String, String)>,
pub privacy: Option<(String, String)>,
}
impl ConsentRecord {
pub fn has_all_required(&self) -> bool {
self.eula.is_some() && self.tos.is_some() && self.privacy.is_some()
}
pub fn accepts(&self, eula: &Doc, tos: &Doc, privacy: &Doc) -> bool {
matches_doc(&self.eula, eula)
&& matches_doc(&self.tos, tos)
&& matches_doc(&self.privacy, privacy)
}
pub fn accept(&mut self, doc: &Doc) {
let value = Some((doc.get_version(), doc.get_hash()));
match doc.doc_type {
Type::EULA => self.eula = value,
Type::TOS => self.tos = value,
Type::PP => self.privacy = value,
}
}
}
fn matches_doc(value: &Option<(String, String)>, doc: &Doc) -> bool {
matches!(value, Some((version, hash)) if version == &doc.get_version() && hash == &doc.get_hash())
}
pub fn load(state_dir: &Path) -> ConsentRecord {
let Ok(text) = fs::read_to_string(state_dir.join(FILE_NAME)) else {
return ConsentRecord::default();
};
let mut record = ConsentRecord::default();
for line in text.lines() {
let Some((key, value)) = line.split_once('=') else {
continue;
};
let Some((version, hash)) = value.split_once(':') else {
continue;
};
let value = Some((version.to_owned(), hash.to_owned()));
match key {
"eula" => record.eula = value,
"tos" => record.tos = value,
"privacy" => record.privacy = value,
_ => {}
}
}
record
}
pub fn save(state_dir: &Path, record: &ConsentRecord) -> io::Result<()> {
fs::create_dir_all(state_dir)?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut text = format!("# Iota terms consent record; accepted_at_unix={timestamp}\n");
for (name, value) in [
("eula", &record.eula),
("tos", &record.tos),
("privacy", &record.privacy),
] {
if let Some((version, hash)) = value {
text.push_str(&format!("{name}={version}:{hash}\n"));
}
}
let path = state_dir.join(FILE_NAME);
let temporary = state_dir.join(format!(".{FILE_NAME}.{}.tmp", std::process::id()));
fs::write(&temporary, text)?;
fs::rename(temporary, path)
}

View file

@ -1,3 +1,4 @@
pub mod consent;
pub mod terms_getter;
pub use terms_getter::Type as TermsType;

View file

@ -10,5 +10,9 @@ iota-installer = { path = "../iota-installer" }
iota-core = { path = "../iota-core" }
iota-process-manager = { path = "../iota-process-manager" }
iota-paths = { path = "../iota-paths" }
iota-terms = { path = "../iota-terms" }
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
serde_json = "1"
serde_yaml = "0.9"
clap = { version = "4.5", features = ["derive"] }

View file

@ -1,15 +1,246 @@
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind};
use iota_cli::theme::ThemeName;
use iota_terms::TermsType;
#[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),
Terms(TermsArgs),
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(Args, Debug)]
struct TermsArgs {
#[command(subcommand)]
action: TermsAction,
}
#[derive(Subcommand, Debug)]
enum TermsAction {
Status {
#[arg(long)]
system: bool,
},
Show {
document: TermsDocument,
},
Accept {
#[arg(long)]
system: bool,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum TermsDocument {
Eula,
Tos,
Privacy,
}
impl From<TermsDocument> for TermsType {
fn from(value: TermsDocument) -> Self {
match value {
TermsDocument::Eula => TermsType::EULA,
TermsDocument::Tos => TermsType::TOS,
TermsDocument::Privacy => TermsType::PP,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Dashboard,
Help,
Version,
Completions {
shell: String,
},
ManPage,
Install {
bundle: String,
operator: Option<String>,
@ -17,95 +248,188 @@ 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,
TermsStatus {
system: bool,
},
TermsShow {
document: TermsType,
},
TermsAccept {
system: bool,
},
}
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",
},
[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,
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(),
}
}
[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()),
});
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::Terms(terms)) => match terms.action {
TermsAction::Status { system } => Command::TermsStatus { system },
TermsAction::Show { document } => Command::TermsShow {
document: document.into(),
},
TermsAction::Accept { system } => Command::TermsAccept { system },
},
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 },
},
};
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 +450,52 @@ 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 +521,92 @@ mod tests {
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
assert_eq!(invocation.command, Command::DaemonStop { confirmed: false });
}
#[test]
fn parses_users_add() {
let invocation =
CliInvocation::parse(["users".into(), "add".into(), "alice".into()]).unwrap();
assert_eq!(
invocation.command,
Command::UsersAdd {
username: "alice".into()
}
);
}
#[test]
fn rejects_malformed_users_add_shape() {
assert!(
CliInvocation::parse([
"users".into(),
"incorrect".into(),
"add".into(),
"alice".into()
])
.is_err()
);
}
#[test]
fn rejects_unknown_destructive_option() {
assert!(CliInvocation::parse(["daemon".into(), "stop".into(), "--later".into()]).is_err());
}
#[test]
fn parses_users_remove_without_confirmation() {
let invocation =
CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap();
assert_eq!(
invocation.command,
Command::UsersRemove {
user_id: 42,
confirmed: false,
}
);
}
#[test]
fn parses_users_remove_with_confirmation() {
let invocation =
CliInvocation::parse(["users".into(), "remove".into(), "42".into(), "--yes".into()])
.unwrap();
assert_eq!(
invocation.command,
Command::UsersRemove {
user_id: 42,
confirmed: true,
}
);
}
#[test]
fn parses_omikron_reconnect() {
let invocation = CliInvocation::parse(["omikron".into(), "reconnect".into()]).unwrap();
assert_eq!(invocation.command, Command::OmikronReconnect);
}
#[test]
fn parses_identity_rotate_requires_yes() {
let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap();
assert_eq!(
invocation.command,
Command::IdentityRotate { confirmed: false }
);
let invocation =
CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap();
assert_eq!(
invocation.command,
Command::IdentityRotate { confirmed: true }
);
}
#[test]
fn rejects_daemon_ping_until_protocol_supports_a_ping_contract() {
assert!(CliInvocation::parse(["daemon".into(), "ping".into()]).is_err());
}
#[test]
fn rejects_daemon_diagnostics_until_protocol_supports_diagnostics() {
assert!(CliInvocation::parse(["daemon".into(), "diagnostics".into()]).is_err());
}
}

View file

@ -65,19 +65,23 @@ pub async fn run(
endpoints: &DaemonEndpoints,
caps: Capabilities,
) -> Result<ConnectionContext, StartupError> {
// Try connecting to an already-running daemon before starting a new one.
// The initial dashboard probe may have raced a daemon that was still
// accepting connections. Re-check both endpoints before offering setup:
// a system-managed daemon normally listens on a different socket from a
// locally launched one.
if let Ok(ipc) = IpcClient::connect(&endpoints.local).await {
return Ok(ConnectionContext { ipc });
}
if endpoints.system != endpoints.local {
if let Ok(ipc) = IpcClient::connect(&endpoints.system).await {
return Ok(ConnectionContext { ipc });
}
}
let options = caps.options();
if !options.iter().any(|o| o.enabled) {
let _ = show(
ui,
options,
"Daemon cannot be started. Correct the reported problem, then Retry, or Exit.",
)
.await?;
return Err(StartupError::Cancelled);
return Err(StartupError::Other(
"No running daemon could be reached, and no daemon launch method is available.".into(),
));
}
if UiConfig::load()
.map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi)

View file

@ -2,7 +2,7 @@ use iota_cli::{
ipc_client::IpcClient, screens::main_screen::MainScreen, theme,
ui::start_bootstrap_tui_with_theme,
};
use iota_ipc::{LocalRequest, ResponseResult};
use iota_ipc::{LocalRequest, ResponsePayload, ResponseResult};
use iota_process_manager::detect;
use std::{path::Path, process::ExitCode, sync::Arc};
@ -10,8 +10,9 @@ mod cli_args;
mod daemon_setup_flow;
mod local_daemon;
mod startup_error;
mod terms;
use cli_args::{CliInvocation, Command};
use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat};
use startup_error::StartupError;
#[tokio::main(flavor = "multi_thread")]
@ -30,6 +31,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 +65,23 @@ 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::TermsStatus { system } => terms::run(terms::TermsCommand::Status { system }).await,
Command::TermsShow { document } => terms::run(terms::TermsCommand::Show { document }).await,
Command::TermsAccept { system } => terms::run(terms::TermsCommand::Accept { system }).await,
Command::Install { bundle, operator } => {
iota_installer::install_linux_bundle_with_operator(
Path::new(&bundle),
@ -72,24 +92,31 @@ 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;
}
if !matches!(command, Command::Dashboard) {
match iota_core::consent_state::non_interactive_consent() {
iota_core::consent_state::NonInteractiveConsent::Accepted => {}
iota_core::consent_state::NonInteractiveConsent::RequiresInteractiveAcceptance => {
return Err(StartupError::Consent("Run `iota` in an interactive terminal to review and accept the required terms.".into()));
}
let state_dir = iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
.map_err(|error| {
StartupError::Other(format!("Cannot resolve consent storage: {error}"))
})?
.state_dir;
if !iota_terms::consent::load(&state_dir).has_all_required() {
return Err(StartupError::Consent("Run `iota terms accept` in an interactive terminal to review and accept the required terms.".into()));
}
let ipc = tokio::select! {
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 +145,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 +212,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 +227,54 @@ 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 {
@ -173,6 +283,7 @@ async fn run_dashboard(
if consent != (true, true) {
return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into()));
}
persist_dashboard_consent().await?;
let initial = tokio::select! {
result = connect_available(&endpoints) => result,
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
@ -223,6 +334,20 @@ async fn run_dashboard(
}
}
async fn persist_dashboard_consent() -> Result<(), StartupError> {
let docs = iota_terms::get_current_docs().await.ok_or_else(|| {
StartupError::Consent("Could not verify the current agreements after acceptance.".into())
})?;
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
.map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))?;
let mut record = iota_terms::consent::load(&paths.state_dir);
for document in [&docs.0, &docs.1, &docs.2] {
record.accept(document);
}
iota_terms::consent::save(&paths.state_dir, &record)
.map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))
}
fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError {
use iota_process_manager::ProcessManagerErrorKind::*;
match error.kind() {
@ -258,32 +383,115 @@ 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::TermsStatus { .. }
| Command::TermsShow { .. }
| Command::TermsAccept { .. }
| Command::DaemonEnable { .. }
| Command::DaemonDisableStartup
| Command::DaemonStartupStatus
| Command::DaemonStart
| Command::DaemonRestartService
| Command::DaemonStopService => {
return Err(StartupError::InvalidCommand(
"Command cannot be run headlessly.".into(),
));
@ -294,12 +502,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(())
}

118
iota/src/terms.rs Normal file
View file

@ -0,0 +1,118 @@
use crate::startup_error::StartupError;
use iota_terms::{Doc, TermsType, consent, get_current_docs, get_terms};
use std::io::{self, IsTerminal, Write};
pub enum TermsCommand {
Status { system: bool },
Show { document: TermsType },
Accept { system: bool },
}
fn state_dir(system: bool) -> Result<std::path::PathBuf, StartupError> {
let scope = if system {
iota_paths::Scope::System
} else {
iota_paths::Scope::User
};
iota_paths::IotaPaths::resolve(scope)
.map(|paths| paths.state_dir)
.map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))
}
pub async fn run(command: TermsCommand) -> Result<(), StartupError> {
match command {
TermsCommand::Status { system } => {
let record = consent::load(&state_dir(system)?);
println!(
"EULA: {}",
if record.eula.is_some() {
"accepted"
} else {
"not accepted"
}
);
println!(
"Terms of Service: {}",
if record.tos.is_some() {
"accepted"
} else {
"not accepted"
}
);
println!(
"Privacy Policy: {}",
if record.privacy.is_some() {
"accepted"
} else {
"not accepted"
}
);
Ok(())
}
TermsCommand::Show { document } => {
let text = get_terms(document).await.ok_or_else(|| {
StartupError::Consent("Could not fetch the requested terms document.".into())
})?;
print!("{text}");
Ok(())
}
TermsCommand::Accept { system } => accept(system).await,
}
}
async fn accept(system: bool) -> Result<(), StartupError> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return Err(StartupError::Consent("`iota terms accept` requires an interactive terminal so the documents can be reviewed.".into()));
}
let documents = get_current_docs().await.ok_or_else(|| {
StartupError::Consent(
"Could not fetch the current agreements from the legal endpoint.".into(),
)
})?;
let mut record = consent::load(&state_dir(system)?);
for document in [&documents.0, &documents.1, &documents.2] {
let text = get_terms(document.doc_type).await.ok_or_else(|| {
StartupError::Consent(format!(
"Could not fetch {}.",
document.doc_type.to_string()
))
})?;
println!(
"\n===== {} =====\nVersion: {}\nDocument hash: {}\n",
document.doc_type.to_string(),
document.get_version(),
document.get_hash()
);
print!("{text}\n");
if !confirm(document)? {
return Err(StartupError::Consent(
"No terms were accepted. Iota remains inactive.".into(),
));
}
record.accept(document);
}
consent::save(&state_dir(system)?, &record)
.map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))?;
println!("Terms accepted. Start Iota again to enable services.");
Ok(())
}
fn confirm(document: &Doc) -> Result<bool, StartupError> {
let hash = document.get_hash();
let prefix = hash.get(..10).unwrap_or(&hash);
let expected = format!(
"ACCEPT {} {} {}",
document.doc_type.to_str().to_ascii_uppercase(),
document.get_version(),
prefix
);
print!("To accept this exact document, type:\n{expected}\n> ");
io::stdout()
.flush()
.map_err(|error| StartupError::Consent(error.to_string()))?;
let mut response = String::new();
io::stdin()
.read_line(&mut response)
.map_err(|error| StartupError::Consent(error.to_string()))?;
Ok(response.trim() == expected)
}

View file

@ -889,6 +889,7 @@ impl OmikronConnection {
dispatch!(CreateApp, handle_create_app);
dispatch!(DeleteApp, handle_delete_app);
dispatch!(ClientConnected, handle_client_connected);
dispatch!(ClientStateAck, handle_client_state_ack);
dispatch!(MessageState, handle_message_state);
dispatch!(MessageSend, handle_message_send);
dispatch!(MessageEdit, handle_message_edit);
@ -1166,6 +1167,12 @@ impl OmikronConnection {
.await;
}
async fn handle_client_state_ack(self: Arc<Self>, cv: &CommunicationValue) {
let _ = self
.send_message(&message_handlers::handle_client_state_ack(cv))
.await;
}
async fn handle_message_state(self: Arc<Self>, cv: &CommunicationValue) {
message_handlers::handle_message_state(cv);
}

View file

@ -149,6 +149,9 @@ type_maps:
MessageReactionRemove: 147
MessageReactionLive: 148
MessageDeleteLive: 150
ClientStateSync: 151
ClientStateAck: 152
StateSubscribe: 153
DataTypes:
ErrorType: 32
ErrorProtocol: 33
@ -267,3 +270,9 @@ type_maps:
Reactions: 156
Reaction: 157
ReplyId: 158
CacheValid: 159
CacheSchemaVersion: 160
SyncMode: 161
MessageId: 162
DeletedMessageIds: 163
DeletedContactIds: 164