[Fix] IPC, cli, daemon

This commit is contained in:
Alex-Emmet 2026-07-21 23:05:34 +02:00
commit 56aad3a023
32 changed files with 1356 additions and 399 deletions

View file

@ -17,7 +17,7 @@ iota-logger = { path = "../iota-logger", optional = true }
iota-state = { path = "../iota-state" }
iota-storage = { path = "../iota-storage", optional = true }
iota-terms = { path = "../iota-terms" }
iota-util = { path = "../iota-util", optional = true }
iota-util = { path = "../iota-util", optional = true }
iota-ipc = { path = "../iota-ipc" }
omikron-connector = { path = "../omikron-connector", optional = true }
@ -64,6 +64,7 @@ strum = "0.27.2"
strum_macros = "0.27.2"
sysinfo = "0.38.3"
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
tokio-tungstenite = { version = "*", features = ["native-tls"] }
tungstenite = "*"
walkdir = "2.5.0"

View file

@ -1,18 +1,4 @@
use crossterm::event::{KeyCode, KeyEvent};
#[cfg(feature = "legacy-commands")]
use iota_logger::{log, log_cv};
#[cfg(feature = "legacy-commands")]
use iota_state::{ACTIVE_TASKS, RELOAD, SHUTDOWN};
#[cfg(feature = "legacy-commands")]
use iota_storage::users::{user_manager, user_profile::UserProfile};
#[cfg(feature = "legacy-commands")]
use iota_storage::util::config_util::modify_config;
#[cfg(feature = "legacy-commands")]
use iota_util::file_util;
#[cfg(feature = "legacy-commands")]
use mtp::codec::{CommunicationType, CommunicationValue};
#[cfg(feature = "legacy-commands")]
use omikron_connector::omikron_connection::OMIKRON_CONNECTION;
use ratatui::{
Frame,
layout::Rect,
@ -47,7 +33,7 @@ pub struct ConsoleCard {
cursor: Arc<Mutex<bool>>,
last_swap: Arc<Mutex<Instant>>,
tab_index: usize,
pending_restore: Arc<Mutex<Option<String>>>,
}
impl ConsoleCard {
@ -62,7 +48,7 @@ impl ConsoleCard {
joins: Borders::NONE,
cursor: Arc::new(Mutex::new(true)),
last_swap: Arc::new(Mutex::new(Instant::now())),
tab_index: 0,
pending_restore: Arc::new(Mutex::new(None)),
}
}
@ -112,12 +98,12 @@ impl ConsoleCard {
spans.push(Span::styled(" ", Style::default().fg(Color::White)));
}
spans.push(Span::styled(
"send command (<help> for info)",
"send command (/help for info)",
Style::default().fg(Color::DarkGray),
));
} else {
spans.push(Span::styled(
" send command (<help> for info)",
" send command (/help for info)",
Style::default().fg(Color::DarkGray),
));
}
@ -295,6 +281,12 @@ impl InteractableElement for ConsoleCard {
}
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
// Check if a previously failed command should be restored.
if let Some(restored) = self.pending_restore.lock().unwrap().take() {
self.content = restored;
self.cursor_position = self.content.chars().count();
}
match key.code {
KeyCode::Enter => {
if self.content.is_empty() {
@ -303,17 +295,15 @@ impl InteractableElement for ConsoleCard {
let command = self.content.clone();
let ipc = self.ipc.clone();
let seq = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let restore = self.pending_restore.clone();
tokio::spawn(async move {
let _ = ipc.send_command(seq, command).await;
if ipc.send_command(0, command.clone()).await.is_err() {
*restore.lock().unwrap() = Some(command);
}
});
self.content.clear();
self.cursor_position = 0;
self.tab_index = 0;
InteractionResult::Handled
}
KeyCode::Backspace => {
@ -350,14 +340,7 @@ impl InteractableElement for ConsoleCard {
self.cursor_position = self.content.chars().count();
InteractionResult::Handled
}
KeyCode::Tab => {
if let Some(prefix) = self.current_prefix() {
if prefix == "/" {
self.tab_index = self.tab_index.saturating_add(1);
}
}
InteractionResult::Handled
}
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
_ => {
if let Some(c) = key.code.as_char() {
self.insert_at_cursor(c);
@ -381,144 +364,3 @@ impl InteractableElement for ConsoleCard {
self.focused = f;
}
}
#[cfg(feature = "legacy-commands")]
pub async fn run_command(command: &str) {
let parts = command.split(" ").collect::<Vec<&str>>();
match parts.as_slice() {
["tasks"] => {
let active_tasks: Vec<String> =
ACTIVE_TASKS.clone().iter().map(|v| v.to_string()).collect();
let info = if *SHUTDOWN.read().await && *RELOAD.read().await {
"Rebooting, "
} else if *SHUTDOWN.read().await {
"Shutting , "
} else {
""
};
log!("{}Active tasks: {:?}", info, active_tasks);
}
["fps"] => {
let (fps, skips) = *FPS.read().await;
log!("{:.1} FPS with {:.1}% of attempts skipped", fps, skips);
}
["help"] => {
log!("Available commands: tasks, fps, ping, user, reconnect, regenerate");
}
["help", "tasks"] => {
log!("Tasks command usage: tasks");
}
["help", "fps"] => {
log!("FPS command usage: fps");
}
["help", "ping"] => {
log!("Ping command usage: ping [time]");
}
["help", "user"] => {
log!("User command usage: user add <username> | user remove <username> | user list");
}
["help", "reconnect"] => {
log!("Reconnect command usage: reconnect. Retry connecting to the Omikron server");
}
["help", "regenerate"] => {
log!(
"Regenerate command usage: regenerate keys. Generate a new Iota key pair and reconnect"
);
}
["ping"] => {
ping(20).await;
}
["ping", time] => {
let time = time.parse::<u64>().unwrap_or(20);
ping(time).await;
}
["user", "add", username] => {
if let (Some(user), Some(_)) = omikron_connector::user_ops::create_user(username).await
{
log!("Created user {}", user.user_id);
} else {
log!("User creation: Failed to create user. See errors above.");
}
}
["user", "remove", username] => {
if let Some(user) = user_manager::get_user_by_username(username) {
let msg = CommunicationValue::new(CommunicationType::DeleteUser)
.with_sender(user.user_id as u64);
let _ = OMIKRON_CONNECTION.send_message(&msg).await;
user_manager::remove_user(user.user_id);
log!("Removed user {}", user.user_id);
} else {
log!("User removal: Username doesn't exist");
}
}
["user", "list"] => {
let users: Vec<UserProfile> = user_manager::get_users();
for user in users {
let storage = file_util::get_designed_storage(user.user_id);
log!(
"> Username: {}, ID: {}, created at: {}, storage: {}",
user.username,
user.user_id,
user.created_at,
storage
);
}
}
["user", "info", username] => {
if let Some(user) = user_manager::get_user_by_username(username) {
user_manager::remove_user(user.user_id);
log!("Removed user {}", user.user_id);
} else {
log!("User info: Username doesn't exist");
}
}
["reconnect"] => {
log!("Reconnecting to Omikron server...");
OMIKRON_CONNECTION.reconnect().await;
log!("Reconnected to Omikron server");
}
["regenerate", "keys"] => {
log!("Regenerating Iota key pair...");
modify_config(|cfg| {
cfg.public_key = None;
cfg.private_key = None;
cfg.iota_id = None;
});
log!("Key pair regenerated. Reconnecting to Omikron server...");
OMIKRON_CONNECTION.reconnect().await;
log!("Reconnected with new key pair");
}
["reload"] | ["restart"] => {
log!("Restarting");
*RELOAD.write().await = true;
*SHUTDOWN.write().await = true;
}
["shutdown"] | ["stop"] => {
log!("Shutting down");
*SHUTDOWN.write().await = true;
}
_ => {
log!("Unknown command");
}
}
}
#[cfg(feature = "legacy-commands")]
pub async fn ping(time: u64) {
let conn = OMIKRON_CONNECTION.clone();
let response_cv = conn
.await_response(
&CommunicationValue::new(CommunicationType::Ping),
Some(Duration::from_secs(time)),
)
.await;
match response_cv {
Ok(response) => log_cv!(response),
Err(err) => log!("Ping error: {:?}", err),
}
}

View file

@ -1,43 +1,472 @@
use iota_ipc::{ClientMessage, DaemonMessage, read_msg, write_msg};
use iota_ipc::{
ClientMessage, DaemonMessage, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
RequestEnvelope, ResponseResult, read_msg, write_msg,
};
use iota_state::{ClientState, UiLogEntry};
use std::collections::HashMap;
use std::io::Result;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::net::UnixStream;
use tokio::net::unix::OwnedWriteHalf;
use tokio::sync::Mutex;
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::sync::{Mutex, oneshot, watch};
const INITIAL_BACKOFF: Duration = Duration::from_millis(200);
const MAX_BACKOFF: Duration = Duration::from_secs(10);
const MAX_RECONNECT_ATTEMPTS: u32 = 50;
/// Connection state exposed to the UI.
#[derive(Clone, Debug)]
pub enum IpcConnectionState {
Connecting,
Connected,
Reconnecting { attempt: u32 },
Incompatible { message: String },
Disconnected,
}
/// Pending request awaiting a response.
struct PendingRequest {
response_tx: oneshot::Sender<ResponseResult>,
}
/* The TUI owns this cache. IPC updates replace daemon snapshots and append
* logs, so rendering never reaches into daemon-owned storage or connections. */
pub struct IpcClient {
state: ClientState,
writer: Mutex<OwnedWriteHalf>,
next_request_id: AtomicU64,
pending: Mutex<HashMap<u64, PendingRequest>>,
connection_state: watch::Sender<IpcConnectionState>,
path: PathBuf,
}
impl IpcClient {
pub async fn connect(path: impl AsRef<Path>) -> Result<Arc<Self>> {
let stream = UnixStream::connect(path).await?;
let path = path.as_ref().to_path_buf();
let stream = Self::try_connect(&path).await?;
let (mut reader, writer) = stream.into_split();
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connecting);
let client = Arc::new(Self {
state: ClientState::new(),
writer: Mutex::new(writer),
next_request_id: AtomicU64::new(1),
pending: Mutex::new(HashMap::new()),
connection_state: conn_state_tx,
path: path.clone(),
});
// --- Handshake: send Hello, read HelloAck ---
{
let mut w = client.writer.lock().await;
write_msg(
&mut *w,
&ClientMessage::Hello {
supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION],
},
)
.await?;
}
match read_msg::<_, DaemonMessage>(&mut reader).await {
Ok(DaemonMessage::HelloAck(ack)) => {
if ack.protocol_version < MIN_PROTOCOL_VERSION {
let _ = client
.connection_state
.send(IpcConnectionState::Incompatible {
message: format!(
"Daemon protocol {} < required {}",
ack.protocol_version, MIN_PROTOCOL_VERSION
),
});
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"Protocol version mismatch: daemon={}, minimum={}",
ack.protocol_version, MIN_PROTOCOL_VERSION
),
));
}
}
Ok(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Expected HelloAck from daemon",
));
}
Err(e) => return Err(e),
}
let _ = client.connection_state.send(IpcConnectionState::Connected);
// Start reader task (continues reading after handshake)
let reader_client = client.clone();
tokio::spawn(async move {
while let Ok(message) = read_msg::<_, DaemonMessage>(&mut reader).await {
reader_client.apply(message).await;
}
reader_client.read_loop(reader).await;
});
client.send(ClientMessage::Subscribe).await?;
// Subscribe to events
client
.send(ClientMessage::Subscribe {
log_classes: vec![],
metric_interval_ms: Some(500),
})
.await?;
Ok(client)
}
/// Try to connect with retries for socket activation.
pub async fn connect_or_activate(path: impl AsRef<Path>) -> Result<Arc<Self>> {
let path = path.as_ref().to_path_buf();
let max_attempts = 30;
for attempt in 0..max_attempts {
match Self::connect(&path).await {
Ok(client) => return Ok(client),
Err(error) => {
if attempt < max_attempts - 1 {
let delay = Duration::from_millis(100 + attempt as u64 * 100);
tokio::time::sleep(delay).await;
continue;
}
return Err(error);
}
}
}
unreachable!()
}
async fn try_connect(path: &Path) -> Result<UnixStream> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
match UnixStream::connect(path).await {
Ok(stream) => return Ok(stream),
Err(error) => {
if tokio::time::Instant::now() >= deadline {
return Err(error);
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
}
}
/// Start the reconnection actor.
pub fn spawn_reconnector(self: &Arc<Self>) {
let client = self.clone();
tokio::spawn(async move {
client.reconnection_loop().await;
});
}
async fn reconnection_loop(self: Arc<Self>) {
let mut rx = self.connection_status();
loop {
// Wait until the connection enters the Disconnected state.
loop {
let disconnected = matches!(*rx.borrow(), IpcConnectionState::Disconnected);
if disconnected {
break;
}
if rx.changed().await.is_err() {
return; // sender dropped
}
}
let mut backoff = INITIAL_BACKOFF;
let mut attempt: u32 = 0;
// Attempt reconnection until success or max attempts.
loop {
tokio::time::sleep(backoff).await;
attempt += 1;
if attempt > MAX_RECONNECT_ATTEMPTS {
let _ = self
.connection_state
.send(IpcConnectionState::Incompatible {
message: "Max reconnection attempts exceeded".into(),
});
return;
}
let _ = self
.connection_state
.send(IpcConnectionState::Reconnecting { attempt });
match Self::try_connect(&self.path).await {
Ok(stream) => {
let (mut reader, writer) = stream.into_split();
*self.writer.lock().await = writer;
// Re-handshake
{
let mut w = self.writer.lock().await;
if write_msg(
&mut *w,
&ClientMessage::Hello {
supported_versions: vec![
MIN_PROTOCOL_VERSION,
PROTOCOL_VERSION,
],
},
)
.await
.is_err()
{
let _ = self
.connection_state
.send(IpcConnectionState::Disconnected);
break;
}
}
// Read HelloAck
match read_msg::<_, DaemonMessage>(&mut reader).await {
Ok(DaemonMessage::HelloAck(ack)) => {
if ack.protocol_version < MIN_PROTOCOL_VERSION {
let _ = self.connection_state.send(
IpcConnectionState::Incompatible {
message: format!(
"Daemon protocol {} < required {}",
ack.protocol_version, MIN_PROTOCOL_VERSION
),
},
);
return;
}
}
_ => {
let _ = self
.connection_state
.send(IpcConnectionState::Disconnected);
break;
}
}
// Clear pending requests with connection-lost errors
{
let mut pending = self.pending.lock().await;
for (_, request) in pending.drain() {
let _ = request.response_tx.send(
ResponseResult::Error(
iota_ipc::IpcErrorCode::Disconnected,
),
);
}
}
let _ = self.connection_state.send(IpcConnectionState::Connected);
// Start new reader loop
let reader_client = self.clone();
tokio::spawn(async move {
reader_client.read_loop(reader).await;
});
// Resubscribe
let _ = self
.send(ClientMessage::Subscribe {
log_classes: vec![],
metric_interval_ms: Some(500),
})
.await;
// Successfully reconnected; go back to waiting for
// the next disconnect.
break;
}
Err(_) => {
backoff = std::cmp::min(backoff * 2, MAX_BACKOFF);
}
}
}
}
}
async fn read_loop(self: Arc<Self>, mut reader: OwnedReadHalf) {
loop {
match read_msg::<_, DaemonMessage>(&mut reader).await {
Ok(message) => self.apply(message).await,
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
break;
}
Err(_) => {
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
break;
}
}
}
}
pub fn state(&self) -> ClientState {
self.state.clone()
}
pub async fn send_command(&self, seq: u64, line: String) -> Result<()> {
self.send(ClientMessage::Command { seq, line }).await
pub fn connection_status(&self) -> watch::Receiver<IpcConnectionState> {
self.connection_state.subscribe()
}
pub fn connection_status_snapshot(&self) -> IpcConnectionState {
self.connection_state.borrow().clone()
}
pub async fn send_request(&self, request: LocalRequest) -> Result<ResponseResult> {
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let (response_tx, response_rx) = oneshot::channel();
{
let mut pending = self.pending.lock().await;
pending.insert(request_id, PendingRequest { response_tx });
}
let envelope = RequestEnvelope {
request_id,
protocol_version: PROTOCOL_VERSION,
request,
};
self.send(ClientMessage::Request(envelope)).await?;
match tokio::time::timeout(Duration::from_secs(30), response_rx).await {
Ok(Ok(result)) => Ok(result),
Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)),
Err(_) => {
self.pending.lock().await.remove(&request_id);
Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected))
}
}
}
/// 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),
["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::RestartDaemon),
["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon),
_ => None,
}
}
/// Legacy command interface: parse text command, send as typed request.
pub async fn send_command(&self, _seq: u64, line: String) -> Result<()> {
let trimmed = line.trim_start_matches('/').trim();
// Handle ping as a direct Ping message (not a LocalRequest).
if trimmed == "ping" || trimmed.starts_with("ping ") {
let seq = self.next_request_id.fetch_add(1, Ordering::Relaxed);
if let Err(e) = self.send(ClientMessage::Ping { seq }).await {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: format!("Failed to send ping: {}", e),
is_error: true,
});
return Err(e);
}
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: "Ping sent".into(),
is_error: false,
});
return Ok(());
}
if let Some(request) = Self::parse_console_command(&line) {
match self.send_request(request).await {
Ok(result) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let message = match &result {
ResponseResult::Ok(msg) => msg.clone(),
ResponseResult::Error(code) => format!("Error: {:?}", code),
};
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Command".into(),
message,
is_error: matches!(&result, ResponseResult::Error(_)),
});
Ok(())
}
Err(e) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: format!("Failed to send: {}", e),
is_error: true,
});
Err(e)
}
}
} else {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: if line.trim() == "help" {
"Available commands: tasks, ping, user, reconnect, regenerate, restart, stop"
.into()
} else {
format!("Unknown command: {}", line)
},
is_error: false,
});
Ok(())
}
}
async fn send(&self, message: ClientMessage) -> Result<()> {
@ -46,19 +475,26 @@ impl IpcClient {
}
async fn apply(&self, message: DaemonMessage) {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
match message {
DaemonMessage::LogEntry(entry) => state.push_log(UiLogEntry {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
message: entry.message,
is_error: entry.is_error,
}),
DaemonMessage::LogEntry(entry) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
message: entry.message,
is_error: entry.is_error,
});
}
DaemonMessage::StateUpdate(snapshot) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.cpu = snapshot.cpu;
state.ram = snapshot.ram;
state.ping = snapshot.ping;
@ -66,18 +502,106 @@ impl IpcClient {
state.net_down = snapshot.net_down;
state.sys_info = snapshot.sys_info;
}
DaemonMessage::CommandResult {
success, message, ..
} => state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Command".into(),
message,
is_error: !success,
}),
DaemonMessage::MetricSample(sample) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(cpu) = sample.cpu {
let idx = state.cpu.len() as f64;
state.cpu.push((idx, cpu));
if state.cpu.len() > iota_state::MAX_POINTS {
state.cpu.remove(0);
}
}
if let Some(ram) = sample.ram {
let idx = state.ram.len() as f64;
state.ram.push((idx, ram));
if state.ram.len() > iota_state::MAX_POINTS {
state.ram.remove(0);
}
}
if let Some(ping) = sample.ping {
state.push_ping_val(ping);
}
if let Some(net_up) = sample.net_up {
let idx = state.net_up.len() as f64;
state.net_up.push((idx, net_up));
if state.net_up.len() > iota_state::MAX_POINTS {
state.net_up.remove(0);
}
}
if let Some(net_down) = sample.net_down {
let idx = state.net_down.len() as f64;
state.net_down.push((idx, net_down));
if state.net_down.len() > iota_state::MAX_POINTS {
state.net_down.remove(0);
}
}
}
DaemonMessage::Response(response) => {
let mut pending = self.pending.lock().await;
if let Some(request) = pending.remove(&response.request_id) {
let _ = request.response_tx.send(response.result);
} else {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let message = match &response.result {
ResponseResult::Ok(msg) => msg.clone(),
ResponseResult::Error(code) => format!("Error: {:?}", code),
};
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Command".into(),
message,
is_error: matches!(&response.result, ResponseResult::Error(_)),
});
}
}
DaemonMessage::HelloAck(_) => {}
DaemonMessage::Pong { .. } => {}
DaemonMessage::LifecycleEvent(event) => match event {
iota_ipc::LifecycleEvent::Shutdown { reason } => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Daemon".into(),
message: format!("Daemon shutting down: {}", reason),
is_error: true,
});
}
_ => {}
},
DaemonMessage::Gap { skipped } => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "System".into(),
message: format!("Skipped {} messages, resynchronizing", skipped),
is_error: false,
});
}
}
}
}

