feat(tui): (experimental) tui option
All checks were successful
ci/woodpecker/push/cron-docker-builder Pipeline was successful
ci/woodpecker/push/push-next Pipeline was successful
ci/woodpecker/push/tag-created Pipeline was successful

When the 'tui' feature is enabled, then server start accepts an optional
--ui parameter. When specified a ratatui ui will display, showing
liveness and a ping update when a valid config is loaded.
This commit is contained in:
Paul Campbell 2024-08-10 08:30:23 +01:00
parent c965586537
commit 76b6eb3903
15 changed files with 283 additions and 22 deletions

View file

@ -12,7 +12,7 @@ keywords = { workspace = true }
categories = { workspace = true } categories = { workspace = true }
[features] [features]
default = ["forgejo", "github", "tui"] default = ["forgejo", "github"]
forgejo = ["git-next-forge-forgejo"] forgejo = ["git-next-forge-forgejo"]
github = ["git-next-forge-github"] github = ["git-next-forge-github"]
tui = ["ratatui"] tui = ["ratatui"]

View file

@ -5,10 +5,13 @@ mod forge;
mod init; mod init;
mod repo; mod repo;
mod server; mod server;
mod webhook;
#[cfg(feature = "tui")]
mod tui;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
mod webhook;
use git_next_core::git; use git_next_core::git;
@ -33,7 +36,12 @@ enum Command {
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
enum Server { enum Server {
Init, Init,
Start, Start {
/// Display a UI (experimental)
#[cfg(feature = "tui")]
#[arg(long, required = false)]
ui: bool,
},
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -50,9 +58,15 @@ fn main() -> Result<()> {
Server::Init => { Server::Init => {
server::init(&fs)?; server::init(&fs)?;
} }
Server::Start => { #[cfg(not(feature = "tui"))]
Server::Start {} => {
let sleep_duration = std::time::Duration::from_secs(10); let sleep_duration = std::time::Duration::from_secs(10);
server::start(fs, net, repository_factory, sleep_duration)?; server::start(false, fs, net, repository_factory, sleep_duration)?;
}
#[cfg(feature = "tui")]
Server::Start { ui } => {
let sleep_duration = std::time::Duration::from_secs(10);
server::start(ui, fs, net, repository_factory, sleep_duration)?;
} }
}, },
} }

View file

@ -2,3 +2,4 @@ mod file_updated;
mod receive_app_config; mod receive_app_config;
mod receive_valid_app_config; mod receive_valid_app_config;
mod shutdown; mod shutdown;
mod subscribe_updates;

View file

@ -71,6 +71,7 @@ impl Handler<ReceiveValidAppConfig> for ServerActor {
let shout = app_config.shout().clone(); let shout = app_config.shout().clone();
self.app_config.replace(app_config); self.app_config.replace(app_config);
self.alerts.do_send(UpdateShout::new(shout)); self.alerts.do_send(UpdateShout::new(shout));
self.send_server_updates();
} }
} }

View file

@ -0,0 +1,10 @@
use crate::server::actor::{messages::SubscribeToUpdates, ServerActor};
//
impl actix::Handler<SubscribeToUpdates> for ServerActor {
type Result = ();
fn handle(&mut self, msg: SubscribeToUpdates, _ctx: &mut Self::Context) -> Self::Result {
self.subscribers.push(msg.unwrap());
}
}

View file

@ -1,9 +1,12 @@
use actix::{Message, Recipient};
//- //-
use derive_more::Constructor; use derive_more::Constructor;
use git_next_core::{ use git_next_core::{
git::graph::Log,
message, message,
server::{AppConfig, Storage}, server::{AppConfig, Storage},
ForgeAlias, RepoAlias, RepoBranches,
}; };
use std::net::SocketAddr; use std::net::SocketAddr;
@ -33,3 +36,28 @@ message!(
); );
message!(Shutdown, "Notification to shutdown the server actor"); message!(Shutdown, "Notification to shutdown the server actor");
#[derive(Clone, Debug, PartialEq, Eq, Message)]
#[rtype(result = "()")]
pub enum ServerUpdate {
/// Status of a repo
UpdateRepoSummary {
forge_alias: ForgeAlias,
repo_alias: RepoAlias,
branches: RepoBranches,
log: Log,
},
/// remove a repo
RemoveRepo {
forge_alias: ForgeAlias,
repo_alias: RepoAlias,
},
/// test message
Ping,
}
message!(
SubscribeToUpdates,
Recipient<ServerUpdate>,
"Subscribe to receive updates from the server"
);

View file

