Added error windows with scrolling text, and a colorized Radarr logo. Also added header row with header tabs
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
# managarr
|
# managarr
|
||||||
A TUI for managing *arr servers.
|
A Servarr management TUI.
|
||||||
|
|||||||
+35
-1
@@ -1,11 +1,13 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use log::{debug, error};
|
use anyhow::anyhow;
|
||||||
|
use log::error;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::mpsc::Sender;
|
use tokio::sync::mpsc::Sender;
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
use crate::app::models::{TabRoute, TabState};
|
||||||
use crate::app::radarr::{ActiveRadarrBlock, RadarrData};
|
use crate::app::radarr::{ActiveRadarrBlock, RadarrData};
|
||||||
use crate::network::radarr_network::RadarrEvent;
|
use crate::network::radarr_network::RadarrEvent;
|
||||||
use crate::network::NetworkEvent;
|
use crate::network::NetworkEvent;
|
||||||
@@ -17,6 +19,7 @@ pub mod radarr;
|
|||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
Radarr(ActiveRadarrBlock),
|
Radarr(ActiveRadarrBlock),
|
||||||
|
Sonarr,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ActiveRadarrBlock> for Route {
|
impl From<ActiveRadarrBlock> for Route {
|
||||||
@@ -30,6 +33,8 @@ const DEFAULT_ROUTE: Route = Route::Radarr(ActiveRadarrBlock::Movies);
|
|||||||
pub struct App {
|
pub struct App {
|
||||||
navigation_stack: Vec<Route>,
|
navigation_stack: Vec<Route>,
|
||||||
network_tx: Option<Sender<NetworkEvent>>,
|
network_tx: Option<Sender<NetworkEvent>>,
|
||||||
|
pub server_tabs: TabState,
|
||||||
|
pub error: String,
|
||||||
pub client: Client,
|
pub client: Client,
|
||||||
pub title: &'static str,
|
pub title: &'static str,
|
||||||
pub tick_until_poll: u64,
|
pub tick_until_poll: u64,
|
||||||
@@ -56,6 +61,7 @@ impl App {
|
|||||||
if let Err(e) = network_tx.send(action).await {
|
if let Err(e) = network_tx.send(action).await {
|
||||||
self.is_loading = false;
|
self.is_loading = false;
|
||||||
error!("Failed to send event. {:?}", e);
|
error!("Failed to send event. {:?}", e);
|
||||||
|
self.handle_error(anyhow!(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,10 +72,26 @@ impl App {
|
|||||||
|
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.reset_tick_count();
|
self.reset_tick_count();
|
||||||
|
self.error = String::default();
|
||||||
self.data = Data::default();
|
self.data = Data::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn handle_error(&mut self, error: anyhow::Error) {
|
||||||
|
if self.error.is_empty() {
|
||||||
|
self.error = format!("{} ", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_error_horizontally(&mut self) {
|
||||||
|
let first_char = self.error.chars().next().unwrap();
|
||||||
|
self.error = format!("{}{}", &self.error[1..], first_char);
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn on_tick(&mut self, is_first_render: bool) {
|
pub async fn on_tick(&mut self, is_first_render: bool) {
|
||||||
|
if !self.error.is_empty() {
|
||||||
|
self.scroll_error_horizontally();
|
||||||
|
}
|
||||||
|
|
||||||
if self.tick_count % self.tick_until_poll == 0 || self.is_routing {
|
if self.tick_count % self.tick_until_poll == 0 || self.is_routing {
|
||||||
match self.get_current_route() {
|
match self.get_current_route() {
|
||||||
Route::Radarr(active_radarr_block) => {
|
Route::Radarr(active_radarr_block) => {
|
||||||
@@ -92,6 +114,7 @@ impl App {
|
|||||||
self.dispatch_by_radarr_block(active_block).await;
|
self.dispatch_by_radarr_block(active_block).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ => (),
|
||||||
}
|
}
|
||||||
|
|
||||||
self.is_routing = false;
|
self.is_routing = false;
|
||||||
@@ -127,6 +150,17 @@ impl Default for App {
|
|||||||
App {
|
App {
|
||||||
navigation_stack: vec![DEFAULT_ROUTE],
|
navigation_stack: vec![DEFAULT_ROUTE],
|
||||||
network_tx: None,
|
network_tx: None,
|
||||||
|
error: String::default(),
|
||||||
|
server_tabs: TabState::new(vec![
|
||||||
|
TabRoute {
|
||||||
|
title: "Radarr".to_owned(),
|
||||||
|
route: ActiveRadarrBlock::Movies.into(),
|
||||||
|
},
|
||||||
|
TabRoute {
|
||||||
|
title: "Sonarr".to_owned(),
|
||||||
|
route: Route::Sonarr,
|
||||||
|
},
|
||||||
|
]),
|
||||||
client: Client::new(),
|
client: Client::new(),
|
||||||
title: "Managarr",
|
title: "Managarr",
|
||||||
tick_until_poll: 20,
|
tick_until_poll: 20,
|
||||||
|
|||||||
@@ -9,5 +9,12 @@ pub async fn handle_key_events(key: Key, app: &mut App) {
|
|||||||
Route::Radarr(active_radarr_block) => {
|
Route::Radarr(active_radarr_block) => {
|
||||||
handle_radarr_key_events(key, app, active_radarr_block).await
|
handle_radarr_key_events(key, app, active_radarr_block).await
|
||||||
}
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_clear_errors(app: &mut App) {
|
||||||
|
if !app.error.is_empty() {
|
||||||
|
app.error = String::default();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
|
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
|
||||||
use crate::app::models::{Scrollable, ScrollableText, StatefulTable};
|
use crate::app::models::{Scrollable, ScrollableText, StatefulTable};
|
||||||
use crate::app::radarr::ActiveRadarrBlock;
|
use crate::app::radarr::ActiveRadarrBlock;
|
||||||
|
use crate::handlers::handle_clear_errors;
|
||||||
use crate::{App, Key};
|
use crate::{App, Key};
|
||||||
|
|
||||||
pub async fn handle_radarr_key_events(
|
pub async fn handle_radarr_key_events(
|
||||||
@@ -97,6 +98,6 @@ async fn handle_esc(app: &mut App, active_radarr_block: ActiveRadarrBlock) {
|
|||||||
app.pop_navigation_stack();
|
app.pop_navigation_stack();
|
||||||
app.data.radarr_data.reset_movie_info_tab();
|
app.data.radarr_data.reset_movie_info_tab();
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => handle_clear_errors(app).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use anyhow::anyhow;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use derivative::Derivative;
|
use derivative::Derivative;
|
||||||
use indoc::formatdoc;
|
use indoc::formatdoc;
|
||||||
@@ -200,7 +201,8 @@ impl<'a> Network<'a> {
|
|||||||
|
|
||||||
async fn get_healthcheck(&self, resource: &str) {
|
async fn get_healthcheck(&self, resource: &str) {
|
||||||
if let Err(e) = self.call_radarr_api(resource).await.send().await {
|
if let Err(e) = self.call_radarr_api(resource).await.send().await {
|
||||||
error!("Healthcheck failed. {:?}", e)
|
error!("Healthcheck failed. {:?}", e);
|
||||||
|
self.app.lock().await.handle_error(anyhow!(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,9 +401,15 @@ impl<'a> Network<'a> {
|
|||||||
let app = self.app.lock().await;
|
let app = self.app.lock().await;
|
||||||
app_update_fn(value, app);
|
app_update_fn(value, app);
|
||||||
}
|
}
|
||||||
Err(e) => error!("Failed to parse response! {:?}", e),
|
Err(e) => {
|
||||||
|
error!("Failed to parse response! {:?}", e);
|
||||||
|
self.app.lock().await.handle_error(anyhow!(e));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Err(e) => error!("Failed to fetch resource. {:?}", e),
|
Err(e) => {
|
||||||
|
error!("Failed to fetch resource. {:?}", e);
|
||||||
|
self.app.lock().await.handle_error(anyhow!(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+77
-10
@@ -1,12 +1,12 @@
|
|||||||
use tui::backend::Backend;
|
use tui::backend::Backend;
|
||||||
use tui::layout::{Constraint, Rect};
|
use tui::layout::{Alignment, Constraint, Rect};
|
||||||
use tui::text::{Span, Spans, Text};
|
use tui::text::{Span, Spans, Text};
|
||||||
use tui::widgets::Block;
|
|
||||||
use tui::widgets::Clear;
|
use tui::widgets::Clear;
|
||||||
use tui::widgets::Paragraph;
|
use tui::widgets::Paragraph;
|
||||||
use tui::widgets::Row;
|
use tui::widgets::Row;
|
||||||
use tui::widgets::Table;
|
use tui::widgets::Table;
|
||||||
use tui::widgets::Tabs;
|
use tui::widgets::Tabs;
|
||||||
|
use tui::widgets::{Block, Borders, Wrap};
|
||||||
use tui::Frame;
|
use tui::Frame;
|
||||||
|
|
||||||
use crate::app::models::{StatefulTable, TabState};
|
use crate::app::models::{StatefulTable, TabState};
|
||||||
@@ -15,7 +15,8 @@ use crate::logos::{
|
|||||||
BAZARR_LOGO, LIDARR_LOGO, PROWLARR_LOGO, RADARR_LOGO, READARR_LOGO, SONARR_LOGO,
|
BAZARR_LOGO, LIDARR_LOGO, PROWLARR_LOGO, RADARR_LOGO, READARR_LOGO, SONARR_LOGO,
|
||||||
};
|
};
|
||||||
use crate::ui::utils::{
|
use crate::ui::utils::{
|
||||||
centered_rect, layout_block_top_border, style_default_bold, style_highlight, style_secondary,
|
centered_rect, horizontal_chunks_with_margin, layout_block_top_border, logo_block,
|
||||||
|
style_default_bold, style_failure, style_help, style_highlight, style_primary, style_secondary,
|
||||||
style_system_function, title_block, vertical_chunks_with_margin,
|
style_system_function, title_block, vertical_chunks_with_margin,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,18 +26,83 @@ mod utils;
|
|||||||
static HIGHLIGHT_SYMBOL: &str = "=> ";
|
static HIGHLIGHT_SYMBOL: &str = "=> ";
|
||||||
|
|
||||||
pub fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
|
pub fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
|
||||||
let main_chunks = vertical_chunks_with_margin(
|
let main_chunks = if !app.error.is_empty() {
|
||||||
vec![Constraint::Length(16), Constraint::Length(0)],
|
let chunks = vertical_chunks_with_margin(
|
||||||
f.size(),
|
vec![
|
||||||
1,
|
Constraint::Length(3),
|
||||||
);
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(16),
|
||||||
|
Constraint::Length(0),
|
||||||
|
],
|
||||||
|
f.size(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
draw_context_row(f, app, main_chunks[0]);
|
draw_error(f, app, chunks[1]);
|
||||||
|
|
||||||
|
vec![chunks[0], chunks[2], chunks[3]]
|
||||||
|
} else {
|
||||||
|
vertical_chunks_with_margin(
|
||||||
|
vec![
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(16),
|
||||||
|
Constraint::Length(0),
|
||||||
|
],
|
||||||
|
f.size(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
draw_header_row(f, app, main_chunks[0]);
|
||||||
|
draw_context_row(f, app, main_chunks[1]);
|
||||||
match app.get_current_route() {
|
match app.get_current_route() {
|
||||||
Route::Radarr(_) => radarr_ui::draw_radarr_ui(f, app, main_chunks[1]),
|
Route::Radarr(_) => radarr_ui::draw_radarr_ui(f, app, main_chunks[2]),
|
||||||
|
_ => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_header_row<B: Backend>(f: &mut Frame<'_, B>, app: &mut App, area: Rect) {
|
||||||
|
let chunks =
|
||||||
|
horizontal_chunks_with_margin(vec![Constraint::Length(75), Constraint::Min(0)], area, 1);
|
||||||
|
|
||||||
|
let titles = app
|
||||||
|
.server_tabs
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.map(|tab| Spans::from(Span::styled(&tab.title, style_default_bold())))
|
||||||
|
.collect();
|
||||||
|
let tabs = Tabs::new(titles)
|
||||||
|
.block(logo_block())
|
||||||
|
.highlight_style(style_secondary())
|
||||||
|
.select(app.server_tabs.index);
|
||||||
|
let help = Paragraph::new(Text::from(
|
||||||
|
"<↑↓> scroll | <enter> select | <tab> change servarr | <?> help ",
|
||||||
|
))
|
||||||
|
.block(Block::default())
|
||||||
|
.style(style_help())
|
||||||
|
.alignment(Alignment::Right);
|
||||||
|
|
||||||
|
f.render_widget(tabs, area);
|
||||||
|
f.render_widget(help, chunks[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_error<B: Backend>(f: &mut Frame<'_, B>, app: &mut App, area: Rect) {
|
||||||
|
let block = Block::default()
|
||||||
|
.title("Error | <esc> to close")
|
||||||
|
.style(style_failure())
|
||||||
|
.borders(Borders::ALL);
|
||||||
|
|
||||||
|
let mut text = Text::from(app.error.clone());
|
||||||
|
text.patch_style(style_failure());
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(text)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: true })
|
||||||
|
.style(style_primary());
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn draw_popup_over<B: Backend>(
|
pub fn draw_popup_over<B: Backend>(
|
||||||
f: &mut Frame<'_, B>,
|
f: &mut Frame<'_, B>,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
@@ -86,6 +152,7 @@ pub fn draw_large_popup_over<B: Backend>(
|
|||||||
fn draw_context_row<B: Backend>(f: &mut Frame<'_, B>, app: &App, area: Rect) {
|
fn draw_context_row<B: Backend>(f: &mut Frame<'_, B>, app: &App, area: Rect) {
|
||||||
match app.get_current_route() {
|
match app.get_current_route() {
|
||||||
Route::Radarr(_) => radarr_ui::draw_radarr_context_row(f, app, area),
|
Route::Radarr(_) => radarr_ui::draw_radarr_context_row(f, app, area),
|
||||||
|
_ => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-8
@@ -5,7 +5,7 @@ use chrono::{Duration, Utc};
|
|||||||
use log::debug;
|
use log::debug;
|
||||||
use tui::backend::Backend;
|
use tui::backend::Backend;
|
||||||
use tui::layout::{Alignment, Constraint, Rect};
|
use tui::layout::{Alignment, Constraint, Rect};
|
||||||
use tui::style::Style;
|
use tui::style::{Color, Style};
|
||||||
use tui::text::Text;
|
use tui::text::Text;
|
||||||
use tui::widgets::{Block, Cell, Paragraph, Row, Wrap};
|
use tui::widgets::{Block, Cell, Paragraph, Row, Wrap};
|
||||||
use tui::Frame;
|
use tui::Frame;
|
||||||
@@ -15,7 +15,7 @@ use crate::app::{App, Route};
|
|||||||
use crate::logos::RADARR_LOGO;
|
use crate::logos::RADARR_LOGO;
|
||||||
use crate::network::radarr_network::{DiskSpace, DownloadRecord, Movie, MovieHistoryItem};
|
use crate::network::radarr_network::{DiskSpace, DownloadRecord, Movie, MovieHistoryItem};
|
||||||
use crate::ui::utils::{
|
use crate::ui::utils::{
|
||||||
horizontal_chunks_with_margin, layout_block_top_border, line_gague_with_label,
|
horizontal_chunks, horizontal_chunks_with_margin, layout_block_top_border, line_gague_with_label,
|
||||||
line_gague_with_title, style_bold, style_failure, style_success, style_warning, title_block,
|
line_gague_with_title, style_bold, style_failure, style_success, style_warning, title_block,
|
||||||
vertical_chunks_with_margin,
|
vertical_chunks_with_margin,
|
||||||
};
|
};
|
||||||
@@ -38,11 +38,7 @@ pub(super) fn draw_radarr_ui<B: Backend>(f: &mut Frame<'_, B>, app: &mut App, ar
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn draw_radarr_context_row<B: Backend>(f: &mut Frame<'_, B>, app: &App, area: Rect) {
|
pub(super) fn draw_radarr_context_row<B: Backend>(f: &mut Frame<'_, B>, app: &App, area: Rect) {
|
||||||
let chunks = horizontal_chunks_with_margin(
|
let chunks = horizontal_chunks(vec![Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], area);
|
||||||
vec![Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)],
|
|
||||||
area,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
draw_stats_context(f, app, chunks[0]);
|
draw_stats_context(f, app, chunks[0]);
|
||||||
draw_downloads_context(f, app, chunks[1]);
|
draw_downloads_context(f, app, chunks[1]);
|
||||||
@@ -332,7 +328,9 @@ fn draw_stats_context<B: Backend>(f: &mut Frame<'_, B>, app: &App, area: Rect) {
|
|||||||
)))
|
)))
|
||||||
.block(Block::default());
|
.block(Block::default());
|
||||||
|
|
||||||
let logo = Paragraph::new(Text::from(RADARR_LOGO))
|
let mut logo_text = Text::from(RADARR_LOGO);
|
||||||
|
logo_text.patch_style(Style::default().fg(Color::LightYellow));
|
||||||
|
let logo = Paragraph::new(logo_text)
|
||||||
.block(Block::default())
|
.block(Block::default())
|
||||||
.alignment(Alignment::Center);
|
.alignment(Alignment::Center);
|
||||||
let storage =
|
let storage =
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ pub fn style_failure() -> Style {
|
|||||||
Style::default().fg(Color::Red)
|
Style::default().fg(Color::Red)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn style_help() -> Style {
|
||||||
|
Style::default().fg(Color::LightBlue)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn title_style(title: &str) -> Span<'_> {
|
pub fn title_style(title: &str) -> Span<'_> {
|
||||||
Span::styled(title, style_bold())
|
Span::styled(title, style_bold())
|
||||||
}
|
}
|
||||||
@@ -114,6 +118,17 @@ pub fn title_block(title: &str) -> Block<'_> {
|
|||||||
layout_block_with_title(title_style(title))
|
layout_block_with_title(title_style(title))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn logo_block<'a>() -> Block<'a> {
|
||||||
|
Block::default().borders(Borders::ALL).title(Span::styled(
|
||||||
|
"Managarr - A Servarr management TUI",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::LightGreen)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||||
let popup_layout = Layout::default()
|
let popup_layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
|
|||||||
Reference in New Issue
Block a user