View file

@ -6,6 +6,7 @@ use crate::{
log_card::LogCard,
},
interaction_result::InteractionResult,
ipc_client::IpcConnectionState,
screens::screens::{NavDirection, Screen},
ui::UI,
};
@ -16,6 +17,7 @@ use ratatui::{
layout::{Constraint, Layout, Margin, Rect},
widgets::{Block, Borders},
};
use tokio::sync::watch;
use std::{any::Any, sync::Arc};
@ -24,6 +26,7 @@ pub struct MainScreen {
nav_grid: Vec<Vec<Option<usize>>>,
selected_coords: (usize, usize),
graphs_open: bool,
connection_status_rx: watch::Receiver<IpcConnectionState>,
}
impl MainScreen {
@ -58,11 +61,14 @@ impl MainScreen {
let graphs_open = true;
let connection_status_rx = ui.ipc().connection_status();
let mut screen = MainScreen {
elements,
nav_grid,
selected_coords: (1, 0),
graphs_open,
connection_status_rx,
};
screen.focus_current();
screen
@ -147,6 +153,40 @@ impl MainScreen {
self.focus_current();
}
/// Cycle focus between unique elements in the navigation grid.
fn navigate_focus(&mut self, forward: bool) {
// Collect unique elements in grid order.
let mut positions: Vec<(usize, usize)> = Vec::new(); // (row, col)
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 elem_opt.is_some() && !seen.contains(elem_opt) {
seen.push(*elem_opt);
positions.push((y, x));
}
}
}
let current = self.selected_coords;
let current_pos = positions
.iter()
.position(|&(r, c)| r == current.0 && c == current.1);
let next_pos = if let Some(idx) = current_pos {
if forward {
(idx + 1) % positions.len()
} else {
(idx + positions.len() - 1) % positions.len()
}
} else {
0
};
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = positions[next_pos];
self.focus_current();
}
}
impl Screen for MainScreen {
@ -159,7 +199,21 @@ impl Screen for MainScreen {
}
fn render(&self, f: &mut Frame, rect: Rect) {
let main_block = Block::default().title("Main").borders(Borders::ALL);
let status = self.connection_status_rx.borrow();
let status_text = match &*status {
IpcConnectionState::Connected => "Connected".to_string(),
IpcConnectionState::Connecting => "Connecting...".to_string(),
IpcConnectionState::Reconnecting { attempt } => {
format!("Reconnecting (attempt {})...", attempt)
}
IpcConnectionState::Incompatible { message } => {
format!("Incompatible: {}", message)
}
IpcConnectionState::Disconnected => "Disconnected".to_string(),
};
let main_block = Block::default()
.title(format!("Main [{}]", status_text))
.borders(Borders::ALL);
f.render_widget(main_block, rect);
let inner = rect.inner(Margin {
@ -215,10 +269,14 @@ impl Screen for MainScreen {
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
match event.code {
KeyCode::Up => self.navigate(NavDirection::Up),
KeyCode::Down => self.navigate(NavDirection::Down),
KeyCode::Left => self.navigate(NavDirection::Left),
KeyCode::Right => self.navigate(NavDirection::Right),
KeyCode::Tab => {
self.navigate_focus(true);
return InteractionResult::Handled;
}
KeyCode::BackTab => {
self.navigate_focus(false);
return InteractionResult::Handled;
}
KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => {
self.graphs_open = !self.graphs_open;
for element in self.elements.iter_mut() {
@ -232,7 +290,17 @@ impl Screen for MainScreen {
let (y, x) = self.selected_coords;
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) {
if let Some(el) = self.elements.get_mut(*index) {
return el.interact(event);
let result = el.interact(event);
if matches!(result, InteractionResult::Unhandled) {
match event.code {
KeyCode::Up => self.navigate(NavDirection::Up),
KeyCode::Down => self.navigate(NavDirection::Down),
KeyCode::Left => self.navigate(NavDirection::Left),
KeyCode::Right => self.navigate(NavDirection::Right),
_ => {}
}
}
return result;
}
}
}