@ -1,6 +1,6 @@
// //
use actix::prelude::*; use actix::prelude::*;
use messages::ReceiveAppConfig; use messages::{ReceiveAppConfig, ServerUpdate};
use tracing::error; use tracing::error;
#[cfg(test)] #[cfg(test)]
@ -58,6 +58,8 @@ pub struct ServerActor {
sleep_duration: std::time::Duration, sleep_duration: std::time::Duration,
repo_actors: BTreeMap<(ForgeAlias, RepoAlias), Addr<RepoActor>>, repo_actors: BTreeMap<(ForgeAlias, RepoAlias), Addr<RepoActor>>,
subscribers: Vec<Recipient<ServerUpdate>>,
// testing // testing
message_log: Option<Arc<RwLock<Vec<String>>>>, message_log: Option<Arc<RwLock<Vec<String>>>>,
} }
@ -82,6 +84,7 @@ impl ServerActor {
net, net,
alerts, alerts,
repository_factory: repo, repository_factory: repo,
subscribers: Vec::default(),
sleep_duration, sleep_duration,
repo_actors: BTreeMap::new(), repo_actors: BTreeMap::new(),
message_log: None, message_log: None,
@ -239,4 +242,10 @@ impl ServerActor {
ctx.address().do_send(msg); ctx.address().do_send(msg);
} }
} }
fn send_server_updates(&self) {
self.subscribers.iter().for_each(|subscriber| {
subscriber.do_send(ServerUpdate::Ping);
});
}
} }

View file

@ -1,16 +1,20 @@
// //
mod actor; pub mod actor;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
use actix::prelude::*; use actix::prelude::*;
use actix_rt::signal;
use crate::{ use crate::{
alerts::{AlertsActor, History}, alerts::{AlertsActor, History},
file_watcher::{watch_file, FileUpdated}, file_watcher::{watch_file, FileUpdated},
}; };
use actor::ServerActor;
#[allow(clippy::module_name_repetitions)]
pub use actor::ServerActor;
use git_next_core::git::RepositoryFactory; use git_next_core::git::RepositoryFactory;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@ -38,26 +42,23 @@ pub fn init(fs: &FileSystem) -> Result<()> {
} }
pub fn start( pub fn start(
ui: bool,
fs: FileSystem, fs: FileSystem,
net: Network, net: Network,
repo: Box<dyn RepositoryFactory>, repo: Box<dyn RepositoryFactory>,
sleep_duration: std::time::Duration, sleep_duration: std::time::Duration,
) -> Result<()> { ) -> Result<()> {
init_logging(); if !ui {
init_logging();
}
let execution = async move { let execution = async move {
info!("Starting Alert Dispatcher..."); info!("Starting Alert Dispatcher...");
let alerts_addr = AlertsActor::new(None, History::new(A_DAY), net.clone()).start(); let alerts_addr = AlertsActor::new(None, History::new(A_DAY), net.clone()).start();
info!("Starting Server..."); info!("Starting Server...");
let server = ServerActor::new( let server =
fs.clone(), ServerActor::new(fs.clone(), net.clone(), alerts_addr, repo, sleep_duration).start();
net.clone(),
alerts_addr.clone(),
repo,
sleep_duration,
)
.start();
server.do_send(FileUpdated); server.do_send(FileUpdated);
info!("Starting File Watcher..."); info!("Starting File Watcher...");
@ -65,14 +66,39 @@ pub fn start(
let fw_shutdown = watch_file("git-next-server.toml".into(), server.clone().recipient()) let fw_shutdown = watch_file("git-next-server.toml".into(), server.clone().recipient())
.expect("file watcher"); .expect("file watcher");
info!("Server running - Press Ctrl-C to stop..."); if ui {
let _ = actix_rt::signal::ctrl_c().await; #[cfg(feature = "tui")]
info!("Ctrl-C received, shutting down..."); {
use crate::server::actor::messages::SubscribeToUpdates;
use crate::tui;
use std::sync::mpsc::channel;
let (tx_shutdown, rx_shutdown) = channel::<()>();
let tui_addr = tui::Tui::new(tx_shutdown).start();
// tui_addr.do_send(tui::Tick);
let _ = tui_addr.send(tui::Tick).await;
server.do_send(SubscribeToUpdates::new(tui_addr.clone().recipient()));
loop {
let _ = tui_addr.send(tui::Tick).await;
if rx_shutdown.try_recv().is_ok() {
break;
}
// actix_rt::task::yield_now().await;
}
}
} else {
info!("Server running - Press Ctrl-C to stop...");
let _ = signal::ctrl_c().await;
info!("Ctrl-C received, shutting down...");
}
// shutdown
fw_shutdown.store(true, Ordering::Relaxed);
server.do_send(crate::server::actor::messages::Shutdown); server.do_send(crate::server::actor::messages::Shutdown);
actix_rt::time::sleep(std::time::Duration::from_millis(200)).await; actix_rt::time::sleep(std::time::Duration::from_millis(200)).await;
System::current().stop(); System::current().stop();
fw_shutdown.store(true, Ordering::Relaxed);
}; };
let system = System::new(); let system = System::new();
Arbiter::current().spawn(execution); Arbiter::current().spawn(execution);
system.run()?; system.run()?;

View file

@ -0,0 +1,4 @@
//
mod server_update;
mod tick;

View file

@ -0,0 +1,28 @@
use std::time::Instant;
use actix::Handler;
use crate::{server::actor::messages::ServerUpdate, tui::Tui};
//
impl Handler<ServerUpdate> for Tui {
type Result = ();
fn handle(&mut self, msg: ServerUpdate, _ctx: &mut Self::Context) -> Self::Result {
match msg {
ServerUpdate::UpdateRepoSummary {
forge_alias,
repo_alias,
branches,
log,
} => todo!(),
ServerUpdate::RemoveRepo {
forge_alias,
repo_alias,
} => todo!(),
ServerUpdate::Ping => {
self.last_ping = Instant::now();
}
}
}
}

View file

@ -0,0 +1,57 @@
//
use std::{borrow::BorrowMut, time::Instant};
use actix::{ActorContext, Handler};
use ratatui::{
crossterm::event::{self, KeyCode, KeyEventKind},
style::Stylize as _,
widgets::Paragraph,
};
use crate::tui::actor::{messages::Tick, Tui};
impl Handler<Tick> for Tui {
type Result = std::io::Result<()>;
fn handle(&mut self, _msg: Tick, ctx: &mut Self::Context) -> Self::Result {
if let Some(terminal) = self.terminal.borrow_mut() {
terminal.draw(|frame| {
let area = frame.area();
frame.render_widget(
Paragraph::new(format!(
"(press 'q' to quit) Ping:[{:?}] UI:[{:?}]",
self.last_ping,
Instant::now()
))
.white()
.on_blue(),
area,
);
})?;
} else {
eprintln!("No terminal setup");
}
if event::poll(std::time::Duration::from_millis(16))? {
if let event::Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') => {
// execute!(stderr(), LeaveAlternateScreen)?;
// disable_raw_mode()?;
ctx.stop();
if let Err(err) = self.signal_shutdown.send(()) {
tracing::error!(?err, "Failed to signal shutdown");
}
}
KeyCode::Esc => {
//
}
_ => (),
}
}
}
}
Ok(())
}
}

