From 40d9edcb73f19150adbae9644a3e0d714e7e28e2 Mon Sep 17 00:00:00 2001 From: Ralf Grubenmann Date: Tue, 9 Dec 2025 11:12:47 +0100 Subject: [PATCH] add status bar component plus some status messages --- coman/src/app/ids.rs | 1 + coman/src/app/messages.rs | 11 ++ coman/src/app/model.rs | 32 ++++-- coman/src/app/user_events.rs | 10 ++ coman/src/cli.rs | 4 - coman/src/components/global_listener.rs | 12 ++- coman/src/components/mod.rs | 1 + coman/src/components/status_bar.rs | 133 ++++++++++++++++++++++++ coman/src/components/toolbar.rs | 10 +- coman/src/cscs/api_client.rs | 4 +- coman/src/cscs/cli.rs | 9 +- coman/src/cscs/ports.rs | 45 +++++--- coman/src/main.rs | 33 +++++- firecrest_client/src/types.rs | 15 +++ 14 files changed, 276 insertions(+), 44 deletions(-) create mode 100644 coman/src/components/status_bar.rs diff --git a/coman/src/app/ids.rs b/coman/src/app/ids.rs index bab7a5b..75c5d94 100644 --- a/coman/src/app/ids.rs +++ b/coman/src/app/ids.rs @@ -1,5 +1,6 @@ #[derive(Debug, Eq, PartialEq, Clone, Hash)] pub enum Id { + StatusBar, Toolbar, WorkloadList, WorkloadLogs, diff --git a/coman/src/app/messages.rs b/coman/src/app/messages.rs index 7c713d9..15004cf 100644 --- a/coman/src/app/messages.rs +++ b/coman/src/app/messages.rs @@ -59,6 +59,16 @@ pub enum View { Workloads, Files, } + +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, strum::Display)] +pub enum StatusMsg { + #[allow(dead_code)] + Progress(String, usize), + Info(String), + #[allow(dead_code)] + Warning(String), +} + #[derive(Debug, PartialEq)] pub enum Msg { AppClose, @@ -72,6 +82,7 @@ pub enum Msg { Info(String), Cscs(CscsMsg), Job(JobMsg), + Status(StatusMsg), ChangeView(View), CreateEvent(UserEvent), None, diff --git a/coman/src/app/model.rs b/coman/src/app/model.rs index a495d49..4d8ff06 100644 --- a/coman/src/app/model.rs +++ b/coman/src/app/model.rs @@ -14,10 +14,10 @@ use crate::{ app::{ ids::Id, messages::{ - CscsMsg, DownloadPopupMsg, ErrorPopupMsg, InfoPopupMsg, JobMsg, LoginPopupMsg, MenuMsg, Msg, + CscsMsg, DownloadPopupMsg, ErrorPopupMsg, InfoPopupMsg, JobMsg, LoginPopupMsg, MenuMsg, Msg, StatusMsg, SystemSelectMsg, View, }, - user_events::{CscsEvent, UserEvent}, + user_events::{CscsEvent, StatusEvent, UserEvent}, }, components::{ context_menu::ContextMenu, download_popup::DownloadTargetInput, error_popup::ErrorPopup, info_popup::InfoPopup, @@ -104,17 +104,19 @@ where .margin(1) .constraints( [ + Constraint::Max(3), //Statusbar Constraint::Min(10), //content Constraint::Max(1), //Toolbar ] .as_ref(), ) .split(f.area()); + app.view(&Id::StatusBar, f, chunks[0]); match current_view { - View::Workloads => Self::view_workloads(app, f, chunks[0]), - View::Files => Self::view_files(app, f, chunks[0]), + View::Workloads => Self::view_workloads(app, f, chunks[1]), + View::Files => Self::view_files(app, f, chunks[1]), } - app.view(&Id::Toolbar, f, chunks[1]); + app.view(&Id::Toolbar, f, chunks[2]); if app.mounted(&Id::Menu) { let popup = draw_area_in_absolute(f.area(), 10); @@ -188,7 +190,6 @@ where .is_ok() ); assert!(self.app.active(&Id::SystemSelectPopup).is_ok()); - trace_dbg!("mounted system select popup"); None } SystemSelectMsg::Closed => { @@ -408,9 +409,10 @@ where None } Msg::Cscs(CscsMsg::SystemSelected(system)) => { + let event_tx = self.user_event_tx.clone(); let error_tx = self.error_tx.clone(); tokio::spawn(async move { - match cscs_system_set(system, true).await { + match cscs_system_set(system.clone(), true).await { Ok(_) => {} Err(e) => error_tx .send(format!( @@ -420,6 +422,10 @@ where .await .unwrap(), }; + event_tx + .send(UserEvent::Cscs(CscsEvent::SystemSelected(system))) + .await + .unwrap(); }); None } @@ -441,6 +447,18 @@ where }); None } + Msg::Status(status) => { + let event_tx = self.user_event_tx.clone(); + let event = match status { + StatusMsg::Progress(msg, progress) => StatusEvent::Progress(msg, progress), + StatusMsg::Info(msg) => StatusEvent::Info(msg), + StatusMsg::Warning(msg) => StatusEvent::Warning(msg), + }; + tokio::spawn(async move { + event_tx.send(UserEvent::Status(event)).await.unwrap(); + }); + None + } Msg::None => None, } } else { diff --git a/coman/src/app/user_events.rs b/coman/src/app/user_events.rs index 0549526..c0bdb92 100644 --- a/coman/src/app/user_events.rs +++ b/coman/src/app/user_events.rs @@ -9,6 +9,7 @@ pub enum CscsEvent { GotWorkloadData(Vec), GotJobLog(String), SelectSystemList(Vec), + SystemSelected(String), } #[derive(Debug, Eq, Clone, PartialEq, PartialOrd, Ord)] @@ -17,12 +18,21 @@ pub enum FileEvent { DownloadCurrentFile, DownloadSuccessful, } + +#[derive(Debug, Eq, Clone, PartialEq, PartialOrd, Ord)] +pub enum StatusEvent { + Progress(String, usize), + Info(String), + Warning(String), +} + #[derive(Debug, Eq, Clone, PartialOrd, Ord)] pub enum UserEvent { Cscs(CscsEvent), File(FileEvent), Error(String), Info(String), + Status(StatusEvent), SwitchedToView(View), } diff --git a/coman/src/cli.rs b/coman/src/cli.rs index bd4b916..4ca9886 100644 --- a/coman/src/cli.rs +++ b/coman/src/cli.rs @@ -139,10 +139,6 @@ pub struct Cli { #[arg(short, long, value_name = "FLOAT", default_value_t = 4.0)] pub tick_rate: f64, - /// Frame rate, i.e. number of frames per second - #[arg(short, long, value_name = "FLOAT", default_value_t = 60.0)] - pub frame_rate: f64, - #[command(subcommand)] pub command: Option, } diff --git a/coman/src/components/global_listener.rs b/coman/src/components/global_listener.rs index 84e6de1..dc7e24e 100644 --- a/coman/src/components/global_listener.rs +++ b/coman/src/components/global_listener.rs @@ -5,7 +5,7 @@ use tuirealm::{ }; use crate::app::{ - messages::{InfoPopupMsg, MenuMsg, Msg, SystemSelectMsg, View}, + messages::{MenuMsg, Msg, StatusMsg, SystemSelectMsg, View}, user_events::{CscsEvent, FileEvent, UserEvent}, }; @@ -42,13 +42,15 @@ impl Component for GlobalListener { } Event::User(UserEvent::Error(msg)) => Some(Msg::Error(msg)), Event::User(UserEvent::Info(msg)) => Some(Msg::Info(msg)), - Event::User(UserEvent::Cscs(CscsEvent::LoggedIn)) => Some(Msg::Info("Successfully logged in".to_string())), + Event::User(UserEvent::Cscs(CscsEvent::LoggedIn)) => { + Some(Msg::Status(StatusMsg::Info("Successfully logged in".to_string()))) + } Event::User(UserEvent::Cscs(CscsEvent::SelectSystemList(systems))) => { Some(Msg::SystemSelectPopup(SystemSelectMsg::Opened(systems))) } - Event::User(UserEvent::File(FileEvent::DownloadSuccessful)) => Some(Msg::InfoPopup(InfoPopupMsg::Opened( - "File successfully downloaded".to_owned(), - ))), + Event::User(UserEvent::File(FileEvent::DownloadSuccessful)) => { + Some(Msg::Status(StatusMsg::Info("File successfully downloaded".to_owned()))) + } _ => None, } } diff --git a/coman/src/components/mod.rs b/coman/src/components/mod.rs index 9b8a5b8..1155cbc 100644 --- a/coman/src/components/mod.rs +++ b/coman/src/components/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod file_tree; pub(crate) mod global_listener; pub(crate) mod info_popup; pub(crate) mod login_popup; +pub(crate) mod status_bar; pub(crate) mod system_select_popup; pub(crate) mod toolbar; pub(crate) mod workload_list; diff --git a/coman/src/components/status_bar.rs b/coman/src/components/status_bar.rs new file mode 100644 index 0000000..05be175 --- /dev/null +++ b/coman/src/components/status_bar.rs @@ -0,0 +1,133 @@ +use std::time::{Duration, Instant}; + +use ratatui::{ + text::{Line, Span}, + widgets::{LineGauge, Paragraph}, +}; +use tuirealm::{ + AttrValue, Attribute, Component, Event, MockComponent, Props, State, + command::CmdResult, + props::{BorderType, Borders, Layout}, + ratatui::{ + Frame, + layout::{Constraint, Direction}, + prelude::Rect, + style::{Color, Style}, + widgets::Block, + }, +}; + +use crate::{ + app::{ + messages::Msg, + user_events::{CscsEvent, StatusEvent, UserEvent}, + }, + config::Config, +}; + +pub struct StatusBar { + props: Props, + last_updated: Instant, + current_status: Option, + status_clear_time: Duration, + current_platform: String, + current_system: String, +} + +impl StatusBar { + pub fn new() -> Self { + let config = Config::new().unwrap(); + Self { + props: Props::default(), + last_updated: Instant::now(), + current_status: None, + status_clear_time: Duration::from_secs(10), + current_platform: config.cscs.current_platform.to_string(), + current_system: config.cscs.current_system, + } + } +} + +impl MockComponent for StatusBar { + fn query(&self, attr: Attribute) -> Option { + self.props.get(attr) + } + + fn attr(&mut self, attr: Attribute, value: AttrValue) { + self.props.set(attr, value); + } + + fn state(&self) -> State { + State::None + } + + fn perform(&mut self, _cmd: tuirealm::command::Cmd) -> CmdResult { + CmdResult::None + } + fn view(&mut self, frame: &mut Frame, area: Rect) { + if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) { + let borders = Borders::default().modifiers(BorderType::Rounded); + let div = Block::default() + .borders(borders.sides) + .border_style(borders.style()) + .border_type(borders.modifiers); + let layout = Layout::default() + .constraints(&[Constraint::Percentage(30), Constraint::Percentage(70)]) + .direction(Direction::Horizontal) + .margin(1); + frame.render_widget(div, area); + + let highlight_style = Style::default().fg(Color::Yellow); + let info_style = Style::default().fg(Color::Blue); + let warn_style = Style::default().fg(Color::Red); + let system_status = Paragraph::new(Line::from(vec![ + Span::styled("Platform: ", highlight_style), + Span::raw(self.current_platform.clone().to_uppercase()), + Span::raw(" "), + Span::styled("System: ", highlight_style), + Span::raw(self.current_system.clone()), + ])); + let chunks = layout.chunks(area); + frame.render_widget(system_status, chunks[0]); + if let Some(status) = self.current_status.clone() { + match status { + StatusEvent::Progress(msg, progress) => { + let gauge = LineGauge::default() + .filled_style(Style::default().fg(Color::DarkGray)) + .label(msg) + .ratio((progress as f64) / 100.0); + frame.render_widget(gauge, chunks[1]); + } + StatusEvent::Info(info) => { + let notification_status = Paragraph::new(info).style(info_style); + frame.render_widget(notification_status, chunks[1]); + } + StatusEvent::Warning(warning) => { + let notification_status = Paragraph::new(warning).style(warn_style); + frame.render_widget(notification_status, chunks[1]); + } + } + } + } + } +} +impl Component for StatusBar { + fn on(&mut self, ev: Event) -> Option { + match ev { + Event::Tick => { + if self.last_updated.elapsed() > self.status_clear_time { + self.current_status = None; + } + } + Event::User(UserEvent::Status(status)) => { + self.current_status = Some(status); + self.last_updated = Instant::now(); + } + Event::User(UserEvent::Cscs(CscsEvent::SystemSelected(system))) => { + self.current_system = system; + } + _ => {} + } + None + } +} diff --git a/coman/src/components/toolbar.rs b/coman/src/components/toolbar.rs index 625db94..3f750b2 100644 --- a/coman/src/components/toolbar.rs +++ b/coman/src/components/toolbar.rs @@ -1,12 +1,9 @@ use tui_realm_stdlib::Label; use tuirealm::{AttrValue, Attribute, Component, Event, MockComponent}; -use crate::{ - app::{ - messages::{Msg, View}, - user_events::UserEvent, - }, - trace_dbg, +use crate::app::{ + messages::{Msg, View}, + user_events::UserEvent, }; const WORKLOAD_TOOLTIP: &str = "q: quit, Esc: close/back, l: logs, f: File view, x: menu, tab: switch view, ?: help"; const FILETREE_TOOLTIP: &str = "q: quit, ↑↓: navigate,←→: collapse/expand, x: menu, ?: help"; @@ -30,7 +27,6 @@ impl Component for Toolbar { fn on(&mut self, ev: tuirealm::Event) -> Option { match ev { Event::User(UserEvent::SwitchedToView(view)) => { - let view = trace_dbg!(view); self.current_view = view; match self.current_view { View::Workloads => self.attr(Attribute::Text, AttrValue::String(WORKLOAD_TOOLTIP.to_owned())), diff --git a/coman/src/cscs/api_client.rs b/coman/src/cscs/api_client.rs index b4d1ffc..8615c49 100644 --- a/coman/src/cscs/api_client.rs +++ b/coman/src/cscs/api_client.rs @@ -388,7 +388,7 @@ impl CscsApi { ) -> Result<()> { let workingdir = script_path.clone(); let workingdir = workingdir.parent(); - let result = post_compute_system_job( + let _result = post_compute_system_job( &self.client, system_name, account, @@ -399,7 +399,6 @@ impl CscsApi { envvars, ) .await?; - let _ = trace_dbg!(result); Ok(()) } @@ -523,7 +522,6 @@ impl CscsApi { let result = get_filesystem_ops_ls(&self.client, system_name, path) .await .wrap_err("couldn't list path")?; - let result = trace_dbg!(result); match result.output { Some(entries) => Ok(entries.into_iter().map(|e| e.into()).collect()), None => Ok(vec![]), diff --git a/coman/src/cscs/cli.rs b/coman/src/cscs/cli.rs index 3b4b70f..ff76343 100644 --- a/coman/src/cscs/cli.rs +++ b/coman/src/cscs/cli.rs @@ -112,7 +112,7 @@ pub(crate) async fn cli_cscs_job_start( platform: Option, account: Option, ) -> Result<()> { - cscs_start_job( + match cscs_start_job( script_file, image, command, @@ -124,6 +124,13 @@ pub(crate) async fn cli_cscs_job_start( account, ) .await + { + Ok(_) => { + println!("Job started"); + Ok(()) + } + Err(e) => Err(e), + } } pub(crate) async fn cli_cscs_job_cancel( diff --git a/coman/src/cscs/ports.rs b/coman/src/cscs/ports.rs index 9287f24..8b1740a 100644 --- a/coman/src/cscs/ports.rs +++ b/coman/src/cscs/ports.rs @@ -6,14 +6,14 @@ use color_eyre::{ }; use futures::StreamExt; use openidconnect::core::CoreDeviceAuthorizationResponse; -use tokio::{fs::File, io::AsyncWriteExt, sync::mpsc}; +use tokio::{fs::File, io::AsyncWriteExt, sync::mpsc, time::Instant}; use tuirealm::{ Event, listener::{ListenerResult, PollAsync}, }; use crate::{ - app::user_events::{CscsEvent, FileEvent, UserEvent}, + app::user_events::{CscsEvent, FileEvent, StatusEvent, UserEvent}, config::Config, cscs::{ api_client::{JobStatus, PathEntry, PathType}, @@ -199,17 +199,13 @@ impl PollAsync for AsyncJobLogPort { Ok(Some(Event::None)) } else if let Some(job_id) = self.current_job { match cscs_job_log(job_id as i64, self.stderr, None, None).await { - Ok(log) => { - let log = trace_dbg!(log); - Ok(Some(Event::User(UserEvent::Cscs(CscsEvent::GotJobLog(log))))) - } + Ok(log) => Ok(Some(Event::User(UserEvent::Cscs(CscsEvent::GotJobLog(log))))), Err(e) => Ok(Some(Event::User(UserEvent::Error(format!( "{:?}", Err::<(), Report>(e).wrap_err("couldn't get log") ))))), } } else { - trace_dbg!("nothing"); Ok(Some(Event::None)) } } @@ -223,11 +219,12 @@ pub enum TreeAction { /// This port handles asynchronous file operations on CSCS pub(crate) struct AsyncFileTreePort { receiver: mpsc::Receiver, + event_tx: mpsc::Sender, } impl AsyncFileTreePort { - pub fn new(receiver: mpsc::Receiver) -> Self { - Self { receiver } + pub fn new(receiver: mpsc::Receiver, event_tx: mpsc::Sender) -> Self { + Self { receiver, event_tx } } } async fn list_files(id: PathBuf) -> Result>> { @@ -273,7 +270,11 @@ async fn list_files(id: PathBuf) -> Result>> { Ok(Some(Event::User(UserEvent::File(FileEvent::List(id_str, subpaths))))) } } -async fn download_file(remote: PathBuf, local: PathBuf) -> Result>> { +async fn download_file( + remote: PathBuf, + local: PathBuf, + event_tx: mpsc::Sender, +) -> Result>> { match cscs_file_download(remote, local.clone(), None, None, None).await { Ok(None) => Ok(Some(Event::User(UserEvent::File(FileEvent::DownloadSuccessful)))), Ok(Some(job_data)) => { @@ -284,7 +285,13 @@ async fn download_file(remote: PathBuf, local: PathBuf) -> Result {} + JobStatus::Pending | JobStatus::Running => { + event_tx + .send(UserEvent::Status(StatusEvent::Info( + "waiting for transfer job".to_owned(), + ))) + .await?; + } JobStatus::Finished => transfer_done = true, JobStatus::Cancelled | JobStatus::Failed => { return Ok(Some(Event::User(UserEvent::Error( @@ -305,9 +312,22 @@ async fn download_file(remote: PathBuf, local: PathBuf) -> Result= Duration::from_millis(500) { + event_tx + .send(UserEvent::Status(StatusEvent::Progress( + "Downloading".to_owned(), + 100 * progress / job_data.2, + ))) + .await?; + start_time = Instant::now(); + } } output.flush().await?; Ok(Some(Event::User(UserEvent::File(FileEvent::DownloadSuccessful)))) @@ -325,6 +345,7 @@ impl PollAsync for AsyncFileTreePort { return Ok(None); } if let Some(action) = self.receiver.recv().await { + let event_tx = self.event_tx.clone(); match action { TreeAction::List(id) => match list_files(id).await { Ok(event) => Ok(event), @@ -333,7 +354,7 @@ impl PollAsync for AsyncFileTreePort { Err::<(), Report>(e).wrap_err("couldn't list subpaths") ))))), }, - TreeAction::Download(remote, local) => match download_file(remote, local).await { + TreeAction::Download(remote, local) => match download_file(remote, local, event_tx).await { Ok(event) => Ok(event), Err(e) => Ok(Some(Event::User(UserEvent::Error(format!( "{:?}", diff --git a/coman/src/main.rs b/coman/src/main.rs index e06317b..78598ae 100644 --- a/coman/src/main.rs +++ b/coman/src/main.rs @@ -15,10 +15,13 @@ use crate::{ ids::Id, messages::{Msg, View}, model::Model, - user_events::{CscsEvent, FileEvent, UserEvent}, + user_events::{CscsEvent, FileEvent, StatusEvent, UserEvent}, }, cli::{Cli, version}, - components::{file_tree::FileTree, global_listener::GlobalListener, toolbar::Toolbar, workload_list::WorkloadList}, + components::{ + file_tree::FileTree, global_listener::GlobalListener, status_bar::StatusBar, toolbar::Toolbar, + workload_list::WorkloadList, + }, config::Config, cscs::{ cli::{ @@ -107,13 +110,13 @@ async fn main() -> Result<()> { }, cli::CliCommands::Init { destination } => Config::create_config(destination)?, }, - None => run_tui()?, + None => run_tui(args.tick_rate)?, } Ok(()) } -fn run_tui() -> Result<()> { +fn run_tui(tick_rate: f64) -> Result<()> { crate::errors::init()?; //we initialize the terminal early so the panic handler that restores the terminal is correctly set up let adapter = CrosstermTerminalAdapter::new()?; @@ -132,6 +135,7 @@ fn run_tui() -> Result<()> { // that the components can handle let event_listener = EventListenerCfg::default() .with_handle(handle) + .tick_interval(Duration::from_millis((1000.0 / tick_rate) as u64)) .async_crossterm_input_listener(Duration::default(), 3) .add_async_port(Box::new(AsyncErrorPort::new(error_rx)), Duration::default(), 1) .add_async_port(Box::new(AsyncFetchWorkloadsPort::new()), Duration::from_secs(2), 1) @@ -141,7 +145,11 @@ fn run_tui() -> Result<()> { 1, ) .add_async_port(Box::new(AsyncJobLogPort::new(job_log_rx)), Duration::from_secs(3), 1) - .add_async_port(Box::new(AsyncFileTreePort::new(file_tree_rx)), Duration::default(), 1) + .add_async_port( + Box::new(AsyncFileTreePort::new(file_tree_rx, user_event_tx.clone())), + Duration::default(), + 1, + ) .add_async_port(Box::new(AsyncUserEventPort::new(user_event_rx)), Duration::default(), 1); let mut app: Application = Application::init(event_listener); @@ -155,6 +163,21 @@ fn run_tui() -> Result<()> { SubClause::Always, )], )?; + app.mount( + Id::StatusBar, + Box::new(StatusBar::new()), + vec![ + Sub::new( + SubEventClause::Discriminant(UserEvent::Status(StatusEvent::Info("".to_owned()))), + SubClause::Always, + ), + Sub::new(SubEventClause::Tick, SubClause::Always), + Sub::new( + SubEventClause::Discriminant(UserEvent::Cscs(CscsEvent::SystemSelected("".to_owned()))), + SubClause::Always, + ), + ], + )?; app.mount( Id::WorkloadList, Box::new(WorkloadList::default()), diff --git a/firecrest_client/src/types.rs b/firecrest_client/src/types.rs index 2a78999..b46af69 100644 --- a/firecrest_client/src/types.rs +++ b/firecrest_client/src/types.rs @@ -1,6 +1,21 @@ // @generated by oas3-gen #![allow(clippy::all)] #![allow(dead_code)] +// @generated by oas3-gen +#![allow(clippy::all)] +#![allow(dead_code)] +// @generated by oas3-gen +#![allow(clippy::all)] +#![allow(dead_code)] +// @generated by oas3-gen +#![allow(clippy::all)] +#![allow(dead_code)] +// @generated by oas3-gen +#![allow(clippy::all)] +#![allow(dead_code)] +// @generated by oas3-gen +#![allow(clippy::all)] +#![allow(dead_code)] #![allow(clippy::doc_markdown)] #![allow(clippy::large_enum_variant)] #![allow(clippy::missing_panics_doc)]