Base working commit with a UI thread (Tokio), Network thread (Tokio), and an input events thread (std).
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "managarr"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
||||||
|
description = "A TUI for managing *arr servers"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.68"
|
||||||
|
chrono = "0.4"
|
||||||
|
clap = { version = "4.0.30", features = ["help", "usage", "error-context", "derive"] }
|
||||||
|
confy = { version = "0.5.1", default_features = false, features = ["yaml_conf"] }
|
||||||
|
crossterm = "0.25.0"
|
||||||
|
log = "0.4.17"
|
||||||
|
log4rs = { version = "1.2.0", features = ["file_appender"] }
|
||||||
|
reqwest = { version = "0.11.13", features = ["json"] }
|
||||||
|
serde_yaml = "0.9.16"
|
||||||
|
serde_json = "1.0.91"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
tokio = { version = "1.24.1", features = ["full"] }
|
||||||
|
tui = "0.19.0"
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
# managarr
|
# managarr
|
||||||
A TUI for managing *arr servers built with Rust
|
A TUI for managing *arr servers.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use crate::event::Key;
|
||||||
|
|
||||||
|
macro_rules! generate_keybindings {
|
||||||
|
($($field:ident),+) => {
|
||||||
|
pub struct KeyBindings {
|
||||||
|
$(pub $field: KeyBinding),+
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_keybindings! {
|
||||||
|
quit
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct KeyBinding {
|
||||||
|
key: Key,
|
||||||
|
desc: &'static str
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings {
|
||||||
|
quit: KeyBinding {
|
||||||
|
key: Key::Char('q'),
|
||||||
|
desc: "Quit",
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
use log::error;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
use crate::app::radarr::RadarrData;
|
||||||
|
|
||||||
|
use super::network::RadarrEvent;
|
||||||
|
|
||||||
|
pub mod radarr;
|
||||||
|
pub(crate) mod key_binding;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct App {
|
||||||
|
network_tx: Option<Sender<RadarrEvent>>,
|
||||||
|
pub client: Client,
|
||||||
|
pub title: &'static str,
|
||||||
|
pub tick_until_poll: u64,
|
||||||
|
pub tick_count: u64,
|
||||||
|
pub config: AppConfig,
|
||||||
|
pub data: Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new(
|
||||||
|
network_tx: Sender<RadarrEvent>,
|
||||||
|
tick_until_poll: u64,
|
||||||
|
config: AppConfig
|
||||||
|
) -> Self {
|
||||||
|
App {
|
||||||
|
network_tx: Some(network_tx),
|
||||||
|
tick_until_poll,
|
||||||
|
config,
|
||||||
|
..App::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dispatch(&mut self, action: RadarrEvent) {
|
||||||
|
if let Some(network_tx) = &self.network_tx {
|
||||||
|
if let Err(e) = network_tx.send(action).await {
|
||||||
|
error!("Failed to send event. {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.tick_count = 0;
|
||||||
|
// self.data = Data::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn on_tick(&mut self) {
|
||||||
|
if self.tick_count % self.tick_until_poll == 0 {
|
||||||
|
self.dispatch(RadarrEvent::GetOverview).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tick_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for App {
|
||||||
|
fn default() -> Self {
|
||||||
|
App {
|
||||||
|
network_tx: None,
|
||||||
|
client: Client::new(),
|
||||||
|
title: "DevTools",
|
||||||
|
tick_until_poll: 0,
|
||||||
|
tick_count: 0,
|
||||||
|
config: AppConfig::default(),
|
||||||
|
data: Data::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct Data {
|
||||||
|
pub radarr_data: RadarrData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Default)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub radarr: RadarrConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct RadarrConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
pub api_token: String
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RadarrConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
RadarrConfig {
|
||||||
|
host: "localhost".to_string(),
|
||||||
|
port: Some(7878),
|
||||||
|
api_token: "".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use crate::network::radarr::DiskSpace;
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct RadarrData {
|
||||||
|
pub free_space: u64,
|
||||||
|
pub total_space: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&DiskSpace> for RadarrData {
|
||||||
|
fn from(disk_space: &DiskSpace) -> Self {
|
||||||
|
RadarrData {
|
||||||
|
free_space: disk_space.free_space.as_u64().unwrap(),
|
||||||
|
total_space: disk_space.total_space.as_u64().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
use std::sync::mpsc;
|
||||||
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crossterm::event;
|
||||||
|
use crossterm::event::Event as CrosstermEvent;
|
||||||
|
|
||||||
|
use crate::event::Key;
|
||||||
|
|
||||||
|
pub enum InputEvent<T> {
|
||||||
|
KeyEvent(T),
|
||||||
|
Tick
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Events {
|
||||||
|
_tx: Sender<InputEvent<Key>>,
|
||||||
|
rx: Receiver<InputEvent<Key>>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Events {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let tick_rate: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
let event_tx = tx.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut last_tick = Instant::now();
|
||||||
|
loop {
|
||||||
|
let timeout = tick_rate.checked_sub(last_tick.elapsed())
|
||||||
|
.unwrap_or_else(|| Duration::from_secs(0));
|
||||||
|
if event::poll(timeout).unwrap() {
|
||||||
|
if let CrosstermEvent::Key(key) = event::read().unwrap() {
|
||||||
|
let key = Key::from(key);
|
||||||
|
event_tx.send(InputEvent::KeyEvent(key)).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if last_tick.elapsed() >= tick_rate {
|
||||||
|
event_tx.send(InputEvent::Tick).unwrap();
|
||||||
|
last_tick = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Events { _tx: tx, rx }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&self) -> Result<InputEvent<Key>, mpsc::RecvError> {
|
||||||
|
self.rx.recv()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
use std::fmt;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum Key {
|
||||||
|
Char(char),
|
||||||
|
Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Key {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
match *self {
|
||||||
|
Key::Char(c) => write!(f, "<{}>", c),
|
||||||
|
_ => write!(f, "<{:?}>", self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<KeyEvent> for Key {
|
||||||
|
fn from(key_event: KeyEvent) -> Self {
|
||||||
|
match key_event {
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Char(c),
|
||||||
|
..
|
||||||
|
} => Key::Char(c),
|
||||||
|
_ => Key::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
|
||||||
|
use crate::event::key::Key;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_formatter() {
|
||||||
|
assert_eq!(format!("{}", Key::Char('q')), "<q>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_from() {
|
||||||
|
assert_eq!(Key::from(KeyEvent::from(KeyCode::Char('q'))), Key::Char('q'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
pub use self::{
|
||||||
|
input_event::{Events, InputEvent},
|
||||||
|
key::Key
|
||||||
|
};
|
||||||
|
|
||||||
|
mod key;
|
||||||
|
pub mod input_event;
|
||||||
|
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
use std::io;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use crossterm::execute;
|
||||||
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
|
use log::{debug, info};
|
||||||
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
use tokio::sync::mpsc::Receiver;
|
||||||
|
use tui::Terminal;
|
||||||
|
use tui::backend::CrosstermBackend;
|
||||||
|
|
||||||
|
use utils::init_logging_config;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::event::input_event::{Events, InputEvent};
|
||||||
|
use crate::event::Key;
|
||||||
|
use crate::network::{Network, RadarrEvent};
|
||||||
|
use crate::ui::ui;
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod event;
|
||||||
|
mod handlers;
|
||||||
|
mod network;
|
||||||
|
mod ui;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
log4rs::init_config(init_logging_config())?;
|
||||||
|
Cli::parse();
|
||||||
|
|
||||||
|
let config = confy::load("managarr", "config")?;
|
||||||
|
let (sync_network_tx, sync_network_rx) = mpsc::channel(500);
|
||||||
|
|
||||||
|
let app = Arc::new(Mutex::new(App::new(
|
||||||
|
sync_network_tx,
|
||||||
|
5000 / 250,
|
||||||
|
config
|
||||||
|
)));
|
||||||
|
|
||||||
|
let app_nw = Arc::clone(&app);
|
||||||
|
|
||||||
|
std::thread::spawn(move || start_networking(sync_network_rx, &app_nw));
|
||||||
|
|
||||||
|
info!("Checking if Radarr server is up and running...");
|
||||||
|
app.lock().await.dispatch(RadarrEvent::HealthCheck).await;
|
||||||
|
|
||||||
|
simple_ui(&app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn start_networking(mut network_rx: Receiver<RadarrEvent>, app: &Arc<Mutex<App>>) {
|
||||||
|
let network = Network::new(reqwest::Client::new(), app);
|
||||||
|
|
||||||
|
while let Some(network_event) = network_rx.recv().await {
|
||||||
|
debug!("Received network event: {:?}", network_event);
|
||||||
|
network.handle_radarr_event(network_event).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn simple_ui(app: &Arc<Mutex<App>>) -> Result<()> {
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
enable_raw_mode()?;
|
||||||
|
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
terminal.clear()?;
|
||||||
|
terminal.hide_cursor()?;
|
||||||
|
|
||||||
|
let input_events = Events::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut app = app.lock().await;
|
||||||
|
terminal.draw(|f| ui(f, &app))?;
|
||||||
|
|
||||||
|
match input_events.next()? {
|
||||||
|
InputEvent::KeyEvent(key) => {
|
||||||
|
if key == Key::Char('q') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputEvent::Tick => {
|
||||||
|
app.on_tick().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use reqwest::Client;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
|
||||||
|
pub(crate) mod radarr;
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub enum RadarrEvent {
|
||||||
|
HealthCheck,
|
||||||
|
GetOverview
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Network<'a> {
|
||||||
|
pub client: Client,
|
||||||
|
|
||||||
|
pub app: &'a Arc<Mutex<App>>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Network<'a> {
|
||||||
|
pub fn new(client: Client, app: &'a Arc<Mutex<App>>) -> Self {
|
||||||
|
Network { client, app }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use log::{debug, error};
|
||||||
|
use reqwest::RequestBuilder;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Number;
|
||||||
|
|
||||||
|
use crate::app::radarr::RadarrData;
|
||||||
|
use crate::app::RadarrConfig;
|
||||||
|
use crate::network::{Network, RadarrEvent};
|
||||||
|
|
||||||
|
impl RadarrEvent {
|
||||||
|
const fn resource(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RadarrEvent::HealthCheck => "/health",
|
||||||
|
RadarrEvent::GetOverview => "/diskspace"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DiskSpace {
|
||||||
|
pub path: String,
|
||||||
|
pub label: String,
|
||||||
|
pub free_space: Number,
|
||||||
|
pub total_space: Number
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Network<'a> {
|
||||||
|
pub async fn handle_radarr_event(&self, radarr_event: RadarrEvent) {
|
||||||
|
match radarr_event {
|
||||||
|
RadarrEvent::HealthCheck => {
|
||||||
|
self.healthcheck(RadarrEvent::HealthCheck.resource()).await;
|
||||||
|
}
|
||||||
|
RadarrEvent::GetOverview => {
|
||||||
|
match self.diskspace(RadarrEvent::GetOverview.resource()).await {
|
||||||
|
Ok(disk_space_vec) => {
|
||||||
|
let mut app = self.app.lock().await;
|
||||||
|
app.data.radarr_data = RadarrData::from(&disk_space_vec[0]);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to fetch disk space. {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut app = self.app.lock().await;
|
||||||
|
app.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn healthcheck(&self, resource: &str) {
|
||||||
|
if let Err(e) = self.call_radarr_api(resource).await.send().await {
|
||||||
|
error!("Healthcheck failed. {:?}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn diskspace(&self, resource: &str) -> Result<Vec<DiskSpace>> {
|
||||||
|
debug!("Handling diskspace event: {:?}", resource);
|
||||||
|
|
||||||
|
Ok(
|
||||||
|
self.call_radarr_api(resource)
|
||||||
|
.await
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<Vec<DiskSpace>>()
|
||||||
|
.await?
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_radarr_api(&self, resource: &str) -> RequestBuilder {
|
||||||
|
debug!("Creating RequestBuilder for resource: {:?}", resource);
|
||||||
|
let app = self.app.lock().await;
|
||||||
|
let RadarrConfig { host, port, api_token } = &app.config.radarr;
|
||||||
|
|
||||||
|
app.client.get(format!("http://{}:{}/api/v3{}", host, port.unwrap_or(7878), resource))
|
||||||
|
.header("X-Api-Key", api_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use tui::backend::Backend;
|
||||||
|
use tui::Frame;
|
||||||
|
use tui::layout::{Constraint, Direction, Layout};
|
||||||
|
use tui::style::{Color, Style};
|
||||||
|
use tui::widgets::{Block, Borders, Gauge};
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::app::radarr::RadarrData;
|
||||||
|
|
||||||
|
pub fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
|
||||||
|
let RadarrData { free_space, total_space } = app.data.radarr_data;
|
||||||
|
let ratio = if total_space == 0 {
|
||||||
|
0f64
|
||||||
|
} else {
|
||||||
|
1f64 - (free_space as f64 / total_space as f64)
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
|
||||||
|
.split(f.size());
|
||||||
|
|
||||||
|
let gauge = Gauge::default()
|
||||||
|
.block(Block::default()
|
||||||
|
.title("Free Space")
|
||||||
|
.borders(Borders::ALL))
|
||||||
|
.gauge_style(Style::default().fg(Color::Cyan))
|
||||||
|
.ratio(ratio);
|
||||||
|
|
||||||
|
f.render_widget(gauge, chunks[0]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
use log4rs::append::file::FileAppender;
|
||||||
|
use log4rs::config::{Appender, Root};
|
||||||
|
use log4rs::encode::pattern::PatternEncoder;
|
||||||
|
use log::LevelFilter;
|
||||||
|
|
||||||
|
pub fn init_logging_config() -> log4rs::Config {
|
||||||
|
let file_path = "/tmp/devtools.log";
|
||||||
|
let logfile = FileAppender::builder()
|
||||||
|
.encoder(Box::new(PatternEncoder::new("{l} - {m}\n")))
|
||||||
|
.build(file_path)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
log4rs::Config::builder()
|
||||||
|
.appender(Appender::builder().build("logfile", Box::new(logfile)))
|
||||||
|
.build(Root::builder()
|
||||||
|
.appender("logfile")
|
||||||
|
.build(LevelFilter::Debug))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user