View file

@ -0,0 +1,4 @@
//
use git_next_core::message;
message!(Tick => std::io::Result<()>, "Start the TUI");

View file

@ -0,0 +1,75 @@
//
mod handlers;
pub mod messages;
use std::{
io::{stderr, Stderr},
sync::mpsc::Sender,
time::Instant,
};
use actix::{Actor, Context};
use ratatui::{
crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
},
prelude::CrosstermBackend,
Terminal,
};
#[derive(Debug)]
pub struct Tui {
terminal: Option<Terminal<CrosstermBackend<Stderr>>>,
signal_shutdown: Sender<()>,
last_ping: Instant,
}
impl Actor for Tui {
type Context = Context<Self>;
fn started(&mut self, _ctx: &mut Self::Context) {
match init() {
Err(err) => {
eprintln!("Failed to enable raw mode: {err:?}");
}
Ok(terminal) => {
self.terminal.replace(terminal);
}
}
}
fn stopped(&mut self, _ctx: &mut Self::Context) {
if let Err(err) = restore() {
match std::env::consts::OS {
"linux" | "macos" => {
eprintln!(
"Failed to restore terminal: Type `reset` to restore terminal: {err:?}"
);
}
"windows" => {
println!("Failed to restore terminal: Reopen a new terminal: {err:?}");
}
_ => println!("Failed to restore terminal: {err:?}"),
}
}
}
}
impl Tui {
pub fn new(signal_shutdown: Sender<()>) -> Self {
Self {
terminal: None,
signal_shutdown,
last_ping: Instant::now(),
}
}
}
fn init() -> std::io::Result<Terminal<CrosstermBackend<Stderr>>> {
execute!(stderr(), EnterAlternateScreen)?;
enable_raw_mode()?;
Terminal::new(CrosstermBackend::new(stderr()))
}
fn restore() -> std::io::Result<()> {
execute!(stderr(), LeaveAlternateScreen)?;
disable_raw_mode()
}

View file

@ -0,0 +1,5 @@
//
mod actor;
pub use actor::messages::Tick;
pub use actor::Tui;

View file

@ -99,7 +99,6 @@ impl git::repository::OpenRepositoryLike for TestOpenRepository {
.fetch_counter .fetch_counter
.read() .read()
.map_err(|_| git::fetch::Error::Lock)?; .map_err(|_| git::fetch::Error::Lock)?;
println!("Fetch: {i}");
self.fetch_counter self.fetch_counter
.write() .write()
.map_err(|_| git::fetch::Error::Lock) .map_err(|_| git::fetch::Error::Lock)