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
|
||||
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